md-semantic-search 0.3.0 → 0.4.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,186 @@
1
+ // @ts-check
2
+ /**
3
+ * Retrieval evaluation metrics (issue #56).
4
+ *
5
+ * Pure, dependency-free ranking metrics used by the golden-set harness. They
6
+ * operate on a list of ranked hits plus graded relevance judgements so the
7
+ * same functions work for chunk-level and document-level evaluation.
8
+ *
9
+ * Relevance judgements use the graded scale from the benchmark design:
10
+ * 0 = irrelevant, 1 = related, 2 = useful support, 3 = direct answer.
11
+ * For metrics that need binary relevance (`MRR`, `Hit@k`, `Recall@k`) a grade
12
+ * threshold separates relevant from irrelevant. The default threshold is > 0
13
+ * (any non-zero grade counts as relevant); pass `minRelevant` to require, e.g.
14
+ * grade >= 2 for stricter precision-oriented slices.
15
+ */
16
+
17
+ /**
18
+ * @typedef {Object} BenchHit
19
+ * @property {string} file - document id of the hit (canonical identity)
20
+ *
21
+ * @typedef {Object} GradeConf
22
+ * @property {number[]} [grades] - per-document top grade: index i is the best
23
+ * grade over all judgements for doc i (default: []).
24
+ * @property {number} [minRelevant=1] - minimum grade that counts as relevant.
25
+ */
26
+
27
+ /**
28
+ * nDCG@k (normalized discounted cumulative gain) with graded relevance.
29
+ * Uses the standard log2 discount (position 1 has no discount).
30
+ * @param {string[]} ranked - ranked document ids (hits, best first)
31
+ * @param {(doc:string, rankIndex:number)=>number} grade - grade(doc, idx) >= 0
32
+ * @param {number} k
33
+ * @returns {number} in [0, 1]; 1 = perfect ordering at the top-k.
34
+ */
35
+ export function ndcgAtK(ranked, grade, k) {
36
+ const top = ranked.slice(0, k);
37
+ if (top.length === 0) return 0;
38
+ let dcg = 0;
39
+ for (let i = 0; i < top.length; i++) {
40
+ const g = grade(top[i], i);
41
+ if (g <= 0) continue;
42
+ dcg += g / Math.log2(i + 2); // log2(1+1)=1 for rank 0, log2(3) for rank 1, ...
43
+ }
44
+ if (dcg === 0) return 0;
45
+ // Ideal DCG: sort all relevant grades (any doc) descending, take top-k.
46
+ const all = (ranked ?? []).map((doc, i) => grade(doc, i)).filter((g) => g > 0);
47
+ const ideal = [...all].sort((a, b) => b - a).slice(0, k);
48
+ let idcg = 0;
49
+ for (let i = 0; i < ideal.length; i++) idcg += ideal[i] / Math.log2(i + 2);
50
+ return idcg === 0 ? 0 : dcg / idcg;
51
+ }
52
+
53
+ /**
54
+ * Reciprocal rank of the first relevant doc (MRR contribution for one query).
55
+ * @param {string[]} ranked
56
+ * @param {(doc:string)=>boolean} isRelevant
57
+ * @returns {number} 1/rank if a relevant doc is found, else 0.
58
+ */
59
+ export function reciprocalRank(ranked, isRelevant) {
60
+ for (let i = 0; i < ranked.length; i++) {
61
+ if (isRelevant(ranked[i])) return 1 / (i + 1);
62
+ }
63
+ return 0;
64
+ }
65
+
66
+ /**
67
+ * Hit@k — 1 if any relevant doc appears in the top-k.
68
+ * @param {string[]} ranked
69
+ * @param {(doc:string)=>boolean} isRelevant
70
+ * @param {number} k
71
+ * @returns {0|1}
72
+ */
73
+ export function hitAtK(ranked, isRelevant, k) {
74
+ return ranked.slice(0, k).some(isRelevant) ? 1 : 0;
75
+ }
76
+
77
+ /**
78
+ * Recall@k — fraction of ALL relevant docs that appear in the top-k.
79
+ * @param {string[]} ranked
80
+ * @param {(doc:string)=>boolean} isRelevant
81
+ * @param {number} k
82
+ * @returns {number} in [0, 1].
83
+ */
84
+ export function recallAtK(ranked, isRelevant, k) {
85
+ const relevantCount = (ranked ?? []).filter(isRelevant).length;
86
+ if (relevantCount === 0) return 0;
87
+ const retrieved = ranked.slice(0, k).filter(isRelevant).length;
88
+ return retrieved / relevantCount;
89
+ }
90
+
91
+ /**
92
+ * Build a top-grade helper: for each doc, the highest grade across qrels.
93
+ * @param {Record<string, number>} docGrades - doc id -> best grade observed.
94
+ * @returns {(doc:string)=>number}
95
+ */
96
+ export function gradeByDoc(docGrades) {
97
+ /** @type {Record<string, number>} */
98
+ const table = Object.create(null);
99
+ for (const [doc, g] of Object.entries(docGrades)) {
100
+ table[doc] = g;
101
+ }
102
+ return (doc) => table[doc] ?? 0;
103
+ }
104
+
105
+ /**
106
+ * Convenience: compute the core aggregate metrics for one query in one pass.
107
+ * @param {string[]} ranked - ranked doc ids, best first (top-N as returned).
108
+ * @param {Record<string, number>} docGrades - doc->top grade (graded qrels).
109
+ * @param {object} [opts]
110
+ * @param {number} [opts.k=10]
111
+ * @param {number} [opts.minRelevant=1]
112
+ * @param {string[]} [opts.allRelevant] - all relevant doc ids (for Recall when
113
+ * the ranked list is truncated at k). Defaults to the keys of docGrades with
114
+ * grade >= minRelevant.
115
+ * @returns {{ndcg:number, mrr:number, hit:0|1, recall:number}}
116
+ */
117
+ export function queryMetrics(ranked, docGrades, opts = {}) {
118
+ const { k = 10, minRelevant = 1 } = opts;
119
+ const grade = gradeByDoc(docGrades);
120
+ const isRelevant = (doc) => grade(doc) >= minRelevant;
121
+ const allRelevant = opts.allRelevant ??
122
+ Object.entries(docGrades).filter(([, g]) => g >= minRelevant).map(([d]) => d);
123
+ // Recall needs the FULL ranked set; when truncated, patch with allRelevant.
124
+ const fullRanked = ranked.length >= k ? ranked : [...ranked, ...allRelevant.filter((d) => !ranked.includes(d))];
125
+ return {
126
+ ndcg: ndcgAtK(ranked, grade, k),
127
+ mrr: reciprocalRank(ranked, isRelevant),
128
+ hit: hitAtK(ranked, isRelevant, k),
129
+ recall: recallAtK(fullRanked, isRelevant, k),
130
+ };
131
+ }
132
+
133
+ /**
134
+ * Aggregate a list of per-query metric objects into means + win/tie/loss.
135
+ * @param {Array<{ndcg:number, mrr:number, hit:0|1, recall:number}>} qm
136
+ * @returns {{mean:{ndcg:number, mrr:number, hit:number, recall:number}, n:number}}
137
+ */
138
+ export function aggregateMetrics(qm) {
139
+ const n = qm.length;
140
+ if (n === 0) return { mean: { ndcg: 0, mrr: 0, hit: 0, recall: 0 }, n: 0 };
141
+ const sum = (key) => qm.reduce((acc, m) => acc + m[key], 0) / n;
142
+ return {
143
+ n,
144
+ mean: { ndcg: sum('ndcg'), mrr: sum('mrr'), hit: sum('hit'), recall: sum('recall') },
145
+ };
146
+ }
147
+
148
+ /**
149
+ * Win/tie/loss between two per-query metric series (paired comparison).
150
+ * Uses nDCG@10 as the primary comparison metric.
151
+ * @param {Array<{ndcg:number}>} a
152
+ * @param {Array<{ndcg:number}>} b
153
+ * @param {number} [epsilon=1e-6]
154
+ * @returns {{win:number, tie:number, loss:number}}
155
+ */
156
+ export function winTieLoss(a, b, epsilon = 1e-6) {
157
+ let win = 0, tie = 0, loss = 0;
158
+ for (let i = 0; i < Math.min(a.length, b.length); i++) {
159
+ const d = a[i].ndcg - b[i].ndcg;
160
+ if (d > epsilon) win++;
161
+ else if (d < -epsilon) loss++;
162
+ else tie++;
163
+ }
164
+ return { win, tie, loss };
165
+ }
166
+
167
+ /**
168
+ * Document-level redundancy fraction over the top-k hits: the proportion of
169
+ * top-k hits whose file id duplicates an EARLIER top-k hit's file. A value of
170
+ * 0 means every top-k hit comes from a distinct file; 1 means every hit after
171
+ * the first reuses an already-seen source file.
172
+ * @param {Array<{file:string}>} ranked - ranked hits, best first.
173
+ * @param {number} k - window size.
174
+ * @returns {number} in [0, 1]; 0 when no hits are present.
175
+ */
176
+ export function redundancyAtK(ranked, k) {
177
+ const seen = new Set();
178
+ let redundant = 0;
179
+ const counted = ranked.slice(0, k);
180
+ for (let i = 0; i < counted.length; i++) {
181
+ const doc = counted[i].file;
182
+ if (seen.has(doc)) redundant++;
183
+ else seen.add(doc);
184
+ }
185
+ return counted.length === 0 ? 0 : redundant / counted.length;
186
+ }
package/src/models.mjs CHANGED
@@ -1,67 +1,299 @@
1
+ // @ts-check
1
2
  /**
2
- * Embedding model registry.
3
+ * Embedding model adapter registry (issue #60).
3
4
  *
4
- * `prefix` matters: the E5 family is trained with instruction prefixes
5
- * ("query: " / "passage: ") and ranks poorly without them. BGE-M3 is trained
6
- * WITHOUT prefixes, so forcing them in would hurt. Each entry declares how to
7
- * format inputs so the rest of the code stays model-agnostic.
5
+ * Each registered model declares an EXPLICIT adapter that makes preprocessing
6
+ * (query/document formatting), pooling, normalization, runtime dtype, token
7
+ * budget, and dimensions auditable. The shared `embed()` path no longer infers
8
+ * any of this from the model name — it reads the adapter fields, so adding a
9
+ * heterogeneous family (e.g. Qwen3 with instruction queries + last-token
10
+ * pooling) cannot silently produce wrong vectors.
11
+ *
12
+ * The adapter fingerprint hashes exactly the fields that change the produced
13
+ * vectors (formatting, pooling, normalization, dimension); model weights
14
+ * (id@revision) remain a separate identity. Unknown raw Hugging Face ids no
15
+ * longer silently inherit E5-style `query:`/`passage:` prefixes and mean
16
+ * pooling — they resolve to a neutral descriptor that FAILS at embed time with
17
+ * an actionable message until an explicit adapter is provided.
18
+ */
19
+ import crypto from 'node:crypto';
20
+
21
+ /**
22
+ * @typedef {'mean'|'last_token'|'none'} Pooling
23
+ *
24
+ * Runtime ONNX data type for the Transformers.js pipeline load. A subset of
25
+ * the valid dtype strings; `q8` is the project default (quantized weights).
26
+ * @typedef {'q8'|'q4'|'fp16'|'fp32'|'int8'|'uint8'} DType
27
+ *
28
+ * @typedef {Object} ModelAdapter
29
+ * @property {string} id - HF repo id (e.g. "Xenova/multilingual-e5-base")
30
+ * @property {string} [revision] - pinned revision (default "main")
31
+ * @property {number} nativeDim - native embedding dimension (>= 0 to infer)
32
+ * @property {number} dim - canonical embedding dimension; alias of nativeDim
33
+ * for resolved adapters (legacy consumers read `dim`)
34
+ * @property {number[]} [dimensions] - allowed output dimensions (MRL policy);
35
+ * always implicitly includes nativeDim when present
36
+ * @property {string} queryPrefix - text prepended to every query ('' = none)
37
+ * @property {string} passagePrefix - text prepended to every passage ('' = none)
38
+ * @property {Pooling} pooling - token pooling strategy
39
+ * @property {boolean} [normalize=true] - whether vectors are L2-normalized
40
+ * @property {DType} [dtype] - runtime ONNX dtype (default 'q8')
41
+ * @property {number} [maxTokens] - declared tokenizer budget (context window)
42
+ * @property {Record<string, unknown>} [sessionOptions] - ONNX Runtime session options (e.g. intraOpNumThreads)
43
+ * @property {string} [family] - formatting/pooling family key exposed by
44
+ * `mdss stats`/`models` (e.g. 'e5', 'bge', 'qwen3'); NOT used for routing
45
+ * @property {string} [note] - human-readable description
46
+ * @property {boolean} [unknownAdapter] - true only for unconfigured raw ids;
47
+ * such adapters are unusable for embedding until explicitly configured
48
+ */
49
+
50
+ /**
51
+ * Registered adapters keyed by CLI/library alias.
52
+ * @type {Record<string, ModelAdapter>}
8
53
  */
