n8n-nodes-ollama-reranker 1.1.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/nodes/OllamaReranker/OllamaReranker.node.d.ts +10 -1
- package/dist/nodes/OllamaReranker/OllamaReranker.node.js +66 -317
- package/dist/nodes/OllamaRerankerWorkflow/OllamaRerankerWorkflow.node.d.ts +10 -1
- package/dist/nodes/OllamaRerankerWorkflow/OllamaRerankerWorkflow.node.js +66 -42
- package/dist/nodes/shared/reranker-logic.d.ts +2 -1
- package/dist/nodes/shared/reranker-logic.js +78 -2
- package/package.json +2 -2
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ISupplyDataFunctions, SupplyData, INodeType, INodeTypeDescription } from 'n8n-workflow';
|
|
1
|
+
import { ILoadOptionsFunctions, INodePropertyOptions, ISupplyDataFunctions, SupplyData, INodeType, INodeTypeDescription } from 'n8n-workflow';
|
|
2
2
|
/**
|
|
3
3
|
* Ollama Reranker Provider
|
|
4
4
|
*
|
|
@@ -12,6 +12,15 @@ import { ISupplyDataFunctions, SupplyData, INodeType, INodeTypeDescription } fro
|
|
|
12
12
|
*/
|
|
13
13
|
export declare class OllamaReranker implements INodeType {
|
|
14
14
|
description: INodeTypeDescription;
|
|
15
|
+
methods: {
|
|
16
|
+
loadOptions: {
|
|
17
|
+
/**
|
|
18
|
+
* Load models from Ollama/Custom Rerank API
|
|
19
|
+
* Dynamically fetches available models from /api/tags endpoint
|
|
20
|
+
*/
|
|
21
|
+
getModels(this: ILoadOptionsFunctions): Promise<INodePropertyOptions[]>;
|
|
22
|
+
};
|
|
23
|
+
};
|
|
15
24
|
/**
|
|
16
25
|
* Supply Data Method (NOT execute!)
|
|
17
26
|
*
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.OllamaReranker = void 0;
|
|
4
4
|
const n8n_workflow_1 = require("n8n-workflow");
|
|
5
|
+
const reranker_logic_1 = require("../shared/reranker-logic");
|
|
5
6
|
/**
|
|
6
7
|
* Ollama Reranker Provider
|
|
7
8
|
*
|
|
@@ -55,48 +56,30 @@ class OllamaReranker {
|
|
|
55
56
|
displayName: 'Model',
|
|
56
57
|
name: 'model',
|
|
57
58
|
type: 'options',
|
|
59
|
+
typeOptions: {
|
|
60
|
+
loadOptionsMethod: 'getModels',
|
|
61
|
+
},
|
|
62
|
+
default: '',
|
|
63
|
+
description: 'The reranker model to use - models are loaded from your configured Ollama/Custom API',
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
displayName: 'API Type',
|
|
67
|
+
name: 'apiType',
|
|
68
|
+
type: 'options',
|
|
58
69
|
options: [
|
|
59
70
|
{
|
|
60
|
-
name: '
|
|
61
|
-
value: '
|
|
62
|
-
description: '
|
|
63
|
-
},
|
|
64
|
-
{
|
|
65
|
-
name: 'Qwen3-Reranker-0.6B (Fast)',
|
|
66
|
-
value: 'dengcao/Qwen3-Reranker-0.6B:Q5_K_M',
|
|
67
|
-
description: 'Fastest option, best for resource-limited environments',
|
|
68
|
-
},
|
|
69
|
-
{
|
|
70
|
-
name: 'Qwen3-Reranker-4B (Balanced)',
|
|
71
|
-
value: 'dengcao/Qwen3-Reranker-4B:Q5_K_M',
|
|
72
|
-
description: 'Recommended for Qwen family - best balance of speed and accuracy',
|
|
71
|
+
name: 'Ollama Generate API',
|
|
72
|
+
value: 'ollama',
|
|
73
|
+
description: 'Standard Ollama /api/generate endpoint (for BGE, Qwen prompt-based rerankers)',
|
|
73
74
|
},
|
|
74
75
|
{
|
|
75
|
-
name: '
|
|
76
|
-
value: 'dengcao/Qwen3-Reranker-8B:Q5_K_M',
|
|
77
|
-
description: 'Highest accuracy, requires more resources',
|
|
78
|
-
},
|
|
79
|
-
{
|
|
80
|
-
name: 'Custom Model',
|
|
76
|
+
name: 'Custom Rerank API',
|
|
81
77
|
value: 'custom',
|
|
82
|
-
description: '
|
|
78
|
+
description: 'Custom /api/rerank endpoint (for deposium-embeddings-turbov2, etc.)',
|
|
83
79
|
},
|
|
84
80
|
],
|
|
85
|
-
default: '
|
|
86
|
-
description: '
|
|
87
|
-
},
|
|
88
|
-
{
|
|
89
|
-
displayName: 'Custom Model Name',
|
|
90
|
-
name: 'customModel',
|
|
91
|
-
type: 'string',
|
|
92
|
-
default: '',
|
|
93
|
-
placeholder: 'your-reranker-model:tag',
|
|
94
|
-
description: 'Name of your custom Ollama reranker model',
|
|
95
|
-
displayOptions: {
|
|
96
|
-
show: {
|
|
97
|
-
model: ['custom'],
|
|
98
|
-
},
|
|
99
|
-
},
|
|
81
|
+
default: 'ollama',
|
|
82
|
+
description: 'Which API endpoint to use for reranking',
|
|
100
83
|
},
|
|
101
84
|
{
|
|
102
85
|
displayName: 'Top K',
|
|
@@ -168,6 +151,47 @@ class OllamaReranker {
|
|
|
168
151
|
},
|
|
169
152
|
],
|
|
170
153
|
};
|
|
154
|
+
this.methods = {
|
|
155
|
+
loadOptions: {
|
|
156
|
+
/**
|
|
157
|
+
* Load models from Ollama/Custom Rerank API
|
|
158
|
+
* Dynamically fetches available models from /api/tags endpoint
|
|
159
|
+
*/
|
|
160
|
+
async getModels() {
|
|
161
|
+
const credentials = await this.getCredentials('ollamaApi');
|
|
162
|
+
if (!(credentials === null || credentials === void 0 ? void 0 : credentials.host)) {
|
|
163
|
+
return [];
|
|
164
|
+
}
|
|
165
|
+
const baseUrl = credentials.host.replace(/\/$/, '');
|
|
166
|
+
try {
|
|
167
|
+
const response = await this.helpers.httpRequest({
|
|
168
|
+
method: 'GET',
|
|
169
|
+
url: `${baseUrl}/api/tags`,
|
|
170
|
+
json: true,
|
|
171
|
+
timeout: 5000,
|
|
172
|
+
});
|
|
173
|
+
if (!(response === null || response === void 0 ? void 0 : response.models) || !Array.isArray(response.models)) {
|
|
174
|
+
return [];
|
|
175
|
+
}
|
|
176
|
+
// Sort models alphabetically
|
|
177
|
+
const models = response.models.sort((a, b) => {
|
|
178
|
+
const nameA = a.name || '';
|
|
179
|
+
const nameB = b.name || '';
|
|
180
|
+
return nameA.localeCompare(nameB);
|
|
181
|
+
});
|
|
182
|
+
return models.map((model) => ({
|
|
183
|
+
name: model.name,
|
|
184
|
+
value: model.name,
|
|
185
|
+
description: model.details || `Size: ${Math.round((model.size || 0) / 1024 / 1024)}MB`,
|
|
186
|
+
}));
|
|
187
|
+
}
|
|
188
|
+
catch (error) {
|
|
189
|
+
// If API call fails, return empty array (user can still type model name manually)
|
|
190
|
+
return [];
|
|
191
|
+
}
|
|
192
|
+
},
|
|
193
|
+
},
|
|
194
|
+
};
|
|
171
195
|
}
|
|
172
196
|
/**
|
|
173
197
|
* Supply Data Method (NOT execute!)
|
|
@@ -180,13 +204,11 @@ class OllamaReranker {
|
|
|
180
204
|
this.logger.debug('Initializing Ollama Reranker Provider');
|
|
181
205
|
const self = this;
|
|
182
206
|
// Get node parameters once (provider nodes use index 0)
|
|
183
|
-
|
|
184
|
-
if (model ===
|
|
185
|
-
|
|
186
|
-
if (!(model === null || model === void 0 ? void 0 : model.trim())) {
|
|
187
|
-
throw new n8n_workflow_1.NodeOperationError(this.getNode(), 'Custom model name is required when "Custom Model" is selected');
|
|
188
|
-
}
|
|
207
|
+
const model = this.getNodeParameter('model', 0);
|
|
208
|
+
if (!(model === null || model === void 0 ? void 0 : model.trim())) {
|
|
209
|
+
throw new n8n_workflow_1.NodeOperationError(this.getNode(), 'Model selection is required. Please select a model from the dropdown.');
|
|
189
210
|
}
|
|
211
|
+
const apiType = this.getNodeParameter('apiType', 0, 'ollama');
|
|
190
212
|
const instruction = this.getNodeParameter('instruction', 0);
|
|
191
213
|
const additionalOptions = this.getNodeParameter('additionalOptions', 0, {});
|
|
192
214
|
const timeout = (_a = additionalOptions.timeout) !== null && _a !== void 0 ? _a : 30000;
|
|
@@ -261,8 +283,8 @@ class OllamaReranker {
|
|
|
261
283
|
});
|
|
262
284
|
self.logger.debug(`Reranking ${processedDocs.length} documents with model: ${model}`);
|
|
263
285
|
try {
|
|
264
|
-
// Rerank documents using Ollama
|
|
265
|
-
const rerankedDocs = await rerankDocuments(self, {
|
|
286
|
+
// Rerank documents using Ollama or Custom API
|
|
287
|
+
const rerankedDocs = await (0, reranker_logic_1.rerankDocuments)(self, {
|
|
266
288
|
ollamaHost,
|
|
267
289
|
model,
|
|
268
290
|
query,
|
|
@@ -273,6 +295,7 @@ class OllamaReranker {
|
|
|
273
295
|
batchSize,
|
|
274
296
|
timeout,
|
|
275
297
|
includeOriginalScores,
|
|
298
|
+
apiType,
|
|
276
299
|
});
|
|
277
300
|
self.logger.debug(`Reranking complete: ${rerankedDocs.length} documents returned`);
|
|
278
301
|
// Log output for n8n execution tracking
|
|
@@ -310,277 +333,3 @@ class OllamaReranker {
|
|
|
310
333
|
}
|
|
311
334
|
}
|
|
312
335
|
exports.OllamaReranker = OllamaReranker;
|
|
313
|
-
/**
|
|
314
|
-
* Rerank documents using Ollama reranker model
|
|
315
|
-
*/
|
|
316
|
-
async function rerankDocuments(context, config) {
|
|
317
|
-
const { ollamaHost, model, query, documents, instruction, topK, threshold, batchSize, timeout, includeOriginalScores } = config;
|
|
318
|
-
const results = [];
|
|
319
|
-
// Process all documents concurrently with controlled concurrency
|
|
320
|
-
const promises = [];
|
|
321
|
-
for (let i = 0; i < documents.length; i++) {
|
|
322
|
-
const doc = documents[i];
|
|
323
|
-
const promise = scoreDocument(context, ollamaHost, model, query, doc.pageContent, instruction, timeout).then(score => ({
|
|
324
|
-
index: i,
|
|
325
|
-
score,
|
|
326
|
-
}));
|
|
327
|
-
promises.push(promise);
|
|
328
|
-
// Process in batches to avoid overwhelming the API
|
|
329
|
-
if (promises.length >= batchSize || i === documents.length - 1) {
|
|
330
|
-
const batchResults = await Promise.all(promises);
|
|
331
|
-
results.push(...batchResults);
|
|
332
|
-
promises.length = 0; // Clear the array
|
|
333
|
-
}
|
|
334
|
-
}
|
|
335
|
-
// Filter by threshold and sort by score (descending)
|
|
336
|
-
const filteredResults = results
|
|
337
|
-
.filter(r => r.score >= threshold)
|
|
338
|
-
.sort((a, b) => b.score - a.score)
|
|
339
|
-
.slice(0, topK);
|
|
340
|
-
// Map back to original documents with scores
|
|
341
|
-
return filteredResults.map(result => {
|
|
342
|
-
const originalDoc = documents[result.index];
|
|
343
|
-
const rerankedDoc = {
|
|
344
|
-
...originalDoc,
|
|
345
|
-
_rerankScore: result.score,
|
|
346
|
-
_originalIndex: result.index,
|
|
347
|
-
};
|
|
348
|
-
if (includeOriginalScores && originalDoc._originalScore !== undefined) {
|
|
349
|
-
rerankedDoc._originalScore = originalDoc._originalScore;
|
|
350
|
-
}
|
|
351
|
-
return rerankedDoc;
|
|
352
|
-
});
|
|
353
|
-
}
|
|
354
|
-
/**
|
|
355
|
-
* Score a single document against the query using Ollama reranker model with retry logic
|
|
356
|
-
*/
|
|
357
|
-
async function scoreDocument(context, ollamaHost, model, query, documentContent, instruction, timeout) {
|
|
358
|
-
var _a, _b, _c, _d;
|
|
359
|
-
// Format prompt based on model type
|
|
360
|
-
const prompt = formatRerankerPrompt(model, query, documentContent, instruction);
|
|
361
|
-
const maxRetries = 3;
|
|
362
|
-
let lastError;
|
|
363
|
-
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
|
364
|
-
try {
|
|
365
|
-
// Use Ollama /api/generate endpoint for reranker models
|
|
366
|
-
const response = await context.helpers.httpRequest({
|
|
367
|
-
method: 'POST',
|
|
368
|
-
url: `${ollamaHost}/api/generate`,
|
|
369
|
-
headers: {
|
|
370
|
-
'Content-Type': 'application/json',
|
|
371
|
-
Accept: 'application/json',
|
|
372
|
-
},
|
|
373
|
-
body: {
|
|
374
|
-
model,
|
|
375
|
-
prompt,
|
|
376
|
-
stream: false,
|
|
377
|
-
options: {
|
|
378
|
-
temperature: 0.0, // Deterministic scoring
|
|
379
|
-
},
|
|
380
|
-
},
|
|
381
|
-
json: true,
|
|
382
|
-
timeout,
|
|
383
|
-
});
|
|
384
|
-
// Parse the response to extract relevance score
|
|
385
|
-
const score = parseRerankerResponse(model, response);
|
|
386
|
-
return score;
|
|
387
|
-
}
|
|
388
|
-
catch (error) {
|
|
389
|
-
lastError = error;
|
|
390
|
-
// Don't retry on permanent errors
|
|
391
|
-
if (((_a = error === null || error === void 0 ? void 0 : error.response) === null || _a === void 0 ? void 0 : _a.statusCode) === 404 || ((_b = error === null || error === void 0 ? void 0 : error.response) === null || _b === void 0 ? void 0 : _b.statusCode) === 400) {
|
|
392
|
-
break;
|
|
393
|
-
}
|
|
394
|
-
// Retry on transient errors (timeout, 5xx, network issues)
|
|
395
|
-
if (attempt < maxRetries - 1) {
|
|
396
|
-
const isTransient = (error === null || error === void 0 ? void 0 : error.name) === 'AbortError' ||
|
|
397
|
-
(error === null || error === void 0 ? void 0 : error.code) === 'ETIMEDOUT' ||
|
|
398
|
-
((_c = error === null || error === void 0 ? void 0 : error.response) === null || _c === void 0 ? void 0 : _c.statusCode) >= 500;
|
|
399
|
-
if (isTransient) {
|
|
400
|
-
// Exponential backoff: 100ms, 200ms, 400ms
|
|
401
|
-
await new Promise(resolve => setTimeout(resolve, 100 * Math.pow(2, attempt)));
|
|
402
|
-
continue;
|
|
403
|
-
}
|
|
404
|
-
}
|
|
405
|
-
break;
|
|
406
|
-
}
|
|
407
|
-
}
|
|
408
|
-
// Handle final error after retries
|
|
409
|
-
const error = lastError;
|
|
410
|
-
if ((error === null || error === void 0 ? void 0 : error.name) === 'AbortError' || (error === null || error === void 0 ? void 0 : error.code) === 'ETIMEDOUT') {
|
|
411
|
-
throw new n8n_workflow_1.NodeApiError(context.getNode(), error, {
|
|
412
|
-
message: `Request timeout after ${timeout}ms (tried ${maxRetries} times)`,
|
|
413
|
-
description: `Model: ${model}\nEndpoint: ${ollamaHost}/api/generate`,
|
|
414
|
-
});
|
|
415
|
-
}
|
|
416
|
-
if ((_d = error === null || error === void 0 ? void 0 : error.response) === null || _d === void 0 ? void 0 : _d.body) {
|
|
417
|
-
throw new n8n_workflow_1.NodeApiError(context.getNode(), error, {
|
|
418
|
-
message: `Ollama API Error (${error.response.statusCode})`,
|
|
419
|
-
description: `Endpoint: ${ollamaHost}/api/generate\nModel: ${model}\nResponse: ${JSON.stringify(error.response.body, null, 2)}`,
|
|
420
|
-
});
|
|
421
|
-
}
|
|
422
|
-
throw new n8n_workflow_1.NodeApiError(context.getNode(), error, {
|
|
423
|
-
message: 'Ollama reranking request failed',
|
|
424
|
-
description: `Endpoint: ${ollamaHost}/api/generate\nModel: ${model}\nError: ${error.message}`,
|
|
425
|
-
});
|
|
426
|
-
}
|
|
427
|
-
/**
|
|
428
|
-
* Format prompt based on reranker model type
|
|
429
|
-
*
|
|
430
|
-
* Different models expect different prompt formats:
|
|
431
|
-
* - BGE Reranker: Simple query + document format
|
|
432
|
-
* - Qwen3-Reranker: Structured chat format with system/user/assistant tags
|
|
433
|
-
*/
|
|
434
|
-
function formatRerankerPrompt(model, query, documentContent, instruction) {
|
|
435
|
-
// Detect model type
|
|
436
|
-
const isBGE = model.toLowerCase().includes('bge');
|
|
437
|
-
const isQwen = model.toLowerCase().includes('qwen');
|
|
438
|
-
if (isBGE) {
|
|
439
|
-
// BGE Reranker uses a simple format
|
|
440
|
-
// See: https://huggingface.co/BAAI/bge-reranker-v2-m3
|
|
441
|
-
return `Instruction: ${instruction}
|
|
442
|
-
|
|
443
|
-
Query: ${query}
|
|
444
|
-
|
|
445
|
-
Document: ${documentContent}
|
|
446
|
-
|
|
447
|
-
Relevance:`;
|
|
448
|
-
}
|
|
449
|
-
else if (isQwen) {
|
|
450
|
-
// Qwen3-Reranker uses structured chat format
|
|
451
|
-
// See: https://huggingface.co/dengcao/Qwen3-Reranker-4B
|
|
452
|
-
return `<|im_start|>system
|
|
453
|
-
Judge whether the Document meets the requirements based on the Query and the Instruct provided. Note that the answer can only be "yes" or "no".<|im_end|>
|
|
454
|
-
<|im_start|>user
|
|
455
|
-
<Instruct>: ${instruction}
|
|
456
|
-
<Query>: ${query}
|
|
457
|
-
<Document>: ${documentContent}<|im_end|>
|
|
458
|
-
<|im_start|>assistant
|
|
459
|
-
<think>`;
|
|
460
|
-
}
|
|
461
|
-
// Default format for unknown models (similar to BGE)
|
|
462
|
-
return `Task: ${instruction}
|
|
463
|
-
|
|
464
|
-
Query: ${query}
|
|
465
|
-
|
|
466
|
-
Document: ${documentContent}
|
|
467
|
-
|
|
468
|
-
Score:`;
|
|
469
|
-
}
|
|
470
|
-
/**
|
|
471
|
-
* Parse BGE model response to extract relevance score
|
|
472
|
-
*/
|
|
473
|
-
function parseBGEScore(output, outputLower) {
|
|
474
|
-
// Try to extract floating point number
|
|
475
|
-
const scoreRegex = /(\d*\.?\d+)/;
|
|
476
|
-
const scoreMatch = scoreRegex.exec(output);
|
|
477
|
-
if (scoreMatch) {
|
|
478
|
-
const score = parseFloat(scoreMatch[1]);
|
|
479
|
-
// BGE returns scores in various ranges, normalize to 0-1
|
|
480
|
-
if (score > 1 && score <= 10) {
|
|
481
|
-
return score / 10;
|
|
482
|
-
}
|
|
483
|
-
else if (score > 10) {
|
|
484
|
-
return score / 100;
|
|
485
|
-
}
|
|
486
|
-
return Math.min(Math.max(score, 0), 1); // Clamp to 0-1
|
|
487
|
-
}
|
|
488
|
-
// Fallback: check for keywords
|
|
489
|
-
if (outputLower.includes('high') || outputLower.includes('relevant')) {
|
|
490
|
-
return 0.8;
|
|
491
|
-
}
|
|
492
|
-
if (outputLower.includes('low') || outputLower.includes('irrelevant')) {
|
|
493
|
-
return 0.2;
|
|
494
|
-
}
|
|
495
|
-
return null;
|
|
496
|
-
}
|
|
497
|
-
/**
|
|
498
|
-
* Parse Qwen model response to extract relevance score
|
|
499
|
-
*/
|
|
500
|
-
function parseQwenScore(output, outputLower) {
|
|
501
|
-
// Look for explicit yes/no in the response
|
|
502
|
-
const yesRegex = /\b(yes|relevant|positive|match)\b/;
|
|
503
|
-
const noRegex = /\b(no|irrelevant|negative|not\s+relevant)\b/;
|
|
504
|
-
const yesMatch = yesRegex.exec(outputLower);
|
|
505
|
-
const noMatch = noRegex.exec(outputLower);
|
|
506
|
-
if (yesMatch && !noMatch) {
|
|
507
|
-
// Higher confidence for detailed explanations
|
|
508
|
-
const hasReasoning = output.length > 100;
|
|
509
|
-
const hasMultiplePositives = (output.match(/relevant|yes|match/gi) || []).length > 1;
|
|
510
|
-
if (hasReasoning && hasMultiplePositives)
|
|
511
|
-
return 0.95;
|
|
512
|
-
if (hasReasoning)
|
|
513
|
-
return 0.85;
|
|
514
|
-
return 0.75;
|
|
515
|
-
}
|
|
516
|
-
if (noMatch && !yesMatch) {
|
|
517
|
-
// Low scores for negative responses
|
|
518
|
-
const hasStrongNegative = outputLower.includes('completely') ||
|
|
519
|
-
outputLower.includes('totally') ||
|
|
520
|
-
outputLower.includes('not at all');
|
|
521
|
-
return hasStrongNegative ? 0.05 : 0.15;
|
|
522
|
-
}
|
|
523
|
-
// Mixed signals - check which appears first
|
|
524
|
-
if (yesMatch && noMatch) {
|
|
525
|
-
const yesIndex = output.toLowerCase().indexOf(yesMatch[0]);
|
|
526
|
-
const noIndex = output.toLowerCase().indexOf(noMatch[0]);
|
|
527
|
-
return yesIndex < noIndex ? 0.6 : 0.4;
|
|
528
|
-
}
|
|
529
|
-
return null;
|
|
530
|
-
}
|
|
531
|
-
/**
|
|
532
|
-
* Parse generic model response with fallback logic
|
|
533
|
-
*/
|
|
534
|
-
function parseGenericScore(output, outputLower) {
|
|
535
|
-
// Try numeric extraction first
|
|
536
|
-
const numericRegex = /(\d*\.?\d+)/;
|
|
537
|
-
const numericMatch = numericRegex.exec(output);
|
|
538
|
-
if (numericMatch) {
|
|
539
|
-
const score = parseFloat(numericMatch[1]);
|
|
540
|
-
if (score >= 0 && score <= 1)
|
|
541
|
-
return score;
|
|
542
|
-
if (score > 1 && score <= 10)
|
|
543
|
-
return score / 10;
|
|
544
|
-
if (score > 10 && score <= 100)
|
|
545
|
-
return score / 100;
|
|
546
|
-
}
|
|
547
|
-
// Keyword-based scoring
|
|
548
|
-
const positiveKeywords = ['relevant', 'yes', 'high', 'strong', 'good', 'match', 'related'];
|
|
549
|
-
const negativeKeywords = ['irrelevant', 'no', 'low', 'weak', 'poor', 'unrelated', 'different'];
|
|
550
|
-
const positiveCount = positiveKeywords.filter(kw => outputLower.includes(kw)).length;
|
|
551
|
-
const negativeCount = negativeKeywords.filter(kw => outputLower.includes(kw)).length;
|
|
552
|
-
if (positiveCount > negativeCount) {
|
|
553
|
-
return 0.5 + (positiveCount * 0.1);
|
|
554
|
-
}
|
|
555
|
-
else if (negativeCount > positiveCount) {
|
|
556
|
-
return 0.5 - (negativeCount * 0.1);
|
|
557
|
-
}
|
|
558
|
-
// Default to neutral if completely ambiguous
|
|
559
|
-
return 0.5;
|
|
560
|
-
}
|
|
561
|
-
/**
|
|
562
|
-
* Parse Ollama reranker response to extract relevance score
|
|
563
|
-
* Uses model-specific parsing logic for better accuracy
|
|
564
|
-
*/
|
|
565
|
-
function parseRerankerResponse(model, response) {
|
|
566
|
-
if (!(response === null || response === void 0 ? void 0 : response.response)) {
|
|
567
|
-
return 0.0;
|
|
568
|
-
}
|
|
569
|
-
const output = response.response;
|
|
570
|
-
const outputLower = output.toLowerCase();
|
|
571
|
-
const isBGE = model.toLowerCase().includes('bge');
|
|
572
|
-
const isQwen = model.toLowerCase().includes('qwen');
|
|
573
|
-
// Try model-specific parsers
|
|
574
|
-
if (isBGE) {
|
|
575
|
-
const score = parseBGEScore(output, outputLower);
|
|
576
|
-
if (score !== null)
|
|
577
|
-
return score;
|
|
578
|
-
}
|
|
579
|
-
if (isQwen) {
|
|
580
|
-
const score = parseQwenScore(output, outputLower);
|
|
581
|
-
if (score !== null)
|
|
582
|
-
return score;
|
|
583
|
-
}
|
|
584
|
-
// Fallback to generic parsing
|
|
585
|
-
return parseGenericScore(output, outputLower);
|
|
586
|
-
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { IExecuteFunctions, INodeExecutionData, INodeType, INodeTypeDescription } from 'n8n-workflow';
|
|
1
|
+
import { IExecuteFunctions, ILoadOptionsFunctions, INodeExecutionData, INodePropertyOptions, INodeType, INodeTypeDescription } from 'n8n-workflow';
|
|
2
2
|
/**
|
|
3
3
|
* Ollama Reranker Workflow Node
|
|
4
4
|
*
|
|
@@ -13,6 +13,15 @@ import { IExecuteFunctions, INodeExecutionData, INodeType, INodeTypeDescription
|
|
|
13
13
|
*/
|
|
14
14
|
export declare class OllamaRerankerWorkflow implements INodeType {
|
|
15
15
|
description: INodeTypeDescription;
|
|
16
|
+
methods: {
|
|
17
|
+
loadOptions: {
|
|
18
|
+
/**
|
|
19
|
+
* Load models from Ollama/Custom Rerank API
|
|
20
|
+
* Dynamically fetches available models from /api/tags endpoint
|
|
21
|
+
*/
|
|
22
|
+
getModels(this: ILoadOptionsFunctions): Promise<INodePropertyOptions[]>;
|
|
23
|
+
};
|
|
24
|
+
};
|
|
16
25
|
/**
|
|
17
26
|
* Execute Method (NOT supplyData!)
|
|
18
27
|
*
|
|
@@ -53,53 +53,36 @@ class OllamaRerankerWorkflow {
|
|
|
53
53
|
},
|
|
54
54
|
],
|
|
55
55
|
properties: [
|
|
56
|
-
// Model selection
|
|
56
|
+
// Model selection with dynamic loading
|
|
57
57
|
{
|
|
58
58
|
displayName: 'Model',
|
|
59
59
|
name: 'model',
|
|
60
60
|
type: 'options',
|
|
61
|
+
typeOptions: {
|
|
62
|
+
loadOptionsMethod: 'getModels',
|
|
63
|
+
},
|
|
64
|
+
default: '',
|
|
65
|
+
description: 'The reranker model to use - models are loaded from your configured Ollama/Custom API',
|
|
66
|
+
},
|
|
67
|
+
// API Type selection
|
|
68
|
+
{
|
|
69
|
+
displayName: 'API Type',
|
|
70
|
+
name: 'apiType',
|
|
71
|
+
type: 'options',
|
|
61
72
|
options: [
|
|
62
73
|
{
|
|
63
|
-
name: '
|
|
64
|
-
value: '
|
|
65
|
-
description: '
|
|
66
|
-
},
|
|
67
|
-
{
|
|
68
|
-
name: 'Qwen3-Reranker-0.6B (Fast)',
|
|
69
|
-
value: 'dengcao/Qwen3-Reranker-0.6B:Q5_K_M',
|
|
70
|
-
description: 'Fastest option',
|
|
71
|
-
},
|
|
72
|
-
{
|
|
73
|
-
name: 'Qwen3-Reranker-4B (Balanced)',
|
|
74
|
-
value: 'dengcao/Qwen3-Reranker-4B:Q5_K_M',
|
|
75
|
-
description: 'Best balance',
|
|
74
|
+
name: 'Ollama Generate API',
|
|
75
|
+
value: 'ollama',
|
|
76
|
+
description: 'Standard Ollama /api/generate endpoint (for BGE, Qwen prompt-based rerankers)',
|
|
76
77
|
},
|
|
77
78
|
{
|
|
78
|
-
name: '
|
|
79
|
-
value: 'dengcao/Qwen3-Reranker-8B:Q5_K_M',
|
|
80
|
-
description: 'Highest accuracy',
|
|
81
|
-
},
|
|
82
|
-
{
|
|
83
|
-
name: 'Custom Model',
|
|
79
|
+
name: 'Custom Rerank API',
|
|
84
80
|
value: 'custom',
|
|
85
|
-
description: '
|
|
81
|
+
description: 'Custom /api/rerank endpoint (for deposium-embeddings-turbov2, etc.)',
|
|
86
82
|
},
|
|
87
83
|
],
|
|
88
|
-
default: '
|
|
89
|
-
description: '
|
|
90
|
-
},
|
|
91
|
-
{
|
|
92
|
-
displayName: 'Custom Model Name',
|
|
93
|
-
name: 'customModel',
|
|
94
|
-
type: 'string',
|
|
95
|
-
default: '',
|
|
96
|
-
placeholder: 'your-reranker-model:tag',
|
|
97
|
-
description: 'Name of your custom Ollama reranker model',
|
|
98
|
-
displayOptions: {
|
|
99
|
-
show: {
|
|
100
|
-
model: ['custom'],
|
|
101
|
-
},
|
|
102
|
-
},
|
|
84
|
+
default: 'ollama',
|
|
85
|
+
description: 'Which API endpoint to use for reranking',
|
|
103
86
|
},
|
|
104
87
|
// Query input (flexible like n8n nodes)
|
|
105
88
|
{
|
|
@@ -269,6 +252,47 @@ class OllamaRerankerWorkflow {
|
|
|
269
252
|
},
|
|
270
253
|
],
|
|
271
254
|
};
|
|
255
|
+
this.methods = {
|
|
256
|
+
loadOptions: {
|
|
257
|
+
/**
|
|
258
|
+
* Load models from Ollama/Custom Rerank API
|
|
259
|
+
* Dynamically fetches available models from /api/tags endpoint
|
|
260
|
+
*/
|
|
261
|
+
async getModels() {
|
|
262
|
+
const credentials = await this.getCredentials('ollamaApi');
|
|
263
|
+
if (!(credentials === null || credentials === void 0 ? void 0 : credentials.host)) {
|
|
264
|
+
return [];
|
|
265
|
+
}
|
|
266
|
+
const baseUrl = credentials.host.replace(/\/$/, '');
|
|
267
|
+
try {
|
|
268
|
+
const response = await this.helpers.httpRequest({
|
|
269
|
+
method: 'GET',
|
|
270
|
+
url: `${baseUrl}/api/tags`,
|
|
271
|
+
json: true,
|
|
272
|
+
timeout: 5000,
|
|
273
|
+
});
|
|
274
|
+
if (!(response === null || response === void 0 ? void 0 : response.models) || !Array.isArray(response.models)) {
|
|
275
|
+
return [];
|
|
276
|
+
}
|
|
277
|
+
// Sort models alphabetically
|
|
278
|
+
const models = response.models.sort((a, b) => {
|
|
279
|
+
const nameA = a.name || '';
|
|
280
|
+
const nameB = b.name || '';
|
|
281
|
+
return nameA.localeCompare(nameB);
|
|
282
|
+
});
|
|
283
|
+
return models.map((model) => ({
|
|
284
|
+
name: model.name,
|
|
285
|
+
value: model.name,
|
|
286
|
+
description: model.details || `Size: ${Math.round((model.size || 0) / 1024 / 1024)}MB`,
|
|
287
|
+
}));
|
|
288
|
+
}
|
|
289
|
+
catch (error) {
|
|
290
|
+
// If API call fails, return empty array (user can still type model name manually)
|
|
291
|
+
return [];
|
|
292
|
+
}
|
|
293
|
+
},
|
|
294
|
+
},
|
|
295
|
+
};
|
|
272
296
|
}
|
|
273
297
|
/**
|
|
274
298
|
* Execute Method (NOT supplyData!)
|
|
@@ -287,13 +311,12 @@ class OllamaRerankerWorkflow {
|
|
|
287
311
|
}
|
|
288
312
|
const ollamaHost = credentials.host.replace(/\/$/, '');
|
|
289
313
|
// Get model
|
|
290
|
-
|
|
291
|
-
if (model ===
|
|
292
|
-
|
|
293
|
-
if (!(model === null || model === void 0 ? void 0 : model.trim())) {
|
|
294
|
-
throw new n8n_workflow_1.NodeOperationError(this.getNode(), 'Custom model name is required');
|
|
295
|
-
}
|
|
314
|
+
const model = this.getNodeParameter('model', 0);
|
|
315
|
+
if (!(model === null || model === void 0 ? void 0 : model.trim())) {
|
|
316
|
+
throw new n8n_workflow_1.NodeOperationError(this.getNode(), 'Model selection is required. Please select a model from the dropdown.');
|
|
296
317
|
}
|
|
318
|
+
// Get API type
|
|
319
|
+
const apiType = this.getNodeParameter('apiType', 0, 'ollama');
|
|
297
320
|
// Get common parameters
|
|
298
321
|
const instruction = this.getNodeParameter('instruction', 0);
|
|
299
322
|
const topK = this.getNodeParameter('topK', 0);
|
|
@@ -380,6 +403,7 @@ class OllamaRerankerWorkflow {
|
|
|
380
403
|
batchSize,
|
|
381
404
|
timeout,
|
|
382
405
|
includeOriginalScores,
|
|
406
|
+
apiType,
|
|
383
407
|
});
|
|
384
408
|
// Format output
|
|
385
409
|
let output;
|
|
@@ -11,9 +11,10 @@ export interface RerankConfig {
|
|
|
11
11
|
batchSize: number;
|
|
12
12
|
timeout: number;
|
|
13
13
|
includeOriginalScores: boolean;
|
|
14
|
+
apiType?: 'ollama' | 'custom';
|
|
14
15
|
}
|
|
15
16
|
/**
|
|
16
|
-
* Rerank documents using Ollama reranker model
|
|
17
|
+
* Rerank documents using Ollama reranker model or Custom Rerank API
|
|
17
18
|
*/
|
|
18
19
|
export declare function rerankDocuments(context: RerankerContext, config: RerankConfig): Promise<any[]>;
|
|
19
20
|
export {};
|
|
@@ -3,10 +3,15 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.rerankDocuments = rerankDocuments;
|
|
4
4
|
const n8n_workflow_1 = require("n8n-workflow");
|
|
5
5
|
/**
|
|
6
|
-
* Rerank documents using Ollama reranker model
|
|
6
|
+
* Rerank documents using Ollama reranker model or Custom Rerank API
|
|
7
7
|
*/
|
|
8
8
|
async function rerankDocuments(context, config) {
|
|
9
|
-
const { ollamaHost, model, query, documents, instruction, topK, threshold, batchSize, timeout, includeOriginalScores } = config;
|
|
9
|
+
const { ollamaHost, model, query, documents, instruction, topK, threshold, batchSize, timeout, includeOriginalScores, apiType = 'ollama' } = config;
|
|
10
|
+
// Use Custom Rerank API if specified
|
|
11
|
+
if (apiType === 'custom') {
|
|
12
|
+
return await rerankWithCustomAPI(context, config);
|
|
13
|
+
}
|
|
14
|
+
// Otherwise use Ollama Generate API (original logic)
|
|
10
15
|
const results = [];
|
|
11
16
|
// Process all documents concurrently with controlled concurrency
|
|
12
17
|
const promises = [];
|
|
@@ -43,6 +48,77 @@ async function rerankDocuments(context, config) {
|
|
|
43
48
|
return rerankedDoc;
|
|
44
49
|
});
|
|
45
50
|
}
|
|
51
|
+
/**
|
|
52
|
+
* Rerank documents using Custom Rerank API (/api/rerank endpoint)
|
|
53
|
+
* This is for services like deposium-embeddings-turbov2 that implement
|
|
54
|
+
* a custom /api/rerank endpoint with direct cosine similarity scoring
|
|
55
|
+
*/
|
|
56
|
+
async function rerankWithCustomAPI(context, config) {
|
|
57
|
+
var _a, _b;
|
|
58
|
+
const { ollamaHost, model, query, documents, topK, threshold, timeout, includeOriginalScores } = config;
|
|
59
|
+
try {
|
|
60
|
+
// Extract document content as strings
|
|
61
|
+
const documentStrings = documents.map(doc => doc.pageContent || JSON.stringify(doc));
|
|
62
|
+
// Call /api/rerank endpoint
|
|
63
|
+
const response = await context.helpers.httpRequest({
|
|
64
|
+
method: 'POST',
|
|
65
|
+
url: `${ollamaHost}/api/rerank`,
|
|
66
|
+
headers: {
|
|
67
|
+
'Content-Type': 'application/json',
|
|
68
|
+
Accept: 'application/json',
|
|
69
|
+
},
|
|
70
|
+
body: {
|
|
71
|
+
model,
|
|
72
|
+
query,
|
|
73
|
+
documents: documentStrings,
|
|
74
|
+
top_k: topK, // Custom API handles top_k filtering
|
|
75
|
+
},
|
|
76
|
+
json: true,
|
|
77
|
+
timeout,
|
|
78
|
+
});
|
|
79
|
+
// Parse response: { model: "...", results: [{index, document, relevance_score}] }
|
|
80
|
+
if (!(response === null || response === void 0 ? void 0 : response.results) || !Array.isArray(response.results)) {
|
|
81
|
+
throw new n8n_workflow_1.NodeApiError(context.getNode(), response, {
|
|
82
|
+
message: 'Invalid response from Custom Rerank API',
|
|
83
|
+
description: `Expected {results: [...]} but got: ${JSON.stringify(response)}`,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
// Filter by threshold and map to our format
|
|
87
|
+
const filteredResults = response.results
|
|
88
|
+
.filter((r) => r.relevance_score >= threshold)
|
|
89
|
+
.map((result) => {
|
|
90
|
+
const originalDoc = documents[result.index];
|
|
91
|
+
const rerankedDoc = {
|
|
92
|
+
...originalDoc,
|
|
93
|
+
_rerankScore: result.relevance_score,
|
|
94
|
+
_originalIndex: result.index,
|
|
95
|
+
};
|
|
96
|
+
if (includeOriginalScores && originalDoc._originalScore !== undefined) {
|
|
97
|
+
rerankedDoc._originalScore = originalDoc._originalScore;
|
|
98
|
+
}
|
|
99
|
+
return rerankedDoc;
|
|
100
|
+
});
|
|
101
|
+
return filteredResults;
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
if (((_a = error === null || error === void 0 ? void 0 : error.response) === null || _a === void 0 ? void 0 : _a.statusCode) === 404) {
|
|
105
|
+
throw new n8n_workflow_1.NodeApiError(context.getNode(), error, {
|
|
106
|
+
message: 'Custom Rerank API endpoint not found',
|
|
107
|
+
description: `The /api/rerank endpoint was not found at ${ollamaHost}.\nMake sure you're using a service that supports this endpoint (like deposium-embeddings-turbov2).`,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
if ((_b = error === null || error === void 0 ? void 0 : error.response) === null || _b === void 0 ? void 0 : _b.body) {
|
|
111
|
+
throw new n8n_workflow_1.NodeApiError(context.getNode(), error, {
|
|
112
|
+
message: `Custom Rerank API Error (${error.response.statusCode})`,
|
|
113
|
+
description: `Endpoint: ${ollamaHost}/api/rerank\nModel: ${model}\nResponse: ${JSON.stringify(error.response.body, null, 2)}`,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
throw new n8n_workflow_1.NodeApiError(context.getNode(), error, {
|
|
117
|
+
message: 'Custom Rerank API request failed',
|
|
118
|
+
description: `Endpoint: ${ollamaHost}/api/rerank\nModel: ${model}\nError: ${error.message}`,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
}
|
|
46
122
|
/**
|
|
47
123
|
* Score a single document against the query using Ollama reranker model with retry logic
|
|
48
124
|
*/
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "n8n-nodes-ollama-reranker",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "Ollama Reranker for n8n -
|
|
3
|
+
"version": "1.3.0",
|
|
4
|
+
"description": "Ollama Reranker for n8n - Dynamic model loading + Ollama/Custom API support (Vector Store provider + workflow node)",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"author": "Gabriel BRUMENT",
|
|
7
7
|
"license": "MIT",
|