graph-indexer 2.1.1 → 3.0.1
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 +312 -1
- package/README.md +236 -312
- package/SECURITY.md +53 -58
- package/bin/graph-indexer.mjs +251 -0
- package/integrations/claude-code/.claude-plugin/plugin.json +9 -0
- package/integrations/claude-code/.mcp.json +8 -0
- package/integrations/claude-code/agents/code-structure.md +22 -0
- package/integrations/claude-code/hooks/gi-hook.mjs +103 -0
- package/integrations/claude-code/hooks/hooks.json +16 -0
- package/integrations/opencode/graph-indexer.js +129 -0
- package/package.json +32 -109
- package/src/cli/helper.mjs +67 -0
- package/src/cli/hook.mjs +366 -0
- package/src/cli/init.mjs +272 -0
- package/src/cli/resident.mjs +185 -0
- package/src/index/discover.mjs +128 -0
- package/src/index/fingerprint.mjs +30 -0
- package/src/index/graph.mjs +61 -0
- package/src/index/indexer.mjs +476 -0
- package/src/index/modules.mjs +332 -0
- package/src/index/resolver.mjs +961 -0
- package/src/mcp/render.mjs +60 -0
- package/src/mcp/server.mjs +147 -0
- package/src/mcp/tools.mjs +974 -0
- package/src/parse/arity.mjs +153 -0
- package/src/parse/extract.mjs +1038 -0
- package/src/parse/lang/csharp.mjs +106 -0
- package/src/parse/lang/ecma.mjs +438 -0
- package/src/parse/lang/go.mjs +194 -0
- package/src/parse/lang/jvm.mjs +118 -0
- package/src/parse/lang/kotlin.mjs +97 -0
- package/src/parse/lang/misc.mjs +115 -0
- package/src/parse/lang/native.mjs +126 -0
- package/src/parse/lang/python.mjs +153 -0
- package/src/parse/lang/rust.mjs +157 -0
- package/src/parse/lang/scripting.mjs +219 -0
- package/src/parse/languages.mjs +48 -0
- package/src/parse/runtime.mjs +56 -0
- package/src/query/check.mjs +345 -0
- package/src/query/facts.mjs +323 -0
- package/src/query/intel.mjs +683 -0
- package/src/query/read.mjs +81 -0
- package/src/query/testcmd.mjs +130 -0
- package/src/query/textsearch.mjs +178 -0
- package/src/search/search.mjs +210 -0
- package/src/search/tokenize.mjs +188 -0
- package/src/store/db.mjs +169 -0
- package/src/util/git.mjs +30 -0
- package/src/util/paths.mjs +63 -0
- package/src/util/regex.mjs +2 -0
- package/vendor/grammars/bash.wasm.gz +0 -0
- package/vendor/grammars/c.wasm.gz +0 -0
- package/vendor/grammars/c_sharp.wasm.gz +0 -0
- package/vendor/grammars/cpp.wasm.gz +0 -0
- package/vendor/grammars/css.wasm.gz +0 -0
- package/vendor/grammars/go.wasm.gz +0 -0
- package/vendor/grammars/java.wasm.gz +0 -0
- package/vendor/grammars/javascript.wasm.gz +0 -0
- package/vendor/grammars/kotlin.wasm.gz +0 -0
- package/vendor/grammars/manifest.json +148 -0
- package/vendor/grammars/php.wasm.gz +0 -0
- package/vendor/grammars/python.wasm.gz +0 -0
- package/vendor/grammars/ruby.wasm.gz +0 -0
- package/vendor/grammars/rust.wasm.gz +0 -0
- package/vendor/grammars/scala.wasm.gz +0 -0
- package/vendor/grammars/tsx.wasm.gz +0 -0
- package/vendor/grammars/typescript.wasm.gz +0 -0
- package/vendor/web-tree-sitter/web-tree-sitter.js +3980 -0
- package/vendor/web-tree-sitter/web-tree-sitter.wasm +0 -0
- package/cli-ui.mjs +0 -78
- package/colbert.mjs +0 -168
- package/config.mjs +0 -494
- package/daemon-ctl.mjs +0 -175
- package/daemon-lock.mjs +0 -69
- package/embedders/python/mlx_embed_server.py +0 -61
- package/embedders/python/requirements-mlx.txt +0 -5
- package/embedders/setup-mlx.mjs +0 -110
- package/embeddings.mjs +0 -571
- package/engine/binary.mjs +0 -437
- package/engine/memory.mjs +0 -1003
- package/engine/sqlite.mjs +0 -1136
- package/enrichment.mjs +0 -521
- package/git-signals.mjs +0 -248
- package/indexer.mjs +0 -546
- package/init.mjs +0 -1809
- package/layout.mjs +0 -123
- package/mcp/centrality.mjs +0 -117
- package/mcp/format.mjs +0 -61
- package/mcp/resolver.mjs +0 -116
- package/mcp/symbolgraph.mjs +0 -137
- package/mcp/taint.mjs +0 -225
- package/mcp/tools.mjs +0 -1582
- package/mcp/topology.mjs +0 -676
- package/mcp-server.mjs +0 -197
- package/parse/extractor.mjs +0 -628
- package/parse/imports.mjs +0 -365
- package/parse/interprocedural.mjs +0 -134
- package/parse/languages.mjs +0 -253
- package/parse/metadata.mjs +0 -701
- package/parse/routes.mjs +0 -327
- package/parse/scip.mjs +0 -391
- package/parse/taint-patterns.mjs +0 -119
- package/prompts/CORE.md +0 -91
- package/prompts/DOMAIN_TEMPLATE.md +0 -46
- package/prompts/INTEGRATION.md +0 -123
- package/prompts/frameworks/ANDROID.md +0 -17
- package/prompts/frameworks/ASPNET_CORE.md +0 -19
- package/prompts/frameworks/FASTAPI_DJANGO.md +0 -20
- package/prompts/frameworks/LARAVEL_SYMFONY.md +0 -22
- package/prompts/frameworks/NODE_EXPRESS_NESTJS.md +0 -19
- package/prompts/frameworks/RAILS.md +0 -20
- package/prompts/frameworks/REACT.md +0 -20
- package/prompts/frameworks/SPRING_BOOT.md +0 -20
- package/prompts/languages/BASH.md +0 -22
- package/prompts/languages/C.md +0 -21
- package/prompts/languages/CSHARP.md +0 -24
- package/prompts/languages/CSS_SCSS.md +0 -22
- package/prompts/languages/GO.md +0 -24
- package/prompts/languages/JAVA.md +0 -23
- package/prompts/languages/JAVASCRIPT_TYPESCRIPT.md +0 -24
- package/prompts/languages/KOTLIN.md +0 -24
- package/prompts/languages/PHP.md +0 -23
- package/prompts/languages/PYTHON.md +0 -23
- package/prompts/languages/RUBY.md +0 -23
- package/prompts/languages/RUST.md +0 -21
- package/prompts/languages/SWIFT.md +0 -21
- package/seal.mjs +0 -400
- package/search-core.mjs +0 -777
- package/search-sparse.mjs +0 -183
- package/storage.mjs +0 -73
- package/watch-daemon.mjs +0 -289
package/engine/binary.mjs
DELETED
|
@@ -1,437 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file engine/binary.mjs
|
|
3
|
-
* @description Dense-embedding binary codec (`.embeddings.bin` read/write/append/scan),
|
|
4
|
-
* the optional HNSW vector index, and the approximate sketch search used
|
|
5
|
-
* for large corpora — plus the thresholds that select eager vs lazy access.
|
|
6
|
-
* @author MaquinaTech <https://github.com/MaquinaTech>
|
|
7
|
-
* @copyright (c) 2026 MaquinaTech. All rights reserved.
|
|
8
|
-
* @license MIT
|
|
9
|
-
*/
|
|
10
|
-
import fs from 'fs';
|
|
11
|
-
|
|
12
|
-
export const HNSW_THRESHOLD = 5000; // Build HNSW index above this (eager mode only)
|
|
13
|
-
export const LAZY_VEC_THRESHOLD = 10000; // Switch to lazy (disk-backed) vector access above this
|
|
14
|
-
export const SKETCH_THRESHOLD = 10000; // Build the binary-quantized sketch above this many VECTORS.
|
|
15
|
-
// Single-sourced so the memory and SQLite stores switch to
|
|
16
|
-
// the approximate sketch at the exact same point (parity).
|
|
17
|
-
|
|
18
|
-
export let HierarchicalNSW = null;
|
|
19
|
-
try {
|
|
20
|
-
const mod = await import('hnswlib-node');
|
|
21
|
-
HierarchicalNSW = mod.HierarchicalNSW ?? mod.default?.HierarchicalNSW ?? null;
|
|
22
|
-
} catch { /* not installed — flat scan used */ }
|
|
23
|
-
|
|
24
|
-
/** Format per entry: [uint32 hashLen][utf8 hash][uint32 dim][float32 × dim] */
|
|
25
|
-
export function writeEmbeddingBinary(embeddingCache) {
|
|
26
|
-
const entries = embeddingCache instanceof Map
|
|
27
|
-
? Array.from(embeddingCache.entries())
|
|
28
|
-
: Object.entries(embeddingCache);
|
|
29
|
-
let size = 4;
|
|
30
|
-
for (const [hash, vec] of entries) {
|
|
31
|
-
size += 4 + Buffer.byteLength(hash, 'utf8') + 4 + vec.length * 4;
|
|
32
|
-
}
|
|
33
|
-
const buf = Buffer.allocUnsafe(size);
|
|
34
|
-
let off = 0;
|
|
35
|
-
buf.writeUInt32LE(entries.length, off); off += 4;
|
|
36
|
-
for (const [hash, vec] of entries) {
|
|
37
|
-
const hashBytes = Buffer.from(hash, 'utf8');
|
|
38
|
-
buf.writeUInt32LE(hashBytes.length, off); off += 4;
|
|
39
|
-
hashBytes.copy(buf, off); off += hashBytes.length;
|
|
40
|
-
buf.writeUInt32LE(vec.length, off); off += 4;
|
|
41
|
-
for (let d = 0; d < vec.length; d++) { buf.writeFloatLE(vec[d], off); off += 4; }
|
|
42
|
-
}
|
|
43
|
-
return buf;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* Decode an `.embeddings.bin` file into a Map<content_hash, Float32Array>.
|
|
48
|
-
* Standalone (mirrors the instance loader) so the indexer can reuse cached
|
|
49
|
-
* vectors across runs regardless of the active storage backend.
|
|
50
|
-
*/
|
|
51
|
-
export function readEmbeddingBinary(filePath) {
|
|
52
|
-
const cache = new Map();
|
|
53
|
-
if (!fs.existsSync(filePath)) return cache;
|
|
54
|
-
const buf = fs.readFileSync(filePath);
|
|
55
|
-
let off = 0;
|
|
56
|
-
const count = buf.readUInt32LE(off); off += 4;
|
|
57
|
-
for (let i = 0; i < count; i++) {
|
|
58
|
-
const hashLen = buf.readUInt32LE(off); off += 4;
|
|
59
|
-
const hash = buf.subarray(off, off + hashLen).toString('utf8'); off += hashLen;
|
|
60
|
-
const dim = buf.readUInt32LE(off); off += 4;
|
|
61
|
-
const vec = new Float32Array(dim);
|
|
62
|
-
for (let d = 0; d < dim; d++) { vec[d] = buf.readFloatLE(off); off += 4; }
|
|
63
|
-
cache.set(hash, vec);
|
|
64
|
-
}
|
|
65
|
-
return cache;
|
|
66
|
-
}
|
|
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
|
-
|
|
106
|
-
/**
|
|
107
|
-
* Append embedding entries to an `.embeddings.bin` file in place (creating it if
|
|
108
|
-
* absent) and return the absolute float-data offset of each appended entry.
|
|
109
|
-
* The leading uint32 entry count is updated so readEmbeddingBinary stays valid.
|
|
110
|
-
*
|
|
111
|
-
* Used by the watch daemon for incremental updates: a full bin rewrite on every
|
|
112
|
-
* file change would be O(corpus) — appending is O(changed chunks). Dead entries
|
|
113
|
-
* from replaced chunks are compacted away on the next full index run.
|
|
114
|
-
*
|
|
115
|
-
* @param {string} filePath
|
|
116
|
-
* @param {Map<string, Float32Array|number[]>|object} entries key → vector
|
|
117
|
-
* @returns {Map<string, {offset:number, dim:number}>}
|
|
118
|
-
*/
|
|
119
|
-
export function appendEmbeddingBinary(filePath, entries) {
|
|
120
|
-
const list = entries instanceof Map ? Array.from(entries.entries()) : Object.entries(entries || {});
|
|
121
|
-
const offsets = new Map();
|
|
122
|
-
if (list.length === 0) return offsets;
|
|
123
|
-
|
|
124
|
-
if (!fs.existsSync(filePath)) {
|
|
125
|
-
const hdr = Buffer.alloc(4);
|
|
126
|
-
hdr.writeUInt32LE(0, 0);
|
|
127
|
-
fs.writeFileSync(filePath, hdr);
|
|
128
|
-
}
|
|
129
|
-
const fd = fs.openSync(filePath, 'r+');
|
|
130
|
-
try {
|
|
131
|
-
const pos = fs.fstatSync(fd).size;
|
|
132
|
-
let size = 0;
|
|
133
|
-
for (const [key, vec] of list) size += 4 + Buffer.byteLength(key, 'utf8') + 4 + vec.length * 4;
|
|
134
|
-
const buf = Buffer.allocUnsafe(size);
|
|
135
|
-
let off = 0;
|
|
136
|
-
for (const [key, vec] of list) {
|
|
137
|
-
const kb = Buffer.from(key, 'utf8');
|
|
138
|
-
buf.writeUInt32LE(kb.length, off); off += 4;
|
|
139
|
-
kb.copy(buf, off); off += kb.length;
|
|
140
|
-
buf.writeUInt32LE(vec.length, off); off += 4;
|
|
141
|
-
offsets.set(key, { offset: pos + off, dim: vec.length });
|
|
142
|
-
for (let d = 0; d < vec.length; d++) { buf.writeFloatLE(vec[d], off); off += 4; }
|
|
143
|
-
}
|
|
144
|
-
fs.writeSync(fd, buf, 0, buf.length, pos);
|
|
145
|
-
const hdr = Buffer.allocUnsafe(4);
|
|
146
|
-
fs.readSync(fd, hdr, 0, 4, 0);
|
|
147
|
-
hdr.writeUInt32LE(hdr.readUInt32LE(0) + list.length, 0);
|
|
148
|
-
fs.writeSync(fd, hdr, 0, 4, 0);
|
|
149
|
-
} finally {
|
|
150
|
-
fs.closeSync(fd);
|
|
151
|
-
}
|
|
152
|
-
return offsets;
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
/**
|
|
156
|
-
* Cosine-score EVERY vector in an `.embeddings.bin` against a query vector,
|
|
157
|
-
* streaming from disk in bounded buffers, and return the top-N entry keys.
|
|
158
|
-
*
|
|
159
|
-
* This is the semantic channel for the disk-backed stores. The previous design
|
|
160
|
-
* scored vectors only for chunks that already matched lexically, which silently
|
|
161
|
-
* disabled semantic search exactly where it matters: conceptual queries that
|
|
162
|
-
* share no tokens with the code. A sequential scan of the bin is fast (the OS
|
|
163
|
-
* page cache holds it after the first query) and keeps resident RAM flat.
|
|
164
|
-
*
|
|
165
|
-
* @param {{fd?: number, buffer?: Buffer}} source Open fd or in-memory buffer of the bin.
|
|
166
|
-
* @param {Float32Array|number[]} queryVector
|
|
167
|
-
* @param {{topN?: number, minScore?: number}} [opts]
|
|
168
|
-
* @returns {Array<{key: string, score: number}>} Sorted by score, descending.
|
|
169
|
-
*/
|
|
170
|
-
export function scanEmbeddingBinary({ fd = -1, buffer = null }, queryVector, { topN = 200, minScore = 0 } = {}) {
|
|
171
|
-
const qDim = queryVector.length;
|
|
172
|
-
let qNorm = 0;
|
|
173
|
-
for (let d = 0; d < qDim; d++) qNorm += queryVector[d] * queryVector[d];
|
|
174
|
-
qNorm = Math.sqrt(qNorm);
|
|
175
|
-
if (qNorm === 0) return [];
|
|
176
|
-
|
|
177
|
-
const top = [];
|
|
178
|
-
let worst = -Infinity;
|
|
179
|
-
const push = (key, score) => {
|
|
180
|
-
if (score <= minScore) return;
|
|
181
|
-
if (top.length >= topN && score <= worst) return;
|
|
182
|
-
let lo = 0, hi = top.length;
|
|
183
|
-
while (lo < hi) { const mid = (lo + hi) >> 1; if (top[mid].score >= score) lo = mid + 1; else hi = mid; }
|
|
184
|
-
top.splice(lo, 0, { key, score });
|
|
185
|
-
if (top.length > topN) top.pop();
|
|
186
|
-
worst = top[top.length - 1].score;
|
|
187
|
-
};
|
|
188
|
-
|
|
189
|
-
// Parses complete entries in buf starting at startOff; returns the offset of
|
|
190
|
-
// the first incomplete entry (callers carry the remainder into the next read).
|
|
191
|
-
const processEntries = (buf, startOff) => {
|
|
192
|
-
let off = startOff;
|
|
193
|
-
for (;;) {
|
|
194
|
-
if (off + 4 > buf.length) return off;
|
|
195
|
-
const keyLen = buf.readUInt32LE(off);
|
|
196
|
-
if (off + 4 + keyLen + 4 > buf.length) return off;
|
|
197
|
-
const dim = buf.readUInt32LE(off + 4 + keyLen);
|
|
198
|
-
const entryEnd = off + 4 + keyLen + 4 + dim * 4;
|
|
199
|
-
if (entryEnd > buf.length) return off;
|
|
200
|
-
if (dim === qDim) {
|
|
201
|
-
const key = buf.toString('utf8', off + 4, off + 4 + keyLen);
|
|
202
|
-
let dp = 0, nv = 0;
|
|
203
|
-
let p = off + 4 + keyLen + 4;
|
|
204
|
-
for (let d = 0; d < dim; d++, p += 4) {
|
|
205
|
-
const v = buf.readFloatLE(p);
|
|
206
|
-
dp += queryVector[d] * v;
|
|
207
|
-
nv += v * v;
|
|
208
|
-
}
|
|
209
|
-
const n = Math.sqrt(nv);
|
|
210
|
-
if (n > 0) push(key, dp / (qNorm * n));
|
|
211
|
-
}
|
|
212
|
-
off = entryEnd;
|
|
213
|
-
}
|
|
214
|
-
};
|
|
215
|
-
|
|
216
|
-
if (buffer) { processEntries(buffer, 4); return top; }
|
|
217
|
-
if (fd < 0) return [];
|
|
218
|
-
|
|
219
|
-
const CHUNK = 4 * 1024 * 1024;
|
|
220
|
-
let filePos = 0;
|
|
221
|
-
let carry = null;
|
|
222
|
-
let first = true;
|
|
223
|
-
for (;;) {
|
|
224
|
-
const buf = Buffer.allocUnsafe(CHUNK);
|
|
225
|
-
const read = fs.readSync(fd, buf, 0, CHUNK, filePos);
|
|
226
|
-
if (read <= 0) break;
|
|
227
|
-
filePos += read;
|
|
228
|
-
const work = carry && carry.length
|
|
229
|
-
? Buffer.concat([carry, buf.subarray(0, read)])
|
|
230
|
-
: buf.subarray(0, read);
|
|
231
|
-
const consumed = processEntries(work, first ? 4 : 0);
|
|
232
|
-
first = false;
|
|
233
|
-
carry = work.subarray(consumed);
|
|
234
|
-
if (read < CHUNK) break;
|
|
235
|
-
}
|
|
236
|
-
return top;
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
// ─── Binary-quantized vector sketch ─────────────────────────────────────────────
|
|
240
|
-
// The streaming full scan above is exact but O(corpus × dim) per query: ~104 ms
|
|
241
|
-
// at 50k chunks and ~519 ms at 200k (measured warm). The sketch removes that
|
|
242
|
-
// ceiling while keeping resident RAM bounded: each vector is reduced to its SIGN
|
|
243
|
-
// BITS (768 dims → 96 bytes, 0.1% of the float32 data). A query is answered by
|
|
244
|
-
// (1) a Hamming-distance pass over the packed bits (XOR + popcount over Uint32
|
|
245
|
-
// words — a few ms even at 200k rows), keeping the best `oversample × topN`
|
|
246
|
-
// candidates, then (2) an exact cosine rescore of only those candidates, pread
|
|
247
|
-
// from the bin. Sign quantization of normalized embeddings preserves cosine
|
|
248
|
-
// ordering well enough that a 4× oversampled rescore returns a NEAR-EXACT head:
|
|
249
|
-
// at the production topN (400) the consumed top-5/top-10 overlap the exhaustive
|
|
250
|
-
// scan ~95% (rank-1 parity is asserted in test/unit.mjs). Full top-N recall is
|
|
251
|
-
// APPROXIMATE — the tail past the consumed head can differ — which is the
|
|
252
|
-
// bounded-RAM, sub-linear trade the sketch exists to make.
|
|
253
|
-
//
|
|
254
|
-
// The sketch is APPEND-AWARE: the bin only ever grows between full rebuilds
|
|
255
|
-
// (the daemon appends), so `updateVectorSketch` re-reads just the tail beyond
|
|
256
|
-
// what it has already consumed, keeping refresh O(changed chunks).
|
|
257
|
-
|
|
258
|
-
function popcnt32(x) {
|
|
259
|
-
x -= (x >> 1) & 0x55555555;
|
|
260
|
-
x = (x & 0x33333333) + ((x >> 2) & 0x33333333);
|
|
261
|
-
x = (x + (x >> 4)) & 0x0f0f0f0f;
|
|
262
|
-
return (x * 0x01010101) >> 24;
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
/** Pack a float vector's sign bits into `words` Uint32 words. */
|
|
266
|
-
function quantizeToBits(vec, out, base, words) {
|
|
267
|
-
for (let w = 0; w < words; w++) {
|
|
268
|
-
let bits = 0;
|
|
269
|
-
const d0 = w * 32;
|
|
270
|
-
const dMax = Math.min(d0 + 32, vec.length);
|
|
271
|
-
for (let d = d0; d < dMax; d++) {
|
|
272
|
-
if (vec[d] > 0) bits |= (1 << (d - d0));
|
|
273
|
-
}
|
|
274
|
-
out[base + w] = bits;
|
|
275
|
-
}
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
/**
|
|
279
|
-
* Build or incrementally extend a binary sketch of an `.embeddings.bin`.
|
|
280
|
-
*
|
|
281
|
-
* @param {object|null} sketch Existing sketch to extend, or null to create.
|
|
282
|
-
* @param {{fd?: number, buffer?: Buffer}} source
|
|
283
|
-
* @returns {object} sketch { dim, words, n, keys[], offsets[], bits: Uint32Array,
|
|
284
|
-
* consumed, headerCount, firstKey }
|
|
285
|
-
*/
|
|
286
|
-
export function updateVectorSketch(sketch, { fd = -1, buffer = null }) {
|
|
287
|
-
const readAt = (buf, pos, len) => {
|
|
288
|
-
if (buffer) return buffer.subarray(pos, pos + len);
|
|
289
|
-
const b = Buffer.allocUnsafe(len);
|
|
290
|
-
const r = fs.readSync(fd, b, 0, len, pos);
|
|
291
|
-
return r < len ? b.subarray(0, r) : b;
|
|
292
|
-
};
|
|
293
|
-
const fileSize = buffer ? buffer.length : (fd >= 0 ? fs.fstatSync(fd).size : 0);
|
|
294
|
-
if (fileSize < 4) return sketch;
|
|
295
|
-
|
|
296
|
-
const headerCount = readAt(null, 0, 4).readUInt32LE(0);
|
|
297
|
-
if (!sketch) {
|
|
298
|
-
sketch = {
|
|
299
|
-
dim: 0, words: 0, n: 0, keys: [], offsets: [],
|
|
300
|
-
bits: new Uint32Array(0), consumed: 4, headerCount: 0, firstKey: null,
|
|
301
|
-
};
|
|
302
|
-
}
|
|
303
|
-
if (fileSize <= sketch.consumed) return sketch;
|
|
304
|
-
|
|
305
|
-
// Read the unseen tail in large blocks. No carry buffers / Buffer.concat —
|
|
306
|
-
// the next block simply re-reads from the last complete entry boundary
|
|
307
|
-
// (at most one partial entry of overlap), so the build leaves no transient
|
|
308
|
-
// garbage behind. Sign bits are quantized straight off the block buffer.
|
|
309
|
-
const CHUNK = 8 * 1024 * 1024;
|
|
310
|
-
const reusable = buffer ? null : Buffer.allocUnsafe(CHUNK);
|
|
311
|
-
let pos = sketch.consumed;
|
|
312
|
-
while (pos < fileSize) {
|
|
313
|
-
const len = Math.min(CHUNK, fileSize - pos);
|
|
314
|
-
let work;
|
|
315
|
-
if (buffer) {
|
|
316
|
-
work = buffer.subarray(pos, pos + len);
|
|
317
|
-
} else {
|
|
318
|
-
const r = fs.readSync(fd, reusable, 0, len, pos);
|
|
319
|
-
work = r < len ? reusable.subarray(0, r) : (len < CHUNK ? reusable.subarray(0, len) : reusable);
|
|
320
|
-
}
|
|
321
|
-
let off = 0;
|
|
322
|
-
let parsedAny = false;
|
|
323
|
-
for (;;) {
|
|
324
|
-
if (off + 4 > work.length) break;
|
|
325
|
-
const keyLen = work.readUInt32LE(off);
|
|
326
|
-
if (off + 4 + keyLen + 4 > work.length) break;
|
|
327
|
-
const dim = work.readUInt32LE(off + 4 + keyLen);
|
|
328
|
-
const entryEnd = off + 4 + keyLen + 4 + dim * 4;
|
|
329
|
-
if (entryEnd > work.length) break;
|
|
330
|
-
|
|
331
|
-
const key = work.toString('utf8', off + 4, off + 4 + keyLen);
|
|
332
|
-
const floatBase = off + 4 + keyLen + 4;
|
|
333
|
-
if (sketch.dim === 0) {
|
|
334
|
-
sketch.dim = dim;
|
|
335
|
-
sketch.words = Math.ceil(dim / 32);
|
|
336
|
-
}
|
|
337
|
-
if (dim === sketch.dim) {
|
|
338
|
-
if ((sketch.n + 1) * sketch.words > sketch.bits.length) {
|
|
339
|
-
const grown = new Uint32Array(Math.max(1024 * sketch.words, sketch.bits.length * 2));
|
|
340
|
-
grown.set(sketch.bits);
|
|
341
|
-
sketch.bits = grown;
|
|
342
|
-
}
|
|
343
|
-
// Quantize sign bits directly from the block buffer.
|
|
344
|
-
const bitBase = sketch.n * sketch.words;
|
|
345
|
-
for (let w = 0; w < sketch.words; w++) {
|
|
346
|
-
let bits = 0;
|
|
347
|
-
const d0 = w * 32;
|
|
348
|
-
const dMax = Math.min(d0 + 32, dim);
|
|
349
|
-
for (let d = d0; d < dMax; d++) {
|
|
350
|
-
if (work.readFloatLE(floatBase + d * 4) > 0) bits |= (1 << (d - d0));
|
|
351
|
-
}
|
|
352
|
-
sketch.bits[bitBase + w] = bits;
|
|
353
|
-
}
|
|
354
|
-
sketch.keys.push(key);
|
|
355
|
-
sketch.offsets.push(pos + floatBase);
|
|
356
|
-
if (sketch.firstKey === null) sketch.firstKey = key;
|
|
357
|
-
sketch.n++;
|
|
358
|
-
}
|
|
359
|
-
off = entryEnd;
|
|
360
|
-
parsedAny = true;
|
|
361
|
-
}
|
|
362
|
-
if (!parsedAny) break; // partial entry larger than the block — corrupt tail
|
|
363
|
-
pos += off; // resume exactly at the last complete entry boundary
|
|
364
|
-
sketch.consumed = pos;
|
|
365
|
-
}
|
|
366
|
-
sketch.headerCount = headerCount;
|
|
367
|
-
return sketch;
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
/**
|
|
371
|
-
* Approximate top-N search via the sketch + exact cosine rescore from the bin.
|
|
372
|
-
* Drop-in replacement for scanEmbeddingBinary (same return shape), ~20–50×
|
|
373
|
-
* faster at scale.
|
|
374
|
-
*
|
|
375
|
-
* @returns {Array<{key: string, score: number}>} sorted by exact cosine, desc.
|
|
376
|
-
*/
|
|
377
|
-
export function searchVectorSketch(sketch, { fd = -1, buffer = null }, queryVector, {
|
|
378
|
-
topN = 200, minScore = 0, oversample = 4,
|
|
379
|
-
} = {}) {
|
|
380
|
-
if (!sketch || sketch.n === 0 || queryVector.length !== sketch.dim) return [];
|
|
381
|
-
const words = sketch.words;
|
|
382
|
-
|
|
383
|
-
const qbits = new Uint32Array(words);
|
|
384
|
-
quantizeToBits(queryVector, qbits, 0, words);
|
|
385
|
-
|
|
386
|
-
const M = Math.min(sketch.n, Math.max(topN * oversample, 64));
|
|
387
|
-
const candIdx = new Int32Array(M);
|
|
388
|
-
const candHam = new Int32Array(M);
|
|
389
|
-
let count = 0, worst = -1;
|
|
390
|
-
const bits = sketch.bits;
|
|
391
|
-
for (let i = 0; i < sketch.n; i++) {
|
|
392
|
-
const base = i * words;
|
|
393
|
-
let ham = 0;
|
|
394
|
-
for (let w = 0; w < words; w++) ham += popcnt32((bits[base + w] ^ qbits[w]) >>> 0);
|
|
395
|
-
if (count >= M && ham >= worst) continue;
|
|
396
|
-
let lo = 0, hi = count;
|
|
397
|
-
while (lo < hi) { const mid = (lo + hi) >> 1; if (candHam[mid] <= ham) lo = mid + 1; else hi = mid; }
|
|
398
|
-
const end = Math.min(count, M - 1);
|
|
399
|
-
for (let j = end; j > lo; j--) { candHam[j] = candHam[j - 1]; candIdx[j] = candIdx[j - 1]; }
|
|
400
|
-
candHam[lo] = ham; candIdx[lo] = i;
|
|
401
|
-
if (count < M) count++;
|
|
402
|
-
worst = candHam[count - 1];
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
let qNorm = 0;
|
|
406
|
-
for (let d = 0; d < queryVector.length; d++) qNorm += queryVector[d] * queryVector[d];
|
|
407
|
-
qNorm = Math.sqrt(qNorm);
|
|
408
|
-
if (qNorm === 0) return [];
|
|
409
|
-
|
|
410
|
-
const byteLen = sketch.dim * 4;
|
|
411
|
-
const raw = Buffer.allocUnsafe(byteLen);
|
|
412
|
-
const scored = [];
|
|
413
|
-
for (let c = 0; c < count; c++) {
|
|
414
|
-
const i = candIdx[c];
|
|
415
|
-
let view;
|
|
416
|
-
if (buffer) {
|
|
417
|
-
view = buffer.subarray(sketch.offsets[i], sketch.offsets[i] + byteLen);
|
|
418
|
-
if (view.length < byteLen) continue;
|
|
419
|
-
} else {
|
|
420
|
-
const read = fs.readSync(fd, raw, 0, byteLen, sketch.offsets[i]);
|
|
421
|
-
if (read < byteLen) continue;
|
|
422
|
-
view = raw;
|
|
423
|
-
}
|
|
424
|
-
let dp = 0, nv = 0;
|
|
425
|
-
for (let d = 0; d < sketch.dim; d++) {
|
|
426
|
-
const v = view.readFloatLE(d * 4);
|
|
427
|
-
dp += queryVector[d] * v;
|
|
428
|
-
nv += v * v;
|
|
429
|
-
}
|
|
430
|
-
const n = Math.sqrt(nv);
|
|
431
|
-
if (n === 0) continue;
|
|
432
|
-
const score = dp / (qNorm * n);
|
|
433
|
-
if (score > minScore) scored.push({ key: sketch.keys[i], score });
|
|
434
|
-
}
|
|
435
|
-
scored.sort((a, b) => b.score - a.score);
|
|
436
|
-
return scored.slice(0, topN);
|
|
437
|
-
}
|