9
54
  export const MODELS = {
10
55
  'e5-small': {
11
56
  id: 'Xenova/multilingual-e5-small',
57
+ nativeDim: 384,
12
58
  dim: 384,
13
59
  queryPrefix: 'query: ',
14
60
  passagePrefix: 'passage: ',
61
+ pooling: 'mean',
62
+ normalize: true,
63
+ dtype: 'q8',
64
+ maxTokens: 512,
65
+ family: 'e5',
15
66
  note: 'Fastest, ~120MB. Weak cross-lingual ranking — see RESEARCH.md.',
16
67
  },
17
68
  'e5-base': {
18
69
  id: 'Xenova/multilingual-e5-base',
70
+ nativeDim: 768,
19
71
  dim: 768,
20
72
  queryPrefix: 'query: ',
21
73
  passagePrefix: 'passage: ',
74
+ pooling: 'mean',
75
+ normalize: true,
76
+ dtype: 'q8',
77
+ maxTokens: 512,
78
+ family: 'e5',
22
79
  note: 'Default. ~280MB. Solid multilingual + cross-lingual balance.',
23
80
  },
24
81
  'e5-large': {
25
82
  id: 'Xenova/multilingual-e5-large',
83
+ nativeDim: 1024,
26
84
  dim: 1024,
27
85
  queryPrefix: 'query: ',
28
86
  passagePrefix: 'passage: ',
87
+ pooling: 'mean',
88
+ normalize: true,
89
+ dtype: 'q8',
90
+ maxTokens: 512,
91
+ family: 'e5',
29
92
  note: '~2.2GB. Higher quality, slower.',
30
93
  },
31
94
  'bge-m3': {
32
95
  id: 'Xenova/bge-m3',
96
+ nativeDim: 1024,
33
97
  dim: 1024,
34
98
  queryPrefix: '',
35
99
  passagePrefix: '',
100
+ pooling: 'mean',
101
+ normalize: true,
102
+ dtype: 'q8',
103
+ maxTokens: 512,
104
+ family: 'bge',
36
105
  note: '~2.3GB. Best cross-lingual separation in our tests; no prefixes.',
37
106
  },
107
+ 'qwen3-embedding-0.6b': {
108
+ id: 'onnx-community/Qwen3-Embedding-0.6B-ONNX',
109
+ revision: 'c25a394dd583836952667c12f008335071b3f43d',
110
+ nativeDim: 1024,
111
+ dim: 1024,
112
+ queryPrefix: 'Instruct: Given a web search query, retrieve relevant passages that answer the query\nQuery:',
113
+ passagePrefix: '',
114
+ pooling: 'last_token',
115
+ normalize: true,
116
+ dtype: 'q8',
117
+ maxTokens: 32768,
118
+ family: 'qwen3',
119
+ note: '~613 MB q8. Opt-in Qwen3 embedding model.',
120
+ },
38
121
  };
