@taodigital/reranker 0.0.0-stage → 1.2.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/index.d.ts +68 -0
- package/dist/index.js +189 -0
- package/package.json +24 -4
- package/README.md +0 -3
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-Encoder Semantic Re-ranker
|
|
3
|
+
*
|
|
4
|
+
* Uses Xenova/ms-marco-MiniLM-L-6-v2 (22MB ONNX, int8 quantised) to
|
|
5
|
+
* re-rank BM25 candidates by semantic relevance. Zero API cost — runs
|
|
6
|
+
* entirely in-process via ONNX Runtime.
|
|
7
|
+
*
|
|
8
|
+
* Architecture:
|
|
9
|
+
* BM25 (PostgreSQL ts_rank) → top-25 candidates
|
|
10
|
+
* → Cross-Encoder scores all 25 (query, chunk) pairs
|
|
11
|
+
* → Returns top-8 by semantic relevance
|
|
12
|
+
*
|
|
13
|
+
* Graceful fallback: if ONNX model unavailable (first cold start, no disk),
|
|
14
|
+
* returns BM25 order unchanged. Zero degradation.
|
|
15
|
+
*
|
|
16
|
+
* NOTE: Reranker is a standalone service without @taodigital/core — reads tuning
|
|
17
|
+
* values from process.env. Configure via RERANKER_* env vars or the
|
|
18
|
+
* RerankerConfig passed to initialize(). To tune from admin UI, set
|
|
19
|
+
* ENV_FALLBACKS in the admin panel and restart the service.
|
|
20
|
+
*/
|
|
21
|
+
export interface RerankerConfig {
|
|
22
|
+
modelId?: string;
|
|
23
|
+
cacheDir?: string;
|
|
24
|
+
cacheDirAllowlist?: string[];
|
|
25
|
+
numThreads?: number;
|
|
26
|
+
minScore?: number;
|
|
27
|
+
inputMaxChars?: number;
|
|
28
|
+
defaultTopK?: number;
|
|
29
|
+
defaultTopKStrings?: number;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Configure the reranker before first use. All fields are optional — omitted
|
|
33
|
+
* fields fall back to environment variables or built-in defaults, preserving
|
|
34
|
+
* existing behaviour when called with no arguments.
|
|
35
|
+
*/
|
|
36
|
+
export declare function initialize(config?: RerankerConfig): void;
|
|
37
|
+
export interface RerankChunk {
|
|
38
|
+
heading: string;
|
|
39
|
+
sourceType: string;
|
|
40
|
+
sourceId: string;
|
|
41
|
+
content: string;
|
|
42
|
+
bm25Score?: number;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Re-rank BM25 candidates by cross-encoder semantic relevance.
|
|
46
|
+
*
|
|
47
|
+
* @param query The user's search string.
|
|
48
|
+
* @param candidates BM25 top-K (typically 20–25).
|
|
49
|
+
* @param topK How many to return after re-ranking (default 8).
|
|
50
|
+
*/
|
|
51
|
+
export declare function rerank(query: string, candidates: RerankChunk[], topK?: number): Promise<RerankChunk[]>;
|
|
52
|
+
/**
|
|
53
|
+
* Re-rank arbitrary string texts by relevance to a query.
|
|
54
|
+
* Returns scored results sorted highest-first (indices into original array).
|
|
55
|
+
* Use for requirement similarity, trace link suggestions, defect dedup — anywhere
|
|
56
|
+
* you need semantic ranking of plain strings without the RerankChunk structure.
|
|
57
|
+
*
|
|
58
|
+
* @param query The reference text (TC title, requirement description, search string).
|
|
59
|
+
* @param texts Array of candidate texts to rank against the query.
|
|
60
|
+
* @param topK How many to return (default 5).
|
|
61
|
+
* @param minScore Minimum relevance score to include (default MIN_SCORE env var).
|
|
62
|
+
*/
|
|
63
|
+
export declare function rerankStrings(query: string, texts: string[], topK?: number, minScore?: number): Promise<Array<{
|
|
64
|
+
index: number;
|
|
65
|
+
score: number;
|
|
66
|
+
}>>;
|
|
67
|
+
/** Warm up the model on server start (non-blocking). */
|
|
68
|
+
export declare function warmup(): void;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-Encoder Semantic Re-ranker
|
|
3
|
+
*
|
|
4
|
+
* Uses Xenova/ms-marco-MiniLM-L-6-v2 (22MB ONNX, int8 quantised) to
|
|
5
|
+
* re-rank BM25 candidates by semantic relevance. Zero API cost — runs
|
|
6
|
+
* entirely in-process via ONNX Runtime.
|
|
7
|
+
*
|
|
8
|
+
* Architecture:
|
|
9
|
+
* BM25 (PostgreSQL ts_rank) → top-25 candidates
|
|
10
|
+
* → Cross-Encoder scores all 25 (query, chunk) pairs
|
|
11
|
+
* → Returns top-8 by semantic relevance
|
|
12
|
+
*
|
|
13
|
+
* Graceful fallback: if ONNX model unavailable (first cold start, no disk),
|
|
14
|
+
* returns BM25 order unchanged. Zero degradation.
|
|
15
|
+
*
|
|
16
|
+
* NOTE: Reranker is a standalone service without @taodigital/core — reads tuning
|
|
17
|
+
* values from process.env. Configure via RERANKER_* env vars or the
|
|
18
|
+
* RerankerConfig passed to initialize(). To tune from admin UI, set
|
|
19
|
+
* ENV_FALLBACKS in the admin panel and restart the service.
|
|
20
|
+
*/
|
|
21
|
+
import { resolve, isAbsolute } from 'path';
|
|
22
|
+
let _config = {};
|
|
23
|
+
let _pipe = null;
|
|
24
|
+
let _loadingPromise = null;
|
|
25
|
+
let _failed = false;
|
|
26
|
+
/**
|
|
27
|
+
* Validate that a model cache/path stays within an allowlisted base directory,
|
|
28
|
+
* preventing directory traversal via a misconfigured MODEL_CACHE_DIR env var.
|
|
29
|
+
*/
|
|
30
|
+
function validateModelCacheDir(dir, allowlist) {
|
|
31
|
+
const resolved = isAbsolute(dir) ? resolve(dir) : resolve(process.cwd(), dir);
|
|
32
|
+
const isAllowed = allowlist.some((base) => resolved === base || resolved.startsWith(base + '/'));
|
|
33
|
+
if (!isAllowed) {
|
|
34
|
+
throw new Error(`[reranker] MODEL_CACHE_DIR "${resolved}" is not within an allowed directory. ` +
|
|
35
|
+
`Allowed: ${allowlist.join(', ')}`);
|
|
36
|
+
}
|
|
37
|
+
return resolved;
|
|
38
|
+
}
|
|
39
|
+
const DEFAULT_CACHE_ALLOWLIST = [
|
|
40
|
+
process.cwd(),
|
|
41
|
+
'/app/.model-cache',
|
|
42
|
+
'/models',
|
|
43
|
+
'/app/models',
|
|
44
|
+
];
|
|
45
|
+
function resolvedAllowlist() {
|
|
46
|
+
const envExtra = process.env.RERANKER_CACHE_ALLOWLIST
|
|
47
|
+
? process.env.RERANKER_CACHE_ALLOWLIST.split(':').filter(Boolean)
|
|
48
|
+
: [];
|
|
49
|
+
return (_config.cacheDirAllowlist ?? [...DEFAULT_CACHE_ALLOWLIST, ...envExtra]).map((d) => resolve(d));
|
|
50
|
+
}
|
|
51
|
+
function resolvedCacheDir() {
|
|
52
|
+
const raw = _config.cacheDir ?? process.env.MODEL_CACHE_DIR ?? '/app/.model-cache';
|
|
53
|
+
return validateModelCacheDir(raw, resolvedAllowlist());
|
|
54
|
+
}
|
|
55
|
+
function resolvedMinScore() {
|
|
56
|
+
if (_config.minScore !== undefined)
|
|
57
|
+
return _config.minScore;
|
|
58
|
+
return parseFloat(process.env.RERANKER_MIN_SCORE ?? '0.1');
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Configure the reranker before first use. All fields are optional — omitted
|
|
62
|
+
* fields fall back to environment variables or built-in defaults, preserving
|
|
63
|
+
* existing behaviour when called with no arguments.
|
|
64
|
+
*/
|
|
65
|
+
export function initialize(config = {}) {
|
|
66
|
+
if (_pipe || _loadingPromise) {
|
|
67
|
+
throw new Error('[reranker] initialize() must be called before the pipeline is loaded.');
|
|
68
|
+
}
|
|
69
|
+
_config = config;
|
|
70
|
+
}
|
|
71
|
+
async function loadPipeline() {
|
|
72
|
+
try {
|
|
73
|
+
const { pipeline, env } = await import('@xenova/transformers');
|
|
74
|
+
env.cacheDir = resolvedCacheDir();
|
|
75
|
+
env.allowLocalModels = true;
|
|
76
|
+
env.allowRemoteModels = true;
|
|
77
|
+
const numThreads = _config.numThreads ?? parseInt(process.env.RERANKER_NUM_THREADS ?? '2', 10);
|
|
78
|
+
env.backends = { onnx: { wasm: { numThreads } } };
|
|
79
|
+
const modelId = _config.modelId ?? process.env.RERANKER_MODEL_ID ?? 'Xenova/ms-marco-MiniLM-L-6-v2';
|
|
80
|
+
console.log('[reranker] Loading cross-encoder...');
|
|
81
|
+
const loaded = await pipeline('text-classification', modelId, { quantized: true });
|
|
82
|
+
console.log('[reranker] Cross-encoder ready ✓');
|
|
83
|
+
_pipe = loaded;
|
|
84
|
+
return loaded;
|
|
85
|
+
}
|
|
86
|
+
catch (err) {
|
|
87
|
+
console.warn('[reranker] Cross-encoder unavailable — BM25 order preserved:', err.message);
|
|
88
|
+
_failed = true;
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
async function getPipeline() {
|
|
93
|
+
if (_pipe)
|
|
94
|
+
return _pipe;
|
|
95
|
+
if (_failed)
|
|
96
|
+
return null;
|
|
97
|
+
if (!_loadingPromise)
|
|
98
|
+
_loadingPromise = loadPipeline();
|
|
99
|
+
return _loadingPromise;
|
|
100
|
+
}
|
|
101
|
+
/** Score an array of (query, text) pairs using the cross-encoder. Returns raw scores. */
|
|
102
|
+
async function scoreAll(query, texts) {
|
|
103
|
+
const pipe = await getPipeline();
|
|
104
|
+
if (!pipe)
|
|
105
|
+
return texts.map(() => 0);
|
|
106
|
+
const maxChars = _config.inputMaxChars ?? parseInt(process.env.RERANKER_INPUT_MAX_CHARS ?? '512', 10);
|
|
107
|
+
const pairs = texts.map((t) => ({ text: query, text_pair: t.slice(0, maxChars) }));
|
|
108
|
+
const results = await pipe(pairs, { topk: null });
|
|
109
|
+
return results.map((res, _i) => {
|
|
110
|
+
const r = Array.isArray(res) ? res : [res];
|
|
111
|
+
return r.find((x) => x.label === 'LABEL_1')?.score
|
|
112
|
+
?? r.find((x) => x.label !== 'LABEL_0')?.score
|
|
113
|
+
?? r[0]?.score
|
|
114
|
+
?? 0;
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Re-rank BM25 candidates by cross-encoder semantic relevance.
|
|
119
|
+
*
|
|
120
|
+
* @param query The user's search string.
|
|
121
|
+
* @param candidates BM25 top-K (typically 20–25).
|
|
122
|
+
* @param topK How many to return after re-ranking (default 8).
|
|
123
|
+
*/
|
|
124
|
+
export async function rerank(query, candidates, topK) {
|
|
125
|
+
const k = topK ?? _config.defaultTopK ?? 8;
|
|
126
|
+
if (candidates.length === 0)
|
|
127
|
+
return [];
|
|
128
|
+
if (candidates.length < 2)
|
|
129
|
+
return candidates;
|
|
130
|
+
const pipe = await getPipeline();
|
|
131
|
+
if (!pipe)
|
|
132
|
+
return candidates.slice(0, k);
|
|
133
|
+
try {
|
|
134
|
+
const t0 = Date.now();
|
|
135
|
+
const texts = candidates.map((c) => `${c.heading}: ${c.content}`);
|
|
136
|
+
const scores = await scoreAll(query, texts);
|
|
137
|
+
const minScore = resolvedMinScore();
|
|
138
|
+
const reranked = candidates
|
|
139
|
+
.map((chunk, i) => ({ chunk, score: scores[i] }))
|
|
140
|
+
.filter((s) => s.score >= minScore)
|
|
141
|
+
.sort((a, b) => b.score - a.score)
|
|
142
|
+
.slice(0, k)
|
|
143
|
+
.map((s) => s.chunk);
|
|
144
|
+
const result = reranked.length > 0 ? reranked : candidates.slice(0, k);
|
|
145
|
+
console.log(`[reranker] Reranked ${candidates.length}→${result.length} in ${Date.now() - t0}ms`);
|
|
146
|
+
return result;
|
|
147
|
+
}
|
|
148
|
+
catch (err) {
|
|
149
|
+
console.warn('[reranker] Inference error — returning BM25 order:', err.message);
|
|
150
|
+
return candidates.slice(0, k);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Re-rank arbitrary string texts by relevance to a query.
|
|
155
|
+
* Returns scored results sorted highest-first (indices into original array).
|
|
156
|
+
* Use for requirement similarity, trace link suggestions, defect dedup — anywhere
|
|
157
|
+
* you need semantic ranking of plain strings without the RerankChunk structure.
|
|
158
|
+
*
|
|
159
|
+
* @param query The reference text (TC title, requirement description, search string).
|
|
160
|
+
* @param texts Array of candidate texts to rank against the query.
|
|
161
|
+
* @param topK How many to return (default 5).
|
|
162
|
+
* @param minScore Minimum relevance score to include (default MIN_SCORE env var).
|
|
163
|
+
*/
|
|
164
|
+
export async function rerankStrings(query, texts, topK, minScore) {
|
|
165
|
+
const k = topK ?? _config.defaultTopKStrings ?? 5;
|
|
166
|
+
const threshold = minScore ?? resolvedMinScore();
|
|
167
|
+
if (texts.length === 0)
|
|
168
|
+
return [];
|
|
169
|
+
const pipe = await getPipeline();
|
|
170
|
+
if (!pipe) {
|
|
171
|
+
return texts.slice(0, k).map((_, i) => ({ index: i, score: 0 }));
|
|
172
|
+
}
|
|
173
|
+
try {
|
|
174
|
+
const scores = await scoreAll(query, texts);
|
|
175
|
+
return scores
|
|
176
|
+
.map((score, index) => ({ index, score }))
|
|
177
|
+
.filter((s) => s.score >= threshold)
|
|
178
|
+
.sort((a, b) => b.score - a.score)
|
|
179
|
+
.slice(0, k);
|
|
180
|
+
}
|
|
181
|
+
catch (err) {
|
|
182
|
+
console.warn('[reranker] rerankStrings error — returning original order:', err.message);
|
|
183
|
+
return texts.slice(0, k).map((_, i) => ({ index: i, score: 0 }));
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
/** Warm up the model on server start (non-blocking). */
|
|
187
|
+
export function warmup() {
|
|
188
|
+
getPipeline().catch(() => { });
|
|
189
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,26 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@taodigital/reranker",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.2.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"publishConfig": {
|
|
6
|
+
"access": "public",
|
|
7
|
+
"registry": "https://registry.npmjs.org/"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist"
|
|
11
|
+
],
|
|
12
|
+
"description": "Cross-encoder semantic re-ranking for knowledge search",
|
|
13
|
+
"type": "module",
|
|
14
|
+
"main": "dist/index.js",
|
|
15
|
+
"types": "dist/index.d.ts",
|
|
16
|
+
"scripts": {
|
|
17
|
+
"build": "tsc",
|
|
18
|
+
"dev": "tsc --watch"
|
|
19
|
+
},
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"@xenova/transformers": "^2.17.2"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"typescript": "^5.5.0"
|
|
25
|
+
}
|
|
26
|
+
}
|
package/README.md
DELETED