graph-indexer 2.0.0 → 2.1.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/CHANGELOG.md +442 -0
- package/README.md +54 -11
- package/SECURITY.md +17 -0
- package/colbert.mjs +168 -0
- package/config.mjs +246 -21
- package/embeddings.mjs +90 -8
- package/engine/binary.mjs +38 -0
- package/engine/memory.mjs +214 -1
- package/engine/sqlite.mjs +278 -5
- package/enrichment.mjs +95 -0
- package/indexer.mjs +254 -9
- package/init.mjs +25 -2
- package/mcp/centrality.mjs +117 -0
- package/mcp/resolver.mjs +116 -0
- package/mcp/symbolgraph.mjs +137 -0
- package/mcp/taint.mjs +225 -0
- package/mcp/tools.mjs +444 -19
- package/mcp/topology.mjs +138 -13
- package/mcp-server.mjs +26 -2
- package/package.json +29 -21
- package/parse/extractor.mjs +17 -6
- package/parse/interprocedural.mjs +134 -0
- package/parse/languages.mjs +221 -57
- package/parse/metadata.mjs +46 -0
- package/parse/scip.mjs +391 -0
- package/parse/taint-patterns.mjs +119 -0
- package/prompts/CORE.md +9 -4
- package/prompts/frameworks/FASTAPI_DJANGO.md +1 -0
- package/prompts/frameworks/NODE_EXPRESS_NESTJS.md +1 -0
- package/prompts/frameworks/REACT.md +1 -1
- package/prompts/frameworks/SPRING_BOOT.md +1 -0
- package/prompts/languages/GO.md +1 -0
- package/prompts/languages/JAVA.md +1 -1
- package/prompts/languages/PYTHON.md +1 -1
- package/prompts/languages/RUST.md +1 -1
- package/seal.mjs +400 -0
- package/search-core.mjs +151 -1
- package/search-sparse.mjs +183 -0
- package/storage.mjs +5 -0
- package/watch-daemon.mjs +7 -2
package/config.mjs
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import fs from 'fs';
|
|
12
12
|
import path from 'path';
|
|
13
13
|
import { artifactPaths, CONFIG_FILE_NAME, DATA_DIR_NAME } from './layout.mjs';
|
|
14
|
+
import { assertSealCompatible } from './seal.mjs';
|
|
14
15
|
|
|
15
16
|
export const DEFAULTS = Object.freeze({
|
|
16
17
|
// 'auto' keeps the index in memory below AUTO_SQLITE_CHUNK_THRESHOLD chunks and
|
|
@@ -20,15 +21,20 @@ export const DEFAULTS = Object.freeze({
|
|
|
20
21
|
embeddings: false, // OFF by default — lexical + stemming need zero dependencies.
|
|
21
22
|
// Opt in with --embeddings or INDEXER_EMBEDDINGS=on.
|
|
22
23
|
embedProvider: 'auto',
|
|
23
|
-
// 'auto'
|
|
24
|
-
// 'ollama'
|
|
25
|
-
// 'local'
|
|
26
|
-
// '
|
|
27
|
-
//
|
|
24
|
+
// 'auto' → Ollama (if running w/ the model) → in-process local → lexical-only
|
|
25
|
+
// 'ollama' → force Ollama daemon (requires: ollama serve + pulled model)
|
|
26
|
+
// 'local' → in-process Xenova MiniLM (requires: npm i @huggingface/transformers)
|
|
27
|
+
// 'code-local'→ in-process code-specialized embedder (jina-code; same optional dep as
|
|
28
|
+
// 'local'; ~160 MB q8 first download, air-gapped after) — sharper code recall
|
|
29
|
+
// 'mlx' → Apple Metal GPU (requires: npm run embed:setup:mlx; macOS only)
|
|
30
|
+
// 'off' → lexical-only, no vectors
|
|
28
31
|
embedModel: 'nomic-embed-text', // default Ollama embed model when embeddings are enabled.
|
|
29
32
|
// qwen3-embedding:4b is a documented opt-in upgrade
|
|
30
33
|
// (--embed-model / EMBED_MODEL), never a hardcoded default.
|
|
31
34
|
localEmbedModel: 'Xenova/all-MiniLM-L6-v2', // in-process model (optional @huggingface/transformers)
|
|
35
|
+
codeEmbedModel: 'jinaai/jina-embeddings-v2-base-code', // in-process CODE-specialized model for
|
|
36
|
+
// provider 'code-local' (768-dim, MIT, 8192-token context).
|
|
37
|
+
// Override with --code-embed-model / INDEXER_CODE_EMBED_MODEL.
|
|
32
38
|
mlxEmbedModel: 'mlx-community/all-MiniLM-L6-v2-4bit', // MLX embedder model (provider 'mlx').
|
|
33
39
|
// Override with --mlx-embed-model / INDEXER_MLX_EMBED_MODEL;
|
|
34
40
|
// must be an mlx_embeddings-compatible sentence model.
|
|
@@ -38,6 +44,42 @@ export const DEFAULTS = Object.freeze({
|
|
|
38
44
|
mlxLmHost: 'http://localhost:8080', // mlx_lm.server endpoint (default port 8080)
|
|
39
45
|
gitSignals: true, // collect local git churn/recency/co-change at index time (air-gapped)
|
|
40
46
|
gitRankBoost: 0, // 0..1 opt-in recency/churn weight in search_code (0 = ranking unchanged)
|
|
47
|
+
interprocedural: false, // OPT-IN: index-time inter-procedural receiver-type fixpoint —
|
|
48
|
+
// propagate return types along factory call chains so multi-hop
|
|
49
|
+
// receivers resolve in get_call_graph / find_references. Air-gapped,
|
|
50
|
+
// deterministic; default index is byte-identical. Enable with
|
|
51
|
+
// --interprocedural / INDEXER_INTERPROCEDURAL.
|
|
52
|
+
symbolGraph: false, // OPT-IN: persist a resolved symbol graph (chunk→chunk edges with
|
|
53
|
+
// kind + confidence) at index time, exposed via getEdges. findCallers/
|
|
54
|
+
// findReferers read it when present (identical sets, fall back to scan).
|
|
55
|
+
// Air-gapped, deterministic; default index byte-identical; search
|
|
56
|
+
// ranking unaffected. Enable with --symbol-graph / INDEXER_SYMBOL_GRAPH.
|
|
57
|
+
taint: false, // OPT-IN: precompute + serialize taint flows (C2) at index time so the
|
|
58
|
+
// trace_taint / find_tainted_sinks tools serve instantly (both backends
|
|
59
|
+
// serve identical flows). Default off → tools compute query-time (the
|
|
60
|
+
// index is byte-identical). --taint / INDEXER_TAINT.
|
|
61
|
+
ranker: 'rrf', // Search ranker (D3). 'rrf' (default): the RRF + boost-ladder fusion —
|
|
62
|
+
// BYTE-IDENTICAL to before. 'learned': a post-fusion linear re-rank over
|
|
63
|
+
// RRF score + A5 centrality + resolved/SCIP in-degree + git (zero-dep dot
|
|
64
|
+
// product; lift is on --symbol-graph indexes). --ranker / INDEXER_RANKER.
|
|
65
|
+
learnedSparse: false, // OPT-IN: learn a corpus co-occurrence (PMI) association model (B3) at
|
|
66
|
+
// index time and serialize it, adding an NL-only vocabulary-EXPANSION
|
|
67
|
+
// channel at query time (zero new dep, fully air-gapped). Default off →
|
|
68
|
+
// no model → byte-identical. --learned-sparse / INDEXER_LEARNED_SPARSE.
|
|
69
|
+
sealed: 'off', // Sealed mode (F1): 'off' (default) | 'local' (loopback only) |
|
|
70
|
+
// 'strict' (zero network egress; lexical-only). Fail-closed:
|
|
71
|
+
// refuses to start if an enabled feature would egress beyond the
|
|
72
|
+
// tier, and installs a deny-by-default runtime egress guard.
|
|
73
|
+
// --sealed [strict|local] / INDEXER_SEALED.
|
|
74
|
+
resolver: 'heuristic', // Resolver provider for the symbol graph (A1/A2). 'heuristic' (default):
|
|
75
|
+
// edges stay { high, name_only } — byte-identical. 'precise' (A1): lift
|
|
76
|
+
// provably-unambiguous edges (sole definition / type-pinned receiver)
|
|
77
|
+
// to a `resolved` tier, powering impact_of_edit precision='strict'.
|
|
78
|
+
// 'scip' (A2): ingest a locally-generated SCIP index (--scip-index) and
|
|
79
|
+
// promote/suppress `calls` edges by real cross-file bindings. Only
|
|
80
|
+
// meaningful with --symbol-graph. --resolver / INDEXER_RESOLVER.
|
|
81
|
+
scipIndex: null, // Path to a locally-generated SCIP index for --resolver scip (A2).
|
|
82
|
+
// --scip-index / INDEXER_SCIP_INDEX / scipIndex config; null = none.
|
|
41
83
|
enrichment: Object.freeze({
|
|
42
84
|
enabled: false,
|
|
43
85
|
model: 'qwen2.5-coder:1.5b', // smallest coder model; quality sufficient for summaries.
|
|
@@ -51,9 +93,16 @@ export const DEFAULTS = Object.freeze({
|
|
|
51
93
|
|
|
52
94
|
}),
|
|
53
95
|
rerank: Object.freeze({
|
|
54
|
-
enabled: false, // opt-in: one
|
|
55
|
-
|
|
56
|
-
|
|
96
|
+
enabled: false, // opt-in: one rerank pass per natural-language query
|
|
97
|
+
provider: 'generative', // 'generative' = LLM judge (default) | 'cross-encoder' = local
|
|
98
|
+
// air-gapped MS-MARCO cross-encoder (no Ollama/daemon) |
|
|
99
|
+
// 'late-interaction' = ColBERT-style multi-vector MaxSim (B4,
|
|
100
|
+
// reuses the embedder + stored doc vectors; needs --embeddings).
|
|
101
|
+
// Set with --rerank-provider / RERANK_PROVIDER.
|
|
102
|
+
model: 'qwen2.5-coder:7b', // generative judge model: 7B measured +50% semantic rank-1, 1.5B ~nil
|
|
103
|
+
crossEncoderModel: 'Xenova/ms-marco-MiniLM-L-6-v2', // cross-encoder provider model
|
|
104
|
+
// (optional @huggingface/transformers; downloaded on first use)
|
|
105
|
+
topM: 12, // candidates the reranker reorders
|
|
57
106
|
poolSize: 15, // over-fetch depth when reranking, so a correct-but-deep
|
|
58
107
|
// hit can be RESCUED into top_k (then truncated to top_k)
|
|
59
108
|
}),
|
|
@@ -150,10 +199,55 @@ export function resolveConfig({ argv = process.argv.slice(2), env = process.env,
|
|
|
150
199
|
?? (Number.isFinite(file.gitRankBoost) ? file.gitRankBoost : DEFAULTS.gitRankBoost);
|
|
151
200
|
const gitRankBoost = Math.min(1, Math.max(0, Number(gitRankBoostRaw) || 0));
|
|
152
201
|
|
|
202
|
+
// Opt-in inter-procedural receiver-type fixpoint (index-time, air-gapped).
|
|
203
|
+
const interprocedural = argv.includes('--interprocedural')
|
|
204
|
+
|| env.INDEXER_INTERPROCEDURAL === 'on'
|
|
205
|
+
|| file.interprocedural === true;
|
|
206
|
+
|
|
207
|
+
// Opt-in persistent resolved symbol graph (index-time, air-gapped).
|
|
208
|
+
const symbolGraph = argv.includes('--symbol-graph')
|
|
209
|
+
|| env.INDEXER_SYMBOL_GRAPH === 'on'
|
|
210
|
+
|| file.symbolGraph === true;
|
|
211
|
+
|
|
212
|
+
// Opt-in index-time taint serialization (C2): precompute + persist the flow set.
|
|
213
|
+
const taint = argv.includes('--taint')
|
|
214
|
+
|| env.INDEXER_TAINT === 'on'
|
|
215
|
+
|| file.taint === true;
|
|
216
|
+
|
|
217
|
+
// Opt-in learned-sparse model (B3): learn + persist the corpus association model at index time.
|
|
218
|
+
const learnedSparse = argv.includes('--learned-sparse')
|
|
219
|
+
|| env.INDEXER_LEARNED_SPARSE === 'on'
|
|
220
|
+
|| file.learnedSparse === true;
|
|
221
|
+
|
|
222
|
+
// Search ranker (D3). Unknown values fall back to the default 'rrf' (byte-identical fusion).
|
|
223
|
+
const rankerRaw = flagValue(argv, '--ranker') || env.INDEXER_RANKER || file.ranker || DEFAULTS.ranker;
|
|
224
|
+
const ranker = (rankerRaw === 'learned' || rankerRaw === 'rrf') ? rankerRaw : 'rrf';
|
|
225
|
+
|
|
226
|
+
// Resolver provider for the symbol graph (A1/A2). Unknown values fall back to 'heuristic'.
|
|
227
|
+
const resolverRaw = flagValue(argv, '--resolver') || env.INDEXER_RESOLVER || file.resolver || DEFAULTS.resolver;
|
|
228
|
+
const resolver = (resolverRaw === 'precise' || resolverRaw === 'heuristic' || resolverRaw === 'scip')
|
|
229
|
+
? resolverRaw : 'heuristic';
|
|
230
|
+
// A2: path to a locally-generated SCIP index, consumed by the `scip` resolver. flag > env > file.
|
|
231
|
+
// Resolved against projectRoot (a relative path) so a config-file value is repo-relative.
|
|
232
|
+
const scipRaw = flagValue(argv, '--scip-index') || env.INDEXER_SCIP_INDEX || file.scipIndex || null;
|
|
233
|
+
const scipIndex = scipRaw ? path.resolve(projectRoot, scipRaw) : null;
|
|
234
|
+
|
|
235
|
+
// Sealed mode (F1). flag > env > file > default. A bare `--sealed` (no value, or followed by
|
|
236
|
+
// another flag) means strict; `--sealed local` opts into the loopback tier; env 'on' → strict.
|
|
237
|
+
const sealedFlagPresent = argv.includes('--sealed');
|
|
238
|
+
const sealedFlagVal = sealedFlagPresent ? flagValue(argv, '--sealed') : undefined;
|
|
239
|
+
let sealedRaw;
|
|
240
|
+
if (sealedFlagPresent) sealedRaw = (sealedFlagVal && !sealedFlagVal.startsWith('--')) ? sealedFlagVal : 'strict';
|
|
241
|
+
else if (env.INDEXER_SEALED) sealedRaw = env.INDEXER_SEALED;
|
|
242
|
+
else sealedRaw = typeof file.sealed === 'string' ? file.sealed : DEFAULTS.sealed;
|
|
243
|
+
const sealed = sealedRaw === 'on' ? 'strict'
|
|
244
|
+
: (['strict', 'local', 'off'].includes(sealedRaw) ? sealedRaw : 'off');
|
|
245
|
+
|
|
153
246
|
const paths = artifactPaths(projectRoot);
|
|
154
247
|
|
|
155
|
-
|
|
248
|
+
const resolved = Object.freeze({
|
|
156
249
|
projectRoot,
|
|
250
|
+
sealed,
|
|
157
251
|
storage,
|
|
158
252
|
dataDir: paths.dataDir,
|
|
159
253
|
indexPath: paths.indexPath,
|
|
@@ -175,12 +269,24 @@ export function resolveConfig({ argv = process.argv.slice(2), env = process.env,
|
|
|
175
269
|
// This is the model `auto`/`ollama` providers pull (e.g. qwen3-embedding:4b).
|
|
176
270
|
embedModel: flagValue(argv, '--embed-model') || env.EMBED_MODEL || file.embedModel || DEFAULTS.embedModel,
|
|
177
271
|
localEmbedModel: file.localEmbedModel || DEFAULTS.localEmbedModel,
|
|
272
|
+
// Code-specialized in-process model (provider 'code-local'): --code-embed-model flag >
|
|
273
|
+
// INDEXER_CODE_EMBED_MODEL env > config > jina-code default. Stamped into the embed-meta
|
|
274
|
+
// so query time and index time agree, and a model switch forces a clean re-embed.
|
|
275
|
+
codeEmbedModel: flagValue(argv, '--code-embed-model') || env.INDEXER_CODE_EMBED_MODEL
|
|
276
|
+
|| file.codeEmbedModel || DEFAULTS.codeEmbedModel,
|
|
178
277
|
// MLX embedder model: --mlx-embed-model flag > INDEXER_MLX_EMBED_MODEL env >
|
|
179
278
|
// config > pinned MiniLM default. Passed to the Python server + stamped in meta.
|
|
180
279
|
mlxEmbedModel: flagValue(argv, '--mlx-embed-model') || env.INDEXER_MLX_EMBED_MODEL
|
|
181
280
|
|| file.mlxEmbedModel || DEFAULTS.mlxEmbedModel,
|
|
182
281
|
gitSignals,
|
|
183
282
|
gitRankBoost,
|
|
283
|
+
interprocedural,
|
|
284
|
+
symbolGraph,
|
|
285
|
+
taint,
|
|
286
|
+
ranker,
|
|
287
|
+
resolver,
|
|
288
|
+
scipIndex,
|
|
289
|
+
learnedSparse,
|
|
184
290
|
|
|
185
291
|
enrichment: Object.freeze({
|
|
186
292
|
enabled: enrichmentEnabled,
|
|
@@ -194,20 +300,34 @@ export function resolveConfig({ argv = process.argv.slice(2), env = process.env,
|
|
|
194
300
|
: (Number(fileEnrich.concurrency) > 0 ? Number(fileEnrich.concurrency) : DEFAULTS.enrichment.concurrency),
|
|
195
301
|
}),
|
|
196
302
|
|
|
197
|
-
rerank:
|
|
198
|
-
//
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
303
|
+
rerank: (() => {
|
|
304
|
+
// Provider: env > --rerank-provider flag > config; validated to a known provider.
|
|
305
|
+
const provRaw = env.RERANK_PROVIDER || flagValue(argv, '--rerank-provider') || (file.rerank || {}).provider;
|
|
306
|
+
const provider = ['generative', 'cross-encoder', 'late-interaction'].includes(provRaw) ? provRaw : DEFAULTS.rerank.provider;
|
|
307
|
+
return Object.freeze({
|
|
308
|
+
// OFF unless requested: --rerank flag, RERANK_MODEL env (naming a model implies
|
|
309
|
+
// enabling it), an explicit provider (env/flag), or the project config.
|
|
310
|
+
enabled: argv.includes('--rerank') || Boolean(env.RERANK_MODEL) || Boolean(provRaw)
|
|
311
|
+
|| Boolean((file.rerank || {}).enabled),
|
|
312
|
+
provider,
|
|
313
|
+
model: env.RERANK_MODEL || (file.rerank || {}).model || DEFAULTS.rerank.model,
|
|
314
|
+
crossEncoderModel: env.RERANK_CROSS_ENCODER_MODEL || (file.rerank || {}).crossEncoderModel
|
|
315
|
+
|| DEFAULTS.rerank.crossEncoderModel,
|
|
316
|
+
topM: Number.isInteger((file.rerank || {}).topM) ? (file.rerank || {}).topM : DEFAULTS.rerank.topM,
|
|
317
|
+
poolSize: Number.isInteger((file.rerank || {}).poolSize) ? (file.rerank || {}).poolSize : DEFAULTS.rerank.poolSize,
|
|
318
|
+
});
|
|
319
|
+
})(),
|
|
205
320
|
|
|
206
321
|
hyde: Object.freeze({
|
|
207
322
|
enabled: Boolean((file.hyde || {}).enabled),
|
|
208
323
|
model: (file.hyde || {}).model || DEFAULTS.hyde.model,
|
|
209
324
|
}),
|
|
210
325
|
});
|
|
326
|
+
|
|
327
|
+
// F1: fail-closed. If --sealed was requested and an enabled feature would egress beyond the
|
|
328
|
+
// tier, throw here so a sealed-incompatible config can never be constructed (no-op when off).
|
|
329
|
+
assertSealCompatible(resolved);
|
|
330
|
+
return resolved;
|
|
211
331
|
}
|
|
212
332
|
|
|
213
333
|
// Memoised singleton for import-time consumers (e.g. parse/languages.mjs language loading).
|
|
@@ -229,10 +349,11 @@ export function getConfig() {
|
|
|
229
349
|
* @returns {string[]}
|
|
230
350
|
*/
|
|
231
351
|
export function describeConfig(config, { backend = config.storage } = {}) {
|
|
232
|
-
// Show the model that the chosen provider actually loads (mlx/local use their own).
|
|
352
|
+
// Show the model that the chosen provider actually loads (mlx/local/code-local use their own).
|
|
233
353
|
const provModel = config.embedProvider === 'mlx' ? config.mlxEmbedModel
|
|
234
354
|
: config.embedProvider === 'local' ? config.localEmbedModel
|
|
235
|
-
: config.
|
|
355
|
+
: config.embedProvider === 'code-local' ? config.codeEmbedModel
|
|
356
|
+
: config.embedModel;
|
|
236
357
|
const emb = config.embeddingsEnabled
|
|
237
358
|
? `on · provider=${config.embedProvider}, model=${provModel}`
|
|
238
359
|
: 'off (lexical + stemming only)';
|
|
@@ -240,8 +361,24 @@ export function describeConfig(config, { backend = config.storage } = {}) {
|
|
|
240
361
|
`storage : ${backend}`,
|
|
241
362
|
`embeddings : ${emb}`,
|
|
242
363
|
`enrichment : ${config.enrichment.enabled ? `on · model=${config.enrichment.model} · provider=${config.llmProvider}` : 'off'}`,
|
|
243
|
-
`reranker : ${config.rerank.enabled
|
|
364
|
+
`reranker : ${config.rerank.enabled
|
|
365
|
+
? (config.rerank.provider === 'cross-encoder'
|
|
366
|
+
? `on · provider=cross-encoder · model=${config.rerank.crossEncoderModel}`
|
|
367
|
+
: config.rerank.provider === 'late-interaction'
|
|
368
|
+
? 'on · provider=late-interaction · ColBERT-style multi-vector MaxSim (needs --embeddings)'
|
|
369
|
+
: `on · provider=generative · model=${config.rerank.model} · llm=${config.llmProvider}`)
|
|
370
|
+
: 'off'}`,
|
|
244
371
|
`git signals : ${config.gitSignals ? 'on' : 'off'}${config.gitRankBoost ? ` · rank boost=${config.gitRankBoost}` : ''}`,
|
|
372
|
+
`interproc. : ${config.interprocedural ? 'on · factory return-type propagation' : 'off'}`,
|
|
373
|
+
`symbol graph: ${config.symbolGraph ? 'on · persisted resolved edges (getEdges)' : 'off'}`,
|
|
374
|
+
`taint : ${config.taint ? 'on · precomputed flows (trace_taint / find_tainted_sinks)' : 'off (query-time)'}`,
|
|
375
|
+
`ranker : ${config.ranker === 'learned' ? 'learned · post-fusion linear re-rank (opt-in)' : 'rrf (default)'}`,
|
|
376
|
+
`sparse : ${config.learnedSparse ? 'on · learned-sparse vocabulary expansion (NL-only channel)' : 'off'}`,
|
|
377
|
+
`resolver : ${config.resolver === 'precise' ? 'precise · unambiguous edges → `resolved` tier'
|
|
378
|
+
: config.resolver === 'scip' ? `scip · cross-file SCIP bindings → \`resolved\` (${config.scipIndex || '⚠️ no --scip-index'})`
|
|
379
|
+
: 'heuristic (default)'}`,
|
|
380
|
+
`sealed mode : ${config.sealed === 'strict' ? '🔒 strict · zero network egress (enforced)'
|
|
381
|
+
: config.sealed === 'local' ? '🔒 local · loopback only (enforced)' : 'off'}`,
|
|
245
382
|
];
|
|
246
383
|
}
|
|
247
384
|
|
|
@@ -256,11 +393,99 @@ export function describeConfig(config, { backend = config.storage } = {}) {
|
|
|
256
393
|
*/
|
|
257
394
|
export function configNotices(config) {
|
|
258
395
|
const out = [];
|
|
396
|
+
if (config.sealed === 'strict' || config.sealed === 'local') {
|
|
397
|
+
out.push(`Sealed mode (${config.sealed}): a deny-by-default egress guard is installed — `
|
|
398
|
+
+ `${config.sealed === 'strict' ? 'NO network connections are permitted' : 'only loopback connections are permitted'}. `
|
|
399
|
+
+ 'The build refused to start if any enabled feature would egress beyond the tier. '
|
|
400
|
+
+ 'The in-process guard (sockets/http/fetch) is airtight; the child_process denylist is '
|
|
401
|
+
+ 'best-effort. Run `idx-index --attest` for the signed-able manifest.');
|
|
402
|
+
}
|
|
403
|
+
if (config.embeddingsEnabled && config.embedProvider === 'code-local') {
|
|
404
|
+
out.push(`Code-specialized embedder (code-local): the dense channel is built in-process by `
|
|
405
|
+
+ `${config.codeEmbedModel} — a CODE-trained model (identifiers, call patterns, type names) `
|
|
406
|
+
+ 'that sharpens semantic recall on typed languages where the general-purpose MiniLM is '
|
|
407
|
+
+ 'weakest. It needs the optional \'@huggingface/transformers\' package; the quantized '
|
|
408
|
+
+ '(q8) model is ~160 MB on the FIRST run (downloaded once, then fully air-gapped — no daemon, no network). '
|
|
409
|
+
+ 'It is slower per chunk than MiniLM and produces 768-dim vectors — switching to it from '
|
|
410
|
+
+ 'another provider forces a clean re-embed (vectors of different models must not be mixed). '
|
|
411
|
+
+ 'Pair with the persistent embed cache to pay the embedding cost only once. Measure on '
|
|
412
|
+
+ 'YOUR repo (`npm run test:eval -- --embeddings --embed-provider code-local`) before adopting.');
|
|
413
|
+
}
|
|
414
|
+
if (config.interprocedural) {
|
|
415
|
+
out.push('Inter-procedural receiver types are resolved at index time (a whole-program pass). '
|
|
416
|
+
+ 'The watch daemon updates one file at a time and does NOT re-run the fixpoint, so '
|
|
417
|
+
+ 'cross-file factory chains refresh on the next full `idx-index`; until then they fall '
|
|
418
|
+
+ 'back to single-hop resolution (never worse than the default). It only sharpens '
|
|
419
|
+
+ 'get_call_graph / find_references — search ranking is unaffected.');
|
|
420
|
+
}
|
|
421
|
+
if (config.symbolGraph) {
|
|
422
|
+
out.push('The resolved symbol graph is built once at index time (a whole-program pass), adding '
|
|
423
|
+
+ 'index time roughly proportional to repo size. The watch daemon does NOT rebuild it, so '
|
|
424
|
+
+ 'edges refresh on the next full `idx-index`; until then findCallers/findReferers fall back '
|
|
425
|
+
+ 'to the name-match scan (never worse). It powers getEdges; search ranking is unaffected.');
|
|
426
|
+
}
|
|
427
|
+
if (config.ranker === 'learned') {
|
|
428
|
+
out.push('Learned ranker (D3): the fused top-N is re-ordered by a zero-dependency linear model '
|
|
429
|
+
+ 'over the RRF score + symbol centrality + resolved/SCIP in-degree + git recency. It is a '
|
|
430
|
+
+ 'post-fusion re-rank (the default RRF ordering is untouched). Its discriminating features '
|
|
431
|
+
+ 'are zero without --symbol-graph, so pair it with --symbol-graph for the measured lift; on a '
|
|
432
|
+
+ 'plain lexical index it is ≈neutral. RRF remains the default; this is opt-in.');
|
|
433
|
+
}
|
|
434
|
+
if (config.taint) {
|
|
435
|
+
out.push('Taint flows are precomputed once at index time and serialized, so trace_taint / '
|
|
436
|
+
+ 'find_tainted_sinks answer instantly (both backends serve identical flows). The watch '
|
|
437
|
+
+ 'daemon does NOT rebuild them — flows refresh on the next full `idx-index`; until then the '
|
|
438
|
+
+ 'tools fall back to a query-time scan (never worse). It only affects the security tools; '
|
|
439
|
+
+ 'search ranking is unaffected. Pair with --symbol-graph for cross-function precision.');
|
|
440
|
+
}
|
|
441
|
+
if (config.learnedSparse) {
|
|
442
|
+
out.push('Learned-sparse (B3): a corpus co-occurrence (PMI) association model is learned + '
|
|
443
|
+
+ 'serialized at index time, adding a vocabulary-EXPANSION channel that fires ONLY for '
|
|
444
|
+
+ 'natural-language queries (symbolic/exact lookups stay byte-identical). Zero new dependency, '
|
|
445
|
+
+ 'fully air-gapped, parity-safe. The whole-program model refreshes on the next full '
|
|
446
|
+
+ '`idx-index` (the daemon drops it on a per-file edit). Measure on YOUR repo (`test:eval`) — '
|
|
447
|
+
+ 'it is a recall aid that helps lexical-gap semantic queries and is ≈neutral elsewhere.');
|
|
448
|
+
}
|
|
449
|
+
if ((config.resolver === 'precise' || config.resolver === 'scip') && !config.symbolGraph) {
|
|
450
|
+
out.push(`--resolver ${config.resolver} has no effect without --symbol-graph (the resolver runs only `
|
|
451
|
+
+ 'when the symbol graph is built). Add --symbol-graph to enable the `resolved` edge tier.');
|
|
452
|
+
} else if (config.resolver === 'precise') {
|
|
453
|
+
out.push('Precise resolver: edges with a provably-unambiguous binding (sole definition, or a '
|
|
454
|
+
+ 'type-pinned receiver) are promoted from `high` to a `resolved` tier — used by '
|
|
455
|
+
+ "impact_of_edit(precision: 'strict') for a false-positive-free blast radius. Confidence "
|
|
456
|
+
+ 'strings only change; edge sets, parity, and search ranking are unaffected.');
|
|
457
|
+
} else if (config.resolver === 'scip' && !config.scipIndex) {
|
|
458
|
+
out.push('--resolver scip needs --scip-index <path.scip> (a SCIP index generated locally by your '
|
|
459
|
+
+ 'own toolchain — scip-typescript, scip-python, scip-java, rust-analyzer --scip). Without it '
|
|
460
|
+
+ 'the resolver is inert and edges stay heuristic { high, name_only }.');
|
|
461
|
+
} else if (config.resolver === 'scip') {
|
|
462
|
+
out.push('SCIP resolver: cross-file bindings from a locally-generated SCIP index promote CONFIRMED '
|
|
463
|
+
+ '`calls` edges to a `resolved` tier AND suppress the wrong-target fan-out an ambiguous name '
|
|
464
|
+
+ 'would otherwise emit (heritage/type edges stay heuristic). Suppression is sound under '
|
|
465
|
+
+ 'partial coverage — a def SCIP never recorded is never dropped. Air-gapped (a local file '
|
|
466
|
+
+ 'read; sealed-compatible). Files the SCIP index does not cover fall back to heuristic, never '
|
|
467
|
+
+ 'worse. Coverage is printed at index time; re-generate the .scip when code changes.');
|
|
468
|
+
}
|
|
259
469
|
if (config.enrichment.enabled && !config.rerank.enabled) {
|
|
260
470
|
out.push('Enrichment is most effective when paired with --rerank. Running enrichment-only may '
|
|
261
471
|
+ 'regress semantic precision (measured: gin MRR 0.48→0.39 on qwen3 embeddings).');
|
|
262
472
|
}
|
|
263
|
-
if (config.rerank.enabled) {
|
|
473
|
+
if (config.rerank.enabled && config.rerank.provider === 'cross-encoder') {
|
|
474
|
+
out.push('Cross-encoder reranker scores (query, candidate) pairs locally and air-gapped — '
|
|
475
|
+
+ 'no LLM/daemon, deterministic, ~tens of ms per natural-language query. It needs the '
|
|
476
|
+
+ "optional '@huggingface/transformers' package (first run downloads the model). It sees "
|
|
477
|
+
+ 'only a candidate signature/summary line, not full code, so it sharpens near-ties rather '
|
|
478
|
+
+ 'than reasoning about behaviour. Measure on your repo (`npm run test:eval -- --rerank '
|
|
479
|
+
+ '--rerank-provider cross-encoder`) before adopting.');
|
|
480
|
+
} else if (config.rerank.enabled && config.rerank.provider === 'late-interaction') {
|
|
481
|
+
out.push('Late-interaction (ColBERT-style) reranker scores the query MULTI-vectors against '
|
|
482
|
+
+ 'each candidate\'s stored document sub-vectors with MaxSim — air-gapped (the same local '
|
|
483
|
+
+ 'embedder), deterministic, parity-safe, with NO storage blow-up (it reuses the vectors '
|
|
484
|
+
+ '--embeddings already stores). It REQUIRES --embeddings (no doc vectors ⇒ no-op) and '
|
|
485
|
+
+ 'approximates true token-level ColBERT with a sentence embedder over query sub-units. '
|
|
486
|
+
+ 'Measure on your repo (`npm run test:eval -- --embeddings --rerank '
|
|
487
|
+
+ '--rerank-provider late-interaction`) before adopting.');
|
|
488
|
+
} else if (config.rerank.enabled) {
|
|
264
489
|
out.push('Reranker improves rank-1 for Go/Python repositories (gin: 0.20→0.40) but has shown '
|
|
265
490
|
+ 'regressions on JavaScript repositories (express: 0.43→0.29). Behaviour depends on '
|
|
266
491
|
+ 'repository language. See README for per-language benchmark data.');
|
package/embeddings.mjs
CHANGED
|
@@ -31,6 +31,16 @@ const PYTHON_DIR = join(__dirname, 'embedders', 'python');
|
|
|
31
31
|
|
|
32
32
|
export const LOCAL_EMBED_MODEL_DEFAULT = 'Xenova/all-MiniLM-L6-v2';
|
|
33
33
|
export const LOCAL_EMBED_DIM = 384;
|
|
34
|
+
// Code-specialized in-process embedder (provider 'code-local'). Same optional
|
|
35
|
+
// @huggingface/transformers feature-extraction path as the general-purpose 'local'
|
|
36
|
+
// MiniLM, but a model trained on code semantics (identifiers, call patterns, type
|
|
37
|
+
// names) where MiniLM is weakest. jina-embeddings-v2-base-code is 768-dim, MIT, ships
|
|
38
|
+
// first-party ONNX (transformers.js reads it directly, no trust_remote_code), supports
|
|
39
|
+
// an 8192-token context, and is ~160 MB (q8 quantized) on first download (air-gapped thereafter).
|
|
40
|
+
// Override with --code-embed-model / INDEXER_CODE_EMBED_MODEL (e.g. the Xenova mirror
|
|
41
|
+
// `Xenova/jina-embeddings-v2-base-code`, or `Xenova/codet5p-110m-embedding`).
|
|
42
|
+
export const CODE_EMBED_MODEL_DEFAULT = 'jinaai/jina-embeddings-v2-base-code';
|
|
43
|
+
export const CODE_EMBED_DIM = 768; // informational only — dim is derived lazily from the first vector.
|
|
34
44
|
// Passed to the Python server at spawn time (argv) AND stamped into the .meta.json
|
|
35
45
|
// sidecar so index time and query time always agree; the indexer's model-change
|
|
36
46
|
// detection forces a clean re-embed when this differs. Override via config.mlxEmbedModel.
|
|
@@ -80,12 +90,13 @@ export async function localEmbedAvailable() {
|
|
|
80
90
|
/**
|
|
81
91
|
* Decide which provider to use for THIS index/run.
|
|
82
92
|
* • INDEXER_EMBEDDINGS=off / embedProvider 'off' → no vectors (lexical-only).
|
|
83
|
-
* • 'ollama' / 'local' / 'mlx'
|
|
84
|
-
* embedder
|
|
93
|
+
* • 'ollama' / 'local' / 'code-local' / 'mlx' → forced (code-local is the
|
|
94
|
+
* in-process code-specialized embedder; mlx is a native Python embedder on the
|
|
95
|
+
* Apple Metal GPU, macOS-only).
|
|
85
96
|
* • 'auto' (default) → running Ollama, else the bundled local model, else off.
|
|
86
97
|
* Probes are injectable for deterministic tests.
|
|
87
98
|
*
|
|
88
|
-
* @returns {Promise<{provider:'ollama'|'local'|'mlx'|'off', model:(string|null)}>}
|
|
99
|
+
* @returns {Promise<{provider:'ollama'|'local'|'code-local'|'mlx'|'off', model:(string|null)}>}
|
|
89
100
|
*/
|
|
90
101
|
export async function resolveEmbedProvider(config, { probe = probeOllama, hasLocal = localEmbedAvailable, hasModel = ollamaHasModel } = {}) {
|
|
91
102
|
if (config.embeddingsEnabled === false) return { provider: 'off', model: null };
|
|
@@ -94,6 +105,9 @@ export async function resolveEmbedProvider(config, { probe = probeOllama, hasLoc
|
|
|
94
105
|
if (want === 'off') return { provider: 'off', model: null };
|
|
95
106
|
if (want === 'ollama') return { provider: 'ollama', model: config.embedModel };
|
|
96
107
|
if (want === 'local') return { provider: 'local', model: localModel };
|
|
108
|
+
// code-local: forced, in-process, code-specialized. Never reached by `auto` (it
|
|
109
|
+
// is an explicit opt-in — `auto` stays MiniLM so the no-config path is unchanged).
|
|
110
|
+
if (want === 'code-local') return { provider: 'code-local', model: config.codeEmbedModel || CODE_EMBED_MODEL_DEFAULT };
|
|
97
111
|
if (want === 'mlx') { assertProviderPlatform('mlx'); return { provider: 'mlx', model: config.mlxEmbedModel || MLX_EMBED_MODEL }; }
|
|
98
112
|
// Prefer Ollama only when it actually HAS the configured model pulled — otherwise
|
|
99
113
|
// the indexer would crash on the first batch. Fall back to in-process, then off.
|
|
@@ -154,7 +168,8 @@ async function _localPipeline(model) {
|
|
|
154
168
|
catch {
|
|
155
169
|
throw new Error(
|
|
156
170
|
"Local embeddings need the optional '@huggingface/transformers' package. "
|
|
157
|
-
+ "Install it (`npm i @huggingface/transformers`)
|
|
171
|
+
+ "Install it (`npm run embed:setup:local`, or `npm i @huggingface/transformers`) "
|
|
172
|
+
+ "or set embedProvider to 'ollama'/'off'."
|
|
158
173
|
);
|
|
159
174
|
}
|
|
160
175
|
_pipe = await mod.pipeline('feature-extraction', model);
|
|
@@ -173,6 +188,67 @@ async function _localEmbedMany(model, texts) {
|
|
|
173
188
|
/** Reset the cached local pipeline (tests). */
|
|
174
189
|
export function _resetLocalPipeline() { _pipe = null; _pipeModel = null; }
|
|
175
190
|
|
|
191
|
+
// Code-specialized in-process embedder (provider 'code-local'). A SEPARATE lazy
|
|
192
|
+
// singleton from _localPipeline so a code-local and a plain-local run in the same
|
|
193
|
+
// process don't evict each other's model. The import lives inside the function, so
|
|
194
|
+
// the default path (lexical / `auto` → MiniLM) NEVER loads the code model — it is
|
|
195
|
+
// pulled only when 'code-local' is actually selected and a vector is requested.
|
|
196
|
+
// `_codeLoads` lets tests prove that lazy-load contract without a network call.
|
|
197
|
+
let _codePipe = null, _codePipeModel = null, _codeLoads = 0;
|
|
198
|
+
async function _codeLocalPipeline(model) {
|
|
199
|
+
if (_codePipe && _codePipeModel === model) return _codePipe;
|
|
200
|
+
let mod;
|
|
201
|
+
try { mod = await import('@huggingface/transformers'); }
|
|
202
|
+
catch {
|
|
203
|
+
throw new Error(
|
|
204
|
+
"The code-local embedder needs the optional '@huggingface/transformers' package. "
|
|
205
|
+
+ "Install it (`npm run embed:setup:local`, or `npm i @huggingface/transformers`) "
|
|
206
|
+
+ "or set embedProvider to 'ollama'/'local'/'off'."
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
// q8 quantized weights: a 161M-param code model is ~4× faster on CPU at q8 with
|
|
210
|
+
// negligible recall loss (the same quantized-ONNX choice the cross-encoder makes),
|
|
211
|
+
// which is what makes an in-process code embedder practical on a laptop. transformers.js
|
|
212
|
+
// falls back to fp32 automatically if a model ships no quantized ONNX.
|
|
213
|
+
_codePipe = await mod.pipeline('feature-extraction', model, { dtype: 'q8' });
|
|
214
|
+
_codePipeModel = model;
|
|
215
|
+
_codeLoads++;
|
|
216
|
+
return _codePipe;
|
|
217
|
+
}
|
|
218
|
+
async function _codeLocalEmbedMany(model, texts) {
|
|
219
|
+
const pipe = await _codeLocalPipeline(model);
|
|
220
|
+
// jina-code is symmetric (no nomic-style query/document prefixes); mean-pool +
|
|
221
|
+
// normalize, identical handling to the MiniLM path, so cache keys / windowing are
|
|
222
|
+
// unchanged. The 8000-char cap matches search-core's EMBEDDING_CONTEXT_LIMIT.
|
|
223
|
+
const out = await pipe(
|
|
224
|
+
texts.map(t => (t.length > DOC_CHAR_LIMIT ? t.slice(0, DOC_CHAR_LIMIT) : t)),
|
|
225
|
+
{ pooling: 'mean', normalize: true }
|
|
226
|
+
);
|
|
227
|
+
return out.tolist();
|
|
228
|
+
}
|
|
229
|
+
/** Reset the cached code-local pipeline (tests). */
|
|
230
|
+
export function _resetCodeLocalPipeline() { _codePipe = null; _codePipeModel = null; }
|
|
231
|
+
/** Number of times the code-local model has been lazy-loaded (tests: lazy-load contract). */
|
|
232
|
+
export function _codeLocalPipelineLoads() { return _codeLoads; }
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Should the cached vectors be discarded before this run? True only when the previous
|
|
236
|
+
* build's embed-meta records a DIFFERENT model than the embedder about to run. Vectors
|
|
237
|
+
* of different models/dims must never be mixed in one `.bin` — a 768-dim code-local
|
|
238
|
+
* vector and a 384-dim MiniLM vector under the same cache would silently no-op the
|
|
239
|
+
* dense channel for the mismatched dim (scan/sketch skip a wrong-dim entry). Model name
|
|
240
|
+
* is the pre-embed signal (the actual dim is unknown until the first vector returns), so
|
|
241
|
+
* switching `--embed-provider local` → `code-local` forces a clean re-embed rather than a
|
|
242
|
+
* silent half-empty index.
|
|
243
|
+
*
|
|
244
|
+
* @param {object|null} prevMeta The prior `.embeddings.bin.meta.json` ({ provider, model, dim }).
|
|
245
|
+
* @param {string|null} model The model the current embedder will use.
|
|
246
|
+
* @returns {boolean}
|
|
247
|
+
*/
|
|
248
|
+
export function embedModelChanged(prevMeta, model) {
|
|
249
|
+
return Boolean(prevMeta?.model && model && prevMeta.model !== model);
|
|
250
|
+
}
|
|
251
|
+
|
|
176
252
|
// ─── Native Python embedder (mlx) ────────────────────────────────────────────
|
|
177
253
|
// MLX (Apple Metal) is Python-first with no Node bindings, so it runs as a long-lived
|
|
178
254
|
// child process speaking newline-delimited JSON:
|
|
@@ -400,6 +476,7 @@ export function _resetSubprocesses() {
|
|
|
400
476
|
/** Canonical model id for a forced provider when no explicit model is supplied. */
|
|
401
477
|
function defaultModelForProvider(provider, config) {
|
|
402
478
|
if (provider === 'local') return config.localEmbedModel || LOCAL_EMBED_MODEL_DEFAULT;
|
|
479
|
+
if (provider === 'code-local') return config.codeEmbedModel || CODE_EMBED_MODEL_DEFAULT;
|
|
403
480
|
if (provider === 'mlx') return config.mlxEmbedModel || MLX_EMBED_MODEL;
|
|
404
481
|
return config.embedModel;
|
|
405
482
|
}
|
|
@@ -411,10 +488,11 @@ function defaultModelForProvider(provider, config) {
|
|
|
411
488
|
*
|
|
412
489
|
* @param {object} config
|
|
413
490
|
* @param {object} [opts]
|
|
414
|
-
* @param {'ollama'|'local'|'mlx'|'off'} [opts.provider] Force a provider
|
|
415
|
-
* the one stamped in the index meta). When omitted, resolveEmbedProvider decides.
|
|
491
|
+
* @param {'ollama'|'local'|'code-local'|'mlx'|'off'} [opts.provider] Force a provider
|
|
492
|
+
* (e.g. the one stamped in the index meta). When omitted, resolveEmbedProvider decides.
|
|
416
493
|
* @param {string} [opts.model]
|
|
417
|
-
* @param {object} [opts.backends] Inject { ollamaEmbedOne, ollamaEmbedMany, localEmbedMany
|
|
494
|
+
* @param {object} [opts.backends] Inject { ollamaEmbedOne, ollamaEmbedMany, localEmbedMany,
|
|
495
|
+
* codeLocalEmbedMany } (tests).
|
|
418
496
|
* @returns {Promise<{provider:string, model:(string|null), dim:(number|null),
|
|
419
497
|
* embedQuery:(t:string)=>Promise<number[]|null>,
|
|
420
498
|
* embedDocuments:(t:string[])=>Promise<number[][]|null>}>}
|
|
@@ -429,6 +507,7 @@ export async function createEmbedder(config, opts = {}) {
|
|
|
429
507
|
const ollamaOne = b.ollamaEmbedOne || _ollamaEmbedOne;
|
|
430
508
|
const ollamaMany = b.ollamaEmbedMany || _ollamaEmbedMany;
|
|
431
509
|
const localMany = b.localEmbedMany || _localEmbedMany;
|
|
510
|
+
const codeLocalMany = b.codeLocalEmbedMany || _codeLocalEmbedMany;
|
|
432
511
|
// Derive dim lazily from the first returned vector. Pre-setting LOCAL_EMBED_DIM=384
|
|
433
512
|
// mis-stamps models whose dim isn't 384 (e.g. jina-embeddings-v2-base-code = 768)
|
|
434
513
|
// into the .meta.json before the first embed call.
|
|
@@ -443,7 +522,8 @@ export async function createEmbedder(config, opts = {}) {
|
|
|
443
522
|
if (v && dim == null) dim = v.length;
|
|
444
523
|
return v ?? null;
|
|
445
524
|
}
|
|
446
|
-
const
|
|
525
|
+
const many = provider === 'code-local' ? codeLocalMany : localMany;
|
|
526
|
+
const [v] = await many(model, [text]);
|
|
447
527
|
if (v && dim == null) dim = v.length;
|
|
448
528
|
return v ?? null;
|
|
449
529
|
} catch { return null; } // graceful: caller drops to lexical
|
|
@@ -454,6 +534,7 @@ export async function createEmbedder(config, opts = {}) {
|
|
|
454
534
|
let vecs;
|
|
455
535
|
if (provider === 'ollama') vecs = await _withRetry(() => ollamaMany(host, model, texts));
|
|
456
536
|
else if (provider === 'mlx') vecs = await _subprocessEmbedMany(provider, texts, model);
|
|
537
|
+
else if (provider === 'code-local') vecs = await codeLocalMany(model, texts);
|
|
457
538
|
else vecs = await localMany(model, texts);
|
|
458
539
|
if (vecs && vecs[0] && dim == null) dim = vecs[0].length;
|
|
459
540
|
return vecs;
|
|
@@ -484,6 +565,7 @@ export function writeEmbedMeta(embeddingPath, meta) {
|
|
|
484
565
|
export function describeEmbedder({ provider, model } = {}) {
|
|
485
566
|
if (provider === 'ollama') return `🧠 Ollama · ${model}`;
|
|
486
567
|
if (provider === 'local') return `🧠 Local (in-process) · ${model}`;
|
|
568
|
+
if (provider === 'code-local') return `🧠 Code-local (in-process, code-specialized) · ${model}`;
|
|
487
569
|
if (provider === 'mlx') return `🧠 MLX (Apple Metal) · ${model}`;
|
|
488
570
|
return '🔤 Lexical only';
|
|
489
571
|
}
|
package/engine/binary.mjs
CHANGED
|
@@ -65,6 +65,44 @@ export function readEmbeddingBinary(filePath) {
|
|
|
65
65
|
return cache;
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
/**
|
|
69
|
+
* B4 — read ONLY the requested keys' vectors from an `.embeddings.bin` in ONE pass, materialising a
|
|
70
|
+
* Float32Array only for matched keys (so it never holds the whole corpus in memory — but it does read
|
|
71
|
+
* the file once and scans until every requested key is found, which for sparsely-present keys means
|
|
72
|
+
* to end-of-file). The late-interaction reranker uses this to fetch a small candidate set's stored
|
|
73
|
+
* sub-vectors IDENTICALLY for both backends (same file, same keys) → parity-by-construction. Returns
|
|
74
|
+
* the matches as Map<key, Float32Array>; missing keys are simply absent.
|
|
75
|
+
*
|
|
76
|
+
* @param {string} filePath the `.embeddings.bin` path
|
|
77
|
+
* @param {Set<string>} keys the exact keys to extract (base / `|s` / `|wN` embedding keys)
|
|
78
|
+
* @returns {Map<string, Float32Array>}
|
|
79
|
+
*/
|
|
80
|
+
export function collectVectorsByKey(filePath, keys) {
|
|
81
|
+
const out = new Map();
|
|
82
|
+
if (!keys || keys.size === 0 || !fs.existsSync(filePath)) return out;
|
|
83
|
+
const buf = fs.readFileSync(filePath);
|
|
84
|
+
if (buf.length < 4) return out;
|
|
85
|
+
// A corrupt (not merely truncated) bin can push `off` past the buffer; any decode error returns
|
|
86
|
+
// the partial Map so callers honour the "missing keys are simply absent / never throws" contract.
|
|
87
|
+
try {
|
|
88
|
+
let off = 0;
|
|
89
|
+
const count = buf.readUInt32LE(off); off += 4;
|
|
90
|
+
for (let i = 0; i < count && out.size < keys.size; i++) {
|
|
91
|
+
const hashLen = buf.readUInt32LE(off); off += 4;
|
|
92
|
+
const hash = buf.subarray(off, off + hashLen).toString('utf8'); off += hashLen;
|
|
93
|
+
const dim = buf.readUInt32LE(off); off += 4;
|
|
94
|
+
if (keys.has(hash)) {
|
|
95
|
+
const vec = new Float32Array(dim);
|
|
96
|
+
for (let d = 0; d < dim; d++) { vec[d] = buf.readFloatLE(off); off += 4; }
|
|
97
|
+
out.set(hash, vec);
|
|
98
|
+
} else {
|
|
99
|
+
off += dim * 4; // skip the float payload
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
} catch { /* corrupt framing — return whatever was decoded cleanly */ }
|
|
103
|
+
return out;
|
|
104
|
+
}
|
|
105
|
+
|
|
68
106
|
/**
|
|
69
107
|
* Append embedding entries to an `.embeddings.bin` file in place (creating it if
|
|
70
108
|
* absent) and return the absolute float-data offset of each appended entry.
|