md-semantic-search 0.3.0 → 0.5.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 +438 -20
- package/RESEARCH.md +117 -37
- package/bin/cli.mjs +931 -36
- package/dist/collapse.d.ts +6 -0
- package/dist/collapse.js +20 -0
- package/dist/core.d.ts +84 -0
- package/dist/core.js +479 -0
- package/dist/frontmatter.d.ts +20 -0
- package/dist/frontmatter.js +150 -0
- package/dist/index-format.d.ts +23 -0
- package/dist/index-format.js +125 -0
- package/dist/index.d.ts +29 -0
- package/dist/index.js +29 -0
- package/dist/indexer.d.ts +70 -0
- package/dist/indexer.js +452 -0
- package/dist/ivf.d.ts +33 -0
- package/dist/ivf.js +144 -0
- package/dist/lexical.d.ts +44 -0
- package/dist/lexical.js +226 -0
- package/dist/markdown-parser.d.ts +27 -0
- package/dist/markdown-parser.js +252 -0
- package/dist/mcp.d.ts +111 -0
- package/dist/mcp.js +216 -0
- package/dist/metrics.d.ts +43 -0
- package/dist/metrics.js +97 -0
- package/dist/models.d.ts +38 -0
- package/dist/models.js +181 -0
- package/dist/rerank.d.ts +10 -0
- package/dist/rerank.js +41 -0
- package/dist/search.d.ts +135 -0
- package/dist/search.js +492 -0
- package/dist/serve.d.ts +49 -0
- package/dist/serve.js +321 -0
- package/dist/tui.d.ts +17 -0
- package/dist/tui.js +196 -0
- package/dist/wikilinks.d.ts +47 -0
- package/dist/wikilinks.js +171 -0
- package/package.json +25 -2
- package/src/collapse.ts +26 -0
- package/src/core.ts +546 -0
- package/src/frontmatter.ts +190 -0
- package/src/index-format.ts +151 -0
- package/src/index.ts +55 -0
- package/src/indexer.ts +585 -0
- package/src/ivf.ts +190 -0
- package/src/lexical.ts +257 -0
- package/src/markdown-parser.ts +303 -0
- package/src/mcp.ts +243 -0
- package/src/metrics.ts +135 -0
- package/src/models.ts +219 -0
- package/src/rerank.ts +54 -0
- package/src/search.ts +628 -0
- package/src/serve.ts +372 -0
- package/src/tui.ts +227 -0
- package/src/wikilinks.ts +244 -0
- package/src/core.mjs +0 -171
- package/src/indexer.mjs +0 -197
- package/src/models.mjs +0 -67
- package/src/search.mjs +0 -107
package/dist/mcp.js
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP (Model Context Protocol) server mode (issue #62).
|
|
3
|
+
* Exposes local semantic search to IDEs and LLM agents (Cursor, Claude Desktop, Copilot)
|
|
4
|
+
* via a zero-dependency JSON-RPC 2.0 stdio transport per the MCP specification.
|
|
5
|
+
*/
|
|
6
|
+
import readline from 'node:readline';
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { buildIndex } from './indexer.js';
|
|
10
|
+
import { loadIndex, searchIndex } from './search.js';
|
|
11
|
+
import { globToRegExp } from './core.js';
|
|
12
|
+
export const MCP_TOOLS = [
|
|
13
|
+
{
|
|
14
|
+
name: 'search_markdown',
|
|
15
|
+
description: 'Search local Markdown knowledge base by meaning and hybrid BM25 lexical ranking',
|
|
16
|
+
inputSchema: {
|
|
17
|
+
type: 'object',
|
|
18
|
+
properties: {
|
|
19
|
+
query: { type: 'string', description: 'Natural language search query' },
|
|
20
|
+
k: { type: 'number', description: 'Number of top results to return (default: 6)' },
|
|
21
|
+
path: { type: 'string', description: 'Optional path glob filter (e.g. "docs/**")' },
|
|
22
|
+
tag: { type: 'array', items: { type: 'string' }, description: 'Optional frontmatter tag filters' },
|
|
23
|
+
},
|
|
24
|
+
required: ['query'],
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
name: 'get_chunk',
|
|
29
|
+
description: 'Retrieve a specific chunk or section by file path and optional heading label',
|
|
30
|
+
inputSchema: {
|
|
31
|
+
type: 'object',
|
|
32
|
+
properties: {
|
|
33
|
+
file: { type: 'string', description: 'Relative file path (e.g. "guides/api.md")' },
|
|
34
|
+
heading: { type: 'string', description: 'Heading title (e.g. "Authentication")' },
|
|
35
|
+
},
|
|
36
|
+
required: ['file'],
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
name: 'list_files',
|
|
41
|
+
description: 'List all indexed Markdown files and their chunk counts',
|
|
42
|
+
inputSchema: {
|
|
43
|
+
type: 'object',
|
|
44
|
+
properties: {
|
|
45
|
+
path: { type: 'string', description: 'Optional glob filter to match file paths' },
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
name: 'index_status',
|
|
51
|
+
description: 'Get current index status, model metadata, chunk count, and build timestamp',
|
|
52
|
+
inputSchema: {
|
|
53
|
+
type: 'object',
|
|
54
|
+
properties: {},
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
];
|
|
58
|
+
export async function handleMcpRequest(req, state) {
|
|
59
|
+
if (!req || typeof req !== 'object')
|
|
60
|
+
return null;
|
|
61
|
+
const { id, method, params } = req;
|
|
62
|
+
if (id === undefined || id === null) {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
if (method === 'initialize') {
|
|
66
|
+
return {
|
|
67
|
+
jsonrpc: '2.0',
|
|
68
|
+
id,
|
|
69
|
+
result: {
|
|
70
|
+
protocolVersion: '2024-11-05',
|
|
71
|
+
capabilities: {
|
|
72
|
+
tools: {},
|
|
73
|
+
},
|
|
74
|
+
serverInfo: {
|
|
75
|
+
name: 'md-semantic-search',
|
|
76
|
+
version: '0.4.0',
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
if (method === 'tools/list') {
|
|
82
|
+
return {
|
|
83
|
+
jsonrpc: '2.0',
|
|
84
|
+
id,
|
|
85
|
+
result: {
|
|
86
|
+
tools: MCP_TOOLS,
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
if (method === 'tools/call') {
|
|
91
|
+
const name = params?.name;
|
|
92
|
+
const args = params?.arguments || {};
|
|
93
|
+
try {
|
|
94
|
+
let content = [];
|
|
95
|
+
if (name === 'search_markdown') {
|
|
96
|
+
const query = typeof args.query === 'string' ? args.query : '';
|
|
97
|
+
const k = Number.isInteger(args.k) && args.k > 0 ? args.k : 6;
|
|
98
|
+
const results = await searchIndex({
|
|
99
|
+
loaded: state.loaded,
|
|
100
|
+
cacheDir: state.cacheDir,
|
|
101
|
+
query,
|
|
102
|
+
k,
|
|
103
|
+
path: args.path,
|
|
104
|
+
tag: args.tag,
|
|
105
|
+
offline: state.offline,
|
|
106
|
+
embedFn: state.embedFn,
|
|
107
|
+
});
|
|
108
|
+
content = [{ type: 'text', text: JSON.stringify(results, null, 2) }];
|
|
109
|
+
}
|
|
110
|
+
else if (name === 'get_chunk') {
|
|
111
|
+
const file = args.file;
|
|
112
|
+
const heading = args.heading;
|
|
113
|
+
const chunks = state.loaded.index.chunks.filter((c) => {
|
|
114
|
+
if (c.file !== file)
|
|
115
|
+
return false;
|
|
116
|
+
if (heading && c.heading.toLowerCase() !== heading.toLowerCase())
|
|
117
|
+
return false;
|
|
118
|
+
return true;
|
|
119
|
+
});
|
|
120
|
+
content = [{ type: 'text', text: JSON.stringify(chunks, null, 2) }];
|
|
121
|
+
}
|
|
122
|
+
else if (name === 'list_files') {
|
|
123
|
+
const fileCounts = new Map();
|
|
124
|
+
for (const c of state.loaded.index.chunks) {
|
|
125
|
+
fileCounts.set(c.file, (fileCounts.get(c.file) || 0) + 1);
|
|
126
|
+
}
|
|
127
|
+
let files = [...fileCounts.entries()].map(([file, count]) => ({ file, chunksCount: count }));
|
|
128
|
+
if (args.path) {
|
|
129
|
+
const re = globToRegExp(args.path);
|
|
130
|
+
files = files.filter((f) => re.test(f.file));
|
|
131
|
+
}
|
|
132
|
+
content = [{ type: 'text', text: JSON.stringify(files, null, 2) }];
|
|
133
|
+
}
|
|
134
|
+
else if (name === 'index_status') {
|
|
135
|
+
const info = {
|
|
136
|
+
ok: true,
|
|
137
|
+
chunks: state.loaded.index.chunks.length,
|
|
138
|
+
model: state.loaded.model.id,
|
|
139
|
+
dim: state.loaded.index.dim || state.loaded.model.dim || 0,
|
|
140
|
+
built: state.loaded.index.built || null,
|
|
141
|
+
};
|
|
142
|
+
content = [{ type: 'text', text: JSON.stringify(info, null, 2) }];
|
|
143
|
+
}
|
|
144
|
+
else {
|
|
145
|
+
return {
|
|
146
|
+
jsonrpc: '2.0',
|
|
147
|
+
id,
|
|
148
|
+
error: { code: -32601, message: `Unknown tool: ${name}` },
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
return {
|
|
152
|
+
jsonrpc: '2.0',
|
|
153
|
+
id,
|
|
154
|
+
result: { content },
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
catch (e) {
|
|
158
|
+
return {
|
|
159
|
+
jsonrpc: '2.0',
|
|
160
|
+
id,
|
|
161
|
+
result: {
|
|
162
|
+
isError: true,
|
|
163
|
+
content: [{ type: 'text', text: `Error: ${e.message}` }],
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return {
|
|
169
|
+
jsonrpc: '2.0',
|
|
170
|
+
id,
|
|
171
|
+
error: { code: -32601, message: `Method not found: ${method}` },
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
export async function startMcpServer(opts) {
|
|
175
|
+
const { indexDir, cacheDir, db, modelName = 'e5-base', ignore = [], offline = false, embedFn, log = () => { }, } = opts;
|
|
176
|
+
fs.mkdirSync(indexDir, { recursive: true });
|
|
177
|
+
if (!fs.existsSync(path.join(indexDir, 'vectors.json'))) {
|
|
178
|
+
if (!db)
|
|
179
|
+
throw new Error('mcp: no index found and no --db given to build one');
|
|
180
|
+
log(`Building index at ${indexDir} from ${db}…`);
|
|
181
|
+
await buildIndex({ db, indexDir, cacheDir, modelName, ignore, offline, log, embedFn });
|
|
182
|
+
}
|
|
183
|
+
const state = {
|
|
184
|
+
loaded: loadIndex(indexDir),
|
|
185
|
+
cacheDir,
|
|
186
|
+
offline,
|
|
187
|
+
embedFn,
|
|
188
|
+
};
|
|
189
|
+
const rl = readline.createInterface({
|
|
190
|
+
input: process.stdin,
|
|
191
|
+
output: process.stdout,
|
|
192
|
+
terminal: false,
|
|
193
|
+
});
|
|
194
|
+
rl.on('line', async (line) => {
|
|
195
|
+
const trimmed = line.trim();
|
|
196
|
+
if (!trimmed)
|
|
197
|
+
return;
|
|
198
|
+
let req;
|
|
199
|
+
try {
|
|
200
|
+
req = JSON.parse(trimmed);
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
process.stdout.write(JSON.stringify({
|
|
204
|
+
jsonrpc: '2.0',
|
|
205
|
+
id: null,
|
|
206
|
+
error: { code: -32700, message: 'Parse error: invalid JSON' },
|
|
207
|
+
}) + '\n');
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
const res = await handleMcpRequest(req, state);
|
|
211
|
+
if (res) {
|
|
212
|
+
process.stdout.write(JSON.stringify(res) + '\n');
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
return { state, rl };
|
|
216
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Retrieval evaluation metrics (issue #56).
|
|
3
|
+
* Pure, dependency-free ranking metrics used by the golden-set harness.
|
|
4
|
+
*/
|
|
5
|
+
export interface BenchHit {
|
|
6
|
+
file: string;
|
|
7
|
+
}
|
|
8
|
+
export interface GradeConf {
|
|
9
|
+
grades?: number[];
|
|
10
|
+
minRelevant?: number;
|
|
11
|
+
}
|
|
12
|
+
export interface MetricValues {
|
|
13
|
+
ndcg: number;
|
|
14
|
+
mrr: number;
|
|
15
|
+
hit: number;
|
|
16
|
+
recall: number;
|
|
17
|
+
}
|
|
18
|
+
export declare function ndcgAtK(ranked: string[], grade: (doc: string, rankIndex: number) => number, k: number): number;
|
|
19
|
+
export declare function reciprocalRank(ranked: string[], isRelevant: (doc: string) => boolean): number;
|
|
20
|
+
export declare function hitAtK(ranked: string[], isRelevant: (doc: string) => boolean, k: number): 0 | 1;
|
|
21
|
+
export declare function recallAtK(ranked: string[], isRelevant: (doc: string) => boolean, k: number): number;
|
|
22
|
+
export declare function gradeByDoc(docGrades: Record<string, number>): (doc: string) => number;
|
|
23
|
+
export declare function queryMetrics(ranked: string[], docGrades: Record<string, number>, opts?: {
|
|
24
|
+
k?: number;
|
|
25
|
+
minRelevant?: number;
|
|
26
|
+
allRelevant?: string[];
|
|
27
|
+
}): MetricValues;
|
|
28
|
+
export declare function aggregateMetrics(qm: MetricValues[]): {
|
|
29
|
+
mean: MetricValues;
|
|
30
|
+
n: number;
|
|
31
|
+
};
|
|
32
|
+
export declare function winTieLoss(a: Array<{
|
|
33
|
+
ndcg: number;
|
|
34
|
+
}>, b: Array<{
|
|
35
|
+
ndcg: number;
|
|
36
|
+
}>, epsilon?: number): {
|
|
37
|
+
win: number;
|
|
38
|
+
tie: number;
|
|
39
|
+
loss: number;
|
|
40
|
+
};
|
|
41
|
+
export declare function redundancyAtK(ranked: Array<{
|
|
42
|
+
file: string;
|
|
43
|
+
}>, k: number): number;
|
package/dist/metrics.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Retrieval evaluation metrics (issue #56).
|
|
3
|
+
* Pure, dependency-free ranking metrics used by the golden-set harness.
|
|
4
|
+
*/
|
|
5
|
+
export function ndcgAtK(ranked, grade, k) {
|
|
6
|
+
const top = ranked.slice(0, k);
|
|
7
|
+
if (top.length === 0)
|
|
8
|
+
return 0;
|
|
9
|
+
let dcg = 0;
|
|
10
|
+
for (let i = 0; i < top.length; i++) {
|
|
11
|
+
const g = grade(top[i], i);
|
|
12
|
+
if (g <= 0)
|
|
13
|
+
continue;
|
|
14
|
+
dcg += g / Math.log2(i + 2);
|
|
15
|
+
}
|
|
16
|
+
if (dcg === 0)
|
|
17
|
+
return 0;
|
|
18
|
+
const all = (ranked ?? []).map((doc, i) => grade(doc, i)).filter((g) => g > 0);
|
|
19
|
+
const ideal = [...all].sort((a, b) => b - a).slice(0, k);
|
|
20
|
+
let idcg = 0;
|
|
21
|
+
for (let i = 0; i < ideal.length; i++)
|
|
22
|
+
idcg += ideal[i] / Math.log2(i + 2);
|
|
23
|
+
return idcg === 0 ? 0 : dcg / idcg;
|
|
24
|
+
}
|
|
25
|
+
export function reciprocalRank(ranked, isRelevant) {
|
|
26
|
+
for (let i = 0; i < ranked.length; i++) {
|
|
27
|
+
if (isRelevant(ranked[i]))
|
|
28
|
+
return 1 / (i + 1);
|
|
29
|
+
}
|
|
30
|
+
return 0;
|
|
31
|
+
}
|
|
32
|
+
export function hitAtK(ranked, isRelevant, k) {
|
|
33
|
+
return ranked.slice(0, k).some(isRelevant) ? 1 : 0;
|
|
34
|
+
}
|
|
35
|
+
export function recallAtK(ranked, isRelevant, k) {
|
|
36
|
+
const relevantCount = (ranked ?? []).filter(isRelevant).length;
|
|
37
|
+
if (relevantCount === 0)
|
|
38
|
+
return 0;
|
|
39
|
+
const retrieved = ranked.slice(0, k).filter(isRelevant).length;
|
|
40
|
+
return retrieved / relevantCount;
|
|
41
|
+
}
|
|
42
|
+
export function gradeByDoc(docGrades) {
|
|
43
|
+
const table = Object.create(null);
|
|
44
|
+
for (const [doc, g] of Object.entries(docGrades)) {
|
|
45
|
+
table[doc] = g;
|
|
46
|
+
}
|
|
47
|
+
return (doc) => table[doc] ?? 0;
|
|
48
|
+
}
|
|
49
|
+
export function queryMetrics(ranked, docGrades, opts = {}) {
|
|
50
|
+
const { k = 10, minRelevant = 1 } = opts;
|
|
51
|
+
const grade = gradeByDoc(docGrades);
|
|
52
|
+
const isRelevant = (doc) => grade(doc) >= minRelevant;
|
|
53
|
+
const allRelevant = opts.allRelevant ?? Object.entries(docGrades).filter(([, g]) => g >= minRelevant).map(([d]) => d);
|
|
54
|
+
const fullRanked = ranked.length >= k ? ranked : [...ranked, ...allRelevant.filter((d) => !ranked.includes(d))];
|
|
55
|
+
return {
|
|
56
|
+
ndcg: ndcgAtK(ranked, grade, k),
|
|
57
|
+
mrr: reciprocalRank(ranked, isRelevant),
|
|
58
|
+
hit: hitAtK(ranked, isRelevant, k),
|
|
59
|
+
recall: recallAtK(fullRanked, isRelevant, k),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
export function aggregateMetrics(qm) {
|
|
63
|
+
const n = qm.length;
|
|
64
|
+
if (n === 0)
|
|
65
|
+
return { mean: { ndcg: 0, mrr: 0, hit: 0, recall: 0 }, n: 0 };
|
|
66
|
+
const sum = (key) => qm.reduce((acc, m) => acc + m[key], 0) / n;
|
|
67
|
+
return {
|
|
68
|
+
n,
|
|
69
|
+
mean: { ndcg: sum('ndcg'), mrr: sum('mrr'), hit: sum('hit'), recall: sum('recall') },
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
export function winTieLoss(a, b, epsilon = 1e-6) {
|
|
73
|
+
let win = 0, tie = 0, loss = 0;
|
|
74
|
+
for (let i = 0; i < Math.min(a.length, b.length); i++) {
|
|
75
|
+
const d = a[i].ndcg - b[i].ndcg;
|
|
76
|
+
if (d > epsilon)
|
|
77
|
+
win++;
|
|
78
|
+
else if (d < -epsilon)
|
|
79
|
+
loss++;
|
|
80
|
+
else
|
|
81
|
+
tie++;
|
|
82
|
+
}
|
|
83
|
+
return { win, tie, loss };
|
|
84
|
+
}
|
|
85
|
+
export function redundancyAtK(ranked, k) {
|
|
86
|
+
const seen = new Set();
|
|
87
|
+
let redundant = 0;
|
|
88
|
+
const counted = ranked.slice(0, k);
|
|
89
|
+
for (let i = 0; i < counted.length; i++) {
|
|
90
|
+
const doc = counted[i].file;
|
|
91
|
+
if (seen.has(doc))
|
|
92
|
+
redundant++;
|
|
93
|
+
else
|
|
94
|
+
seen.add(doc);
|
|
95
|
+
}
|
|
96
|
+
return counted.length === 0 ? 0 : redundant / counted.length;
|
|
97
|
+
}
|
package/dist/models.d.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export type Pooling = 'mean' | 'last_token' | 'none';
|
|
2
|
+
export type DType = 'q8' | 'q4' | 'fp16' | 'fp32' | 'int8' | 'uint8';
|
|
3
|
+
export interface ModelAdapter {
|
|
4
|
+
id: string;
|
|
5
|
+
revision?: string;
|
|
6
|
+
nativeDim: number;
|
|
7
|
+
dim: number;
|
|
8
|
+
dimensions?: number[];
|
|
9
|
+
queryPrefix: string;
|
|
10
|
+
passagePrefix: string;
|
|
11
|
+
pooling: Pooling;
|
|
12
|
+
normalize?: boolean;
|
|
13
|
+
dtype?: DType | string;
|
|
14
|
+
maxTokens?: number;
|
|
15
|
+
sessionOptions?: Record<string, unknown>;
|
|
16
|
+
family?: string;
|
|
17
|
+
note?: string;
|
|
18
|
+
unknownAdapter?: boolean;
|
|
19
|
+
}
|
|
20
|
+
export declare const MODELS: Record<string, ModelAdapter>;
|
|
21
|
+
export declare const DEFAULT_MODEL = "e5-base";
|
|
22
|
+
export declare const POOLING: Pooling[];
|
|
23
|
+
export declare function normalizeAdapter(raw: Partial<ModelAdapter> | ModelAdapter): ModelAdapter;
|
|
24
|
+
export declare function resolveModel(name?: string | Partial<ModelAdapter> | ModelAdapter | null): ModelAdapter;
|
|
25
|
+
export interface FingerprintInput {
|
|
26
|
+
queryPrefix?: string;
|
|
27
|
+
passagePrefix?: string;
|
|
28
|
+
pooling?: Pooling;
|
|
29
|
+
normalize?: boolean;
|
|
30
|
+
dim?: number;
|
|
31
|
+
nativeDim?: number;
|
|
32
|
+
}
|
|
33
|
+
export declare function embeddingAdapterFingerprint(model: FingerprintInput): string;
|
|
34
|
+
export declare function legacyEmbeddingAdapterFingerprint(model: {
|
|
35
|
+
id: string;
|
|
36
|
+
dim: number;
|
|
37
|
+
nativeDim?: number;
|
|
38
|
+
}): string;
|
package/dist/models.js
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Embedding model adapter registry (issue #60).
|
|
3
|
+
*/
|
|
4
|
+
import crypto from 'node:crypto';
|
|
5
|
+
export const MODELS = {
|
|
6
|
+
'e5-small': {
|
|
7
|
+
id: 'Xenova/multilingual-e5-small',
|
|
8
|
+
nativeDim: 384,
|
|
9
|
+
dim: 384,
|
|
10
|
+
queryPrefix: 'query: ',
|
|
11
|
+
passagePrefix: 'passage: ',
|
|
12
|
+
pooling: 'mean',
|
|
13
|
+
normalize: true,
|
|
14
|
+
dtype: 'q8',
|
|
15
|
+
maxTokens: 512,
|
|
16
|
+
family: 'e5',
|
|
17
|
+
note: 'Fastest, ~120MB. Weak cross-lingual ranking — see RESEARCH.md.',
|
|
18
|
+
},
|
|
19
|
+
'e5-base': {
|
|
20
|
+
id: 'Xenova/multilingual-e5-base',
|
|
21
|
+
nativeDim: 768,
|
|
22
|
+
dim: 768,
|
|
23
|
+
queryPrefix: 'query: ',
|
|
24
|
+
passagePrefix: 'passage: ',
|
|
25
|
+
pooling: 'mean',
|
|
26
|
+
normalize: true,
|
|
27
|
+
dtype: 'q8',
|
|
28
|
+
maxTokens: 512,
|
|
29
|
+
family: 'e5',
|
|
30
|
+
note: 'Default. ~280MB. Solid multilingual + cross-lingual balance.',
|
|
31
|
+
},
|
|
32
|
+
'e5-large': {
|
|
33
|
+
id: 'Xenova/multilingual-e5-large',
|
|
34
|
+
nativeDim: 1024,
|
|
35
|
+
dim: 1024,
|
|
36
|
+
queryPrefix: 'query: ',
|
|
37
|
+
passagePrefix: 'passage: ',
|
|
38
|
+
pooling: 'mean',
|
|
39
|
+
normalize: true,
|
|
40
|
+
dtype: 'q8',
|
|
41
|
+
maxTokens: 512,
|
|
42
|
+
family: 'e5',
|
|
43
|
+
note: '~2.2GB. Higher quality, slower.',
|
|
44
|
+
},
|
|
45
|
+
'bge-m3': {
|
|
46
|
+
id: 'Xenova/bge-m3',
|
|
47
|
+
nativeDim: 1024,
|
|
48
|
+
dim: 1024,
|
|
49
|
+
queryPrefix: '',
|
|
50
|
+
passagePrefix: '',
|
|
51
|
+
pooling: 'mean',
|
|
52
|
+
normalize: true,
|
|
53
|
+
dtype: 'q8',
|
|
54
|
+
maxTokens: 512,
|
|
55
|
+
family: 'bge',
|
|
56
|
+
note: '~2.3GB. Best cross-lingual separation in our tests; no prefixes.',
|
|
57
|
+
},
|
|
58
|
+
'qwen3-embedding-0.6b': {
|
|
59
|
+
id: 'onnx-community/Qwen3-Embedding-0.6B-ONNX',
|
|
60
|
+
revision: 'c25a394dd583836952667c12f008335071b3f43d',
|
|
61
|
+
nativeDim: 1024,
|
|
62
|
+
dim: 1024,
|
|
63
|
+
queryPrefix: 'Instruct: Given a web search query, retrieve relevant passages that answer the query\nQuery:',
|
|
64
|
+
passagePrefix: '',
|
|
65
|
+
pooling: 'last_token',
|
|
66
|
+
normalize: true,
|
|
67
|
+
dtype: 'q8',
|
|
68
|
+
maxTokens: 32768,
|
|
69
|
+
family: 'qwen3',
|
|
70
|
+
note: '~613 MB q8. Opt-in Qwen3 embedding model.',
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
export const DEFAULT_MODEL = 'e5-base';
|
|
74
|
+
export const POOLING = ['mean', 'last_token', 'none'];
|
|
75
|
+
const POOLING_SET = new Set(POOLING);
|
|
76
|
+
const aliasByRepoId = new Map();
|
|
77
|
+
for (const [alias, adapter] of Object.entries(MODELS)) {
|
|
78
|
+
if (!aliasByRepoId.has(adapter.id))
|
|
79
|
+
aliasByRepoId.set(adapter.id, alias);
|
|
80
|
+
}
|
|
81
|
+
function withDim(adapter) {
|
|
82
|
+
return { ...adapter, dim: adapter.nativeDim ?? adapter.dim };
|
|
83
|
+
}
|
|
84
|
+
export function normalizeAdapter(raw) {
|
|
85
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
86
|
+
throw new Error('a custom model adapter must be an object with id, pooling, normalize, and dimension');
|
|
87
|
+
}
|
|
88
|
+
if (typeof raw.id !== 'string' || raw.id.trim().length === 0) {
|
|
89
|
+
throw new Error('a custom model adapter requires a nonempty `id`');
|
|
90
|
+
}
|
|
91
|
+
const pooling = raw.pooling ?? 'mean';
|
|
92
|
+
if (!POOLING_SET.has(pooling)) {
|
|
93
|
+
throw new Error(`unsupported pooling "${pooling}" — expected one of: ${POOLING.join(', ')}`);
|
|
94
|
+
}
|
|
95
|
+
const nativeDim = raw.nativeDim ?? raw.dim ?? 0;
|
|
96
|
+
if (!Number.isSafeInteger(nativeDim) || nativeDim < 0) {
|
|
97
|
+
throw new Error(`invalid adapter dimension ${nativeDim} — must be a non-negative safe integer`);
|
|
98
|
+
}
|
|
99
|
+
const dimensions = raw.dimensions !== undefined
|
|
100
|
+
? Array.isArray(raw.dimensions) && raw.dimensions.every((d) => Number.isSafeInteger(d) && d > 0)
|
|
101
|
+
? [...new Set(raw.dimensions)].sort((a, b) => a - b)
|
|
102
|
+
: []
|
|
103
|
+
: [];
|
|
104
|
+
const adapter = withDim({
|
|
105
|
+
id: raw.id,
|
|
106
|
+
...(raw.revision ? { revision: raw.revision } : {}),
|
|
107
|
+
nativeDim,
|
|
108
|
+
dim: nativeDim,
|
|
109
|
+
...(dimensions.length > 0 ? { dimensions } : {}),
|
|
110
|
+
queryPrefix: typeof raw.queryPrefix === 'string' ? raw.queryPrefix : '',
|
|
111
|
+
passagePrefix: typeof raw.passagePrefix === 'string' ? raw.passagePrefix : '',
|
|
112
|
+
pooling,
|
|
113
|
+
normalize: raw.normalize !== false,
|
|
114
|
+
dtype: typeof raw.dtype === 'string' && raw.dtype.length > 0 ? raw.dtype : 'q8',
|
|
115
|
+
...(Number.isSafeInteger(raw.maxTokens) && raw.maxTokens && raw.maxTokens > 0 ? { maxTokens: raw.maxTokens } : {}),
|
|
116
|
+
...(typeof raw.family === 'string' && raw.family.length > 0 ? { family: raw.family } : {}),
|
|
117
|
+
...(typeof raw.note === 'string' ? { note: raw.note } : {}),
|
|
118
|
+
});
|
|
119
|
+
if (raw.unknownAdapter === true && typeof raw.dtype !== 'string') {
|
|
120
|
+
return { ...adapter, unknownAdapter: true };
|
|
121
|
+
}
|
|
122
|
+
return adapter;
|
|
123
|
+
}
|
|
124
|
+
export function resolveModel(name) {
|
|
125
|
+
if (name === undefined || name === null || name === '') {
|
|
126
|
+
return withDim(MODELS[DEFAULT_MODEL]);
|
|
127
|
+
}
|
|
128
|
+
if (typeof name === 'object' && !Array.isArray(name)) {
|
|
129
|
+
return normalizeAdapter(name);
|
|
130
|
+
}
|
|
131
|
+
if (typeof name !== 'string') {
|
|
132
|
+
throw new Error(`model must be an alias, id, or adapter object — got ${typeof name}`);
|
|
133
|
+
}
|
|
134
|
+
if (MODELS[name])
|
|
135
|
+
return withDim(MODELS[name]);
|
|
136
|
+
let id = name;
|
|
137
|
+
let revision = 'main';
|
|
138
|
+
const at = name.indexOf('@');
|
|
139
|
+
const hasExplicitRevision = at > 0;
|
|
140
|
+
if (hasExplicitRevision) {
|
|
141
|
+
id = name.slice(0, at);
|
|
142
|
+
const requested = name.slice(at + 1);
|
|
143
|
+
revision = requested === '' ? 'main' : requested;
|
|
144
|
+
}
|
|
145
|
+
const alias = aliasByRepoId.get(id);
|
|
146
|
+
if (alias !== undefined) {
|
|
147
|
+
const registered = MODELS[alias];
|
|
148
|
+
const resolved = hasExplicitRevision ? { ...registered, revision } : registered;
|
|
149
|
+
return withDim(resolved);
|
|
150
|
+
}
|
|
151
|
+
return normalizeAdapter({
|
|
152
|
+
id,
|
|
153
|
+
...(revision !== 'main' ? { revision } : {}),
|
|
154
|
+
pooling: 'none',
|
|
155
|
+
normalize: false,
|
|
156
|
+
nativeDim: 0,
|
|
157
|
+
dim: 0,
|
|
158
|
+
unknownAdapter: true,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
export function embeddingAdapterFingerprint(model) {
|
|
162
|
+
const semantics = [
|
|
163
|
+
'embedding-adapter-v1',
|
|
164
|
+
`query-prefix:${model.queryPrefix ?? ''}`,
|
|
165
|
+
`passage-prefix:${model.passagePrefix ?? ''}`,
|
|
166
|
+
`pooling:${model.pooling ?? 'mean'}`,
|
|
167
|
+
`normalize:${model.normalize !== false}`,
|
|
168
|
+
`dimension:${model.nativeDim ?? model.dim ?? 0}`,
|
|
169
|
+
].join('\u0000');
|
|
170
|
+
return `adapter-v1:${crypto.createHash('sha256').update(semantics).digest('hex')}`;
|
|
171
|
+
}
|
|
172
|
+
export function legacyEmbeddingAdapterFingerprint(model) {
|
|
173
|
+
const isBge = /bge/i.test(model.id);
|
|
174
|
+
return embeddingAdapterFingerprint({
|
|
175
|
+
nativeDim: model.nativeDim ?? model.dim,
|
|
176
|
+
queryPrefix: isBge ? '' : 'query: ',
|
|
177
|
+
passagePrefix: isBge ? '' : 'passage: ',
|
|
178
|
+
pooling: 'mean',
|
|
179
|
+
normalize: true,
|
|
180
|
+
});
|
|
181
|
+
}
|
package/dist/rerank.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export declare const RERANK_MODEL = "Xenova/bge-reranker-base";
|
|
2
|
+
export declare function getReranker(cacheDir: string, offline?: boolean, retryOpts?: {
|
|
3
|
+
maxRetries?: number;
|
|
4
|
+
delays?: number[];
|
|
5
|
+
log?: (msg: string) => void;
|
|
6
|
+
}): Promise<{
|
|
7
|
+
tokenizer: any;
|
|
8
|
+
model: any;
|
|
9
|
+
}>;
|
|
10
|
+
export declare function rerankScores(query: string, passages: string[], cacheDir: string, offline?: boolean): Promise<number[]>;
|
package/dist/rerank.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-encoder re-ranking (issue #15).
|
|
3
|
+
*/
|
|
4
|
+
import { resolveSessionOptions, retryWithBackoff } from './core.js';
|
|
5
|
+
const _rerankers = new Map();
|
|
6
|
+
export const RERANK_MODEL = 'Xenova/bge-reranker-base';
|
|
7
|
+
export async function getReranker(cacheDir, offline = false, retryOpts = {}) {
|
|
8
|
+
const sessionOptions = resolveSessionOptions();
|
|
9
|
+
const sessionKey = sessionOptions ? JSON.stringify(sessionOptions) : 'default';
|
|
10
|
+
const key = `${RERANK_MODEL}|${offline ? 'off' : 'on'}|${sessionKey}`;
|
|
11
|
+
if (_rerankers.has(key))
|
|
12
|
+
return _rerankers.get(key);
|
|
13
|
+
const loadPipeline = async () => {
|
|
14
|
+
const { AutoModelForSequenceClassification, AutoTokenizer, env } = await import('@huggingface/transformers');
|
|
15
|
+
if (cacheDir)
|
|
16
|
+
env.cacheDir = cacheDir;
|
|
17
|
+
env.allowRemoteModels = !offline;
|
|
18
|
+
const tokenizer = await AutoTokenizer.from_pretrained(RERANK_MODEL);
|
|
19
|
+
const loadOpts = {
|
|
20
|
+
dtype: 'q8',
|
|
21
|
+
...(sessionOptions ? { session_options: sessionOptions } : {}),
|
|
22
|
+
};
|
|
23
|
+
const model = await AutoModelForSequenceClassification.from_pretrained(RERANK_MODEL, loadOpts);
|
|
24
|
+
const r = { tokenizer, model };
|
|
25
|
+
_rerankers.set(key, r);
|
|
26
|
+
return r;
|
|
27
|
+
};
|
|
28
|
+
if (offline) {
|
|
29
|
+
return await loadPipeline();
|
|
30
|
+
}
|
|
31
|
+
return await retryWithBackoff(loadPipeline, retryOpts);
|
|
32
|
+
}
|
|
33
|
+
export async function rerankScores(query, passages, cacheDir, offline = false) {
|
|
34
|
+
if (passages.length === 0)
|
|
35
|
+
return [];
|
|
36
|
+
const { tokenizer, model } = await getReranker(cacheDir, offline);
|
|
37
|
+
const queries = new Array(passages.length).fill(query);
|
|
38
|
+
const inputs = tokenizer(queries, { text_pair: passages, padding: true, truncation: true });
|
|
39
|
+
const outputs = await model(inputs);
|
|
40
|
+
return outputs.logits.tolist().map((row) => row[0]);
|
|
41
|
+
}
|