sweet-search 2.6.9 → 2.6.11
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-expansion.js +48 -6
- package/core/incremental-indexing/application/production-li-delta.mjs +28 -3
- package/core/incremental-indexing/application/production-reconciler-helpers.mjs +48 -11
- package/core/incremental-indexing/application/production-reconciler.mjs +23 -5
- 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/infrastructure/hamming-kernel.js +293 -0
- package/core/infrastructure/maxsim.wasm +0 -0
- package/core/infrastructure/simd-distance.js +39 -6
- package/core/infrastructure/simd-distance.wasm +0 -0
- package/core/infrastructure/tombstone-bitmap-reader.js +34 -0
- package/core/ranking/late-interaction-index.js +31 -3
- package/core/ranking/late-interaction-model.js +25 -4
- package/core/search/search-semantic.js +8 -1
- package/core/vector-store/binary-heap.js +12 -3
- package/core/vector-store/binary-hnsw-index.js +679 -136
- 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);
|
|
@@ -249,6 +249,18 @@ const TYPE_BOOST = {
|
|
|
249
249
|
struct: 1.2,
|
|
250
250
|
};
|
|
251
251
|
|
|
252
|
+
/**
|
|
253
|
+
* Fetch int8 vectors for many ids with ONE stale-bitmap snapshot when the
|
|
254
|
+
* index supports it (BinaryHNSWIndex.getInt8VectorsForIds); falls back to
|
|
255
|
+
* per-id lookups for index doubles that only expose getInt8Vector.
|
|
256
|
+
*/
|
|
257
|
+
function batchInt8Vectors(hnswIndex, ids) {
|
|
258
|
+
if (typeof hnswIndex.getInt8VectorsForIds === 'function') {
|
|
259
|
+
return hnswIndex.getInt8VectorsForIds(ids);
|
|
260
|
+
}
|
|
261
|
+
return ids.map((id) => hnswIndex.getInt8Vector(id));
|
|
262
|
+
}
|
|
263
|
+
|
|
252
264
|
function clampSemanticWeight(value) {
|
|
253
265
|
if (!Number.isFinite(value)) return 0.4;
|
|
254
266
|
return Math.max(0, Math.min(1, value));
|
|
@@ -614,21 +626,27 @@ export function expandSecondHop(db, seedIds, expanded, edgeTypes, options = {})
|
|
|
614
626
|
}
|
|
615
627
|
|
|
616
628
|
const excluded = new Set([...seedIds, ...expanded.keys()]);
|
|
617
|
-
const
|
|
629
|
+
const eligible = [];
|
|
618
630
|
for (const rel of hop2Forward) {
|
|
619
631
|
if (!edgeTypes.has(rel.type) || excluded.has(rel.target_id)) continue;
|
|
620
632
|
|
|
621
633
|
const hop1Entry = expanded.get(rel.source_id);
|
|
622
634
|
const hop1Score = hop1Entry?.score ?? 1; // identity: preserves old edgePriority × weight
|
|
623
635
|
const graphScore = hop1Score * (EDGE_PRIORITY[rel.type] || 1) * (rel.weight || 1.0);
|
|
636
|
+
eligible.push({ rel, graphScore });
|
|
637
|
+
}
|
|
638
|
+
// Batch int8 lookup: one stale-bitmap snapshot for all targets instead of
|
|
639
|
+
// one stat per target.
|
|
640
|
+
const eligibleInt8 = batchInt8Vectors(hnswIndex, eligible.map((e) => e.rel.target_id));
|
|
641
|
+
const candidates = eligible.map((e, i) => {
|
|
642
|
+
const entityInt8 = eligibleInt8[i];
|
|
624
643
|
let normSim = null;
|
|
625
|
-
const entityInt8 = hnswIndex.getInt8Vector(rel.target_id);
|
|
626
644
|
if (entityInt8) {
|
|
627
645
|
const cosSim = cosineSimilarity(queryInt8, entityInt8);
|
|
628
646
|
normSim = (cosSim + 1) / 2;
|
|
629
647
|
}
|
|
630
|
-
|
|
631
|
-
}
|
|
648
|
+
return { rel: e.rel, graphScore: e.graphScore, normSim };
|
|
649
|
+
});
|
|
632
650
|
|
|
633
651
|
if (candidates.length === 0) return;
|
|
634
652
|
const normalizedGraphScores = normalizeMinMax(candidates.map(c => c.graphScore));
|
|
@@ -729,6 +747,25 @@ export function expandSecondHopAdaptive(db, seedIds, hop1Expanded, edgeTypes, op
|
|
|
729
747
|
const vectorCache = semanticEnabled ? new Map() : null;
|
|
730
748
|
const scoredCandidates = [];
|
|
731
749
|
|
|
750
|
+
// Pre-seed the vector cache with ONE batched int8 fetch (single
|
|
751
|
+
// stale-bitmap snapshot) so the scoring loop below never does per-target
|
|
752
|
+
// lookups.
|
|
753
|
+
if (semanticEnabled) {
|
|
754
|
+
const uniqueTargets = [];
|
|
755
|
+
for (const c of rawCandidates) {
|
|
756
|
+
if (!edgeTypes.has(c.type) || excluded.has(c.target_id)) continue;
|
|
757
|
+
if (vectorCache.has(c.target_id)) continue;
|
|
758
|
+
vectorCache.set(c.target_id, undefined);
|
|
759
|
+
uniqueTargets.push(c.target_id);
|
|
760
|
+
}
|
|
761
|
+
if (uniqueTargets.length > 0) {
|
|
762
|
+
const fetched = batchInt8Vectors(hnswIndex, uniqueTargets);
|
|
763
|
+
for (let i = 0; i < uniqueTargets.length; i++) {
|
|
764
|
+
vectorCache.set(uniqueTargets[i], fetched[i]);
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
|
|
732
769
|
for (const c of rawCandidates) {
|
|
733
770
|
if (!edgeTypes.has(c.type) || excluded.has(c.target_id)) continue;
|
|
734
771
|
|
|
@@ -743,6 +780,7 @@ export function expandSecondHopAdaptive(db, seedIds, hop1Expanded, edgeTypes, op
|
|
|
743
780
|
let normSim = null;
|
|
744
781
|
if (semanticEnabled) {
|
|
745
782
|
if (!vectorCache.has(c.target_id)) {
|
|
783
|
+
// Miss fallback only — the pre-seed pass below batch-fills the cache.
|
|
746
784
|
vectorCache.set(c.target_id, hnswIndex.getInt8Vector(c.target_id));
|
|
747
785
|
}
|
|
748
786
|
const entityInt8 = vectorCache.get(c.target_id);
|
|
@@ -929,12 +967,16 @@ export function rerankExpanded(expandedResults, seedResults, options = {}) {
|
|
|
929
967
|
}
|
|
930
968
|
} else {
|
|
931
969
|
const normalizedGraphScores = normalizeMinMax(baseScores);
|
|
970
|
+
// Batch int8 lookup: one stale-bitmap snapshot for the whole result set.
|
|
971
|
+
const entityInt8s = batchInt8Vectors(
|
|
972
|
+
hnswIndex,
|
|
973
|
+
expandedResults.map((er) => er.entity_id || er.id)
|
|
974
|
+
);
|
|
932
975
|
for (let i = 0; i < expandedResults.length; i++) {
|
|
933
976
|
const er = expandedResults[i];
|
|
934
977
|
const normGraph = normalizedGraphScores[i];
|
|
935
978
|
let rerankScore = normGraph;
|
|
936
|
-
const
|
|
937
|
-
const entityInt8 = hnswIndex.getInt8Vector(entityId);
|
|
979
|
+
const entityInt8 = entityInt8s[i];
|
|
938
980
|
if (entityInt8) {
|
|
939
981
|
const cosSim = cosineSimilarity(queryInt8, entityInt8);
|
|
940
982
|
const normSim = (cosSim + 1) / 2;
|
|
@@ -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
|
|
|
@@ -60,19 +60,56 @@ export function insertRelationships(db, relationships, liveIdFor, epoch) {
|
|
|
60
60
|
}
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
63
|
+
/**
|
|
64
|
+
* Batched stale marking. The per-id `markBinaryStale` loads, mutates, and
|
|
65
|
+
* fsyncs the bitmap file once PER RETIRED ID — O(retires × bitmap size) with
|
|
66
|
+
* an fsync each. A batch loads the bitmap once (lazily, on the first mark),
|
|
67
|
+
* applies every mark in memory with the exact per-op semantics (idToIndex /
|
|
68
|
+
* int8Vectors pruned immediately, reader cache invalidated), and persists
|
|
69
|
+
* once in flush(). End state on disk is identical to N sequential
|
|
70
|
+
* markBinaryStale calls.
|
|
71
|
+
*
|
|
72
|
+
* Crash window: marks staged between flush()es are lost on crash, which the
|
|
73
|
+
* reconciler's persist-before-advance gate already handles — an unflushed
|
|
74
|
+
* tick replays its ops.
|
|
75
|
+
*/
|
|
76
|
+
export function createStaleBatch(index) {
|
|
66
77
|
const stalePath = index.stalePath || `${index.indexPath}.stale.bin`;
|
|
67
78
|
let bitmap = null;
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
79
|
+
let loaded = false;
|
|
80
|
+
let dirty = false;
|
|
81
|
+
return {
|
|
82
|
+
markStale(id) {
|
|
83
|
+
const idx = index.idToIndex.get(id);
|
|
84
|
+
if (idx == null) return false;
|
|
85
|
+
if (!loaded) {
|
|
86
|
+
try { bitmap = loadBitmap(stalePath); } catch {}
|
|
87
|
+
loaded = true;
|
|
88
|
+
}
|
|
89
|
+
bitmap = bitmap
|
|
90
|
+
? resizeBitmap(bitmap, Math.max(idx + 1, index.vectors.length, 1))
|
|
91
|
+
: createBitmap(Math.max(idx + 1, index.vectors.length, 1));
|
|
92
|
+
setBit(bitmap, idx);
|
|
93
|
+
dirty = true;
|
|
94
|
+
index.idToIndex.delete(id);
|
|
95
|
+
index.int8Vectors.delete(id);
|
|
96
|
+
index._staleBitmapCache = null;
|
|
97
|
+
return true;
|
|
98
|
+
},
|
|
99
|
+
flush() {
|
|
100
|
+
if (!dirty) return false;
|
|
101
|
+
saveBitmap(stalePath, bitmap);
|
|
102
|
+
dirty = false;
|
|
103
|
+
return true;
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function markBinaryStale(index, id) {
|
|
109
|
+
const batch = createStaleBatch(index);
|
|
110
|
+
const marked = batch.markStale(id);
|
|
111
|
+
batch.flush();
|
|
112
|
+
return marked;
|
|
76
113
|
}
|
|
77
114
|
|
|
78
115
|
/**
|
|
@@ -13,7 +13,7 @@ import { readManifest, writeManifest } from '../infrastructure/manifest.mjs';
|
|
|
13
13
|
import { annotateChunksForDelta, snapshotFileRows, diffChunks, applyDiff } from '../infrastructure/vector-delta-writer.mjs';
|
|
14
14
|
import { appendDeltaRecord, FALLBACK_WEIGHTS_ID, fileIdFor, listDeltaSegments } from '../infrastructure/sparse-gram-delta.mjs';
|
|
15
15
|
import { fts5Merge, fts5MergeBudgetPages } from '../infrastructure/sqlite-fts5.mjs';
|
|
16
|
-
import { insertEntity, insertRelationships, markBinaryStale, maintainFloatStore, flushFloatStore } from './production-reconciler-helpers.mjs';
|
|
16
|
+
import { insertEntity, insertRelationships, markBinaryStale, createStaleBatch, maintainFloatStore, flushFloatStore } from './production-reconciler-helpers.mjs';
|
|
17
17
|
import {
|
|
18
18
|
chunkCutoffEnabled,
|
|
19
19
|
computeCutoffSignature,
|
|
@@ -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),
|
|
@@ -1110,9 +1110,12 @@ class ProductionReconcileAdapter {
|
|
|
1110
1110
|
if (ctx?.index) {
|
|
1111
1111
|
const index = ctx.index;
|
|
1112
1112
|
let append = 0; let tombstone = 0;
|
|
1113
|
+
// One bitmap load + one save+fsync per file's ops instead of per
|
|
1114
|
+
// retired id; marks apply in op order with identical semantics.
|
|
1115
|
+
const staleBatch = createStaleBatch(index);
|
|
1113
1116
|
for (const op of ops) {
|
|
1114
1117
|
if (op.retireId) {
|
|
1115
|
-
if (
|
|
1118
|
+
if (staleBatch.markStale(op.retireId)) tombstone += 1;
|
|
1116
1119
|
ctx.floatRemoveIds.push(op.retireId);
|
|
1117
1120
|
}
|
|
1118
1121
|
if (op.addId && op.embedding) {
|
|
@@ -1122,6 +1125,7 @@ class ProductionReconcileAdapter {
|
|
|
1122
1125
|
append += 1;
|
|
1123
1126
|
}
|
|
1124
1127
|
}
|
|
1128
|
+
staleBatch.flush();
|
|
1125
1129
|
ctx.tombstone += tombstone;
|
|
1126
1130
|
// append is committed to ctx.append in finalize after the sorted inserts.
|
|
1127
1131
|
ctx.append += append;
|
|
@@ -1137,9 +1141,13 @@ class ProductionReconcileAdapter {
|
|
|
1137
1141
|
let append = 0; let tombstone = 0;
|
|
1138
1142
|
const floatUpserts = [];
|
|
1139
1143
|
const floatRemoveIds = [];
|
|
1144
|
+
// One bitmap load + one save+fsync for the whole op list instead of per
|
|
1145
|
+
// retired id. Marks still apply inline in op order (adds never consult
|
|
1146
|
+
// the bitmap), so retire-then-readd sequences behave exactly as before.
|
|
1147
|
+
const staleBatch = createStaleBatch(index);
|
|
1140
1148
|
for (const op of ops) {
|
|
1141
1149
|
if (op.retireId) {
|
|
1142
|
-
if (
|
|
1150
|
+
if (staleBatch.markStale(op.retireId)) tombstone += 1;
|
|
1143
1151
|
floatRemoveIds.push(op.retireId);
|
|
1144
1152
|
}
|
|
1145
1153
|
if (op.addId && op.embedding) {
|
|
@@ -1150,6 +1158,7 @@ class ProductionReconcileAdapter {
|
|
|
1150
1158
|
}
|
|
1151
1159
|
if ((append + tombstone) > 0 && (append + tombstone) % 100 === 0) this.progress('production:binary-hnsw-loop');
|
|
1152
1160
|
}
|
|
1161
|
+
staleBatch.flush();
|
|
1153
1162
|
await index.save(indexPath);
|
|
1154
1163
|
this.progress('production:binary-hnsw-saved');
|
|
1155
1164
|
await maintainFloatStore(indexPath, { upserts: floatUpserts, removeIds: floatRemoveIds, binaryVectorsBefore, dimension: this.modelInfo.hnswDimension });
|
|
@@ -1157,7 +1166,7 @@ class ProductionReconcileAdapter {
|
|
|
1157
1166
|
return { ops: { binary_hnsw_append: append, binary_hnsw_tombstone: tombstone }, manifest: { path: 'codebase-binary-hnsw.idx' } };
|
|
1158
1167
|
}
|
|
1159
1168
|
|
|
1160
|
-
async applyLIDelta(file, ops) {
|
|
1169
|
+
async applyLIDelta(file, ops, ctx = null) {
|
|
1161
1170
|
if (!Array.isArray(ops) || ops.length === 0) return { ops: { li_segment_append: 0, li_tombstone: 0 } };
|
|
1162
1171
|
// LI generated-content parity: full indexing's buildLateInteractionIndex runs
|
|
1163
1172
|
// applyIndexingChunkPolicy so @generated / config-excluded files never reach
|
|
@@ -1170,12 +1179,21 @@ class ProductionReconcileAdapter {
|
|
|
1170
1179
|
? ops.filter((op) => !(op.addId && op.chunk))
|
|
1171
1180
|
: ops;
|
|
1172
1181
|
const { applyLateInteractionDelta } = await import('./production-li-delta.mjs');
|
|
1182
|
+
// E.1-LI: tick-scoped READER cache. The per-file delta previously paid a
|
|
1183
|
+
// full loadExisting init (segment manifest + doc positions over a multi-
|
|
1184
|
+
// hundred-MB index) once per file; within one tick the reader view it
|
|
1185
|
+
// needs is stable (per-file ops only reference the file's own pre-tick
|
|
1186
|
+
// docs, appendGrowingSegment re-reads the manifest from disk, and segment
|
|
1187
|
+
// compaction only runs in maintenance after the tick). Null ctx (per-file
|
|
1188
|
+
// path) ⇒ fresh load per file, exactly as before.
|
|
1189
|
+
const readerCache = ctx ? (ctx._liReaderCache ??= { key: null, index: null }) : null;
|
|
1173
1190
|
const { appended, tombstone } = await applyLateInteractionDelta({
|
|
1174
1191
|
indexPath: path.join(this.stateDir, 'codebase-late-interaction.db'),
|
|
1175
1192
|
ops: filteredOps,
|
|
1176
1193
|
liEncoder: this.liEncoder,
|
|
1177
1194
|
pickLiInput,
|
|
1178
1195
|
onProgress: () => this.progress('production:li-delta'),
|
|
1196
|
+
readerCache,
|
|
1179
1197
|
});
|
|
1180
1198
|
return { ops: { li_segment_append: appended, li_tombstone: tombstone }, manifest: { path: 'codebase-late-interaction.db', segments: 'codebase-late-interaction.db.segments/manifest.json' } };
|
|
1181
1199
|
}
|
|
@@ -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,
|