sweet-search 2.6.9 → 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/incremental-indexing/application/production-li-delta.mjs +28 -3
- package/core/incremental-indexing/application/production-reconciler.mjs +11 -2
- package/core/incremental-indexing/application/reconciler.mjs +1 -1
- package/core/indexing/artifact-builder.js +5 -3
- package/core/indexing/index-maintainer.mjs +49 -6
- package/core/indexing/model-pool.js +26 -0
- package/core/indexing/rss-budget.mjs +65 -0
- package/core/ranking/late-interaction-model.js +25 -4
- package/core/vector-store/binary-hnsw-index.js +325 -29
- package/package.json +8 -8
package/README.md
CHANGED
|
@@ -711,6 +711,21 @@ without you ever running a command.
|
|
|
711
711
|
- **Resource-polite:** ticks are budgeted (≤50 files / ≤2 s CPU per tick), run CPU-only (the GPU is reserved for cold full indexing), and the interval auto-tunes from load average, churn, and backlog.
|
|
712
712
|
- `sweet-search reconcile status` / `reconcile inspect <path>` explain exactly what the daemon thinks and why. Opt out any time with `SWEET_SEARCH_RECONCILE_V2=0`.
|
|
713
713
|
|
|
714
|
+
**Memory controls.** The resident daemons show up in `ps` / Activity Monitor as
|
|
715
|
+
`sweet-search-maintainer` and `sweet-search-daemon`. A maintainer's steady state is
|
|
716
|
+
roughly 2–3 GB (embedding + late-interaction models stay loaded so ticks are fast),
|
|
717
|
+
and four independent mechanisms keep that bounded:
|
|
718
|
+
|
|
719
|
+
| Mechanism | Default | Override |
|
|
720
|
+
|-----------|---------|----------|
|
|
721
|
+
| Background ORT profile (arena-off + parked threads) in the maintainer | on | `SWEET_SEARCH_ORT_BACKGROUND=0` |
|
|
722
|
+
| Per-process recycle ceiling — the maintainer finishes its tick, exits cleanly, and respawns fresh on the next edit when its RSS crosses the line | clamp(25 % of RAM, 4 GiB, 8 GiB) | `SWEET_SEARCH_MAINTAINER_RSS_MAX_MB` (0 disables) |
|
|
723
|
+
| Idle TTL — unattended daemons shut down and respawn on demand | tier-aware | `SWEET_SEARCH_MAINTAINER_IDLE_TTL_MS` / `SWEET_SEARCH_DAEMON_IDLE_TTL_MS` |
|
|
724
|
+
| Fleet RSS budget — across all repos' daemons, the longest-idle one is evicted when the sum crosses a RAM-scaled budget | tier-aware | `SWEET_SEARCH_RSS_BUDGET_FRACTION` |
|
|
725
|
+
|
|
726
|
+
A recycle or eviction never touches index state: every tick publishes atomically
|
|
727
|
+
before the process exits, and the next edit (or query) respawns a fresh daemon.
|
|
728
|
+
|
|
714
729
|
</details>
|
|
715
730
|
|
|
716
731
|
## 🦀 The Native Engine Room
|
package/core/cli.js
CHANGED
|
@@ -94,6 +94,13 @@ if (args[0] === 'init') {
|
|
|
94
94
|
await runIndexer();
|
|
95
95
|
}
|
|
96
96
|
} else if (args[0] === '--serve' || args[0] === '--stop') {
|
|
97
|
+
// Name the resident daemon so ps/Activity Monitor shows what it is instead
|
|
98
|
+
// of an anonymous `node`. Entrypoint-scoped: library consumers of the
|
|
99
|
+
// search modules are never retitled. Liveness checks are lock/pid-based,
|
|
100
|
+
// never command-line matching, so this is cosmetic-only.
|
|
101
|
+
if (args[0] === '--serve') {
|
|
102
|
+
try { process.title = 'sweet-search-daemon'; } catch { /* best-effort */ }
|
|
103
|
+
}
|
|
97
104
|
// Warm search server lifecycle is implemented in JS.
|
|
98
105
|
const { runCli } = await import('./search/index.js');
|
|
99
106
|
await runCli(args);
|
|
@@ -117,6 +117,7 @@ export async function applyLateInteractionDelta({
|
|
|
117
117
|
liEncoder,
|
|
118
118
|
pickLiInput,
|
|
119
119
|
onProgress = null,
|
|
120
|
+
readerCache = null,
|
|
120
121
|
}) {
|
|
121
122
|
const progress = typeof onProgress === 'function'
|
|
122
123
|
? (phase) => { onProgress(phase); }
|
|
@@ -128,11 +129,35 @@ export async function applyLateInteractionDelta({
|
|
|
128
129
|
const encode = liEncoder || ((texts) => encodeDocumentsCpu(texts));
|
|
129
130
|
const existing = fs.existsSync(indexPath);
|
|
130
131
|
const segmented = existing ? segmentedState(indexPath) : null;
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
132
|
+
|
|
133
|
+
// E.1-LI reader cache: reuse the loaded read view across the tick's files.
|
|
134
|
+
// Only the SEGMENTED path is cacheable — everything the reader serves there
|
|
135
|
+
// (config fields, positions/counts of pre-tick docs) is immutable within a
|
|
136
|
+
// tick: per-file ops only reference the file's own pre-tick docs, appends
|
|
137
|
+
// go through appendGrowingSegment (which re-reads the manifest from disk),
|
|
138
|
+
// and tombstone sidecar state is opened fresh per call. The legacy path
|
|
139
|
+
// MUTATES the loaded index (rewriteLegacyIndex), so it always loads fresh
|
|
140
|
+
// and drops any cached reader.
|
|
141
|
+
const cacheable = !!(readerCache && segmented);
|
|
142
|
+
let index;
|
|
143
|
+
if (cacheable && readerCache.index && readerCache.key === indexPath) {
|
|
144
|
+
index = readerCache.index;
|
|
145
|
+
progress('li:init-cached');
|
|
146
|
+
} else {
|
|
147
|
+
index = new LateInteractionIndex({ indexPath, loadExisting: true });
|
|
148
|
+
await index.init();
|
|
149
|
+
progress('li:init');
|
|
150
|
+
if (cacheable) {
|
|
151
|
+
readerCache.key = indexPath;
|
|
152
|
+
readerCache.index = index;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
134
155
|
|
|
135
156
|
if (existing && !segmented) {
|
|
157
|
+
if (readerCache) {
|
|
158
|
+
readerCache.key = null;
|
|
159
|
+
readerCache.index = null;
|
|
160
|
+
}
|
|
136
161
|
return rewriteLegacyIndex(index, ops, encode, pickLiInput, progress);
|
|
137
162
|
}
|
|
138
163
|
|
|
@@ -419,7 +419,7 @@ class ProductionReconcileAdapter {
|
|
|
419
419
|
applyGraphDelta: (file, hashes, epoch, ctx) => this.applyGraphDelta(file, hashes, epoch, ctx),
|
|
420
420
|
applyVectorDelta: (file, chunks, hashes, epoch, ctx) => this.applyVectorDelta(file, chunks, hashes, epoch, ctx),
|
|
421
421
|
applyBinaryHNSWDelta: (file, ops, epoch, ctx) => this.applyBinaryHNSWDelta(file, ops, epoch, ctx),
|
|
422
|
-
applyLIDelta: (file, ops, epoch) => this.applyLIDelta(file, ops,
|
|
422
|
+
applyLIDelta: (file, ops, epoch, ctx) => this.applyLIDelta(file, ops, ctx),
|
|
423
423
|
applySparseGramDelta: (file, ops, epoch) => this.applySparseGramDelta(file, ops, epoch),
|
|
424
424
|
readMaintenanceState: () => this.readMaintenanceState(),
|
|
425
425
|
scheduleMaintenance: (job) => enqueueMaintenanceJob(this.stateDir, job),
|
|
@@ -1157,7 +1157,7 @@ class ProductionReconcileAdapter {
|
|
|
1157
1157
|
return { ops: { binary_hnsw_append: append, binary_hnsw_tombstone: tombstone }, manifest: { path: 'codebase-binary-hnsw.idx' } };
|
|
1158
1158
|
}
|
|
1159
1159
|
|
|
1160
|
-
async applyLIDelta(file, ops) {
|
|
1160
|
+
async applyLIDelta(file, ops, ctx = null) {
|
|
1161
1161
|
if (!Array.isArray(ops) || ops.length === 0) return { ops: { li_segment_append: 0, li_tombstone: 0 } };
|
|
1162
1162
|
// LI generated-content parity: full indexing's buildLateInteractionIndex runs
|
|
1163
1163
|
// applyIndexingChunkPolicy so @generated / config-excluded files never reach
|
|
@@ -1170,12 +1170,21 @@ class ProductionReconcileAdapter {
|
|
|
1170
1170
|
? ops.filter((op) => !(op.addId && op.chunk))
|
|
1171
1171
|
: ops;
|
|
1172
1172
|
const { applyLateInteractionDelta } = await import('./production-li-delta.mjs');
|
|
1173
|
+
// E.1-LI: tick-scoped READER cache. The per-file delta previously paid a
|
|
1174
|
+
// full loadExisting init (segment manifest + doc positions over a multi-
|
|
1175
|
+
// hundred-MB index) once per file; within one tick the reader view it
|
|
1176
|
+
// needs is stable (per-file ops only reference the file's own pre-tick
|
|
1177
|
+
// docs, appendGrowingSegment re-reads the manifest from disk, and segment
|
|
1178
|
+
// compaction only runs in maintenance after the tick). Null ctx (per-file
|
|
1179
|
+
// path) ⇒ fresh load per file, exactly as before.
|
|
1180
|
+
const readerCache = ctx ? (ctx._liReaderCache ??= { key: null, index: null }) : null;
|
|
1173
1181
|
const { appended, tombstone } = await applyLateInteractionDelta({
|
|
1174
1182
|
indexPath: path.join(this.stateDir, 'codebase-late-interaction.db'),
|
|
1175
1183
|
ops: filteredOps,
|
|
1176
1184
|
liEncoder: this.liEncoder,
|
|
1177
1185
|
pickLiInput,
|
|
1178
1186
|
onProgress: () => this.progress('production:li-delta'),
|
|
1187
|
+
readerCache,
|
|
1179
1188
|
});
|
|
1180
1189
|
return { ops: { li_segment_append: appended, li_tombstone: tombstone }, manifest: { path: 'codebase-late-interaction.db', segments: 'codebase-late-interaction.db.segments/manifest.json' } };
|
|
1181
1190
|
}
|
|
@@ -461,7 +461,7 @@ export class Reconciler {
|
|
|
461
461
|
if (bin?.ops?.binary_hnsw_append != null) ops.binary_hnsw_append = bin.ops.binary_hnsw_append;
|
|
462
462
|
if (bin?.ops?.binary_hnsw_tombstone != null) ops.binary_hnsw_tombstone = bin.ops.binary_hnsw_tombstone;
|
|
463
463
|
this.progress('reconciler:li:start');
|
|
464
|
-
const li = await this.adapters.applyLIDelta?.(file, vec?.tokenOps ?? [], epoch);
|
|
464
|
+
const li = await this.adapters.applyLIDelta?.(file, vec?.tokenOps ?? [], epoch, tickCtx);
|
|
465
465
|
this.progress('reconciler:li:done');
|
|
466
466
|
collectManifestTier(manifestTiers, 'lateInteraction', li);
|
|
467
467
|
if (li?.ops?.li_segment_append != null) ops.li_segment_append = li.ops.li_segment_append;
|
|
@@ -52,7 +52,7 @@ export const ARTIFACT_THRESHOLDS = {
|
|
|
52
52
|
stateFile: '.sweet-search/artifact-rebuild-state.json',
|
|
53
53
|
};
|
|
54
54
|
|
|
55
|
-
import { BinaryHNSWIndex } from '../vector-store/binary-hnsw-index.js';
|
|
55
|
+
import { BinaryHNSWIndex, int8SidecarCount } from '../vector-store/binary-hnsw-index.js';
|
|
56
56
|
import { truncateForHNSW, fisherYatesShuffle, normalizedFloatToInt8, floatToBinary } from '../infrastructure/quantization.js';
|
|
57
57
|
import { FloatVectorStore, getFloatStorePath } from '../vector-store/float-vector-store.js';
|
|
58
58
|
|
|
@@ -828,8 +828,10 @@ export async function getArtifactStats() {
|
|
|
828
828
|
if (existsSync(int8SidecarPath)) {
|
|
829
829
|
try {
|
|
830
830
|
const fileStats = await fs.stat(int8SidecarPath);
|
|
831
|
-
|
|
832
|
-
|
|
831
|
+
// Header-only count (NDJSON v2 sidecar; v1 back-compat) — never
|
|
832
|
+
// JSON.parse the whole file, which exceeds V8's string ceiling on
|
|
833
|
+
// large indexes.
|
|
834
|
+
const count = await int8SidecarCount(int8SidecarPath);
|
|
833
835
|
|
|
834
836
|
stats.int8Sidecar = {
|
|
835
837
|
exists: true,
|
|
@@ -1267,12 +1267,14 @@ async function runReconcileV2Main({ runOnce, merkleOnce }) {
|
|
|
1267
1267
|
// Best-effort — never block startup on the embedding module.
|
|
1268
1268
|
if (process.env.SWEET_SEARCH_ORT_BACKGROUND !== '0') {
|
|
1269
1269
|
try {
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1270
|
+
// Arms BOTH resident CPU sessions (dense + late-interaction) — the LI
|
|
1271
|
+
// session previously stayed on the foreground profile (arena ON +
|
|
1272
|
+
// allow_spinning) inside the daemon; measured consequence: 354 × 128MB
|
|
1273
|
+
// arena extensions (~34GB RSS) after one edit-heavy day plus ~40% idle
|
|
1274
|
+
// CPU from spinning workers.
|
|
1275
|
+
const { armBackgroundOrtProfiles } = await import('./model-pool.js');
|
|
1276
|
+
armBackgroundOrtProfiles();
|
|
1277
|
+
log('INFO', 'ORT background profile armed for maintainer daemon (dense + LI: force_spinning_stop + arena-off + bg threads)');
|
|
1276
1278
|
} catch (err) {
|
|
1277
1279
|
log('WARN', `ORT background profile arming failed (continuing on foreground profile): ${err?.message ?? err}`);
|
|
1278
1280
|
}
|
|
@@ -1314,15 +1316,30 @@ async function runReconcileV2Main({ runOnce, merkleOnce }) {
|
|
|
1314
1316
|
// system-RAM tier auto-enables a cap (small-RAM hosts). Best-effort, guarded
|
|
1315
1317
|
// so a missing module is a no-op.
|
|
1316
1318
|
let rssRegistration = null;
|
|
1319
|
+
// D.5 per-process recycle ceiling: fleet-budget eviction only sheds the
|
|
1320
|
+
// longest-IDLE daemon, so an active maintainer needs its own line. Resolved
|
|
1321
|
+
// once; checked at tick boundaries below. 0 ⇒ disabled.
|
|
1322
|
+
let rssCeiling = { ceilingBytes: 0, minUptimeMs: 0, shouldRecycleForRss: null };
|
|
1317
1323
|
try {
|
|
1318
1324
|
const mod = await import('./rss-budget.mjs');
|
|
1319
1325
|
if (typeof mod.isEnabled === 'function' && mod.isEnabled()
|
|
1320
1326
|
&& typeof mod.registerDaemon === 'function') {
|
|
1321
1327
|
rssRegistration = await mod.registerDaemon({ pid: process.pid, stateDir: ctx.stateDir, kind: 'maintainer' });
|
|
1322
1328
|
}
|
|
1329
|
+
if (typeof mod.maintainerRssCeilingBytes === 'function') {
|
|
1330
|
+
rssCeiling = {
|
|
1331
|
+
ceilingBytes: mod.maintainerRssCeilingBytes(),
|
|
1332
|
+
minUptimeMs: mod.maintainerRssMinUptimeMs(),
|
|
1333
|
+
shouldRecycleForRss: mod.shouldRecycleForRss,
|
|
1334
|
+
};
|
|
1335
|
+
if (rssCeiling.ceilingBytes > 0) {
|
|
1336
|
+
log('INFO', `Maintainer RSS recycle ceiling armed: ${(rssCeiling.ceilingBytes / 1048576).toFixed(0)}MB (min uptime ${rssCeiling.minUptimeMs}ms; override SWEET_SEARCH_MAINTAINER_RSS_MAX_MB)`);
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1323
1339
|
} catch (err) {
|
|
1324
1340
|
log('WARN', `RSS-budget registry unavailable (no soft cap on this daemon): ${err?.message ?? err}`);
|
|
1325
1341
|
}
|
|
1342
|
+
let completedTicks = 0;
|
|
1326
1343
|
|
|
1327
1344
|
// D.1 idle-TTL: an unattended maintainer self-shuts-down after the configured
|
|
1328
1345
|
// wall-clock idle budget so N resident model-loaded daemons collapse to 1–2
|
|
@@ -1432,6 +1449,23 @@ async function runReconcileV2Main({ runOnce, merkleOnce }) {
|
|
|
1432
1449
|
maintenanceBacklog: backlog,
|
|
1433
1450
|
skipped: tickCounters?.skipped === true,
|
|
1434
1451
|
});
|
|
1452
|
+
// D.5: per-process RSS recycle check — tick boundary only (the tick
|
|
1453
|
+
// above has published; a recycle here can never tear an artifact).
|
|
1454
|
+
completedTicks += 1;
|
|
1455
|
+
if (rssCeiling.ceilingBytes > 0 && typeof rssCeiling.shouldRecycleForRss === 'function') {
|
|
1456
|
+
const rssNow = process.memoryUsage.rss();
|
|
1457
|
+
const verdict = rssCeiling.shouldRecycleForRss({
|
|
1458
|
+
rssBytes: rssNow,
|
|
1459
|
+
ceilingBytes: rssCeiling.ceilingBytes,
|
|
1460
|
+
uptimeMs: process.uptime() * 1000,
|
|
1461
|
+
minUptimeMs: rssCeiling.minUptimeMs,
|
|
1462
|
+
ticksCompleted: completedTicks,
|
|
1463
|
+
});
|
|
1464
|
+
if (verdict.recycle) {
|
|
1465
|
+
log('WARN', `Maintainer RSS ${(rssNow / 1048576).toFixed(0)}MB exceeds recycle ceiling ${(rssCeiling.ceilingBytes / 1048576).toFixed(0)}MB; requesting clean shutdown for on-demand respawn. If this repeats right after startup, raise SWEET_SEARCH_MAINTAINER_RSS_MAX_MB.`);
|
|
1466
|
+
shutdownRequested = true;
|
|
1467
|
+
}
|
|
1468
|
+
}
|
|
1435
1469
|
} catch (err) {
|
|
1436
1470
|
if (err instanceof MaintainerLifecycleAbort) {
|
|
1437
1471
|
log('WARN', `Reconcile v2 lifecycle abort: ${err.message}. Cleaning up cancellation-orphaned temps and exiting cleanly.`);
|
|
@@ -1457,6 +1491,10 @@ async function runReconcileV2Main({ runOnce, merkleOnce }) {
|
|
|
1457
1491
|
log('ERROR', `Reconcile v2 tick failed: ${err?.message ?? err}`);
|
|
1458
1492
|
}
|
|
1459
1493
|
}
|
|
1494
|
+
// Skip the sleep when a shutdown was requested during this iteration
|
|
1495
|
+
// (idle-TTL, signal, or D.5 RSS recycle) — exit promptly instead of
|
|
1496
|
+
// waiting out one more interval.
|
|
1497
|
+
if (shutdownRequested) break;
|
|
1460
1498
|
await sleepWithProgress(intervalMs, lock.lockFile, {
|
|
1461
1499
|
// G6 early-wake: break the sleep the instant the watcher reports new
|
|
1462
1500
|
// events so a fresh edit is reconciled without waiting out the interval.
|
|
@@ -2482,6 +2520,11 @@ async function main() {
|
|
|
2482
2520
|
const dryRun = process.argv.includes('--dry-run');
|
|
2483
2521
|
const merkleOnce = process.argv.includes('--merkle-once');
|
|
2484
2522
|
|
|
2523
|
+
// Name the resident daemon so ps/Activity Monitor shows what it is instead
|
|
2524
|
+
// of an anonymous `node`. Cosmetic-only: all liveness/takeover checks are
|
|
2525
|
+
// lock/pid-based, never command-line matching.
|
|
2526
|
+
try { process.title = 'sweet-search-maintainer'; } catch { /* best-effort */ }
|
|
2527
|
+
|
|
2485
2528
|
// A.1 (Tier-1, UNGATED): demote the maintainer daemon to low OS priority so
|
|
2486
2529
|
// the foreground (editor / git / shell) never feels the background indexer's
|
|
2487
2530
|
// CPU. Identical index output — only *when* CPU is granted changes. Covers
|
|
@@ -29,12 +29,15 @@ import {
|
|
|
29
29
|
getLocalPipeline,
|
|
30
30
|
callLocalModelCpu,
|
|
31
31
|
unloadLocalModel,
|
|
32
|
+
configureLocalModelRuntime,
|
|
32
33
|
} from '../embedding/embedding-local-model.js';
|
|
33
34
|
import {
|
|
34
35
|
getLateInteractionPipeline,
|
|
35
36
|
encodeDocumentsCpu,
|
|
36
37
|
unloadLateInteractionModel,
|
|
38
|
+
configureLateInteractionRuntime,
|
|
37
39
|
} from '../ranking/late-interaction-model.js';
|
|
40
|
+
import { backgroundIntraOpThreads } from '../infrastructure/onnx-session-utils.js';
|
|
38
41
|
|
|
39
42
|
/**
|
|
40
43
|
* Small-changeset threshold. Incremental runs with fewer files than this use
|
|
@@ -69,6 +72,29 @@ export function selectAcceleratorDeviceKind(pref) {
|
|
|
69
72
|
return null;
|
|
70
73
|
}
|
|
71
74
|
|
|
75
|
+
/**
|
|
76
|
+
* G3: arm the BACKGROUND/maintainer ORT profile for BOTH resident CPU
|
|
77
|
+
* sessions — dense (embedding-local-model) and late-interaction. The profile
|
|
78
|
+
* (force_spinning_stop + arena-off + 2–4 intra-op threads) prevents the two
|
|
79
|
+
* resident-daemon pathologies: idle spinning workers (~a core of idle CPU)
|
|
80
|
+
* and monotonic RSS from ORT arena extension (#25325; measured 354 × 128MB
|
|
81
|
+
* ≈ 34GB after one edit-heavy day when the LI session was left on the
|
|
82
|
+
* foreground profile). MUST run before the first encode — both session
|
|
83
|
+
* singletons are built once; configuring after is a silent no-op.
|
|
84
|
+
*
|
|
85
|
+
* Lives here (model-pool owns indexing-side model lifecycle) so daemon
|
|
86
|
+
* entrypoints don't need their own cross-context imports of the two model
|
|
87
|
+
* modules.
|
|
88
|
+
*
|
|
89
|
+
* @returns {{armed: boolean}}
|
|
90
|
+
*/
|
|
91
|
+
export function armBackgroundOrtProfiles() {
|
|
92
|
+
const intraOpThreads = backgroundIntraOpThreads();
|
|
93
|
+
configureLocalModelRuntime({ intraOpThreads, background: true });
|
|
94
|
+
configureLateInteractionRuntime({ intraOpThreads, background: true });
|
|
95
|
+
return { armed: true };
|
|
96
|
+
}
|
|
97
|
+
|
|
72
98
|
/**
|
|
73
99
|
* Whether this process can use an inference accelerator (Metal / CoreML
|
|
74
100
|
* cascade / CUDA) for indexing. Requires both accelerator-capable hardware
|
|
@@ -112,6 +112,71 @@ export function isOverBudget(totalRssBytes, budgetBytesValue) {
|
|
|
112
112
|
);
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
+
// =============================================================================
|
|
116
|
+
// D.5: per-process maintainer RSS recycle ceiling
|
|
117
|
+
// =============================================================================
|
|
118
|
+
//
|
|
119
|
+
// The fleet budget above evicts the longest-IDLE daemon, so a single fat
|
|
120
|
+
// ACTIVE maintainer can legitimately ride up to the whole fleet budget
|
|
121
|
+
// (~76 GB on a 128 GB host) — measured in the wild when a session-config
|
|
122
|
+
// regression left the LI ORT session on the foreground arena profile
|
|
123
|
+
// (354 × 128MB arena extensions ≈ 34 GB RSS). This ceiling is the
|
|
124
|
+
// per-process backstop: when the maintainer's OWN rss crosses the line at a
|
|
125
|
+
// tick boundary, it requests the same graceful shutdown the idle-TTL uses
|
|
126
|
+
// (finish tick, publish, release lock, exit). The O_EXCL launch trigger
|
|
127
|
+
// respawns a fresh few-hundred-MB process on the next dirty event, so the
|
|
128
|
+
// index is unchanged — exit-to-reclaim is already the sanctioned way to
|
|
129
|
+
// return ORT memory (#25325).
|
|
130
|
+
//
|
|
131
|
+
// Guards against restart-thrash:
|
|
132
|
+
// - at least one COMPLETED tick (never abort startup work),
|
|
133
|
+
// - minimum uptime (default 10 min ⇒ worst case 6 recycles/hour even when
|
|
134
|
+
// the ceiling is misconfigured below the resident floor — visible via
|
|
135
|
+
// the per-recycle WARN, which names the env knob to raise).
|
|
136
|
+
//
|
|
137
|
+
// Measured anchors (2026-07-03, arena-off profile): steady floor ~2.7-2.9 GB
|
|
138
|
+
// on a mid-size repo, encode-active peaks ~4.7 GB — hence the 4 GiB default
|
|
139
|
+
// floor and 8 GiB cap.
|
|
140
|
+
|
|
141
|
+
const MAINTAINER_RSS_CEILING_FRACTION = 0.25;
|
|
142
|
+
const MAINTAINER_RSS_CEILING_MIN_BYTES = 4 * 1024 * 1024 * 1024; // 4 GiB
|
|
143
|
+
const MAINTAINER_RSS_CEILING_MAX_BYTES = 8 * 1024 * 1024 * 1024; // 8 GiB
|
|
144
|
+
const MAINTAINER_RSS_MIN_UPTIME_MS_DEFAULT = 10 * 60 * 1000; // 10 min
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Resolve the per-process maintainer RSS ceiling in bytes.
|
|
148
|
+
* `SWEET_SEARCH_MAINTAINER_RSS_MAX_MB` set: explicit MB value; 0/garbage
|
|
149
|
+
* disables (returns 0). Unset: clamp(25% of RAM, 4 GiB, 8 GiB).
|
|
150
|
+
*/
|
|
151
|
+
export function maintainerRssCeilingBytes(env = process.env, totalMem = os.totalmem()) {
|
|
152
|
+
const raw = env.SWEET_SEARCH_MAINTAINER_RSS_MAX_MB;
|
|
153
|
+
if (raw != null && raw !== '') {
|
|
154
|
+
const mb = Number(raw);
|
|
155
|
+
if (!Number.isFinite(mb) || mb <= 0) return 0;
|
|
156
|
+
return Math.floor(mb * 1024 * 1024);
|
|
157
|
+
}
|
|
158
|
+
const scaled = Math.floor(totalMem * MAINTAINER_RSS_CEILING_FRACTION);
|
|
159
|
+
return Math.min(MAINTAINER_RSS_CEILING_MAX_BYTES, Math.max(MAINTAINER_RSS_CEILING_MIN_BYTES, scaled));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Minimum uptime before an RSS recycle may fire. */
|
|
163
|
+
export function maintainerRssMinUptimeMs(env = process.env) {
|
|
164
|
+
const raw = Number(env.SWEET_SEARCH_MAINTAINER_RSS_MIN_UPTIME_MS);
|
|
165
|
+
return Number.isFinite(raw) && raw >= 0 ? raw : MAINTAINER_RSS_MIN_UPTIME_MS_DEFAULT;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Pure recycle decision — evaluated at tick boundaries only.
|
|
170
|
+
* @returns {{recycle: boolean, reason: string}}
|
|
171
|
+
*/
|
|
172
|
+
export function shouldRecycleForRss({ rssBytes, ceilingBytes, uptimeMs, minUptimeMs, ticksCompleted }) {
|
|
173
|
+
if (!Number.isFinite(ceilingBytes) || ceilingBytes <= 0) return { recycle: false, reason: 'disabled' };
|
|
174
|
+
if (!Number.isFinite(rssBytes) || rssBytes <= ceilingBytes) return { recycle: false, reason: 'under-ceiling' };
|
|
175
|
+
if (!(ticksCompleted >= 1)) return { recycle: false, reason: 'no-completed-tick' };
|
|
176
|
+
if (!(uptimeMs >= minUptimeMs)) return { recycle: false, reason: 'min-uptime' };
|
|
177
|
+
return { recycle: true, reason: 'over-ceiling' };
|
|
178
|
+
}
|
|
179
|
+
|
|
115
180
|
/**
|
|
116
181
|
* Read the resident-set size (bytes) of an arbitrary pid, best-effort and
|
|
117
182
|
* cross-platform. Returns a non-negative integer, or 0 when unknown (dead pid,
|
|
@@ -26,6 +26,18 @@ let lateInteractionPipeline = null;
|
|
|
26
26
|
let loadPromise = null;
|
|
27
27
|
let lateInteractionRuntimeConfig = {
|
|
28
28
|
intraOpThreads: null,
|
|
29
|
+
// G3-LI: background/maintainer ORT profile for the LI session. When truthy,
|
|
30
|
+
// the session is created with the CPU mem arena OFF and force_spinning_stop
|
|
31
|
+
// instead of allow_spinning — the same profile buildLocalSessionOptions
|
|
32
|
+
// applies to the dense session. ORT never returns arena memory once grown
|
|
33
|
+
// (#25325), and per-file reconcile encodes produce highly variable batch
|
|
34
|
+
// shapes, so an arena-ON LI session in the resident maintainer accrues
|
|
35
|
+
// monotonic RSS in 128MB arena-extension steps (measured: 354×128MB ≈ 34GB
|
|
36
|
+
// after one heavy edit day). Must be set BEFORE the first encode — the
|
|
37
|
+
// session singleton is built once; configuring after is a silent no-op.
|
|
38
|
+
// Default null/off everywhere else (search/query path keeps the foreground
|
|
39
|
+
// arena+spinning profile for latency).
|
|
40
|
+
background: null,
|
|
29
41
|
};
|
|
30
42
|
|
|
31
43
|
// Lightweight timing accumulators for profiling (Phase 6a).
|
|
@@ -90,6 +102,7 @@ export function configureLateInteractionRuntime(overrides = {}) {
|
|
|
90
102
|
export function resetLateInteractionRuntime() {
|
|
91
103
|
lateInteractionRuntimeConfig = {
|
|
92
104
|
intraOpThreads: null,
|
|
105
|
+
background: null,
|
|
93
106
|
};
|
|
94
107
|
}
|
|
95
108
|
|
|
@@ -191,18 +204,26 @@ async function loadModel() {
|
|
|
191
204
|
// 'extended' + memArena + memPattern: marginal overhead for no measurable gain.
|
|
192
205
|
// Conclusion: keep LI session lean. Only proven-beneficial options added.
|
|
193
206
|
const { getOptimizedGraphPath } = await import('../infrastructure/onnx-session-utils.js');
|
|
207
|
+
const liBackground = !!lateInteractionRuntimeConfig.background;
|
|
194
208
|
const session = await ort.InferenceSession.create(onnxPath, {
|
|
195
209
|
executionProviders: ['cpu'],
|
|
196
210
|
logSeverityLevel: 3, // ERROR — silence ORT's expected "optimized model is machine-specific" warning
|
|
197
211
|
intraOpNumThreads: lateInteractionRuntimeConfig.intraOpThreads ?? bestIntraOpThreads(),
|
|
198
212
|
interOpNumThreads: 1,
|
|
199
213
|
optimizedModelFilePath: getOptimizedGraphPath(modelConfig.hfId, 'lateon'),
|
|
214
|
+
// G3-LI: background (maintainer) profile disables the CPU mem arena — ORT
|
|
215
|
+
// never returns arena memory once grown (#25325), and variable-shaped
|
|
216
|
+
// per-file reconcile batches make the arena extend monotonically (128MB
|
|
217
|
+
// steps) in a resident daemon. Foreground keeps the arena for throughput.
|
|
218
|
+
enableCpuMemArena: !liBackground,
|
|
200
219
|
// Thread spinning keeps ORT worker threads hot between batches — trades idle
|
|
201
|
-
// CPU for lower per-batch latency during sustained indexing runs.
|
|
220
|
+
// CPU for lower per-batch latency during sustained indexing runs. The
|
|
221
|
+
// background profile parks workers after the last Run() instead (the
|
|
222
|
+
// maintainer sits idle 20-60s between ticks; ~14% re-spin latency cost).
|
|
202
223
|
extra: {
|
|
203
|
-
session:
|
|
204
|
-
|
|
205
|
-
|
|
224
|
+
session: liBackground
|
|
225
|
+
? { force_spinning_stop: '1' }
|
|
226
|
+
: { intra_op: { allow_spinning: '1' } },
|
|
206
227
|
},
|
|
207
228
|
});
|
|
208
229
|
const coremlActive = false;
|
|
@@ -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
|
// =============================================================================
|
|
@@ -1070,12 +1343,6 @@ export class BinaryHNSWIndex {
|
|
|
1070
1343
|
savedAt: new Date().toISOString(),
|
|
1071
1344
|
};
|
|
1072
1345
|
|
|
1073
|
-
const vectorsData = this.vectors.map(v => ({
|
|
1074
|
-
id: v.id,
|
|
1075
|
-
binary: Array.from(v.binary),
|
|
1076
|
-
metadata: v.metadata,
|
|
1077
|
-
}));
|
|
1078
|
-
|
|
1079
1346
|
const metaPath = indexPath.replace('.idx', '.meta.json');
|
|
1080
1347
|
const vectorsPath = indexPath.replace('.idx', '.vectors.json');
|
|
1081
1348
|
const graphPath = indexPath.replace('.idx', '.graph.json');
|
|
@@ -1083,21 +1350,53 @@ export class BinaryHNSWIndex {
|
|
|
1083
1350
|
const calibPath = indexPath.replace('.idx', '.calibration.json');
|
|
1084
1351
|
const pidSuffix = `.tmp.${process.pid}`;
|
|
1085
1352
|
|
|
1086
|
-
// 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.
|
|
1087
1358
|
await fs.writeFile(metaPath + pidSuffix, JSON.stringify(meta, null, 2));
|
|
1088
|
-
|
|
1089
|
-
|
|
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
|
+
);
|
|
1090
1380
|
|
|
1091
1381
|
let stagedInt8 = false;
|
|
1092
1382
|
if (this.int8Vectors.size > 0) {
|
|
1093
1383
|
const liveIds = new Set(this.vectors.map(v => v.id));
|
|
1094
|
-
|
|
1095
|
-
for (const
|
|
1096
|
-
if (
|
|
1097
|
-
int8Data[id] = Array.from(vec);
|
|
1384
|
+
let liveCount = 0;
|
|
1385
|
+
for (const id of this.int8Vectors.keys()) {
|
|
1386
|
+
if (liveIds.has(id)) liveCount++;
|
|
1098
1387
|
}
|
|
1099
|
-
if (
|
|
1100
|
-
|
|
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
|
+
);
|
|
1101
1400
|
stagedInt8 = true;
|
|
1102
1401
|
}
|
|
1103
1402
|
}
|
|
@@ -1183,7 +1482,7 @@ export class BinaryHNSWIndex {
|
|
|
1183
1482
|
let attempt = 0;
|
|
1184
1483
|
while (true) {
|
|
1185
1484
|
meta = attempt === 0 ? initialMeta : JSON.parse(await fs.readFile(metaPath, 'utf-8'));
|
|
1186
|
-
vectorsData =
|
|
1485
|
+
vectorsData = await readVectorsSidecar(vectorsPath);
|
|
1187
1486
|
const consistent = meta.vectorCount === vectorsData.length
|
|
1188
1487
|
&& (meta.entryPoint === -1 || meta.entryPoint < vectorsData.length);
|
|
1189
1488
|
if (consistent) break;
|
|
@@ -1224,15 +1523,12 @@ export class BinaryHNSWIndex {
|
|
|
1224
1523
|
}
|
|
1225
1524
|
|
|
1226
1525
|
// Load graph
|
|
1227
|
-
this.graph =
|
|
1526
|
+
this.graph = await readGraphSidecar(graphPath);
|
|
1228
1527
|
|
|
1229
1528
|
// Load int8 vectors if available
|
|
1230
1529
|
this.int8Vectors.clear();
|
|
1231
1530
|
if (existsSync(int8Path)) {
|
|
1232
|
-
|
|
1233
|
-
for (const [id, vec] of Object.entries(int8Data)) {
|
|
1234
|
-
this.int8Vectors.set(id, new Int8Array(vec));
|
|
1235
|
-
}
|
|
1531
|
+
await readInt8Sidecar(int8Path, this.int8Vectors);
|
|
1236
1532
|
}
|
|
1237
1533
|
|
|
1238
1534
|
// Load asymmetric calibration data
|
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"
|