@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.
@@ -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": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.