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.
- package/README.md +357 -20
- package/RESEARCH.md +117 -37
- package/bin/cli.mjs +573 -32
- package/package.json +11 -2
- package/src/collapse.mjs +31 -0
- package/src/core.mjs +424 -60
- package/src/frontmatter.mjs +213 -0
- package/src/index-format.mjs +134 -0
- package/src/index.mjs +47 -0
- package/src/indexer.mjs +390 -47
- package/src/lexical.mjs +291 -0
- package/src/markdown-parser.mjs +347 -0
- package/src/metrics.mjs +186 -0
- package/src/models.mjs +250 -18
- package/src/rerank.mjs +75 -0
- package/src/search.mjs +571 -42
- package/src/serve.mjs +387 -0
- package/src/wikilinks.mjs +241 -0
package/src/metrics.mjs
ADDED
|
@@ -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
|
-
*
|
|
5
|
-
* (
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
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 (
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
221
|
+
const hasExplicitRevision = at > 0;
|
|
222
|
+
if (hasExplicitRevision) {
|
|
56
223
|
id = name.slice(0, at);
|
|
57
|
-
|
|
224
|
+
const requested = name.slice(at + 1);
|
|
225
|
+
revision = requested === '' ? 'main' : requested;
|
|
58
226
|
}
|
|
59
|
-
|
|
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
|
-
|
|
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
|
+
}
|