sweet-search 2.6.8 → 2.6.10
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 +15 -0
- package/core/cli.js +7 -0
- package/core/graph/graph-extractor.js +24 -14
- package/core/graph/hcgs-generator.js +4 -3
- package/core/incremental-indexing/application/production-li-delta.mjs +28 -3
- package/core/incremental-indexing/application/production-reconciler.mjs +40 -11
- package/core/incremental-indexing/application/reconciler.mjs +1 -1
- package/core/incremental-indexing/domain/encoder-input.mjs +9 -4
- package/core/indexing/artifact-builder.js +5 -3
- package/core/indexing/ast-chunker.js +65 -62
- package/core/indexing/index-maintainer.mjs +49 -6
- package/core/indexing/indexer-build.js +18 -4
- package/core/indexing/indexer-phases.js +45 -24
- package/core/indexing/indexer-utils.js +96 -6
- package/core/indexing/indexing-file-policy.js +30 -7
- package/core/indexing/model-pool.js +26 -0
- package/core/indexing/rss-budget.mjs +65 -0
- package/core/infrastructure/code-graph-repository.js +18 -18
- package/core/infrastructure/db-utils.js +29 -0
- package/core/infrastructure/simd-distance.js +35 -21
- package/core/infrastructure/tombstone-bitmap-reader.js +3 -1
- package/core/ranking/file-kind-ranking.js +14 -2
- package/core/ranking/late-interaction-index.js +86 -15
- package/core/ranking/late-interaction-model.js +25 -4
- package/core/ranking/mmr.js +15 -10
- package/core/search/search-anchor.js +21 -3
- package/core/search/search-boost.js +25 -13
- package/core/search/search-fusion.js +13 -5
- package/core/search/search-read-semantic.js +13 -12
- package/core/search/search-server.js +2 -1
- package/core/vector-store/binary-hnsw-index.js +338 -33
- package/core/vector-store/float-vector-store.js +9 -5
- package/eval/agent-read-workflows/bin/_ss-helpers.mjs +11 -0
- package/package.json +8 -8
|
@@ -155,8 +155,7 @@ function _lateInteractionIndexPath(projectRoot, manifest) {
|
|
|
155
155
|
return path.join(_stateDirForProject(root), path.basename(DB_PATHS.lateInteraction));
|
|
156
156
|
}
|
|
157
157
|
|
|
158
|
-
function _sourceStaleness(projectRoot, filePathRel) {
|
|
159
|
-
const manifest = _readReconcileManifest(projectRoot);
|
|
158
|
+
function _sourceStaleness(projectRoot, filePathRel, manifest = _readReconcileManifest(projectRoot)) {
|
|
160
159
|
const publishedMs = Date.parse(manifest?.publishedAt || '');
|
|
161
160
|
if (!Number.isFinite(publishedMs)) return null;
|
|
162
161
|
try {
|
|
@@ -178,9 +177,8 @@ function _sourceStaleness(projectRoot, filePathRel) {
|
|
|
178
177
|
}
|
|
179
178
|
|
|
180
179
|
const _repos = new Map();
|
|
181
|
-
function _getRepo(projectRoot) {
|
|
180
|
+
function _getRepo(projectRoot, manifest = _readReconcileManifest(projectRoot)) {
|
|
182
181
|
const key = _projectKey(projectRoot);
|
|
183
|
-
const manifest = _readReconcileManifest(projectRoot);
|
|
184
182
|
const dbPath = _codebasePathForProject(projectRoot, manifest);
|
|
185
183
|
const baseDbPath = _defaultCodebasePathForProject(projectRoot);
|
|
186
184
|
let entry = _repos.get(key);
|
|
@@ -202,9 +200,8 @@ let _liIndex = null;
|
|
|
202
200
|
let _liInitPromise = null;
|
|
203
201
|
let _liProjectKey = null;
|
|
204
202
|
let _liManifestEpoch = null;
|
|
205
|
-
async function _getLateInteractionIndex(projectRoot) {
|
|
203
|
+
async function _getLateInteractionIndex(projectRoot, manifest = _readReconcileManifest(projectRoot)) {
|
|
206
204
|
const projectKey = _projectKey(projectRoot);
|
|
207
|
-
const manifest = _readReconcileManifest(projectRoot);
|
|
208
205
|
const manifestEpoch = Number.isInteger(manifest?.epoch) ? manifest.epoch : null;
|
|
209
206
|
const samePin = _liProjectKey === projectKey && _liManifestEpoch === manifestEpoch;
|
|
210
207
|
if (_liIndex !== null && samePin) return _liIndex || null;
|
|
@@ -331,8 +328,8 @@ function _escapeRegex(s) {
|
|
|
331
328
|
// Candidate enumeration — load chunk metadata + per-chunk on-disk text slice
|
|
332
329
|
// ---------------------------------------------------------------------------
|
|
333
330
|
|
|
334
|
-
async function _loadFileChunks(filePathRel, projectRoot) {
|
|
335
|
-
const repo = _getRepo(projectRoot);
|
|
331
|
+
async function _loadFileChunks(filePathRel, projectRoot, manifest = _readReconcileManifest(projectRoot)) {
|
|
332
|
+
const repo = _getRepo(projectRoot, manifest);
|
|
336
333
|
if (!repo) return { chunks: [], language: null };
|
|
337
334
|
const rows = repo.getChunksByFilePath(filePathRel);
|
|
338
335
|
if (rows.length === 0) return { chunks: [], language: null };
|
|
@@ -445,9 +442,9 @@ function _scoreSymbol(chunks, queryTerms, queryRaw) {
|
|
|
445
442
|
return scores;
|
|
446
443
|
}
|
|
447
444
|
|
|
448
|
-
async function _scoreLateInteraction(chunks, query, projectRoot, lateInteractionIndexOverride = null) {
|
|
445
|
+
async function _scoreLateInteraction(chunks, query, projectRoot, lateInteractionIndexOverride = null, manifest = undefined) {
|
|
449
446
|
if (chunks.length === 0) return { scores: new Map(), ran: false };
|
|
450
|
-
const liIndex = lateInteractionIndexOverride || await _getLateInteractionIndex(projectRoot);
|
|
447
|
+
const liIndex = lateInteractionIndexOverride || await _getLateInteractionIndex(projectRoot, manifest);
|
|
451
448
|
if (!liIndex) return { scores: new Map(), ran: false };
|
|
452
449
|
|
|
453
450
|
// Only score chunks whose IDs actually appear in the LI index. Use the
|
|
@@ -636,7 +633,10 @@ async function _readSemanticUnpinned(req) {
|
|
|
636
633
|
const projectRoot = req.projectRoot || process.cwd();
|
|
637
634
|
_ensurePersistedLiModelApplied(projectRoot);
|
|
638
635
|
const filePathRel = _projectRelative(req.path, projectRoot);
|
|
639
|
-
|
|
636
|
+
// One manifest read per request — _sourceStaleness/_getRepo/_getLateInteractionIndex
|
|
637
|
+
// each re-read the same file otherwise (it cannot change mid-request usefully).
|
|
638
|
+
const reconcileManifest = _readReconcileManifest(projectRoot);
|
|
639
|
+
const staleness = _sourceStaleness(projectRoot, filePathRel, reconcileManifest);
|
|
640
640
|
|
|
641
641
|
const topK = req.topK ?? DEFAULTS.topK;
|
|
642
642
|
const threshold = req.threshold ?? DEFAULTS.threshold;
|
|
@@ -646,7 +646,7 @@ async function _readSemanticUnpinned(req) {
|
|
|
646
646
|
const verbose = !!req.verbose;
|
|
647
647
|
|
|
648
648
|
const tLoad0 = performance.now();
|
|
649
|
-
const { chunks, language, totalLines, fileText } = await _loadFileChunks(filePathRel, projectRoot);
|
|
649
|
+
const { chunks, language, totalLines, fileText } = await _loadFileChunks(filePathRel, projectRoot, reconcileManifest);
|
|
650
650
|
const tLoad1 = performance.now();
|
|
651
651
|
|
|
652
652
|
// No chunks at all → fall back to plain read so the caller still gets
|
|
@@ -692,6 +692,7 @@ async function _readSemanticUnpinned(req) {
|
|
|
692
692
|
req.query,
|
|
693
693
|
projectRoot,
|
|
694
694
|
req._lateInteractionIndex || null,
|
|
695
|
+
reconcileManifest,
|
|
695
696
|
);
|
|
696
697
|
const tLi1 = performance.now();
|
|
697
698
|
|
|
@@ -37,6 +37,8 @@ export const SEARCH_SERVER_MAX_URL_LENGTH = 16_384;
|
|
|
37
37
|
export const SEARCH_SERVER_MAX_QUERY_LENGTH = 2_000;
|
|
38
38
|
export const SEARCH_SERVER_MAX_READ_PATH_LENGTH = 8_192;
|
|
39
39
|
|
|
40
|
+
const AGENT_FORMATS = new Set(['agent', 'agent_preview', 'agent_full', 'agent_full_xl']);
|
|
41
|
+
|
|
40
42
|
function canonicalProjectRoot(root) {
|
|
41
43
|
const resolved = path.resolve(root || process.cwd());
|
|
42
44
|
try {
|
|
@@ -686,7 +688,6 @@ export async function startServer() {
|
|
|
686
688
|
|
|
687
689
|
// Agent mode: context packaging (ColGrep agent format)
|
|
688
690
|
const rawFormat = url.searchParams.get('format');
|
|
689
|
-
const AGENT_FORMATS = new Set(['agent', 'agent_preview', 'agent_full', 'agent_full_xl']);
|
|
690
691
|
const agentFormat = AGENT_FORMATS.has(rawFormat) ? rawFormat : undefined;
|
|
691
692
|
const tokenBudget = url.searchParams.has('budget')
|
|
692
693
|
? parseInt(url.searchParams.get('budget'), 10)
|
|
@@ -11,8 +11,8 @@
|
|
|
11
11
|
* CANONICAL INT8 STORAGE (Workstream H resolution):
|
|
12
12
|
* Int8 vectors for stage-2 rescoring are stored in this index's .int8.json sidecar.
|
|
13
13
|
* This file is saved/loaded alongside the binary HNSW index artifacts.
|
|
14
|
-
* - save(): Writes .int8.json
|
|
15
|
-
* - load(): Populates this.int8Vectors Map from .int8.json
|
|
14
|
+
* - save(): Writes .int8.json (NDJSON v2: header line + one {id, v} per line)
|
|
15
|
+
* - load(): Populates this.int8Vectors Map from .int8.json (v1 back-compat)
|
|
16
16
|
* - getInt8Vector(id): O(1) lookup used by sweet-search.js during stage-2
|
|
17
17
|
*
|
|
18
18
|
* This is the ONLY source of int8 vectors. The SQLite approach (codebase-int8.db)
|
|
@@ -28,9 +28,11 @@
|
|
|
28
28
|
|
|
29
29
|
import fs from 'fs/promises';
|
|
30
30
|
import {
|
|
31
|
-
existsSync, statSync,
|
|
31
|
+
existsSync, statSync, readFileSync,
|
|
32
32
|
openSync, readSync, closeSync, fstatSync,
|
|
33
|
+
createWriteStream, createReadStream,
|
|
33
34
|
} from 'fs';
|
|
35
|
+
import readline from 'readline';
|
|
34
36
|
import path from 'path';
|
|
35
37
|
import { BINARY_HNSW_CONFIG, DB_PATHS } from '../infrastructure/config/index.js';
|
|
36
38
|
import {
|
|
@@ -53,11 +55,12 @@ const PIPELINE_VERSION = 2;
|
|
|
53
55
|
//
|
|
54
56
|
// The default on-disk format is the JSON sidecar tuple
|
|
55
57
|
// (.meta.json / .vectors.json / .graph.json / .int8.json / .calibration.json),
|
|
56
|
-
// written by save() and read by load()
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
//
|
|
58
|
+
// written by save() and read by load() — the big sidecars as NDJSON v2 with
|
|
59
|
+
// v1 read back-compat (see the sidecar-format block below). This whole mmap
|
|
60
|
+
// subsystem is inert when SWEET_SEARCH_HNSW_MMAP !== '1'. Existing on-disk
|
|
61
|
+
// indexes (the 200 bench repos) keep loading via the JSON path regardless of
|
|
62
|
+
// the flag: format is detected by inspecting the .idx file's magic header
|
|
63
|
+
// (`_indexFormatOnDisk`), never by the flag.
|
|
61
64
|
//
|
|
62
65
|
// When SWEET_SEARCH_HNSW_MMAP === '1', NEWLY written indexes are packed into a
|
|
63
66
|
// single flat-binary `.idx` file (magic-prefixed) whose vectors / graph
|
|
@@ -202,6 +205,276 @@ function makeMmapVectorProxy(store) {
|
|
|
202
205
|
});
|
|
203
206
|
}
|
|
204
207
|
|
|
208
|
+
// =============================================================================
|
|
209
|
+
// NDJSON SIDECAR FORMAT (v2)
|
|
210
|
+
// =============================================================================
|
|
211
|
+
//
|
|
212
|
+
// The big JSON sidecars (.vectors.json / .graph.json / .int8.json) used to be
|
|
213
|
+
// written with one monolithic JSON.stringify. Past ~500k vectors that single
|
|
214
|
+
// string crosses V8's ~512 MB ceiling and save() dies with "Invalid string
|
|
215
|
+
// length" (libsql: 637,550 vectors) — the same failure mode the LI alias
|
|
216
|
+
// sidecar hit, fixed the same way in 2.6.9. Cure: NDJSON v2 — one
|
|
217
|
+
// {version:2,...} header line, then one record per line, flushed through a
|
|
218
|
+
// write stream in bounded batches so no serialized string ever approaches the
|
|
219
|
+
// ceiling. .meta.json / .calibration.json stay monolithic JSON (O(1)-sized).
|
|
220
|
+
//
|
|
221
|
+
// Back-compat: a v1 sidecar is a single line holding the whole payload, and
|
|
222
|
+
// any v1 file on disk was produced by a JSON.stringify that SUCCEEDED — so it
|
|
223
|
+
// fits back into one string, and the line-streaming readers handle it as
|
|
224
|
+
// "first line IS the payload". Format is detected from the first line, never
|
|
225
|
+
// from config, so old and new indexes coexist with zero migration.
|
|
226
|
+
//
|
|
227
|
+
// Truncation guard: NDJSON cut at a line boundary (crash mid-write, disk
|
|
228
|
+
// full) is valid prefix NDJSON and would otherwise load a silent subset, so
|
|
229
|
+
// every v2 reader enforces the header's record count and throws on mismatch —
|
|
230
|
+
// callers route that to the rebuild path like any other corrupt artifact.
|
|
231
|
+
// (The tmp+rename publish in save() makes this unreachable in normal
|
|
232
|
+
// operation; the guard covers torn tmp files that somehow got renamed.)
|
|
233
|
+
|
|
234
|
+
const NDJSON_LINE_BATCH = 20000;
|
|
235
|
+
// Graph chunk lines are ~MB-sized, so batch flushes are bounded by chars too.
|
|
236
|
+
const NDJSON_CHAR_BATCH = 8 * 1024 * 1024;
|
|
237
|
+
const GRAPH_CHUNK_NODES = 10000;
|
|
238
|
+
|
|
239
|
+
async function writeNdjsonSidecar(filePath, header, records) {
|
|
240
|
+
const ws = createWriteStream(filePath, { encoding: 'utf8' });
|
|
241
|
+
const finished = new Promise((resolve, reject) => {
|
|
242
|
+
ws.on('error', reject);
|
|
243
|
+
ws.on('finish', resolve);
|
|
244
|
+
});
|
|
245
|
+
const flush = (str) => new Promise((resolve, reject) => {
|
|
246
|
+
ws.write(str, (err) => (err ? reject(err) : resolve()));
|
|
247
|
+
});
|
|
248
|
+
try {
|
|
249
|
+
let lines = [JSON.stringify(header)];
|
|
250
|
+
let chars = lines[0].length;
|
|
251
|
+
for (const record of records) {
|
|
252
|
+
const line = JSON.stringify(record);
|
|
253
|
+
lines.push(line);
|
|
254
|
+
chars += line.length;
|
|
255
|
+
if (lines.length >= NDJSON_LINE_BATCH || chars >= NDJSON_CHAR_BATCH) {
|
|
256
|
+
await flush(lines.join('\n') + '\n');
|
|
257
|
+
lines = [];
|
|
258
|
+
chars = 0;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
if (lines.length > 0) await flush(lines.join('\n') + '\n');
|
|
262
|
+
ws.end();
|
|
263
|
+
await finished;
|
|
264
|
+
} catch (err) {
|
|
265
|
+
finished.catch(() => { /* surfaced via throw below */ });
|
|
266
|
+
ws.destroy();
|
|
267
|
+
throw err;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Yields one parsed JSON document per non-empty line. The input stream is
|
|
272
|
+
// destroyed in `finally`: early exits abandon the readline iterator, and
|
|
273
|
+
// rl.close() does NOT destroy its input — without the explicit destroy every
|
|
274
|
+
// early return leaks an fd (fatal only in long-lived processes like the
|
|
275
|
+
// daemon, but a leak everywhere).
|
|
276
|
+
async function* iterateJsonLines(filePath) {
|
|
277
|
+
const input = createReadStream(filePath, { encoding: 'utf8' });
|
|
278
|
+
const rl = readline.createInterface({ input, crlfDelay: Infinity });
|
|
279
|
+
try {
|
|
280
|
+
for await (const line of rl) {
|
|
281
|
+
if (!line) continue;
|
|
282
|
+
yield JSON.parse(line);
|
|
283
|
+
}
|
|
284
|
+
} finally {
|
|
285
|
+
rl.close();
|
|
286
|
+
input.destroy();
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function isV2Header(doc) {
|
|
291
|
+
return !!doc && !Array.isArray(doc) && doc.version === 2 && Number.isFinite(doc.count);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export async function readVectorsSidecar(vectorsPath) {
|
|
295
|
+
let header = null;
|
|
296
|
+
let first = true;
|
|
297
|
+
const entries = [];
|
|
298
|
+
for await (const doc of iterateJsonLines(vectorsPath)) {
|
|
299
|
+
if (first) {
|
|
300
|
+
first = false;
|
|
301
|
+
if (Array.isArray(doc)) return doc; // v1 — the whole payload is this line
|
|
302
|
+
if (!isV2Header(doc)) {
|
|
303
|
+
throw new Error(`BinaryHNSW: unrecognized vectors sidecar format: ${vectorsPath}`);
|
|
304
|
+
}
|
|
305
|
+
header = doc;
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
entries.push(doc);
|
|
309
|
+
}
|
|
310
|
+
if (!header) {
|
|
311
|
+
throw new Error(`BinaryHNSW: empty vectors sidecar: ${vectorsPath}`);
|
|
312
|
+
}
|
|
313
|
+
if (entries.length !== header.count) {
|
|
314
|
+
throw new Error(
|
|
315
|
+
`BinaryHNSW: truncated vectors sidecar (${entries.length}/${header.count} records): ${vectorsPath}`
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
return entries;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Graph v2 records tile each level's node array in fixed-size chunks. Holes in
|
|
322
|
+
// the (sparse) upper-level arrays serialize to null inside a chunk, matching
|
|
323
|
+
// what JSON.parse produced for the v1 whole-array format.
|
|
324
|
+
function* graphChunkRecords(graph) {
|
|
325
|
+
for (let level = 0; level < graph.length; level++) {
|
|
326
|
+
const nodes = graph[level];
|
|
327
|
+
for (let start = 0; start < nodes.length; start += GRAPH_CHUNK_NODES) {
|
|
328
|
+
yield { level, start, nodes: nodes.slice(start, start + GRAPH_CHUNK_NODES) };
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function graphChunkCount(graph) {
|
|
334
|
+
let count = 0;
|
|
335
|
+
for (const nodes of graph) count += Math.ceil(nodes.length / GRAPH_CHUNK_NODES);
|
|
336
|
+
return count;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
export async function readGraphSidecar(graphPath) {
|
|
340
|
+
let header = null;
|
|
341
|
+
let first = true;
|
|
342
|
+
let graph = null;
|
|
343
|
+
let parsed = 0;
|
|
344
|
+
for await (const doc of iterateJsonLines(graphPath)) {
|
|
345
|
+
if (first) {
|
|
346
|
+
first = false;
|
|
347
|
+
if (Array.isArray(doc)) return doc; // v1 — the whole payload is this line
|
|
348
|
+
if (!doc || doc.version !== 2 || !Array.isArray(doc.lengths) || !Number.isFinite(doc.chunks)) {
|
|
349
|
+
throw new Error(`BinaryHNSW: unrecognized graph sidecar format: ${graphPath}`);
|
|
350
|
+
}
|
|
351
|
+
header = doc;
|
|
352
|
+
graph = doc.lengths.map((len) => new Array(len).fill(null));
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
const target = graph[doc.level];
|
|
356
|
+
for (let i = 0; i < doc.nodes.length; i++) {
|
|
357
|
+
target[doc.start + i] = doc.nodes[i];
|
|
358
|
+
}
|
|
359
|
+
parsed++;
|
|
360
|
+
}
|
|
361
|
+
if (!header) {
|
|
362
|
+
throw new Error(`BinaryHNSW: empty graph sidecar: ${graphPath}`);
|
|
363
|
+
}
|
|
364
|
+
if (parsed !== header.chunks) {
|
|
365
|
+
throw new Error(
|
|
366
|
+
`BinaryHNSW: truncated graph sidecar (${parsed}/${header.chunks} chunks): ${graphPath}`
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
return graph;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
export async function readInt8Sidecar(int8Path, out) {
|
|
373
|
+
let header = null;
|
|
374
|
+
let first = true;
|
|
375
|
+
let parsed = 0;
|
|
376
|
+
for await (const doc of iterateJsonLines(int8Path)) {
|
|
377
|
+
if (first) {
|
|
378
|
+
first = false;
|
|
379
|
+
if (!isV2Header(doc)) {
|
|
380
|
+
// v1 — the whole { id: number[] } map is this line. A v1 map can never
|
|
381
|
+
// look like a v2 header: its values are arrays, so doc.version is
|
|
382
|
+
// either undefined or a number[] and fails isV2Header.
|
|
383
|
+
for (const [id, vec] of Object.entries(doc || {})) {
|
|
384
|
+
out.set(id, new Int8Array(vec));
|
|
385
|
+
}
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
header = doc;
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
391
|
+
out.set(doc.id, new Int8Array(doc.v));
|
|
392
|
+
parsed++;
|
|
393
|
+
}
|
|
394
|
+
if (header && parsed !== header.count) {
|
|
395
|
+
throw new Error(
|
|
396
|
+
`BinaryHNSW: truncated int8 sidecar (${parsed}/${header.count} records): ${int8Path}`
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// Entry count without materializing the payload: v2 reads only the header
|
|
402
|
+
// line; v1 (single line = whole map) is parsed from that same first line.
|
|
403
|
+
// Used by diagnostics (getArtifactStats) so stats never re-load the index.
|
|
404
|
+
export async function int8SidecarCount(int8Path) {
|
|
405
|
+
for await (const doc of iterateJsonLines(int8Path)) {
|
|
406
|
+
return isV2Header(doc) ? doc.count : Object.keys(doc || {}).length;
|
|
407
|
+
}
|
|
408
|
+
return 0;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// ---------------------------------------------------------------------------
|
|
412
|
+
// Sync sidecar readers — TOOLING ONLY (soak probes, determinism dumps).
|
|
413
|
+
// These readFileSync the whole file, so they inherit the very string ceiling
|
|
414
|
+
// the streaming readers exist to avoid; fine at tool-corpus scale, never for
|
|
415
|
+
// production-scale indexes. Same v1/v2 detection and truncation guards.
|
|
416
|
+
// ---------------------------------------------------------------------------
|
|
417
|
+
|
|
418
|
+
function parseSidecarLinesSync(filePath) {
|
|
419
|
+
return readFileSync(filePath, 'utf-8')
|
|
420
|
+
.split('\n')
|
|
421
|
+
.filter((l) => l.length > 0)
|
|
422
|
+
.map((l) => JSON.parse(l));
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
export function readVectorsSidecarSync(vectorsPath) {
|
|
426
|
+
const docs = parseSidecarLinesSync(vectorsPath);
|
|
427
|
+
if (Array.isArray(docs[0])) return docs[0]; // v1
|
|
428
|
+
if (!isV2Header(docs[0])) {
|
|
429
|
+
throw new Error(`BinaryHNSW: unrecognized vectors sidecar format: ${vectorsPath}`);
|
|
430
|
+
}
|
|
431
|
+
const entries = docs.slice(1);
|
|
432
|
+
if (entries.length !== docs[0].count) {
|
|
433
|
+
throw new Error(
|
|
434
|
+
`BinaryHNSW: truncated vectors sidecar (${entries.length}/${docs[0].count} records): ${vectorsPath}`
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
return entries;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
export function readGraphSidecarSync(graphPath) {
|
|
441
|
+
const docs = parseSidecarLinesSync(graphPath);
|
|
442
|
+
if (Array.isArray(docs[0])) return docs[0]; // v1
|
|
443
|
+
const header = docs[0];
|
|
444
|
+
if (!header || header.version !== 2 || !Array.isArray(header.lengths) || !Number.isFinite(header.chunks)) {
|
|
445
|
+
throw new Error(`BinaryHNSW: unrecognized graph sidecar format: ${graphPath}`);
|
|
446
|
+
}
|
|
447
|
+
if (docs.length - 1 !== header.chunks) {
|
|
448
|
+
throw new Error(
|
|
449
|
+
`BinaryHNSW: truncated graph sidecar (${docs.length - 1}/${header.chunks} chunks): ${graphPath}`
|
|
450
|
+
);
|
|
451
|
+
}
|
|
452
|
+
const graph = header.lengths.map((len) => new Array(len).fill(null));
|
|
453
|
+
for (const rec of docs.slice(1)) {
|
|
454
|
+
const target = graph[rec.level];
|
|
455
|
+
for (let i = 0; i < rec.nodes.length; i++) {
|
|
456
|
+
target[rec.start + i] = rec.nodes[i];
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
return graph;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// Returns the v1-shaped plain object { id: number[] } for both formats.
|
|
463
|
+
export function readInt8SidecarSync(int8Path) {
|
|
464
|
+
const docs = parseSidecarLinesSync(int8Path);
|
|
465
|
+
if (docs.length === 0) return {};
|
|
466
|
+
if (!isV2Header(docs[0])) return docs[0] || {}; // v1 — whole map on one line
|
|
467
|
+
const records = docs.slice(1);
|
|
468
|
+
if (records.length !== docs[0].count) {
|
|
469
|
+
throw new Error(
|
|
470
|
+
`BinaryHNSW: truncated int8 sidecar (${records.length}/${docs[0].count} records): ${int8Path}`
|
|
471
|
+
);
|
|
472
|
+
}
|
|
473
|
+
const out = {};
|
|
474
|
+
for (const rec of records) out[rec.id] = rec.v;
|
|
475
|
+
return out;
|
|
476
|
+
}
|
|
477
|
+
|
|
205
478
|
// =============================================================================
|
|
206
479
|
// BINARY HNSW INDEX CLASS
|
|
207
480
|
// =============================================================================
|
|
@@ -236,6 +509,11 @@ export class BinaryHNSWIndex {
|
|
|
236
509
|
|
|
237
510
|
// Pre-allocated visited list (generation-stamped, reused across searches)
|
|
238
511
|
this._visitedList = new VisitedList(this.maxElements);
|
|
512
|
+
// Pooled search heaps — searchLayer/searchLayerQuery are synchronous and
|
|
513
|
+
// never re-entered, so one pair (auto-growing) serves all calls. Same
|
|
514
|
+
// single-flight contract as _visitedList.
|
|
515
|
+
this._candHeap = new TypedMinHeap(64);
|
|
516
|
+
this._resHeap = new TypedMaxHeap(64);
|
|
239
517
|
|
|
240
518
|
// Asymmetric binary quantization calibration data
|
|
241
519
|
this.centroid = null; // Float32Array — dataset centroid
|
|
@@ -652,11 +930,13 @@ export class BinaryHNSWIndex {
|
|
|
652
930
|
const startDist = hammingDistance(this.vectors[startNode].binary, targetBinary);
|
|
653
931
|
|
|
654
932
|
// candidates = min-heap (explore closest first)
|
|
655
|
-
const candidates =
|
|
933
|
+
const candidates = this._candHeap;
|
|
934
|
+
candidates.clear();
|
|
656
935
|
candidates.insert(startNode, startDist);
|
|
657
936
|
|
|
658
937
|
// results = max-heap of size ef (peek furthest, replaceMax when closer found)
|
|
659
|
-
const results =
|
|
938
|
+
const results = this._resHeap;
|
|
939
|
+
results.clear();
|
|
660
940
|
results.insert(startNode, startDist);
|
|
661
941
|
|
|
662
942
|
while (candidates.size > 0) {
|
|
@@ -894,8 +1174,10 @@ export class BinaryHNSWIndex {
|
|
|
894
1174
|
visited.mark(startNode);
|
|
895
1175
|
|
|
896
1176
|
const startDist = hammingDistance(this.vectors[startNode].binary, queryBinary);
|
|
897
|
-
const candidates =
|
|
898
|
-
|
|
1177
|
+
const candidates = this._candHeap;
|
|
1178
|
+
candidates.clear();
|
|
1179
|
+
const results = this._resHeap;
|
|
1180
|
+
results.clear();
|
|
899
1181
|
candidates.insert(startNode, startDist);
|
|
900
1182
|
results.insert(startNode, startDist);
|
|
901
1183
|
|
|
@@ -1061,12 +1343,6 @@ export class BinaryHNSWIndex {
|
|
|
1061
1343
|
savedAt: new Date().toISOString(),
|
|
1062
1344
|
};
|
|
1063
1345
|
|
|
1064
|
-
const vectorsData = this.vectors.map(v => ({
|
|
1065
|
-
id: v.id,
|
|
1066
|
-
binary: Array.from(v.binary),
|
|
1067
|
-
metadata: v.metadata,
|
|
1068
|
-
}));
|
|
1069
|
-
|
|
1070
1346
|
const metaPath = indexPath.replace('.idx', '.meta.json');
|
|
1071
1347
|
const vectorsPath = indexPath.replace('.idx', '.vectors.json');
|
|
1072
1348
|
const graphPath = indexPath.replace('.idx', '.graph.json');
|
|
@@ -1074,21 +1350,53 @@ export class BinaryHNSWIndex {
|
|
|
1074
1350
|
const calibPath = indexPath.replace('.idx', '.calibration.json');
|
|
1075
1351
|
const pidSuffix = `.tmp.${process.pid}`;
|
|
1076
1352
|
|
|
1077
|
-
// Stage all sidecars to sibling temp paths.
|
|
1353
|
+
// Stage all sidecars to sibling temp paths. The big three are NDJSON v2
|
|
1354
|
+
// (see the sidecar-format block above the class) — one record per line
|
|
1355
|
+
// through a write stream — so no serialized string can cross V8's ceiling
|
|
1356
|
+
// on large indexes. Records are yielded lazily so no whole-index
|
|
1357
|
+
// intermediate array/object is materialized either.
|
|
1078
1358
|
await fs.writeFile(metaPath + pidSuffix, JSON.stringify(meta, null, 2));
|
|
1079
|
-
|
|
1080
|
-
|
|
1359
|
+
|
|
1360
|
+
const vectors = this.vectors;
|
|
1361
|
+
await writeNdjsonSidecar(
|
|
1362
|
+
vectorsPath + pidSuffix,
|
|
1363
|
+
{ version: 2, count: vectors.length },
|
|
1364
|
+
(function* () {
|
|
1365
|
+
for (const v of vectors) {
|
|
1366
|
+
yield { id: v.id, binary: Array.from(v.binary), metadata: v.metadata };
|
|
1367
|
+
}
|
|
1368
|
+
})()
|
|
1369
|
+
);
|
|
1370
|
+
|
|
1371
|
+
await writeNdjsonSidecar(
|
|
1372
|
+
graphPath + pidSuffix,
|
|
1373
|
+
{
|
|
1374
|
+
version: 2,
|
|
1375
|
+
lengths: this.graph.map((nodes) => nodes.length),
|
|
1376
|
+
chunks: graphChunkCount(this.graph),
|
|
1377
|
+
},
|
|
1378
|
+
graphChunkRecords(this.graph)
|
|
1379
|
+
);
|
|
1081
1380
|
|
|
1082
1381
|
let stagedInt8 = false;
|
|
1083
1382
|
if (this.int8Vectors.size > 0) {
|
|
1084
1383
|
const liveIds = new Set(this.vectors.map(v => v.id));
|
|
1085
|
-
|
|
1086
|
-
for (const
|
|
1087
|
-
if (
|
|
1088
|
-
int8Data[id] = Array.from(vec);
|
|
1384
|
+
let liveCount = 0;
|
|
1385
|
+
for (const id of this.int8Vectors.keys()) {
|
|
1386
|
+
if (liveIds.has(id)) liveCount++;
|
|
1089
1387
|
}
|
|
1090
|
-
if (
|
|
1091
|
-
|
|
1388
|
+
if (liveCount > 0) {
|
|
1389
|
+
const int8Vectors = this.int8Vectors;
|
|
1390
|
+
await writeNdjsonSidecar(
|
|
1391
|
+
int8Path + pidSuffix,
|
|
1392
|
+
{ version: 2, count: liveCount },
|
|
1393
|
+
(function* () {
|
|
1394
|
+
for (const [id, vec] of int8Vectors) {
|
|
1395
|
+
if (!liveIds.has(id)) continue;
|
|
1396
|
+
yield { id, v: Array.from(vec) };
|
|
1397
|
+
}
|
|
1398
|
+
})()
|
|
1399
|
+
);
|
|
1092
1400
|
stagedInt8 = true;
|
|
1093
1401
|
}
|
|
1094
1402
|
}
|
|
@@ -1174,7 +1482,7 @@ export class BinaryHNSWIndex {
|
|
|
1174
1482
|
let attempt = 0;
|
|
1175
1483
|
while (true) {
|
|
1176
1484
|
meta = attempt === 0 ? initialMeta : JSON.parse(await fs.readFile(metaPath, 'utf-8'));
|
|
1177
|
-
vectorsData =
|
|
1485
|
+
vectorsData = await readVectorsSidecar(vectorsPath);
|
|
1178
1486
|
const consistent = meta.vectorCount === vectorsData.length
|
|
1179
1487
|
&& (meta.entryPoint === -1 || meta.entryPoint < vectorsData.length);
|
|
1180
1488
|
if (consistent) break;
|
|
@@ -1215,15 +1523,12 @@ export class BinaryHNSWIndex {
|
|
|
1215
1523
|
}
|
|
1216
1524
|
|
|
1217
1525
|
// Load graph
|
|
1218
|
-
this.graph =
|
|
1526
|
+
this.graph = await readGraphSidecar(graphPath);
|
|
1219
1527
|
|
|
1220
1528
|
// Load int8 vectors if available
|
|
1221
1529
|
this.int8Vectors.clear();
|
|
1222
1530
|
if (existsSync(int8Path)) {
|
|
1223
|
-
|
|
1224
|
-
for (const [id, vec] of Object.entries(int8Data)) {
|
|
1225
|
-
this.int8Vectors.set(id, new Int8Array(vec));
|
|
1226
|
-
}
|
|
1531
|
+
await readInt8Sidecar(int8Path, this.int8Vectors);
|
|
1227
1532
|
}
|
|
1228
1533
|
|
|
1229
1534
|
// Load asymmetric calibration data
|
|
@@ -117,12 +117,16 @@ export class FloatVectorStore {
|
|
|
117
117
|
const idsPath = binPath.replace(/\.bin$/, '.ids.json');
|
|
118
118
|
if (!existsSync(idsPath)) return false;
|
|
119
119
|
|
|
120
|
-
// Read binary
|
|
120
|
+
// Read binary. Large reads return an unpooled Buffer that owns its whole
|
|
121
|
+
// ArrayBuffer — adopt it directly; only pooled (offset/short) buffers need
|
|
122
|
+
// the defensive copy.
|
|
121
123
|
const fileBuffer = await readFile(binPath);
|
|
122
|
-
this.buffer = fileBuffer.buffer.
|
|
123
|
-
fileBuffer.
|
|
124
|
-
|
|
125
|
-
|
|
124
|
+
this.buffer = (fileBuffer.byteOffset === 0 && fileBuffer.byteLength === fileBuffer.buffer.byteLength)
|
|
125
|
+
? fileBuffer.buffer
|
|
126
|
+
: fileBuffer.buffer.slice(
|
|
127
|
+
fileBuffer.byteOffset,
|
|
128
|
+
fileBuffer.byteOffset + fileBuffer.byteLength
|
|
129
|
+
);
|
|
126
130
|
|
|
127
131
|
// Parse header
|
|
128
132
|
const header = new DataView(this.buffer, 0, HEADER_SIZE);
|
|
@@ -19,6 +19,17 @@ import {
|
|
|
19
19
|
parseLineRange, looksLikeOption,
|
|
20
20
|
} from './_ss-argparse.mjs';
|
|
21
21
|
|
|
22
|
+
// Diagnostic-log isolation (agent-facing tools). The Sweet Search engine emits
|
|
23
|
+
// model/index load banners via console.log → stdout ("LateInteraction: Loaded…",
|
|
24
|
+
// "BinaryHNSW: Loaded…", "Warming up embedding…", "✓ Vocabulary loaded…"). When the
|
|
25
|
+
// engine runs IN-PROCESS in this wrapper, that stdout IS the agent's tool result, so a
|
|
26
|
+
// cold start crowds out the actual hits and the agent falls back to native tools
|
|
27
|
+
// (diagnosed root cause of the sweet≈native tie). The wrappers emit REAL results only
|
|
28
|
+
// via process.stdout.write, so redirect every console.log to stderr; the ss-* shell
|
|
29
|
+
// wrapper's `2>` suppresses it on success → the agent sees clean results, warm or cold.
|
|
30
|
+
// (Production search/grep/find already avoid this: the daemon is spawned stdio:'ignore'.)
|
|
31
|
+
console.log = (...args) => process.stderr.write(args.map(a => (typeof a === 'string' ? a : String(a))).join(' ') + '\n');
|
|
32
|
+
|
|
22
33
|
// 8-char SHA1 prefix is enough for grouping identical queries across
|
|
23
34
|
// benchmark runs without bloating artifacts.
|
|
24
35
|
function shortQueryHash(q) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sweet-search",
|
|
3
|
-
"version": "2.6.
|
|
3
|
+
"version": "2.6.10",
|
|
4
4
|
"description": "Sweet Search - SOTA Hybrid Code Search Engine with WASM CatBoost Query Router, Semantic/Lexical/Structural Search, and Multilingual Support",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "core/search/sweet-search.js",
|
|
@@ -167,13 +167,13 @@
|
|
|
167
167
|
"vitest": "^4.0.16"
|
|
168
168
|
},
|
|
169
169
|
"optionalDependencies": {
|
|
170
|
-
"@sweet-search/native-darwin-arm64": "2.6.
|
|
171
|
-
"@sweet-search/native-darwin-x64": "2.6.
|
|
172
|
-
"@sweet-search/native-linux-arm64-gnu": "2.6.
|
|
173
|
-
"@sweet-search/native-linux-arm64-gnu-cuda": "2.6.
|
|
174
|
-
"@sweet-search/native-linux-x64-gnu": "2.6.
|
|
175
|
-
"@sweet-search/native-linux-x64-gnu-cuda": "2.6.
|
|
176
|
-
"@sweet-search/bg-priority": "2.6.
|
|
170
|
+
"@sweet-search/native-darwin-arm64": "2.6.10",
|
|
171
|
+
"@sweet-search/native-darwin-x64": "2.6.10",
|
|
172
|
+
"@sweet-search/native-linux-arm64-gnu": "2.6.10",
|
|
173
|
+
"@sweet-search/native-linux-arm64-gnu-cuda": "2.6.10",
|
|
174
|
+
"@sweet-search/native-linux-x64-gnu": "2.6.10",
|
|
175
|
+
"@sweet-search/native-linux-x64-gnu-cuda": "2.6.10",
|
|
176
|
+
"@sweet-search/bg-priority": "2.6.10"
|
|
177
177
|
},
|
|
178
178
|
"engines": {
|
|
179
179
|
"node": ">=18.0.0"
|