39
122
 
40
123
  export const DEFAULT_MODEL = 'e5-base';
41
124
 
125
+ /** True for the pooling strategies the pipeline accepts. */
126
+ export const POOLING = ['mean', 'last_token', 'none'];
127
+
128
+ const POOLING_SET = new Set(POOLING);
129
+
130
+ /** Map HF repo id → alias of the first registered adapter that owns it. */
131
+ const aliasByRepoId = new Map();
132
+ for (const [alias, adapter] of Object.entries(MODELS)) {
133
+ if (!aliasByRepoId.has(adapter.id)) aliasByRepoId.set(adapter.id, alias);
134
+ }
135
+
136
+ /** Ensure a resolved adapter exposes the legacy `dim` alias for nativeDim. */
137
+ function withDim(adapter) {
138
+ return { ...adapter, dim: adapter.nativeDim };
139
+ }
140
+
42
141
  /**
43
- * Resolve a model alias OR a raw HF id into a model descriptor.
44
- * A raw id may pin a revision with the `id@revision` syntax (defaults to
45
- * `main`), e.g. `--model "Xenova/multilingual-e5-small@a1b2c3d"`.
142
+ * Validate + normalize an explicit user-supplied adapter descriptor. Called
143
+ * with the neutral unknown-raw-id shape as a guard: a descriptor that lacks the
144
+ * vector-producing fields stays unusable.
145
+ * @param {Partial<ModelAdapter>|ModelAdapter} raw
146
+ * @returns {ModelAdapter}
147
+ */
148
+ export function normalizeAdapter(raw) {
149
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
150
+ throw new Error('a custom model adapter must be an object with id, pooling, normalize, and dimension');
151
+ }
152
+ if (typeof raw.id !== 'string' || raw.id.trim().length === 0) {
153
+ throw new Error('a custom model adapter requires a nonempty `id`');
154
+ }
155
+ const pooling = raw.pooling ?? 'mean';
156
+ if (!POOLING_SET.has(pooling)) {
157
+ throw new Error(`unsupported pooling "${pooling}" — expected one of: ${POOLING.join(', ')}`);
158
+ }
159
+ const nativeDim = raw.nativeDim ?? raw.dim ?? 0;
160
+ if (!Number.isSafeInteger(nativeDim) || nativeDim < 0) {
161
+ throw new Error(`invalid adapter dimension ${nativeDim} — must be a non-negative safe integer`);
162
+ }
163
+ const dimensions = raw.dimensions !== undefined
164
+ ? (Array.isArray(raw.dimensions) && raw.dimensions.every(d => Number.isSafeInteger(d) && d > 0)
165
+ ? [...new Set(raw.dimensions)].sort((a, b) => a - b)
166
+ : [])
167
+ : [];
168
+ const adapter = withDim({
169
+ id: raw.id,
170
+ ...(raw.revision ? { revision: raw.revision } : {}),
171
+ nativeDim,
172
+ ...(dimensions.length > 0 ? { dimensions } : {}),
173
+ queryPrefix: typeof raw.queryPrefix === 'string' ? raw.queryPrefix : '',
174
+ passagePrefix: typeof raw.passagePrefix === 'string' ? raw.passagePrefix : '',
175
+ pooling,
176
+ normalize: raw.normalize !== false,
177
+ dtype: typeof raw.dtype === 'string' && raw.dtype.length > 0 ? raw.dtype : 'q8',
178
+ ...(Number.isSafeInteger(raw.maxTokens) && raw.maxTokens > 0
179
+ ? { maxTokens: raw.maxTokens } : {}),
180
+ ...(typeof raw.family === 'string' && raw.family.length > 0
181
+ ? { family: raw.family } : {}),
182
+ ...(typeof raw.note === 'string' ? { note: raw.note } : {}),
183
+ });
184
+ if (raw.unknownAdapter === true && typeof raw.dtype !== 'string') {
185
+ return { ...adapter, unknownAdapter: true };
186
+ }
187
+ return adapter;
188
+ }
189
+
190
+ /**
191
+ * Resolve a model alias, a registered id, or an explicit adapter descriptor
192
+ * into a usable ModelAdapter.
193
+ *
194
+ * - alias (`e5-base`) → the registered adapter;
195
+ * - registered repo id (± `@revision`) → the owning registered adapter;
196
+ * - an object descriptor → validated explicit adapter (advanced/library use);
197
+ * - ANY OTHER raw id → a NEUTRAL adapter flagged `unknownAdapter: true`. It is
198
+ * NOT guessed to be E5-compatible; it keeps the index readable (load/check
199
+ * only need id/revision/dim) but FAILS with an actionable message at embed
200
+ * time until the caller supplies an explicit adapter (issue #60).
201
+ *
202
+ * @param {string|Partial<ModelAdapter>} [name]
203
+ * @returns {ModelAdapter}
46
204
  */
47
205
  export function resolveModel(name) {
48
- if (!name) return MODELS[DEFAULT_MODEL];
49
- if (MODELS[name]) return MODELS[name];
50
- // Allow passing a raw HF/Xenova id; assume E5-style prefixes unless it's bge.
51
- const isBge = /bge/i.test(name);
206
+ if (name === undefined || name === null || name === '') {
207
+ return withDim(MODELS[DEFAULT_MODEL]);
208
+ }
209
+ if (typeof name === 'object' && !Array.isArray(name)) {
210
+ return normalizeAdapter(name);
211
+ }
212
+ if (typeof name !== 'string') {
213
+ throw new Error(`model must be an alias, id, or adapter object — got ${typeof name}`);
214
+ }
215
+ if (MODELS[name]) return withDim(MODELS[name]);
216
+
217
+ // id@revision split; default revision "main" (issue #27 keeps it in the key).
52
218
  let id = name;
53
219
  let revision = 'main';
54
220
  const at = name.indexOf('@');
55
- if (at > 0) {
221
+ const hasExplicitRevision = at > 0;
222
+ if (hasExplicitRevision) {
56
223
  id = name.slice(0, at);
57
- revision = name.slice(at + 1) || 'main';
224
+ const requested = name.slice(at + 1);
225
+ revision = requested === '' ? 'main' : requested;
58
226
  }
59
- return {
227
+ const alias = aliasByRepoId.get(id);
228
+ if (alias !== undefined) {
229
+ const registered = MODELS[alias];
230
+ const resolved = hasExplicitRevision ? { ...registered, revision } : registered;
231
+ return withDim(resolved);
232
+ }
233
+ // Unknown repo id: neutral, explicit-config-required adapter.
234
+ return normalizeAdapter({
60
235
  id,
61
- revision,
236
+ ...(revision !== 'main' ? { revision } : {}),
237
+ pooling: 'none',
238
+ normalize: false,
239
+ nativeDim: 0,
62
240
  dim: 0,
241
+ unknownAdapter: true,
242
+ });
243
+ }
244
+
245
+ /**
246
+ * The subset of adapter fields that change the produced vectors and therefore
247
+ * participate in the adapter fingerprint. Kept as its own structural type so
248
+ * both registered adapters and legacy pre-profile descriptors can be hashed
249
+ * without requiring a full `ModelAdapter`.
250
+ * @typedef {Object} FingerprintInput
251
+ * @property {string} [queryPrefix]
252
+ * @property {string} [passagePrefix]
253
+ * @property {Pooling} [pooling]
254
+ * @property {boolean} [normalize]
255
+ * @property {number} [dim]
256
+ * @property {number} [nativeDim]
257
+ */
258
+
259
+ /**
260
+ * Hash the vector-producing adapter semantics independently of model weights.
261
+ * Model id and revision remain in the existing persisted `model` identity.
262
+ *
263
+ * The hashed fields are EXACTLY those that change the produced vectors:
264
+ * formatting prefixes, pooling, normalization, and dimension. This hashing
265
+ * contract is stable (`adapter-v1`): normalizing default fields in a registered
266
+ * adapter must NOT change the bytes, so existing schema-v3 indexes and
267
+ * checkpoints built with E5/BGE keep their vectors reusable.
268
+ *
269
+ * @param {FingerprintInput} model
270
+ */
271
+ export function embeddingAdapterFingerprint(model) {
272
+ const semantics = [
273
+ 'embedding-adapter-v1',
274
+ `query-prefix:${model.queryPrefix ?? ''}`,
275
+ `passage-prefix:${model.passagePrefix ?? ''}`,
276
+ `pooling:${model.pooling ?? 'mean'}`,
277
+ `normalize:${model.normalize !== false}`,
278
+ `dimension:${model.nativeDim ?? model.dim ?? 0}`,
279
+ ].join('\u0000');
280
+ return `adapter-v1:${crypto.createHash('sha256').update(semantics).digest('hex')}`;
281
+ }
282
+
283
+ /**
284
+ * Adapter semantics used before registered model profiles (#50): the generic
285
+ * E5-style prefixes + mean pooling that once applied to every raw id (except a
286
+ * heuristic `bge` name match). Kept so pre-profile indexes remain readable and
287
+ * re-usable (see indexer `adapterCompatible`).
288
+ * @param {{id:string, dim:number, nativeDim?:number}} model
289
+ */
290
+ export function legacyEmbeddingAdapterFingerprint(model) {
291
+ const isBge = /bge/i.test(model.id);
292
+ return embeddingAdapterFingerprint({
293
+ nativeDim: model.nativeDim ?? model.dim,
63
294
  queryPrefix: isBge ? '' : 'query: ',
64
295
  passagePrefix: isBge ? '' : 'passage: ',
65
- note: 'Custom model id.',
66
- };
296
+ pooling: 'mean',
297
+ normalize: true,
298
+ });
67
299
  }
package/src/rerank.mjs ADDED
@@ -0,0 +1,75 @@
1
+ // @ts-check
2
+ /**
3
+ * Cross-encoder re-ranking (issue #15).
4
+ *
5
+ * The first-pass ranking (cosine + RRF) is shallow: it scores a query against
6
+ * chunk *embeddings* independently. A cross-encoder feeds the query AND the
7
+ * passage into one transformer together, capturing pairwise relevance that
8
+ * bi-encoder cosine can miss — at the cost of a forward pass per candidate.
9
+ *
10
+ * `Xenova/bge-reranker-base` is an `XLMRobertaForSequenceClassification` with a
11
+ * SINGLE output class. Its raw logit IS the relevance score — that's exactly
12
+ * what BAAI's own FlagReranker returns (`model(...).logits.view(-1)`). Because
13
+ * of the single class we must NOT route this through the `text-classification`
14
+ * pipeline: it applies softmax, and softmax over one class is always 1.0 —
15
+ * useless. Hence the direct AutoModel + AutoTokenizer call below, with the
16
+ * query as `text` and the passage as `text_pair` (the cross-encoder input
17
+ * format).
18
+ */
19
+
20
+ import { resolveSessionOptions } from './core.mjs';
21
+
22
+ const _rerankers = new Map();
23
+
24
+ export const RERANK_MODEL = 'Xenova/bge-reranker-base';
25
+
26
+ /**
27
+ * Lazily load (and cache) the cross-encoder reranker. Nothing is loaded until
28
+ * the first actual re-rank, so `--rerank` costs nothing when not requested.
29
+ * @param {string} cacheDir
30
+ * @param {boolean} [offline=false] - never touch the network; require a cached model
31
+ * @returns {Promise<{tokenizer: import('@huggingface/transformers').PreTrainedTokenizer,
32
+ * model: import('@huggingface/transformers').PreTrainedModel}>}
33
+ */
34
+ export async function getReranker(cacheDir, offline = false) {
35
+ const sessionOptions = resolveSessionOptions();
36
+ const sessionKey = sessionOptions ? JSON.stringify(sessionOptions) : 'default';
37
+ const key = `${RERANK_MODEL}|${offline ? 'off' : 'on'}|${sessionKey}`;
38
+ if (_rerankers.has(key)) return _rerankers.get(key);
39
+ const { AutoModelForSequenceClassification, AutoTokenizer, env } =
40
+ await import('@huggingface/transformers');
41
+ if (cacheDir) env.cacheDir = cacheDir;
42
+ env.allowRemoteModels = !offline;
43
+ const tokenizer = await AutoTokenizer.from_pretrained(RERANK_MODEL);
44
+ // q8: the repo ships onnx/model_quantized.onnx (~1/4 of fp32). Same trade as
45
+ // the embedding extractor in core.mjs — near-identical scores, 4x smaller.
46
+ /** @type {Record<string, unknown>} */
47
+ const loadOpts = {
48
+ dtype: 'q8',
49
+ ...(sessionOptions ? { session_options: sessionOptions } : {}),
50
+ };
51
+ const model = await AutoModelForSequenceClassification.from_pretrained(RERANK_MODEL, loadOpts);
52
+ const r = { tokenizer, model };
53
+ _rerankers.set(key, r);
54
+ return r;
55
+ }
56
+
57
+ /**
58
+ * Score query↔passage relevance with the cross-encoder. Returns one raw logit
59
+ * per passage (higher = more relevant). Passages are NOT embedded: each
60
+ * (query, passage) pair runs through the model together, batched.
61
+ * @param {string} query
62
+ * @param {string[]} passages
63
+ * @param {string} cacheDir
64
+ * @param {boolean} [offline=false] - never touch the network; require a cached model
65
+ * @returns {Promise<number[]>}
66
+ */
67
+ export async function rerankScores(query, passages, cacheDir, offline = false) {
68
+ if (passages.length === 0) return [];
69
+ const { tokenizer, model } = await getReranker(cacheDir, offline);
70
+ const queries = new Array(passages.length).fill(query);
71
+ const inputs = tokenizer(queries, { text_pair: passages, padding: true, truncation: true });
72
+ const outputs = await model(inputs);
73
+ // Single-class head → logits shape [batch, 1]; the logit is the relevance score.
74
+ return outputs.logits.tolist().map((row) => /** @type {number} */ (row[0]));
75
+ }