sweet-search 0.0.1 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (161) hide show
  1. package/LICENSE +190 -0
  2. package/NOTICE +23 -0
  3. package/core/cli.js +51 -0
  4. package/core/config.js +27 -0
  5. package/core/embedding/embedding-cache.js +467 -0
  6. package/core/embedding/embedding-local-model.js +845 -0
  7. package/core/embedding/embedding-remote.js +492 -0
  8. package/core/embedding/embedding-service.js +712 -0
  9. package/core/embedding/embedding-telemetry.js +219 -0
  10. package/core/embedding/index.js +40 -0
  11. package/core/graph/community-detector.js +294 -0
  12. package/core/graph/graph-expansion.js +839 -0
  13. package/core/graph/graph-extractor.js +2304 -0
  14. package/core/graph/graph-search.js +2148 -0
  15. package/core/graph/hcgs-generator.js +666 -0
  16. package/core/graph/index.js +16 -0
  17. package/core/graph/leiden-algorithm.js +547 -0
  18. package/core/graph/relationship-resolver.js +366 -0
  19. package/core/graph/repo-map.js +408 -0
  20. package/core/graph/summary-manager.js +549 -0
  21. package/core/indexing/artifact-builder.js +1054 -0
  22. package/core/indexing/ast-chunker.js +709 -0
  23. package/core/indexing/chunking/chunk-builder.js +170 -0
  24. package/core/indexing/chunking/markdown-chunker.js +503 -0
  25. package/core/indexing/chunking/plaintext-chunker.js +104 -0
  26. package/core/indexing/dedup/dedup-phase.js +159 -0
  27. package/core/indexing/dedup/exemplar-selector.js +65 -0
  28. package/core/indexing/document-chunker.js +56 -0
  29. package/core/indexing/incremental-parser.js +390 -0
  30. package/core/indexing/incremental-tracker.js +761 -0
  31. package/core/indexing/index-codebase-v21.js +472 -0
  32. package/core/indexing/index-maintainer.mjs +1674 -0
  33. package/core/indexing/index.js +90 -0
  34. package/core/indexing/indexer-ann.js +1077 -0
  35. package/core/indexing/indexer-build.js +742 -0
  36. package/core/indexing/indexer-phases.js +800 -0
  37. package/core/indexing/indexer-pool.js +764 -0
  38. package/core/indexing/indexer-sparse-gram.js +98 -0
  39. package/core/indexing/indexer-utils.js +536 -0
  40. package/core/indexing/indexer-worker.js +148 -0
  41. package/core/indexing/li-skip-policy.js +225 -0
  42. package/core/indexing/merkle-tracker.js +244 -0
  43. package/core/indexing/model-pool.js +166 -0
  44. package/core/infrastructure/code-graph-repository.js +120 -0
  45. package/core/infrastructure/codebase-repository.js +131 -0
  46. package/core/infrastructure/config/dedup.js +54 -0
  47. package/core/infrastructure/config/embedding.js +298 -0
  48. package/core/infrastructure/config/graph.js +80 -0
  49. package/core/infrastructure/config/index.js +82 -0
  50. package/core/infrastructure/config/indexing.js +8 -0
  51. package/core/infrastructure/config/platform.js +254 -0
  52. package/core/infrastructure/config/ranking.js +221 -0
  53. package/core/infrastructure/config/search.js +396 -0
  54. package/core/infrastructure/config/translation.js +89 -0
  55. package/core/infrastructure/config/vector-store.js +114 -0
  56. package/core/infrastructure/constants.js +86 -0
  57. package/core/infrastructure/coreml-cascade.js +909 -0
  58. package/core/infrastructure/coreml-cascade.json +46 -0
  59. package/core/infrastructure/coreml-provider.js +81 -0
  60. package/core/infrastructure/db-utils.js +69 -0
  61. package/core/infrastructure/dedup-hashing.js +83 -0
  62. package/core/infrastructure/hardware-capability.js +332 -0
  63. package/core/infrastructure/index.js +104 -0
  64. package/core/infrastructure/language-patterns/maps.js +121 -0
  65. package/core/infrastructure/language-patterns/registry-core.js +323 -0
  66. package/core/infrastructure/language-patterns/registry-data-query.js +155 -0
  67. package/core/infrastructure/language-patterns/registry-object-oriented.js +285 -0
  68. package/core/infrastructure/language-patterns/registry-tooling.js +240 -0
  69. package/core/infrastructure/language-patterns/registry-web-style.js +143 -0
  70. package/core/infrastructure/language-patterns/registry.js +19 -0
  71. package/core/infrastructure/language-patterns.js +141 -0
  72. package/core/infrastructure/llm-provider.js +733 -0
  73. package/core/infrastructure/manifest.json +46 -0
  74. package/core/infrastructure/maxsim.wasm +0 -0
  75. package/core/infrastructure/model-fetcher.js +423 -0
  76. package/core/infrastructure/model-registry.js +214 -0
  77. package/core/infrastructure/native-inference.js +587 -0
  78. package/core/infrastructure/native-resolver.js +187 -0
  79. package/core/infrastructure/native-sparse-gram.js +257 -0
  80. package/core/infrastructure/native-tokenizer.js +160 -0
  81. package/core/infrastructure/onnx-mutex.js +45 -0
  82. package/core/infrastructure/onnx-session-utils.js +261 -0
  83. package/core/infrastructure/ort-pipeline.js +111 -0
  84. package/core/infrastructure/project-detector.js +102 -0
  85. package/core/infrastructure/quantization.js +410 -0
  86. package/core/infrastructure/simd-distance.js +502 -0
  87. package/core/infrastructure/simd-distance.wasm +0 -0
  88. package/core/infrastructure/tree-sitter-provider.js +665 -0
  89. package/core/infrastructure/webgpu-maxsim.js +222 -0
  90. package/core/query/index.js +35 -0
  91. package/core/query/intent-detector.js +201 -0
  92. package/core/query/intent-router.js +156 -0
  93. package/core/query/query-router-catboost.js +222 -0
  94. package/core/query/query-router-ml.js +266 -0
  95. package/core/query/query-router.js +213 -0
  96. package/core/ranking/cascaded-scorer.js +379 -0
  97. package/core/ranking/flashrank.js +810 -0
  98. package/core/ranking/index.js +49 -0
  99. package/core/ranking/late-interaction-index.js +2383 -0
  100. package/core/ranking/late-interaction-model.js +812 -0
  101. package/core/ranking/local-reranker.js +374 -0
  102. package/core/ranking/mmr.js +379 -0
  103. package/core/ranking/quality-scorer.js +363 -0
  104. package/core/search/context-expander.js +1167 -0
  105. package/core/search/dedup/sibling-expander.js +327 -0
  106. package/core/search/index.js +16 -0
  107. package/core/search/search-boost.js +259 -0
  108. package/core/search/search-cli.js +544 -0
  109. package/core/search/search-format.js +282 -0
  110. package/core/search/search-fusion.js +327 -0
  111. package/core/search/search-hybrid.js +204 -0
  112. package/core/search/search-pattern-chunks.js +337 -0
  113. package/core/search/search-pattern-planner.js +439 -0
  114. package/core/search/search-pattern-prefilter.js +412 -0
  115. package/core/search/search-pattern-ripgrep.js +663 -0
  116. package/core/search/search-pattern.js +463 -0
  117. package/core/search/search-postprocess.js +452 -0
  118. package/core/search/search-semantic.js +706 -0
  119. package/core/search/search-server.js +554 -0
  120. package/core/search/session-daemon-prewarm.mjs +164 -0
  121. package/core/search/session-warmup.js +595 -0
  122. package/core/search/sweet-search.js +632 -0
  123. package/core/search/warmup-metrics.js +532 -0
  124. package/core/start-server.js +6 -0
  125. package/core/training/query-router/features/extractor.js +762 -0
  126. package/core/training/query-router/features/multilingual-patterns.js +431 -0
  127. package/core/training/query-router/features/text-segmenter.js +303 -0
  128. package/core/training/query-router/features/unicode-utils.js +383 -0
  129. package/core/training/query-router/output/v45_router_d4.js +11521 -0
  130. package/core/training/query-router/output/v46_router_d4.js +11498 -0
  131. package/core/vector-store/binary-heap.js +227 -0
  132. package/core/vector-store/binary-hnsw-index.js +1004 -0
  133. package/core/vector-store/float-vector-store.js +234 -0
  134. package/core/vector-store/hnsw-index.js +580 -0
  135. package/core/vector-store/index.js +39 -0
  136. package/core/vector-store/seismic-index.js +498 -0
  137. package/core/vocabulary/index.js +84 -0
  138. package/core/vocabulary/vocab-constants.js +20 -0
  139. package/core/vocabulary/vocab-miner-extractors.js +375 -0
  140. package/core/vocabulary/vocab-miner-nl.js +404 -0
  141. package/core/vocabulary/vocab-miner-utils.js +146 -0
  142. package/core/vocabulary/vocab-miner.js +574 -0
  143. package/core/vocabulary/vocab-prewarm-cli.js +110 -0
  144. package/core/vocabulary/vocab-ranker.js +492 -0
  145. package/core/vocabulary/vocab-warmer.js +523 -0
  146. package/core/vocabulary/vocab-warmup-orchestrator.js +425 -0
  147. package/core/vocabulary/vocabulary-utils.js +704 -0
  148. package/crates/wasm-router/pkg/package.json +13 -0
  149. package/crates/wasm-router/pkg/query_router_wasm.d.ts +36 -0
  150. package/crates/wasm-router/pkg/query_router_wasm.js +271 -0
  151. package/crates/wasm-router/pkg/query_router_wasm_bg.wasm +0 -0
  152. package/crates/wasm-router/pkg/query_router_wasm_bg.wasm.d.ts +19 -0
  153. package/mcp/config-gen.js +121 -0
  154. package/mcp/server.js +335 -0
  155. package/mcp/tool-handlers.js +476 -0
  156. package/package.json +131 -9
  157. package/scripts/benchmark-harness.js +794 -0
  158. package/scripts/init.js +1058 -0
  159. package/scripts/smoke-test.js +435 -0
  160. package/scripts/uninstall.js +478 -0
  161. package/scripts/verify-runtime.js +176 -0
@@ -0,0 +1,2383 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Late Interaction Index
5
+ *
6
+ * Token-level embeddings with compression for MaxSim scoring.
7
+ * Uses LateOn-Code models for real late interaction.
8
+ *
9
+ * Index format v2.0: stores modelId in header for cross-model consistency checks.
10
+ * Index format v3.0 (SSLX): fully binary segment format — 3.4x smaller on disk.
11
+ */
12
+
13
+ import fs from 'fs/promises';
14
+ import { existsSync, createWriteStream, createReadStream } from 'fs';
15
+ import path from 'path';
16
+ import { DB_PATHS, LATE_INTERACTION_CONFIG } from '../infrastructure/config/index.js';
17
+ import { wasmMaxSimF32, wasmMaxSimDequantPerToken, wasmMaxSimDequant4Bit, nativeMaxSimBatch, nativeMaxSimBatchPerToken, nativeMaxSimBatch4Bit, initWasm, isNativeMaxSimAvailable, isNativePerTokenAvailable, isNative4BitAvailable } from '../infrastructure/simd-distance.js';
18
+ import { fastRotate, generateSignVector, calibrateWUSH, wushRotate } from '../infrastructure/quantization.js';
19
+ import { poolTokens } from './late-interaction-model.js';
20
+
21
+ // =============================================================================
22
+ // CRC32 (IEEE 802.3 polynomial, used for SSLX segment footer checksum)
23
+ // =============================================================================
24
+ const CRC32_TABLE = new Uint32Array(256);
25
+ {
26
+ for (let i = 0; i < 256; i++) {
27
+ let c = i;
28
+ for (let j = 0; j < 8; j++) c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
29
+ CRC32_TABLE[i] = c >>> 0;
30
+ }
31
+ }
32
+
33
+ function crc32(buf) {
34
+ let crc = 0xFFFFFFFF;
35
+ for (let i = 0; i < buf.length; i++) {
36
+ crc = CRC32_TABLE[(crc ^ buf[i]) & 0xFF] ^ (crc >>> 8);
37
+ }
38
+ return (crc ^ 0xFFFFFFFF) >>> 0;
39
+ }
40
+
41
+ /**
42
+ * Quantize float32 to int8 for storage (per-document: single min/scale)
43
+ */
44
+ function quantizeToInt8(floatArray) {
45
+ let min = Infinity;
46
+ let max = -Infinity;
47
+ for (let i = 0; i < floatArray.length; i++) {
48
+ const v = floatArray[i];
49
+ if (v < min) min = v;
50
+ if (v > max) max = v;
51
+ }
52
+
53
+ if (max === min) {
54
+ const int8Array = new Int8Array(floatArray.length);
55
+ int8Array.fill(0);
56
+ return { data: int8Array, min, scale: 0 };
57
+ }
58
+
59
+ const scale = (max - min) / 255;
60
+
61
+ const int8Array = new Int8Array(floatArray.length);
62
+ for (let i = 0; i < floatArray.length; i++) {
63
+ int8Array[i] = Math.round((floatArray[i] - min) / scale) - 128;
64
+ }
65
+
66
+ return { data: int8Array, min, scale };
67
+ }
68
+
69
+ /**
70
+ * Quantize float32 to int8 with per-token min/scale (Phase 1).
71
+ * Each token gets its own min/scale, using the full INT8 range.
72
+ *
73
+ * @param {Float32Array} flat - Flattened token data (numTokens * tokenDim)
74
+ * @param {number} numTokens
75
+ * @param {number} tokenDim
76
+ * @returns {{ data: Int8Array, minArray: Float32Array, scaleArray: Float32Array }}
77
+ */
78
+ function quantizeToInt8PerToken(flat, numTokens, tokenDim) {
79
+ const int8Array = new Int8Array(flat.length);
80
+ const minArray = new Float32Array(numTokens);
81
+ const scaleArray = new Float32Array(numTokens);
82
+
83
+ for (let t = 0; t < numTokens; t++) {
84
+ const off = t * tokenDim;
85
+ let tMin = Infinity;
86
+ let tMax = -Infinity;
87
+ for (let d = 0; d < tokenDim; d++) {
88
+ const v = flat[off + d];
89
+ if (v < tMin) tMin = v;
90
+ if (v > tMax) tMax = v;
91
+ }
92
+
93
+ minArray[t] = tMin;
94
+
95
+ if (tMax === tMin) {
96
+ scaleArray[t] = 0;
97
+ // All zeros — midpoint
98
+ for (let d = 0; d < tokenDim; d++) int8Array[off + d] = 0;
99
+ } else {
100
+ const sc = (tMax - tMin) / 255;
101
+ scaleArray[t] = sc;
102
+ for (let d = 0; d < tokenDim; d++) {
103
+ int8Array[off + d] = Math.round((flat[off + d] - tMin) / sc) - 128;
104
+ }
105
+ }
106
+ }
107
+
108
+ return { data: int8Array, minArray, scaleArray };
109
+ }
110
+
111
+ /**
112
+ * Quantize float32 to int4 with per-token min/scale (Phase 4).
113
+ * Packs 2 values per byte: low nibble = even index, high nibble = odd index.
114
+ * Each token gets its own min/scale, using the full 4-bit (16 levels) range.
115
+ *
116
+ * NOTE: CRA-2 (quantile-based boundaries) was reverted — a correct implementation
117
+ * requires storing quantile centroids in the binary format so dequant can use them.
118
+ * Without matching centroids, the encode/decode mismatch increases error.
119
+ * CRA-2 should be revisited as a format-level change, not a drop-in encoder swap.
120
+ *
121
+ * @param {Float32Array} flat - Flattened token data (numTokens * tokenDim)
122
+ * @param {number} numTokens
123
+ * @param {number} tokenDim
124
+ * @returns {{ data: Uint8Array, minArray: Float32Array, scaleArray: Float32Array }}
125
+ */
126
+ function quantizeToInt4PerToken(flat, numTokens, tokenDim) {
127
+ const packedSize = Math.ceil(tokenDim / 2);
128
+ const packedArray = new Uint8Array(numTokens * packedSize);
129
+ const minArray = new Float32Array(numTokens);
130
+ const scaleArray = new Float32Array(numTokens);
131
+
132
+ for (let t = 0; t < numTokens; t++) {
133
+ const off = t * tokenDim;
134
+ let tMin = Infinity;
135
+ let tMax = -Infinity;
136
+ for (let d = 0; d < tokenDim; d++) {
137
+ const v = flat[off + d];
138
+ if (v < tMin) tMin = v;
139
+ if (v > tMax) tMax = v;
140
+ }
141
+
142
+ minArray[t] = tMin;
143
+
144
+ const pOff = t * packedSize;
145
+ if (tMax === tMin) {
146
+ scaleArray[t] = 0;
147
+ } else {
148
+ const sc = (tMax - tMin) / 15; // 16 levels: 0..15
149
+ scaleArray[t] = sc;
150
+ for (let d = 0; d < tokenDim; d += 2) {
151
+ const v0 = Math.round((flat[off + d] - tMin) / sc);
152
+ const nibble0 = Math.max(0, Math.min(15, v0));
153
+ let nibble1 = 0;
154
+ if (d + 1 < tokenDim) {
155
+ const v1 = Math.round((flat[off + d + 1] - tMin) / sc);
156
+ nibble1 = Math.max(0, Math.min(15, v1));
157
+ }
158
+ packedArray[pOff + (d >> 1)] = (nibble1 << 4) | nibble0;
159
+ }
160
+ }
161
+ }
162
+
163
+ return { data: packedArray, minArray, scaleArray };
164
+ }
165
+
166
+ /**
167
+ * Dequantize int4 (nibble-packed) back to float32 with per-token min/scale.
168
+ *
169
+ * @param {Uint8Array} packed - Nibble-packed data (numTokens * ceil(tokenDim/2))
170
+ * @param {Float32Array} minArray - Per-token min values
171
+ * @param {Float32Array} scaleArray - Per-token scale values
172
+ * @param {number} numTokens
173
+ * @param {number} tokenDim
174
+ * @returns {Float32Array}
175
+ */
176
+ function dequantInt4ToBuffer(packed, minArray, scaleArray, numTokens, tokenDim, out) {
177
+ const packedSize = Math.ceil(tokenDim / 2);
178
+ for (let t = 0; t < numTokens; t++) {
179
+ const fOff = t * tokenDim;
180
+ const pOff = t * packedSize;
181
+ const tMin = minArray[t];
182
+ const tScale = scaleArray[t];
183
+ if (tScale === 0) {
184
+ for (let d = 0; d < tokenDim; d++) out[fOff + d] = tMin;
185
+ } else {
186
+ for (let d = 0; d < tokenDim; d += 2) {
187
+ const byte = packed[pOff + (d >> 1)];
188
+ out[fOff + d] = (byte & 0x0F) * tScale + tMin;
189
+ if (d + 1 < tokenDim) {
190
+ out[fOff + d + 1] = ((byte >> 4) & 0x0F) * tScale + tMin;
191
+ }
192
+ }
193
+ }
194
+ }
195
+ }
196
+
197
+ function dequantizeFromInt4PerToken(packed, minArray, scaleArray, numTokens, tokenDim) {
198
+ const out = new Float32Array(numTokens * tokenDim);
199
+ dequantInt4ToBuffer(packed, minArray, scaleArray, numTokens, tokenDim, out);
200
+ return out;
201
+ }
202
+
203
+ /**
204
+ * Dequantize int8 back to float32
205
+ */
206
+ function dequantizeFromInt8(int8Array, min, scale) {
207
+ const floatArray = new Float32Array(int8Array.length);
208
+
209
+ // Edge case: if scale is 0, all original values were equal to min
210
+ if (scale === 0) {
211
+ floatArray.fill(min);
212
+ return floatArray;
213
+ }
214
+
215
+ for (let i = 0; i < int8Array.length; i++) {
216
+ floatArray[i] = (int8Array[i] + 128) * scale + min;
217
+ }
218
+ return floatArray;
219
+ }
220
+
221
+ /**
222
+ * Cosine similarity with pre-computed norms.
223
+ * Avoids recomputing norms on every call (3x fewer FLOPs in inner loop).
224
+ */
225
+ function cosineSimilarityFast(a, b, normA, normB) {
226
+ let dot = 0;
227
+ for (let i = 0; i < a.length; i++) {
228
+ dot += a[i] * b[i];
229
+ }
230
+ return dot / (normA * normB + 1e-8);
231
+ }
232
+
233
+ /**
234
+ * Compute L2 norm of a vector.
235
+ */
236
+ function l2Norm(vec) {
237
+ let sum = 0;
238
+ for (let i = 0; i < vec.length; i++) sum += vec[i] * vec[i];
239
+ return Math.sqrt(sum);
240
+ }
241
+
242
+ /**
243
+ * CRA-9: Voronoi-guided token pruning. Estimates token "uniqueness" by computing
244
+ * mean distance to nearest neighbors. Tokens with small mean NN distance (tiny
245
+ * Voronoi cells) are redundant — their information is well-represented by
246
+ * neighbors. Tokens with large mean NN distance are unique and must be kept.
247
+ *
248
+ * @param {Array} tokens - Token vectors (first is protected)
249
+ * @param {number} threshold - Keep ratio (0..1): fraction of tokens to keep
250
+ * @returns {Array} Pruned token list
251
+ */
252
+ /**
253
+ * @param {Array} tokens - Token vectors (first is protected)
254
+ * @param {number} keepRatio - Fraction of tokens to keep (0..1), e.g. 0.7 = keep 70%
255
+ */
256
+ function voronoiPruneTokens(tokens, keepRatio) {
257
+ if (tokens.length <= 2) return tokens;
258
+
259
+ const dim = tokens[0].length;
260
+ const n = tokens.length;
261
+
262
+ // Compute nearest-neighbor distance for each non-protected token
263
+ const importance = new Float32Array(n);
264
+ importance[0] = Infinity; // protect first token
265
+
266
+ for (let i = 1; i < n; i++) {
267
+ let minDist = Infinity;
268
+ for (let j = 0; j < n; j++) {
269
+ if (i === j) continue;
270
+ let dot = 0;
271
+ for (let d = 0; d < dim; d++) dot += tokens[i][d] * tokens[j][d];
272
+ const dist = 1 - dot; // cosine distance for L2-normalized vectors
273
+ if (dist < minDist) minDist = dist;
274
+ }
275
+ importance[i] = minDist; // larger = more unique = keep
276
+ }
277
+
278
+ // Keep top keepRatio fraction by importance + always keep protected token
279
+ const keepCount = Math.max(1, Math.round(n * keepRatio));
280
+ const indexed = Array.from({ length: n }, (_, i) => ({ i, imp: importance[i] }));
281
+ indexed.sort((a, b) => b.imp - a.imp);
282
+ const keepSet = new Set(indexed.slice(0, keepCount).map(x => x.i));
283
+ keepSet.add(0);
284
+
285
+ return tokens.filter((_, i) => keepSet.has(i));
286
+ }
287
+
288
+ /**
289
+ * Late Interaction Index Class
290
+ */
291
+ const LI_SEGMENT_SIZE = 10_000;
292
+ const LI_SEGMENT_MAGIC = 0x4C495345; // "LISE" = Late Interaction Segment (legacy)
293
+ const SSLX_SEGMENT_MAGIC = 0x53534C58; // "SSLX" = Sweet Search Late indeX (v3 binary)
294
+ const SSLX_VERSION = 3;
295
+ const SSLX_HEADER_SIZE = 64;
296
+ const SSLX_DOC_ENTRY_SIZE = 20;
297
+
298
+ export class LateInteractionIndex {
299
+ constructor(options = {}) {
300
+ this.tokenDim = options.tokenDim || LATE_INTERACTION_CONFIG.tokenDimension;
301
+ this.maxTokens = options.maxTokens || 512;
302
+ const defaultQuantization = LATE_INTERACTION_CONFIG.quantization || 'int8';
303
+ this.useInt8 = options.useInt8 ?? (defaultQuantization === 'int8' || defaultQuantization === 'int4');
304
+ this.quantBits = options.quantBits ?? (
305
+ options.useInt8 === true ? 8
306
+ : options.useInt8 === false ? 32
307
+ : defaultQuantization === 'int4' ? 4
308
+ : (this.useInt8 ? 8 : 32)
309
+ ); // 8=int8, 4=int4-nibble, 32=float32
310
+ this.indexPath = options.indexPath || DB_PATHS.lateInteraction || path.join(process.cwd(), '.sweet-search', 'late-interaction-tokens.db');
311
+ this.modelId = options.modelId || LATE_INTERACTION_CONFIG.model || null;
312
+ this.poolFactor = options.poolFactor || 1;
313
+ this.streamChunkSize = options.streamChunkSize || (8 * 1024 * 1024);
314
+ this.loadExisting = options.loadExisting ?? true;
315
+
316
+ // Phase 3: norm-based token pruning threshold (0 = disabled)
317
+ this.normPruneThreshold = options.normPruneThreshold || 0;
318
+ // CRA-9: Use Voronoi cell volume instead of norm for pruning decisions.
319
+ // voronoiKeepRatio is a separate param (0..1, fraction to KEEP), not overloaded
320
+ // from normPruneThreshold which is an absolute norm threshold.
321
+ this.voronoiPrune = options.voronoiPrune ?? false;
322
+ this.voronoiKeepRatio = options.voronoiKeepRatio ?? 0.7; // keep 70% by default
323
+
324
+ // WHT rotation (Phase 2): seed > 0 enables Walsh-Hadamard rotation
325
+ // before quantization. Equalizes dimension variance for better INT8 fidelity.
326
+ // Default OFF (0) for backward compat — legacy indexes don't persist whtSeed,
327
+ // so defaulting ON would rotate queries against unrotated documents.
328
+ this.whtSeed = options.whtSeed ?? 0;
329
+ // CRA-4: 'natural' (default, backward-compat) or 'sequency' (opt-in, new indexes only)
330
+ this.whtOrdering = options.whtOrdering || 'natural';
331
+ this._signVector = null; // lazy-init on first add/query
332
+
333
+ // CRA-10: SAQ-style adaptive bit allocation per dimension segment.
334
+ // When enabled, dimensions with higher post-WHT variance get more bits (up to 6)
335
+ // and low-variance dimensions get fewer (down to 3). Average stays at 4 bits.
336
+ // Only useful if post-WHT variance ratio > 1.5 (otherwise uniform is optimal).
337
+ this.adaptiveBitAlloc = options.adaptiveBitAlloc ?? false;
338
+
339
+ // CRA-8 / Phase 5: Matryoshka dimension truncation. When matryoshkaDim is set
340
+ // and less than tokenDim, token vectors are truncated to the first matryoshkaDim
341
+ // dimensions before quantization. Requires a model trained with Matryoshka objective.
342
+ // The effective storage dimension becomes matryoshkaDim instead of tokenDim.
343
+ this.matryoshkaDim = options.matryoshkaDim || 0; // 0 = disabled
344
+
345
+ // CRA-6: Token importance weights. When enabled, MaxSim scoring weights each
346
+ // doc token's similarity by its pre-normalization L2 norm (a proxy for IDF —
347
+ // high-norm tokens carry more information). Uses softmax over tokenNorms.
348
+ this.useTokenWeights = options.useTokenWeights ?? false;
349
+
350
+ // CRA-3: WUSH calibrated rotation. When wushCalibrate is true and whtSeed > 0,
351
+ // collects sample embeddings during add() and calibrates the rotation matrix
352
+ // after wushSampleSize samples. Replaces bare WHT with WUSH transform.
353
+ this.wushCalibrate = options.wushCalibrate ?? false;
354
+ this._wushSamples = []; // collected during add(), cleared after calibration
355
+ this._wushSampleSize = options.wushSampleSize || 10000;
356
+ this._wushCalibration = null; // { eigenVecs, invSqrtEigenVals } after calibration
357
+
358
+ // In-memory storage
359
+ this.documents = new Map(); // id -> { tokens, metadata }
360
+ this.initialized = false;
361
+ this._hasPerTokenQuant = false; // tracked for getStats — set on add/load
362
+ this._loadedExisting = false;
363
+
364
+ // Dedup: alias pointer sidecar. aliasId -> { exemplarId, clusterId, metadata }.
365
+ // Aliases skip LI encoding entirely — MaxSim dereferences the exemplar's
366
+ // per-token matrix from `this.documents` on every getTokens/getTokensFlat.
367
+ // Persisted as a JSON sidecar next to the SSLX stub so the binary segment
368
+ // format is untouched.
369
+ this.aliasPointers = new Map();
370
+
371
+ // Segmented flush state (Phase C)
372
+ this._currentSegment = new Map();
373
+ this._segments = []; // { path, count } of flushed segments
374
+ this._segmentDir = null;
375
+ this._segmentSize = options.segmentSize || LI_SEGMENT_SIZE;
376
+ }
377
+
378
+ /**
379
+ * Reset segment state for a new save path. Call before save() when the
380
+ * output path differs from the load path (staged builds).
381
+ *
382
+ * @param {string} newIndexPath - Where save() should write the stub file.
383
+ * @param {object} [options]
384
+ * @param {string} [options.stagingSegmentDir] - Distinct directory to write
385
+ * segments into during staging. Must NOT collide with the live segments
386
+ * directory ({finalIndexPath}.segments). Defaults to {newIndexPath}.segments
387
+ * which is unsafe when the staging stub is adjacent to a live stub — callers
388
+ * doing stage-and-swap MUST pass an explicit non-colliding path.
389
+ * @param {string} [options.finalIndexPath] - The post-swap location of the
390
+ * stub file. Used to derive the `segmentDir` basename recorded inside the
391
+ * stub so that after atomic promotion the stub points at {finalIndexPath}.segments
392
+ * (resolved relative to the stub's dirname on load).
393
+ */
394
+ resetForSave(newIndexPath, options = {}) {
395
+ this.indexPath = newIndexPath;
396
+ // Discard loaded segment refs — save() will rewrite from this.documents.
397
+ const { stagingSegmentDir = null, finalIndexPath = null } = options;
398
+ // Pre-seed _segmentDir with the staging path so _flushSegment() writes
399
+ // into the staging directory rather than re-deriving it from indexPath
400
+ // (which would collide with a live {indexPath}.segments when the caller
401
+ // uses {live}.tmp as the stub staging path).
402
+ this._segmentDir = stagingSegmentDir;
403
+ this._finalIndexPath = finalIndexPath;
404
+ this._segments = [];
405
+ this._currentSegment = new Map();
406
+ }
407
+
408
+ /**
409
+ * Initialize or load index
410
+ */
411
+ async init() {
412
+ if (this.initialized) return;
413
+
414
+ // Ensure WASM MaxSim kernel is ready
415
+ await initWasm();
416
+
417
+ if (this.loadExisting && existsSync(this.indexPath)) {
418
+ await this.load();
419
+ this._loadedExisting = true;
420
+ }
421
+
422
+ this.initialized = true;
423
+ }
424
+
425
+ /**
426
+ * Add document with token embeddings
427
+ *
428
+ * @param {string} id - Document ID
429
+ * @param {number[][]} tokenEmbeddings - Array of token embeddings (each token is a vector)
430
+ * @param {Object} metadata - Optional metadata
431
+ */
432
+ async add(id, tokenEmbeddings, metadata = {}) {
433
+ await this.init();
434
+
435
+ // Truncate embeddings to tokenDim
436
+ let truncated = tokenEmbeddings.slice(0, this.maxTokens).map(emb =>
437
+ emb.slice(0, this.tokenDim)
438
+ );
439
+
440
+ // CRA-6: Capture pre-normalization norms for token importance weighting.
441
+ // The model's preNorms reflect how much each token was "activated" — after L2
442
+ // normalization all norms become ~1, making post-norm weighting a no-op.
443
+ const preNorms = tokenEmbeddings.preNorms
444
+ ? new Float32Array(tokenEmbeddings.preNorms).slice(0, truncated.length)
445
+ : null;
446
+
447
+ // Phase 3: Token pruning — drop low-information tokens before pooling.
448
+ if ((this.normPruneThreshold > 0 || this.voronoiPrune) && truncated.length > 1) {
449
+ if (this.voronoiPrune) {
450
+ // CRA-9: Voronoi-guided token importance — tokens with small Voronoi cells
451
+ // (many nearby neighbors) are redundant and safe to prune.
452
+ truncated = voronoiPruneTokens(truncated, this.voronoiKeepRatio);
453
+ } else if (this.normPruneThreshold > 0) {
454
+ // Original norm-based pruning (Phase 3 baseline).
455
+ // Uses pre-normalization norms when available (from the model's L2 normalization
456
+ // step, attached as tokenEmbeddings.preNorms). Pre-norm magnitude measures how much
457
+ // the model "activated" for each token — low pre-norm = low information content.
458
+ const preNorms = tokenEmbeddings.preNorms;
459
+ const kept = [truncated[0]]; // always keep first token (CLS-equivalent)
460
+ for (let t = 1; t < truncated.length; t++) {
461
+ let norm;
462
+ if (preNorms) {
463
+ norm = preNorms[t];
464
+ } else {
465
+ let normSq = 0;
466
+ const v = truncated[t];
467
+ for (let d = 0; d < v.length; d++) normSq += v[d] * v[d];
468
+ norm = Math.sqrt(normSq);
469
+ }
470
+ if (norm >= this.normPruneThreshold) kept.push(truncated[t]);
471
+ }
472
+ truncated = kept;
473
+ }
474
+ }
475
+
476
+ // Phase 3: Token pooling — reduce token count by averaging consecutive groups.
477
+ // Only pool here if tokens haven't already been pooled upstream (e.g., by
478
+ // encodeDocuments). The _pooledUpstream flag is set by the indexing pipeline
479
+ // when it calls encodeDocuments with poolFactor > 1. If tokens arrive from
480
+ // an external source without going through encodeDocuments, this pools them.
481
+ if (this.poolFactor > 1 && !metadata._pooledUpstream) {
482
+ truncated = poolTokens(truncated.map(t => t instanceof Float32Array ? t : new Float32Array(t)), this.poolFactor);
483
+ }
484
+
485
+ // CRA-8: Matryoshka dimension truncation — keep only first matryoshkaDim dimensions.
486
+ // Applied before rotation (Matryoshka training ensures prefix dimensions are most informative).
487
+ if (this.matryoshkaDim > 0 && this.matryoshkaDim < this.tokenDim) {
488
+ for (let t = 0; t < truncated.length; t++) {
489
+ truncated[t] = new Float32Array(truncated[t]).slice(0, this.matryoshkaDim);
490
+ }
491
+ }
492
+
493
+ // Effective dimension for quantization (respects Matryoshka truncation)
494
+ const effectiveDim = (this.matryoshkaDim > 0 && this.matryoshkaDim < this.tokenDim)
495
+ ? this.matryoshkaDim : this.tokenDim;
496
+
497
+ // WHT rotation (Phase 2): rotate each token before quantization.
498
+ // Equalizes dimension variance so scalar quantization uses full INT8 range.
499
+ // Lazy-init sign vector (deterministic from whtSeed).
500
+ if (this.whtSeed > 0 && !this._signVector) {
501
+ this._signVector = generateSignVector(effectiveDim, this.whtSeed);
502
+ }
503
+
504
+ // CRA-3: Collect samples for WUSH calibration (before rotation)
505
+ if (this.wushCalibrate && this.whtSeed > 0 && !this._wushCalibration) {
506
+ for (let t = 0; t < truncated.length && this._wushSamples.length < this._wushSampleSize; t++) {
507
+ this._wushSamples.push(new Float32Array(truncated[t]));
508
+ }
509
+ // Calibrate once we have enough samples
510
+ if (this._wushSamples.length >= this._wushSampleSize) {
511
+ this._wushCalibration = calibrateWUSH(this._wushSamples, effectiveDim);
512
+ this._wushSamples = []; // free memory
513
+ }
514
+ }
515
+
516
+ // Apply rotation in-place before flattening (if WHT enabled)
517
+ if (this.whtSeed > 0) {
518
+ for (let t = 0; t < truncated.length; t++) {
519
+ if (this._wushCalibration) {
520
+ // CRA-3: WUSH calibrated rotation
521
+ truncated[t] = wushRotate(
522
+ new Float32Array(truncated[t]),
523
+ this._wushCalibration.eigenVecs,
524
+ this._wushCalibration.invSqrtEigenVals,
525
+ this._signVector,
526
+ );
527
+ } else {
528
+ truncated[t] = fastRotate(new Float32Array(truncated[t]), this._signVector, this.whtOrdering === 'sequency');
529
+ }
530
+ }
531
+ }
532
+
533
+ // Flatten typed arrays into a single contiguous buffer.
534
+ // Use effectiveDim (respects Matryoshka truncation) for stride and quantization.
535
+ const storageDim = effectiveDim;
536
+ const totalElements = truncated.length * storageDim;
537
+ const flat = new Float32Array(totalElements);
538
+ for (let i = 0; i < truncated.length; i++) {
539
+ flat.set(truncated[i], i * storageDim);
540
+ }
541
+
542
+ // Pre-compute per-token L2 norms (on rotated vectors — WHT preserves norms).
543
+ const tokenNorms = new Float32Array(truncated.length);
544
+ for (let t = 0; t < truncated.length; t++) {
545
+ const offset = t * storageDim;
546
+ let normSq = 0;
547
+ for (let d = 0; d < storageDim; d++) {
548
+ normSq += flat[offset + d] * flat[offset + d];
549
+ }
550
+ tokenNorms[t] = Math.sqrt(normSq);
551
+ }
552
+
553
+ let docEntry;
554
+ if (this.quantBits === 4) {
555
+ // Phase 4: 4-bit nibble-packed quantization (2 values per byte)
556
+ const { data, minArray, scaleArray } = quantizeToInt4PerToken(flat, truncated.length, storageDim);
557
+ docEntry = { tokens: data, numTokens: truncated.length, dim: storageDim, minArray, scaleArray, metadata, tokenNorms, preNorms, quantBits: 4 };
558
+ } else if (this.useInt8) {
559
+ const { data, minArray, scaleArray } = quantizeToInt8PerToken(flat, truncated.length, storageDim);
560
+ docEntry = { tokens: data, numTokens: truncated.length, dim: storageDim, minArray, scaleArray, metadata, tokenNorms, preNorms };
561
+ } else {
562
+ docEntry = { tokens: flat, numTokens: truncated.length, dim: storageDim, metadata, tokenNorms, preNorms };
563
+ }
564
+
565
+ this.documents.set(id, docEntry);
566
+ if (docEntry.minArray) this._hasPerTokenQuant = true;
567
+ this._currentSegment.set(id, docEntry);
568
+
569
+ // Flush segment to disk when full — releases memory for completed segments
570
+ if (this._currentSegment.size >= this._segmentSize) {
571
+ await this._flushSegment();
572
+ }
573
+ }
574
+
575
+ /**
576
+ * Flush the current segment to disk and release its memory.
577
+ * Documents remain in this.documents for search during indexing.
578
+ */
579
+ async _flushSegment() {
580
+ if (this._currentSegment.size === 0) return;
581
+
582
+ if (!this._segmentDir) {
583
+ this._segmentDir = this.indexPath + '.segments';
584
+ }
585
+ // Always ensure the segment directory exists — resetForSave() may have
586
+ // pre-seeded _segmentDir with a staging path that hasn't been created yet.
587
+ await fs.mkdir(this._segmentDir, { recursive: true });
588
+
589
+ const segIdx = this._segments.length;
590
+ const segPath = path.join(this._segmentDir, `segment-${String(segIdx).padStart(4, '0')}.bin`);
591
+
592
+ await this._writeSegmentFile(segPath, this._currentSegment);
593
+ this._segments.push({ path: segPath, count: this._currentSegment.size });
594
+
595
+ // Release segment memory — these docs will be reloaded from segments during load()
596
+ this._currentSegment = new Map();
597
+ }
598
+
599
+ /**
600
+ * Write a segment in SSLX v3 fully-binary format.
601
+ *
602
+ * Layout: HEADER (64B) + DOC_TABLE (N*20B) + ID_TABLE + TOKEN_SLAB + FOOTER (4B CRC32)
603
+ */
604
+ async _writeSegmentFile(segPath, segmentMap) {
605
+ const numDocs = segmentMap.size;
606
+ const entries = []; // { id, idBuf, doc } in insertion order
607
+ // Collect entries and compute total sizes.
608
+ // Use each doc's stored dim (may differ from this.tokenDim due to Matryoshka truncation).
609
+ let totalTokenBytes = 0;
610
+ let totalIdBytes = 0;
611
+
612
+ for (const [id, doc] of segmentMap) {
613
+ const idBuf = Buffer.from(id, 'utf-8');
614
+ totalIdBytes += 2 + idBuf.length; // u16 len + utf8 bytes
615
+ const numTokens = doc.numTokens;
616
+ const docDim = doc.dim;
617
+ const docBytesPerDim = (doc.quantBits === 4) ? 0.5 : (this.useInt8 ? 1 : 4);
618
+ const tokenPayload = Math.ceil(numTokens * docDim * docBytesPerDim);
619
+ // token data + norms (always) + preNorms (if present) + per-token min/scale (if this doc has them)
620
+ totalTokenBytes += tokenPayload + numTokens * 4; // tokens + norms
621
+ if (doc.preNorms) totalTokenBytes += numTokens * 4; // preNorms (f32)
622
+ if (doc.minArray) totalTokenBytes += numTokens * 8; // min(f32) + scale(f32) per token
623
+ entries.push({ id, idBuf, doc });
624
+ }
625
+
626
+ const docTableSize = numDocs * SSLX_DOC_ENTRY_SIZE;
627
+ const idTableSize = 4 + totalIdBytes; // u32 totalIdBytes + per-doc entries
628
+ const totalSize = SSLX_HEADER_SIZE + docTableSize + idTableSize + totalTokenBytes + 4; // +4 for CRC32 footer
629
+ const buf = Buffer.alloc(totalSize);
630
+
631
+ // --- HEADER (64 bytes) ---
632
+ buf.writeUInt32LE(SSLX_SEGMENT_MAGIC, 0); // [0..3] magic
633
+ buf.writeUInt16LE(SSLX_VERSION, 4); // [4..5] version
634
+ // Use the effective storage dim in the header so reload parses slabs correctly.
635
+ // For Matryoshka, this is matryoshkaDim; otherwise tokenDim.
636
+ const headerDim = (this.matryoshkaDim > 0 && this.matryoshkaDim < this.tokenDim)
637
+ ? this.matryoshkaDim : this.tokenDim;
638
+ buf.writeUInt8(this.quantBits, 6); // [6] quantBits (4=int4, 8=int8, 32=float32)
639
+ buf.writeUInt8(headerDim, 7); // [7] tokenDim (effective storage dim)
640
+ buf.writeUInt32LE(numDocs, 8); // [8..11] numDocuments
641
+ buf.writeUInt8(this.poolFactor || 1, 12); // [12] poolFactor
642
+ // [13..15] reserved
643
+ if (this.modelId) { // [16..47] modelId (32B, zero-padded)
644
+ const modelBuf = Buffer.from(this.modelId, 'utf-8');
645
+ modelBuf.copy(buf, 16, 0, Math.min(modelBuf.length, 32));
646
+ }
647
+ buf.writeUInt32LE(this.whtSeed || 0, 48); // [48..51] whtSeed
648
+ buf.writeUInt8(this.whtOrdering === 'sequency' ? 1 : 0, 52); // [52] whtOrdering: 0=natural, 1=sequency
649
+ // [53..63] reserved (already zero)
650
+ // Note: quantScheme is per-doc (stored in doc table reserved byte), not per-segment.
651
+
652
+ // --- DOCUMENT TABLE (numDocs * 20 bytes) ---
653
+ let docTableOffset = SSLX_HEADER_SIZE;
654
+ let tokenSlabCursor = 0; // relative offset within token slab
655
+
656
+ for (let i = 0; i < entries.length; i++) {
657
+ const { doc } = entries[i];
658
+ const off = docTableOffset + i * SSLX_DOC_ENTRY_SIZE;
659
+
660
+ const isPerToken = !!doc.minArray;
661
+ buf.writeUInt32LE(tokenSlabCursor, off); // [0..3] tokenDataOffset
662
+ buf.writeUInt16LE(doc.numTokens, off + 4); // [4..5] numTokens
663
+ buf.writeFloatLE(doc.min ?? 0, off + 6); // [6..9] min (scalar; 0 for per-token)
664
+ buf.writeFloatLE(doc.scale ?? 0, off + 10); // [10..13] scale (scalar; 0 for per-token)
665
+ buf.writeUInt8(isPerToken ? 1 : 0, off + 14); // [14] quantScheme: 0=per-doc, 1=per-token
666
+ buf.writeUInt8(doc.preNorms ? 1 : 0, off + 15); // [15] hasPreNorms: CRA-6 token importance
667
+ // [16..19] reserved
668
+
669
+ const docBPD = (doc.quantBits === 4) ? 0.5 : (this.useInt8 ? 1 : 4);
670
+ const tokenPayload = Math.ceil(doc.numTokens * doc.dim * docBPD);
671
+ tokenSlabCursor += tokenPayload + doc.numTokens * 4 // tokens + norms
672
+ + (doc.preNorms ? doc.numTokens * 4 : 0) // preNorms
673
+ + (isPerToken ? doc.numTokens * 8 : 0); // min/scale
674
+ }
675
+
676
+ // --- ID TABLE ---
677
+ let idOffset = SSLX_HEADER_SIZE + docTableSize;
678
+ buf.writeUInt32LE(totalIdBytes, idOffset);
679
+ idOffset += 4;
680
+
681
+ for (const { idBuf } of entries) {
682
+ buf.writeUInt16LE(idBuf.length, idOffset);
683
+ idBuf.copy(buf, idOffset + 2);
684
+ idOffset += 2 + idBuf.length;
685
+ }
686
+
687
+ // --- TOKEN SLAB ---
688
+ // Layout per doc: token_data | [minArray | scaleArray] (per-token only) | tokenNorms
689
+ let slabOffset = SSLX_HEADER_SIZE + docTableSize + idTableSize;
690
+
691
+ for (const { doc } of entries) {
692
+ // Token data — 4-bit packed or int8/float32
693
+ const slabBPD = (doc.quantBits === 4) ? 0.5 : (this.useInt8 ? 1 : 4);
694
+ const tokenPayload = Math.ceil(doc.numTokens * doc.dim * slabBPD);
695
+ const tokenSrc = Buffer.from(doc.tokens.buffer, doc.tokens.byteOffset, tokenPayload);
696
+ tokenSrc.copy(buf, slabOffset);
697
+ slabOffset += tokenPayload;
698
+
699
+ // Per-token min/scale arrays — only for docs that actually have them
700
+ if (doc.minArray) {
701
+ Buffer.from(doc.minArray.buffer, doc.minArray.byteOffset, doc.numTokens * 4).copy(buf, slabOffset);
702
+ slabOffset += doc.numTokens * 4;
703
+ Buffer.from(doc.scaleArray.buffer, doc.scaleArray.byteOffset, doc.numTokens * 4).copy(buf, slabOffset);
704
+ slabOffset += doc.numTokens * 4;
705
+ }
706
+
707
+ const norms = doc.tokenNorms || new Float32Array(doc.numTokens);
708
+ const normSrc = Buffer.from(norms.buffer, norms.byteOffset, doc.numTokens * 4);
709
+ normSrc.copy(buf, slabOffset);
710
+ slabOffset += doc.numTokens * 4;
711
+
712
+ // CRA-6: preNorms (pre-normalization norms for token importance weighting)
713
+ if (doc.preNorms) {
714
+ Buffer.from(doc.preNorms.buffer, doc.preNorms.byteOffset, doc.numTokens * 4).copy(buf, slabOffset);
715
+ slabOffset += doc.numTokens * 4;
716
+ }
717
+ }
718
+
719
+ // --- FOOTER (CRC32) ---
720
+ const checksum = crc32(buf.subarray(0, totalSize - 4));
721
+ buf.writeUInt32LE(checksum, totalSize - 4);
722
+
723
+ await fs.writeFile(segPath, buf);
724
+ }
725
+
726
+ /**
727
+ * Read a segment file. Auto-detects format by magic:
728
+ * 0x4C495345 ("LISE") → legacy binary-header + JSON body
729
+ * 0x53534C58 ("SSLX") → v3 fully-binary format
730
+ *
731
+ * Returns an array of doc objects for uniform downstream handling.
732
+ */
733
+ async _readSegmentFile(segPath) {
734
+ const buf = await fs.readFile(segPath);
735
+ const magic = buf.readUInt32LE(0);
736
+
737
+ if (magic === SSLX_SEGMENT_MAGIC) {
738
+ return this._readSegmentSSLX(buf, segPath);
739
+ }
740
+ if (magic === LI_SEGMENT_MAGIC) {
741
+ const body = buf.subarray(64);
742
+ return JSON.parse(body.toString('utf-8'));
743
+ }
744
+ throw new Error(`Invalid segment file (unknown magic 0x${magic.toString(16)}): ${segPath}`);
745
+ }
746
+
747
+ /**
748
+ * Parse an SSLX v3 fully-binary segment buffer.
749
+ * Returns array of { id, tokens (Int8Array/Float32Array), numTokens, dim, min, scale, tokenNorms }.
750
+ */
751
+ _readSegmentSSLX(buf, segPath) {
752
+ // Validate CRC32
753
+ const storedCrc = buf.readUInt32LE(buf.length - 4);
754
+ const computedCrc = crc32(buf.subarray(0, buf.length - 4));
755
+ if (storedCrc !== computedCrc) {
756
+ throw new Error(`CRC32 mismatch in ${segPath}: stored=0x${storedCrc.toString(16)} computed=0x${computedCrc.toString(16)}`);
757
+ }
758
+
759
+ // --- HEADER ---
760
+ const quantBits = buf.readUInt8(6);
761
+ const tokenDim = buf.readUInt8(7);
762
+ const numDocs = buf.readUInt32LE(8);
763
+ const is4bit = quantBits === 4;
764
+ const isInt8 = quantBits === 8;
765
+ const isQuantized = is4bit || isInt8;
766
+ const bytesPerDim = is4bit ? 0.5 : (isInt8 ? 1 : 4);
767
+
768
+ // --- DOCUMENT TABLE ---
769
+ const docTableStart = SSLX_HEADER_SIZE;
770
+ const docEntries = [];
771
+ for (let i = 0; i < numDocs; i++) {
772
+ const off = docTableStart + i * SSLX_DOC_ENTRY_SIZE;
773
+ docEntries.push({
774
+ tokenDataOffset: buf.readUInt32LE(off),
775
+ numTokens: buf.readUInt16LE(off + 4),
776
+ min: buf.readFloatLE(off + 6),
777
+ scale: buf.readFloatLE(off + 10),
778
+ isPerToken: buf.readUInt8(off + 14) === 1,
779
+ hasPreNorms: buf.readUInt8(off + 15) === 1,
780
+ });
781
+ }
782
+
783
+ // --- ID TABLE ---
784
+ let idOffset = docTableStart + numDocs * SSLX_DOC_ENTRY_SIZE;
785
+ const totalIdBytes = buf.readUInt32LE(idOffset);
786
+ idOffset += 4;
787
+
788
+ const ids = [];
789
+ for (let i = 0; i < numDocs; i++) {
790
+ const idLen = buf.readUInt16LE(idOffset);
791
+ const id = buf.toString('utf-8', idOffset + 2, idOffset + 2 + idLen);
792
+ ids.push(id);
793
+ idOffset += 2 + idLen;
794
+ }
795
+
796
+ // --- TOKEN SLAB ---
797
+ // Layout per doc: token_data | [minArray | scaleArray] (per-token only) | tokenNorms
798
+ const slabStart = docTableStart + numDocs * SSLX_DOC_ENTRY_SIZE + 4 + totalIdBytes;
799
+ const docs = [];
800
+
801
+ for (let i = 0; i < numDocs; i++) {
802
+ const { tokenDataOffset, numTokens, min, scale, isPerToken, hasPreNorms } = docEntries[i];
803
+ const absOffset = slabStart + tokenDataOffset;
804
+ // 4-bit: ceil(tokenDim/2) bytes per token; 8-bit: tokenDim; float32: tokenDim*4
805
+ const tokenPayload = Math.ceil(numTokens * tokenDim * bytesPerDim);
806
+ let cursor = absOffset;
807
+
808
+ // Alignment-safe copy: create fresh ArrayBuffer (always aligned) and
809
+ // copy raw bytes from the file buffer. This avoids RangeError when the
810
+ // slab start isn't 4-byte-aligned (ID table has variable length).
811
+ let tokens;
812
+ {
813
+ const ab = new ArrayBuffer(tokenPayload);
814
+ new Uint8Array(ab).set(buf.subarray(cursor, cursor + tokenPayload));
815
+ tokens = is4bit ? new Uint8Array(ab) : (isInt8 ? new Int8Array(ab) : new Float32Array(ab));
816
+ }
817
+ cursor += tokenPayload;
818
+
819
+ // Per-token min/scale arrays (only for docs flagged as per-token)
820
+ let minArray, scaleArray;
821
+ if (isPerToken) {
822
+ const minAb = new ArrayBuffer(numTokens * 4);
823
+ new Uint8Array(minAb).set(buf.subarray(cursor, cursor + numTokens * 4));
824
+ minArray = new Float32Array(minAb);
825
+ cursor += numTokens * 4;
826
+
827
+ const scaleAb = new ArrayBuffer(numTokens * 4);
828
+ new Uint8Array(scaleAb).set(buf.subarray(cursor, cursor + numTokens * 4));
829
+ scaleArray = new Float32Array(scaleAb);
830
+ cursor += numTokens * 4;
831
+ }
832
+
833
+ // Per-token norms (f32)
834
+ const normAb = new ArrayBuffer(numTokens * 4);
835
+ new Uint8Array(normAb).set(buf.subarray(cursor, cursor + numTokens * 4));
836
+ const tokenNorms = new Float32Array(normAb);
837
+ cursor += numTokens * 4;
838
+
839
+ // CRA-6: Pre-normalization norms for token importance weighting
840
+ let preNorms = null;
841
+ if (hasPreNorms) {
842
+ const preNormAb = new ArrayBuffer(numTokens * 4);
843
+ new Uint8Array(preNormAb).set(buf.subarray(cursor, cursor + numTokens * 4));
844
+ preNorms = new Float32Array(preNormAb);
845
+ cursor += numTokens * 4;
846
+ }
847
+
848
+ const doc = {
849
+ id: ids[i],
850
+ tokens,
851
+ numTokens,
852
+ dim: tokenDim,
853
+ tokenNorms,
854
+ };
855
+ if (preNorms) doc.preNorms = preNorms;
856
+ if (is4bit) doc.quantBits = 4;
857
+
858
+ if (isPerToken) {
859
+ doc.minArray = minArray;
860
+ doc.scaleArray = scaleArray;
861
+ } else {
862
+ doc.min = min;
863
+ doc.scale = scale;
864
+ }
865
+
866
+ docs.push(doc);
867
+ }
868
+
869
+ return docs;
870
+ }
871
+
872
+ /**
873
+ * Get token embeddings for a document
874
+ */
875
+ /**
876
+ * Record an alias pointer for a chunk that shares an exemplar's per-token
877
+ * matrix. MaxSim scoring dereferences `exemplarId` on every lookup, so no
878
+ * embedding work happens for aliases.
879
+ */
880
+ addAlias(id, exemplarId, clusterId, metadata = {}) {
881
+ this.aliasPointers.set(id, { exemplarId, clusterId, metadata });
882
+ }
883
+
884
+ _resolveForRead(id) {
885
+ const ptr = this.aliasPointers.get(id);
886
+ if (ptr) return ptr.exemplarId;
887
+ return id;
888
+ }
889
+
890
+ _aliasSidecarPath(indexPath = this.indexPath) {
891
+ return indexPath + '.aliases.json';
892
+ }
893
+
894
+ async _saveAliasSidecar(indexPath = this.indexPath) {
895
+ if (this.aliasPointers.size === 0) {
896
+ // Remove any stale sidecar from a previous build.
897
+ try { await fs.unlink(this._aliasSidecarPath(indexPath)); } catch (_e) { /* not present */ }
898
+ return;
899
+ }
900
+ const entries = [];
901
+ for (const [aliasId, ptr] of this.aliasPointers) {
902
+ entries.push({ aliasId, exemplarId: ptr.exemplarId, clusterId: ptr.clusterId });
903
+ }
904
+ const payload = { version: 1, count: entries.length, aliases: entries };
905
+ await fs.writeFile(this._aliasSidecarPath(indexPath), JSON.stringify(payload));
906
+ }
907
+
908
+ async _loadAliasSidecar(indexPath = this.indexPath) {
909
+ const p = this._aliasSidecarPath(indexPath);
910
+ if (!existsSync(p)) return;
911
+ try {
912
+ const raw = await fs.readFile(p, 'utf-8');
913
+ const payload = JSON.parse(raw);
914
+ if (!payload || !Array.isArray(payload.aliases)) return;
915
+ this.aliasPointers.clear();
916
+ for (const { aliasId, exemplarId, clusterId } of payload.aliases) {
917
+ // Orphan guard: drop aliases whose exemplar is no longer in documents.
918
+ // Happens if the file containing the exemplar was removed between
919
+ // save and load (incremental re-index removed the exemplar file
920
+ // but did not re-run dedup over the alias files).
921
+ if (!this.documents.has(exemplarId)) continue;
922
+ this.aliasPointers.set(aliasId, { exemplarId, clusterId, metadata: {} });
923
+ }
924
+ } catch (_e) {
925
+ // Malformed sidecar — treat as absent; aliases will be skipped at query time.
926
+ }
927
+ }
928
+
929
+ getTokens(id) {
930
+ const resolved = this._resolveForRead(id);
931
+ const doc = this.documents.get(resolved);
932
+ if (!doc) return null;
933
+
934
+ let tokens;
935
+ if (doc.quantBits === 4 && doc.minArray) {
936
+ // Phase 4: 4-bit nibble-packed dequantize
937
+ tokens = dequantizeFromInt4PerToken(doc.tokens, doc.minArray, doc.scaleArray, doc.numTokens, doc.dim);
938
+ } else if (this.useInt8 && doc.minArray) {
939
+ // Per-token dequantize (Phase 1)
940
+ tokens = this._dequantPerToken(doc);
941
+ } else if (this.useInt8 && doc.min !== undefined) {
942
+ // Per-document dequantize (legacy)
943
+ tokens = dequantizeFromInt8(doc.tokens, doc.min, doc.scale);
944
+ } else {
945
+ tokens = doc.tokens;
946
+ }
947
+
948
+ // Reshape into array of vectors
949
+ const result = [];
950
+ for (let i = 0; i < doc.numTokens; i++) {
951
+ result.push(Array.from(tokens.slice(i * doc.dim, (i + 1) * doc.dim)));
952
+ }
953
+
954
+ return result;
955
+ }
956
+
957
+ /**
958
+ * Get dequantized tokens as a flat Float32Array + metadata.
959
+ * Avoids the expensive reshape step (creating N sub-arrays).
960
+ * Used by the optimized scoreWithLateInteraction path.
961
+ *
962
+ * @param {string} id
963
+ * @returns {{ flat: Float32Array, numTokens: number, dim: number } | null}
964
+ */
965
+ getTokensFlat(id) {
966
+ const resolved = this._resolveForRead(id);
967
+ const doc = this.documents.get(resolved);
968
+ if (!doc) return null;
969
+
970
+ let flat;
971
+ if (doc.quantBits === 4 && doc.minArray) {
972
+ const floatSize = doc.numTokens * doc.dim;
973
+ if (!this._dequantBuf || this._dequantBuf.length < floatSize) {
974
+ this._dequantBuf = new Float32Array(floatSize);
975
+ }
976
+ dequantInt4ToBuffer(doc.tokens, doc.minArray, doc.scaleArray, doc.numTokens, doc.dim, this._dequantBuf);
977
+ flat = this._dequantBuf;
978
+ } else if (this.useInt8 && doc.minArray) {
979
+ // Per-token dequantize (Phase 1) — pooled buffer
980
+ const size = doc.tokens.length;
981
+ if (!this._dequantBuf || this._dequantBuf.length < size) {
982
+ this._dequantBuf = new Float32Array(size);
983
+ }
984
+ const buf = this._dequantBuf;
985
+ const dim = doc.dim;
986
+ for (let t = 0; t < doc.numTokens; t++) {
987
+ const off = t * dim;
988
+ const tMin = doc.minArray[t];
989
+ const tScale = doc.scaleArray[t];
990
+ if (tScale === 0) {
991
+ for (let d = 0; d < dim; d++) buf[off + d] = tMin;
992
+ } else {
993
+ for (let d = 0; d < dim; d++) {
994
+ buf[off + d] = (doc.tokens[off + d] + 128) * tScale + tMin;
995
+ }
996
+ }
997
+ }
998
+ flat = buf;
999
+ } else if (this.useInt8 && doc.min !== undefined) {
1000
+ // Per-document dequantize (legacy) — pooled buffer
1001
+ const size = doc.tokens.length;
1002
+ if (!this._dequantBuf || this._dequantBuf.length < size) {
1003
+ this._dequantBuf = new Float32Array(size);
1004
+ }
1005
+ const buf = this._dequantBuf;
1006
+ const { min, scale } = doc;
1007
+ if (scale === 0) {
1008
+ buf.fill(min);
1009
+ } else {
1010
+ for (let i = 0; i < size; i++) {
1011
+ buf[i] = (doc.tokens[i] + 128) * scale + min;
1012
+ }
1013
+ }
1014
+ // SAFETY: Pooled buffer — caller MUST consume or copy contents before the
1015
+ // next getTokensFlat() call, which overwrites this buffer. The native batch
1016
+ // path (maxsim_batch) does not call getTokensFlat, so no aliasing risk there.
1017
+ flat = buf;
1018
+ } else {
1019
+ flat = doc.tokens instanceof Float32Array ? doc.tokens : new Float32Array(doc.tokens);
1020
+ }
1021
+
1022
+ return { flat, numTokens: doc.numTokens, dim: doc.dim };
1023
+ }
1024
+
1025
+ /**
1026
+ * Dequantize per-token int8 data to float32.
1027
+ * @private
1028
+ */
1029
+ _dequantPerToken(doc) {
1030
+ const float = new Float32Array(doc.tokens.length);
1031
+ const dim = doc.dim;
1032
+ for (let t = 0; t < doc.numTokens; t++) {
1033
+ const off = t * dim;
1034
+ const tMin = doc.minArray[t];
1035
+ const tScale = doc.scaleArray[t];
1036
+ if (tScale === 0) {
1037
+ for (let d = 0; d < dim; d++) float[off + d] = tMin;
1038
+ } else {
1039
+ for (let d = 0; d < dim; d++) {
1040
+ float[off + d] = (doc.tokens[off + d] + 128) * tScale + tMin;
1041
+ }
1042
+ }
1043
+ }
1044
+ return float;
1045
+ }
1046
+
1047
+ /**
1048
+ * Prune low-value query tokens before MaxSim scoring.
1049
+ *
1050
+ * Strategies:
1051
+ * - normThreshold: drop tokens with L2 norm below this value (low-information tokens)
1052
+ * - maxQueryTokens: hard cap on query token count (keep highest-norm tokens)
1053
+ *
1054
+ * @param {number[][]} queryTokens
1055
+ * @param {Object} [options]
1056
+ * @param {number} [options.normThreshold=0] - Min L2 norm to keep a token
1057
+ * @param {number} [options.maxQueryTokens=0] - Max tokens (0 = unlimited)
1058
+ * @returns {number[][]} Pruned query tokens
1059
+ */
1060
+ pruneQueryTokens(queryTokens, options = {}) {
1061
+ const { normThreshold = 0, maxQueryTokens = 0 } = options;
1062
+
1063
+ if (!normThreshold && !maxQueryTokens) return queryTokens;
1064
+
1065
+ const withNorms = queryTokens.map(token => ({ token, norm: l2Norm(token) }));
1066
+
1067
+ let filtered = normThreshold > 0
1068
+ ? withNorms.filter(t => t.norm >= normThreshold)
1069
+ : withNorms;
1070
+
1071
+ // Ensure at least 1 token survives pruning
1072
+ if (filtered.length === 0) filtered = [withNorms[0]];
1073
+
1074
+ // Apply budget cap (keep highest-norm tokens)
1075
+ if (maxQueryTokens > 0 && filtered.length > maxQueryTokens) {
1076
+ filtered.sort((a, b) => b.norm - a.norm);
1077
+ filtered = filtered.slice(0, maxQueryTokens);
1078
+ }
1079
+
1080
+ return filtered.map(t => t.token);
1081
+ }
1082
+
1083
+ /**
1084
+ * Subsample document tokens by stride to fit within a budget.
1085
+ * Keeps the first token (CLS-equivalent) plus evenly-spaced tokens.
1086
+ *
1087
+ * @param {number[][]} docTokens
1088
+ * @param {number} maxDocTokens - Budget (0 = unlimited)
1089
+ * @returns {number[][]}
1090
+ */
1091
+ subsampleDocTokens(docTokens, maxDocTokens) {
1092
+ if (!maxDocTokens || docTokens.length <= maxDocTokens) return docTokens;
1093
+
1094
+ const result = [docTokens[0]]; // Keep first token (CLS/special)
1095
+ const remaining = maxDocTokens - 1;
1096
+ const stride = (docTokens.length - 1) / remaining;
1097
+
1098
+ for (let i = 0; i < remaining; i++) {
1099
+ const idx = 1 + Math.round(i * stride);
1100
+ if (idx < docTokens.length) result.push(docTokens[idx]);
1101
+ }
1102
+
1103
+ return result;
1104
+ }
1105
+
1106
+ /**
1107
+ * MaxSim scoring - late interaction (optimized)
1108
+ *
1109
+ * For each query token, find max similarity with any document token.
1110
+ * Sum these max similarities for final score.
1111
+ * Pre-computed norms avoid recomputing per pair (3x fewer FLOPs).
1112
+ *
1113
+ * @param {number[][]} queryTokens
1114
+ * @param {number[][]} docTokens
1115
+ * @param {Object} [options] - Pruning options
1116
+ * @param {number} [options.maxQueryTokens] - Query token budget
1117
+ * @param {number} [options.normThreshold] - Min query token norm
1118
+ * @param {number} [options.maxDocTokens] - Document token budget (stride subsample)
1119
+ */
1120
+ maxSimScore(queryTokens, docTokens, options) {
1121
+ if (!docTokens || docTokens.length === 0) return 0;
1122
+
1123
+ const effectiveQuery = (options && (options.maxQueryTokens || options.normThreshold))
1124
+ ? this.pruneQueryTokens(queryTokens, options)
1125
+ : queryTokens;
1126
+
1127
+ const effectiveDocs = (options && options.maxDocTokens)
1128
+ ? this.subsampleDocTokens(docTokens, options.maxDocTokens)
1129
+ : docTokens;
1130
+
1131
+ const qNorms = new Float32Array(effectiveQuery.length);
1132
+ for (let qi = 0; qi < effectiveQuery.length; qi++) {
1133
+ qNorms[qi] = l2Norm(effectiveQuery[qi]);
1134
+ }
1135
+
1136
+ const dNorms = new Float32Array(effectiveDocs.length);
1137
+ for (let di = 0; di < effectiveDocs.length; di++) {
1138
+ dNorms[di] = l2Norm(effectiveDocs[di]);
1139
+ }
1140
+
1141
+ let totalScore = 0;
1142
+
1143
+ for (let qi = 0; qi < effectiveQuery.length; qi++) {
1144
+ const queryToken = effectiveQuery[qi];
1145
+ const qNorm = qNorms[qi];
1146
+ let maxSim = -Infinity;
1147
+
1148
+ for (let di = 0; di < effectiveDocs.length; di++) {
1149
+ const sim = cosineSimilarityFast(queryToken, effectiveDocs[di], qNorm, dNorms[di]);
1150
+ if (sim > maxSim) maxSim = sim;
1151
+ }
1152
+
1153
+ totalScore += Math.max(0, maxSim);
1154
+ }
1155
+
1156
+ return totalScore / effectiveQuery.length;
1157
+ }
1158
+
1159
+ /**
1160
+ * MaxSim scoring from flat buffers (avoids reshape/sub-array creation).
1161
+ *
1162
+ * Operates directly on a contiguous Float32Array with offset indexing.
1163
+ * Saves the cost of creating numTokens sub-arrays per candidate.
1164
+ *
1165
+ * @param {number[][]} queryTokens - Array of query token vectors
1166
+ * @param {Float32Array} docFlat - Flat buffer of dequantized doc tokens
1167
+ * @param {number} numDocTokens - Number of document tokens
1168
+ * @param {number} dim - Token dimension
1169
+ * @param {Float32Array} [docTokenNorms] - Pre-computed norms (from add-time)
1170
+ * @returns {number} MaxSim score
1171
+ */
1172
+ maxSimScoreFlat(queryTokens, docFlat, numDocTokens, dim, docTokenNorms, precomputedQueryFlat, preNorms) {
1173
+ if (!docFlat || numDocTokens === 0) return 0;
1174
+
1175
+ // Try WASM kernel first — pass pre-flattened query if available
1176
+ // Skip WASM when token weighting is active (WASM doesn't support weights)
1177
+ if (!this.useTokenWeights) {
1178
+ const queryFlat = precomputedQueryFlat || this._flattenQueryTokens(queryTokens, dim);
1179
+ const wasmScore = wasmMaxSimF32(queryFlat, docFlat, queryTokens.length, numDocTokens, dim);
1180
+ if (wasmScore !== null) return wasmScore;
1181
+ }
1182
+
1183
+ // CRA-6: Pre-compute softmax token weights from pre-normalization norms.
1184
+ // preNorms reflect how much the model "activated" for each token before L2
1185
+ // normalization. Post-norm norms are all ~1, making weighting meaningless.
1186
+ let tokenWeights = null;
1187
+ const weightSource = preNorms || null;
1188
+ if (this.useTokenWeights && weightSource && numDocTokens > 1) {
1189
+ tokenWeights = new Float32Array(numDocTokens);
1190
+ let maxNorm = -Infinity;
1191
+ for (let di = 0; di < numDocTokens; di++) {
1192
+ if (weightSource[di] > maxNorm) maxNorm = weightSource[di];
1193
+ }
1194
+ let expSum = 0;
1195
+ for (let di = 0; di < numDocTokens; di++) {
1196
+ tokenWeights[di] = Math.exp(weightSource[di] - maxNorm);
1197
+ expSum += tokenWeights[di];
1198
+ }
1199
+ const scale = numDocTokens / expSum;
1200
+ for (let di = 0; di < numDocTokens; di++) tokenWeights[di] *= scale;
1201
+ }
1202
+
1203
+ // JS fallback
1204
+ let totalScore = 0;
1205
+
1206
+ for (let qi = 0; qi < queryTokens.length; qi++) {
1207
+ const qVec = queryTokens[qi];
1208
+
1209
+ // Compute query norm once
1210
+ let qNormSq = 0;
1211
+ for (let k = 0; k < dim; k++) qNormSq += qVec[k] * qVec[k];
1212
+ const qNorm = Math.sqrt(qNormSq);
1213
+
1214
+ let maxSim = -Infinity;
1215
+
1216
+ for (let di = 0; di < numDocTokens; di++) {
1217
+ const offset = di * dim;
1218
+
1219
+ // Dot product with offset indexing (no sub-array creation)
1220
+ let dot = 0;
1221
+ for (let k = 0; k < dim; k++) {
1222
+ dot += qVec[k] * docFlat[offset + k];
1223
+ }
1224
+
1225
+ // Use pre-computed doc norm if available, else compute
1226
+ let dNorm;
1227
+ if (docTokenNorms) {
1228
+ dNorm = docTokenNorms[di];
1229
+ } else {
1230
+ let dNormSq = 0;
1231
+ for (let k = 0; k < dim; k++) {
1232
+ const v = docFlat[offset + k];
1233
+ dNormSq += v * v;
1234
+ }
1235
+ dNorm = Math.sqrt(dNormSq);
1236
+ }
1237
+
1238
+ let sim = dot / (qNorm * dNorm + 1e-8);
1239
+ // CRA-6: Apply token importance weight
1240
+ if (tokenWeights) sim *= tokenWeights[di];
1241
+ if (sim > maxSim) maxSim = sim;
1242
+ }
1243
+
1244
+ if (maxSim > 0) totalScore += maxSim;
1245
+ }
1246
+
1247
+ return totalScore / queryTokens.length;
1248
+ }
1249
+
1250
+ /**
1251
+ * Flatten query token arrays into a contiguous Float32Array (reuses pooled buffer).
1252
+ * @private
1253
+ */
1254
+ _flattenQueryTokens(queryTokens, dim) {
1255
+ const numQ = queryTokens.length;
1256
+ if (!this._queryFlatBuf || this._queryFlatBuf.length < numQ * dim) {
1257
+ this._queryFlatBuf = new Float32Array(numQ * dim);
1258
+ }
1259
+ for (let qi = 0; qi < numQ; qi++) {
1260
+ const q = queryTokens[qi];
1261
+ const off = qi * dim;
1262
+ for (let k = 0; k < dim; k++) this._queryFlatBuf[off + k] = q[k];
1263
+ }
1264
+ return this._queryFlatBuf;
1265
+ }
1266
+
1267
+ /**
1268
+ * CRA-5: Implicit decompression 4-bit MaxSim scorer.
1269
+ * Scores directly from nibble-packed data without materializing float32 arrays.
1270
+ *
1271
+ * For each (query_token, doc_token) pair, uses a 16-entry LUT built from the
1272
+ * doc token's min/scale. The LUT maps bucket index → partial dot product
1273
+ * contribution. This eliminates the dequant allocation entirely.
1274
+ * @private
1275
+ */
1276
+ _maxSimScore4BitImplicit(queryTokens, doc, precomputedQueryFlat) {
1277
+ const { tokens: packed, minArray, scaleArray, tokenNorms, numTokens, dim, preNorms } = doc;
1278
+ if (numTokens === 0) return 0;
1279
+
1280
+ // CRA-6: Pre-compute token importance weights from pre-normalization norms.
1281
+ let tokenWeights = null;
1282
+ const weightSource = preNorms || null;
1283
+ if (this.useTokenWeights && weightSource && numTokens > 1) {
1284
+ tokenWeights = new Float32Array(numTokens);
1285
+ let maxNorm = -Infinity;
1286
+ for (let di = 0; di < numTokens; di++) {
1287
+ if (weightSource[di] > maxNorm) maxNorm = weightSource[di];
1288
+ }
1289
+ let expSum = 0;
1290
+ for (let di = 0; di < numTokens; di++) {
1291
+ tokenWeights[di] = Math.exp(weightSource[di] - maxNorm);
1292
+ expSum += tokenWeights[di];
1293
+ }
1294
+ const scale = numTokens / expSum;
1295
+ for (let di = 0; di < numTokens; di++) tokenWeights[di] *= scale;
1296
+ }
1297
+
1298
+ const packedSize = Math.ceil(dim / 2);
1299
+ const numQ = queryTokens.length;
1300
+ let totalScore = 0;
1301
+
1302
+ for (let qi = 0; qi < numQ; qi++) {
1303
+ const qVec = queryTokens[qi];
1304
+
1305
+ let qNormSq = 0;
1306
+ for (let k = 0; k < dim; k++) qNormSq += qVec[k] * qVec[k];
1307
+ const qNorm = Math.sqrt(qNormSq);
1308
+
1309
+ let maxSim = -Infinity;
1310
+
1311
+ for (let di = 0; di < numTokens; di++) {
1312
+ const tMin = minArray[di];
1313
+ const tScale = scaleArray[di];
1314
+ const dNorm = tokenNorms[di];
1315
+ const pOff = di * packedSize;
1316
+
1317
+ let dot = 0;
1318
+ for (let d = 0; d < dim; d += 2) {
1319
+ const byte = packed[pOff + (d >> 1)];
1320
+ const bucket0 = byte & 0x0F;
1321
+ dot += qVec[d] * (tMin + bucket0 * tScale);
1322
+
1323
+ if (d + 1 < dim) {
1324
+ const bucket1 = (byte >>> 4) & 0x0F;
1325
+ dot += qVec[d + 1] * (tMin + bucket1 * tScale);
1326
+ }
1327
+ }
1328
+
1329
+ let sim = dot / (qNorm * dNorm + 1e-8);
1330
+ if (tokenWeights) sim *= tokenWeights[di];
1331
+ if (sim > maxSim) maxSim = sim;
1332
+ }
1333
+
1334
+ if (maxSim > 0) totalScore += maxSim;
1335
+ }
1336
+
1337
+ return totalScore / numQ;
1338
+ }
1339
+
1340
+ /**
1341
+ * Score candidates using MaxSim late interaction
1342
+ *
1343
+ * @param {number[][]} queryTokenEmbeddings - Query token embeddings
1344
+ * @param {Array} candidates - Array of { id, ... } candidates
1345
+ * @param {Object} [options] - Scoring options
1346
+ * @param {number} [options.maxCandidates] - Max candidates to score with MaxSim (rest keep original score)
1347
+ * @param {number} [options.maxQueryTokens] - Budget cap on query tokens (keep highest-norm tokens)
1348
+ * @param {number} [options.normThreshold] - Min L2 norm for query tokens
1349
+ * @param {number} [options.maxDocTokens] - Budget cap on doc tokens per candidate (stride subsample)
1350
+ * @returns {Array} Candidates with lateInteractionScore added
1351
+ */
1352
+ async scoreWithLateInteraction(queryTokenEmbeddings, candidates, options = {}) {
1353
+ await this.init();
1354
+
1355
+ const { maxCandidates, maxQueryTokens, normThreshold, maxDocTokens } = options;
1356
+
1357
+ // P1-fix: Truncate query tokens to the effective storage dimension.
1358
+ // When matryoshkaDim is active, docs are stored at matryoshkaDim width, so queries
1359
+ // must match to avoid comparing vectors in different-dimensional spaces.
1360
+ const scoringDim = (this.matryoshkaDim > 0 && this.matryoshkaDim < this.tokenDim)
1361
+ ? this.matryoshkaDim : this.tokenDim;
1362
+
1363
+ let queryTokens = queryTokenEmbeddings.map(emb =>
1364
+ emb.slice(0, scoringDim)
1365
+ );
1366
+
1367
+ // WHT rotation (Phase 2): rotate query tokens once (amortized across all candidates).
1368
+ // Required when index was built with whtSeed > 0 — scoring must happen in rotated space.
1369
+ if (this.whtSeed > 0) {
1370
+ if (!this._signVector) {
1371
+ this._signVector = generateSignVector(scoringDim, this.whtSeed);
1372
+ }
1373
+ if (this._wushCalibration) {
1374
+ // CRA-3: WUSH calibrated rotation for queries
1375
+ queryTokens = queryTokens.map(q => wushRotate(
1376
+ new Float32Array(q),
1377
+ this._wushCalibration.eigenVecs,
1378
+ this._wushCalibration.invSqrtEigenVals,
1379
+ this._signVector,
1380
+ ));
1381
+ } else {
1382
+ queryTokens = queryTokens.map(q => fastRotate(new Float32Array(q), this._signVector, this.whtOrdering === 'sequency'));
1383
+ }
1384
+ }
1385
+
1386
+ // Pruning options (passed through to maxSimScore)
1387
+ const pruneOpts = (maxQueryTokens || normThreshold || maxDocTokens)
1388
+ ? { maxQueryTokens, normThreshold, maxDocTokens }
1389
+ : undefined;
1390
+
1391
+ // Candidate pruning: pre-sort by initial score, only MaxSim-score the top N
1392
+ let toScore = candidates;
1393
+ let pruned = [];
1394
+ if (maxCandidates && candidates.length > maxCandidates) {
1395
+ const sorted = [...candidates].sort((a, b) => (b.score || 0) - (a.score || 0));
1396
+ toScore = sorted.slice(0, maxCandidates);
1397
+ pruned = sorted.slice(maxCandidates);
1398
+ }
1399
+
1400
+ // Apply query token pruning once (shared across all candidates)
1401
+ const effectiveQueryTokens = (maxQueryTokens || normThreshold)
1402
+ ? this.pruneQueryTokens(queryTokens, { maxQueryTokens, normThreshold })
1403
+ : queryTokens;
1404
+
1405
+ const scored = [];
1406
+ const pushScored = (c, score) => scored.push({ ...c, lateInteractionScore: score, originalScore: c.score });
1407
+ const pushFallback = (c, extra) => scored.push({ ...c, lateInteractionScore: c.score || 0, originalScore: c.score, ...extra });
1408
+
1409
+ const useFlatPath = !maxDocTokens;
1410
+ const queryFlat = this._flattenQueryTokens(effectiveQueryTokens, scoringDim);
1411
+
1412
+ // Tier 1: Native batch scoring (rayon parallel + SIMD)
1413
+ // Skip native/WASM tiers when useTokenWeights is active — those kernels
1414
+ // don't support importance weighting, so we must use the JS-tier weighted path.
1415
+ const nativeScored = new Set();
1416
+
1417
+ if (useFlatPath && !this.useTokenWeights) {
1418
+ const groups = { bit4: [], perToken: [], perDoc: [] };
1419
+ for (const candidate of toScore) {
1420
+ const doc = this.documents.get(candidate.id);
1421
+ if (!doc) continue;
1422
+ if (doc.quantBits === 4 && doc.minArray && doc.tokenNorms) {
1423
+ groups.bit4.push({ candidate, doc });
1424
+ } else if (doc.minArray && doc.tokenNorms) {
1425
+ groups.perToken.push({ candidate, doc });
1426
+ } else if (doc.min !== undefined) {
1427
+ groups.perDoc.push({ candidate, doc });
1428
+ }
1429
+ }
1430
+
1431
+ const tokenBuf = doc => Buffer.from(doc.tokens.buffer, doc.tokens.byteOffset, doc.tokens.byteLength);
1432
+ const perTokenCand = doc => ({ tokens: tokenBuf(doc), numTokens: doc.numTokens, dim: doc.dim, minArray: doc.minArray, scaleArray: doc.scaleArray, tokenNorms: doc.tokenNorms });
1433
+
1434
+ // Score each group with its native kernel, collect results
1435
+ const batchScore = (group, buildCand, scoreFn) => {
1436
+ if (group.length === 0 || !scoreFn) return;
1437
+ const nativeCands = group.map(g => buildCand(g.doc));
1438
+ const scores = scoreFn(queryFlat, effectiveQueryTokens.length, scoringDim, nativeCands);
1439
+ if (scores) {
1440
+ for (let i = 0; i < group.length; i++) {
1441
+ pushScored(group[i].candidate, scores[i]);
1442
+ nativeScored.add(group[i].candidate.id);
1443
+ }
1444
+ }
1445
+ };
1446
+
1447
+ batchScore(groups.bit4, perTokenCand, isNative4BitAvailable() ? nativeMaxSimBatch4Bit : null);
1448
+ batchScore(groups.perToken, perTokenCand, isNativePerTokenAvailable() ? nativeMaxSimBatchPerToken : null);
1449
+ batchScore(groups.perDoc, doc => ({ tokens: tokenBuf(doc), numTokens: doc.numTokens, dim: doc.dim, min: doc.min, scale: doc.scale }), isNativeMaxSimAvailable() ? nativeMaxSimBatch : null);
1450
+ }
1451
+
1452
+ // Tier 2 & 3: WASM fused dequant or JS fallback for candidates not scored natively.
1453
+ // Try WASM fused kernels first (avoids JS-side dequant), fall back to JS dequant + wasmMaxSimF32.
1454
+ for (const candidate of toScore) {
1455
+ if (nativeScored.has(candidate.id)) continue;
1456
+ const doc = this.documents.get(candidate.id);
1457
+ if (!doc) { pushFallback(candidate); continue; }
1458
+
1459
+ if (useFlatPath) {
1460
+ // Try WASM fused kernels only when token weighting is OFF (WASM doesn't support weights)
1461
+ if (!this.useTokenWeights) {
1462
+ // Try WASM fused 4-bit kernel (no JS dequant needed)
1463
+ if (doc.quantBits === 4 && doc.minArray && doc.tokenNorms) {
1464
+ const wasmScore = wasmMaxSimDequant4Bit(
1465
+ queryFlat, doc.tokens, doc.minArray, doc.scaleArray, doc.tokenNorms,
1466
+ effectiveQueryTokens.length, doc.numTokens, doc.dim,
1467
+ );
1468
+ if (wasmScore !== null) { pushScored(candidate, wasmScore); continue; }
1469
+ }
1470
+
1471
+ // Try WASM fused per-token int8 kernel (no JS dequant needed)
1472
+ if (doc.minArray && doc.tokenNorms && doc.quantBits !== 4) {
1473
+ const wasmScore = wasmMaxSimDequantPerToken(
1474
+ queryFlat, doc.tokens, doc.minArray, doc.scaleArray, doc.tokenNorms,
1475
+ effectiveQueryTokens.length, doc.numTokens, doc.dim,
1476
+ );
1477
+ if (wasmScore !== null) { pushScored(candidate, wasmScore); continue; }
1478
+ }
1479
+ }
1480
+
1481
+ // CRA-5: JS implicit decompression — score directly from packed nibbles
1482
+ // using a per-query-token LUT (16 entries), avoiding f32 allocation.
1483
+ if (doc.quantBits === 4 && doc.minArray && doc.tokenNorms) {
1484
+ pushScored(candidate, this._maxSimScore4BitImplicit(
1485
+ effectiveQueryTokens, doc, queryFlat,
1486
+ ));
1487
+ continue;
1488
+ }
1489
+
1490
+ // JS dequant → WASM f32 or JS fallback
1491
+ const flatData = this.getTokensFlat(candidate.id);
1492
+ if (flatData) {
1493
+ pushScored(candidate, this.maxSimScoreFlat(
1494
+ effectiveQueryTokens, flatData.flat, flatData.numTokens, flatData.dim,
1495
+ doc?.tokenNorms, queryFlat, doc?.preNorms,
1496
+ ));
1497
+ } else {
1498
+ pushFallback(candidate);
1499
+ }
1500
+ } else {
1501
+ const docTokens = this.getTokens(candidate.id);
1502
+ if (docTokens) {
1503
+ pushScored(candidate, this.maxSimScore(effectiveQueryTokens, docTokens, pruneOpts));
1504
+ } else {
1505
+ pushFallback(candidate);
1506
+ }
1507
+ }
1508
+ }
1509
+
1510
+ for (const candidate of pruned) pushFallback(candidate, { _pruned: true });
1511
+
1512
+ scored.sort((a, b) => b.lateInteractionScore - a.lateInteractionScore);
1513
+ return scored;
1514
+ }
1515
+
1516
+ /**
1517
+ * Save index to disk.
1518
+ *
1519
+ * Streams documents one-by-one to avoid V8's ~512MB string length limit
1520
+ * that JSON.stringify() hits on large indexes (>10K docs).
1521
+ */
1522
+ async save() {
1523
+ await fs.mkdir(path.dirname(this.indexPath), { recursive: true });
1524
+
1525
+ // Use segmented format when the doc count exceeds one segment.
1526
+ // Always rewrite ALL segments from this.documents (the authoritative
1527
+ // state) — never reuse stale segment files from a previous load,
1528
+ // because documents may have been removed since then.
1529
+ const useSegmented = this.documents.size >= this._segmentSize;
1530
+
1531
+ if (useSegmented) {
1532
+ if (!this._loadedExisting) {
1533
+ if (this._currentSegment.size > 0) {
1534
+ await this._flushSegment();
1535
+ }
1536
+
1537
+ const flushedCount = this._segments.reduce((sum, segment) => sum + segment.count, 0);
1538
+ if (flushedCount === this.documents.size && this._segments.length > 0) {
1539
+ // Staging-aware segment directory. _segmentDir was pre-seeded by
1540
+ // resetForSave() when staging; otherwise derive from indexPath.
1541
+ const segDir = this._segmentDir || (this.indexPath + '.segments');
1542
+ const manifest = {
1543
+ version: '3.0',
1544
+ format: 'sslx-v3',
1545
+ modelId: this.modelId,
1546
+ tokenDim: this.tokenDim,
1547
+ matryoshkaDim: this.matryoshkaDim || 0,
1548
+ maxTokens: this.maxTokens,
1549
+ useInt8: this.useInt8,
1550
+ quantBits: this.quantBits,
1551
+ poolFactor: this.poolFactor,
1552
+ whtSeed: this.whtSeed || 0,
1553
+ whtOrdering: this.whtOrdering,
1554
+ totalDocuments: this.documents.size,
1555
+ segments: this._segments.map((segment) => ({
1556
+ path: path.basename(segment.path),
1557
+ count: segment.count,
1558
+ })),
1559
+ };
1560
+
1561
+ await fs.writeFile(path.join(segDir, 'manifest.json'), JSON.stringify(manifest, null, 2));
1562
+ if (this._wushCalibration) {
1563
+ const cal = {
1564
+ eigenVecs: Array.from(this._wushCalibration.eigenVecs),
1565
+ invSqrtEigenVals: Array.from(this._wushCalibration.invSqrtEigenVals),
1566
+ dim: this.tokenDim,
1567
+ };
1568
+ await fs.writeFile(path.join(segDir, 'wush-calibration.json'), JSON.stringify(cal));
1569
+ }
1570
+ // Stub stores segmentDir as a basename resolved relative to the
1571
+ // stub's dirname on load. When staging, record the basename derived
1572
+ // from _finalIndexPath so that after atomic promotion the stub
1573
+ // points at {finalIndexPath}.segments without a rewrite.
1574
+ const stubSegmentDirBasename = this._finalIndexPath
1575
+ ? path.basename(this._finalIndexPath) + '.segments'
1576
+ : path.basename(segDir);
1577
+ await fs.writeFile(this.indexPath, JSON.stringify({
1578
+ version: '3.0',
1579
+ format: 'segmented',
1580
+ segmentDir: stubSegmentDirBasename,
1581
+ }));
1582
+ this._segmentDir = segDir;
1583
+ this._currentSegment = new Map();
1584
+ await this._saveAliasSidecar();
1585
+ console.log(`LateInteraction: Saved ${this.documents.size} documents across ${this._segments.length} segments`);
1586
+ return;
1587
+ }
1588
+ }
1589
+
1590
+ // Staging-aware segment directory (see resetForSave comment)
1591
+ const segDir = this._segmentDir || (this.indexPath + '.segments');
1592
+ await fs.mkdir(segDir, { recursive: true });
1593
+
1594
+ // Remove any old segment files in this directory
1595
+ try {
1596
+ const existing = await fs.readdir(segDir);
1597
+ for (const f of existing) {
1598
+ await fs.unlink(path.join(segDir, f));
1599
+ }
1600
+ } catch (_err) { /* dir may not exist yet */ }
1601
+
1602
+ // Write fresh segments from this.documents
1603
+ const newSegments = [];
1604
+ let batch = new Map();
1605
+ let segIdx = 0;
1606
+
1607
+ for (const [id, doc] of this.documents) {
1608
+ batch.set(id, doc);
1609
+ if (batch.size >= this._segmentSize) {
1610
+ const segPath = path.join(segDir, `segment-${String(segIdx).padStart(4, '0')}.bin`);
1611
+ await this._writeSegmentFile(segPath, batch);
1612
+ newSegments.push({ path: path.basename(segPath), count: batch.size });
1613
+ batch = new Map();
1614
+ segIdx++;
1615
+ }
1616
+ }
1617
+ // Flush remainder
1618
+ if (batch.size > 0) {
1619
+ const segPath = path.join(segDir, `segment-${String(segIdx).padStart(4, '0')}.bin`);
1620
+ await this._writeSegmentFile(segPath, batch);
1621
+ newSegments.push({ path: path.basename(segPath), count: batch.size });
1622
+ }
1623
+
1624
+ const manifest = {
1625
+ version: '3.0',
1626
+ format: 'sslx-v3',
1627
+ modelId: this.modelId,
1628
+ tokenDim: this.tokenDim,
1629
+ matryoshkaDim: this.matryoshkaDim || 0,
1630
+ maxTokens: this.maxTokens,
1631
+ useInt8: this.useInt8,
1632
+ quantBits: this.quantBits,
1633
+ poolFactor: this.poolFactor,
1634
+ whtSeed: this.whtSeed || 0,
1635
+ whtOrdering: this.whtOrdering,
1636
+ totalDocuments: this.documents.size,
1637
+ segments: newSegments,
1638
+ };
1639
+
1640
+ await fs.writeFile(path.join(segDir, 'manifest.json'), JSON.stringify(manifest, null, 2));
1641
+
1642
+ // CRA-3: Persist WUSH calibration alongside segments
1643
+ if (this._wushCalibration) {
1644
+ const cal = {
1645
+ eigenVecs: Array.from(this._wushCalibration.eigenVecs),
1646
+ invSqrtEigenVals: Array.from(this._wushCalibration.invSqrtEigenVals),
1647
+ dim: this.tokenDim,
1648
+ };
1649
+ await fs.writeFile(path.join(segDir, 'wush-calibration.json'), JSON.stringify(cal));
1650
+ }
1651
+
1652
+ // Write a stub at the main index path pointing to segments.
1653
+ // segmentDir is stored as a basename relative to the stub's dirname so
1654
+ // atomic stage-and-swap semantics work: when staging to {live}.tmp with
1655
+ // segments at {live}.tmp-stage.segments, the stub records the POST-swap
1656
+ // basename ({basename(finalIndexPath)}.segments) so no stub rewrite is
1657
+ // needed when the caller atomically promotes both the stub and the
1658
+ // segments directory.
1659
+ const stubSegmentDirBasename = this._finalIndexPath
1660
+ ? path.basename(this._finalIndexPath) + '.segments'
1661
+ : path.basename(segDir);
1662
+ await fs.writeFile(this.indexPath, JSON.stringify({
1663
+ version: '3.0',
1664
+ format: 'segmented',
1665
+ segmentDir: stubSegmentDirBasename,
1666
+ }));
1667
+
1668
+ // Update internal state to reflect fresh segments
1669
+ this._segmentDir = segDir;
1670
+ this._segments = newSegments.map(s => ({ path: path.join(segDir, s.path), count: s.count }));
1671
+ this._currentSegment = new Map();
1672
+
1673
+ await this._saveAliasSidecar();
1674
+ console.log(`LateInteraction: Saved ${this.documents.size} documents across ${newSegments.length} segments`);
1675
+ return;
1676
+ }
1677
+
1678
+ // Legacy single-file save (for small indexes or when no add() was called)
1679
+ const header = {
1680
+ version: '2.1',
1681
+ modelId: this.modelId,
1682
+ tokenDim: this.tokenDim,
1683
+ matryoshkaDim: this.matryoshkaDim || 0,
1684
+ maxTokens: this.maxTokens,
1685
+ useInt8: this.useInt8,
1686
+ quantBits: this.quantBits,
1687
+ poolFactor: this.poolFactor,
1688
+ whtSeed: this.whtSeed || 0,
1689
+ whtOrdering: this.whtOrdering,
1690
+ };
1691
+
1692
+ let bytesWritten = 0;
1693
+
1694
+ await new Promise((resolve, reject) => {
1695
+ const ws = createWriteStream(this.indexPath, { encoding: 'utf-8' });
1696
+ ws.on('error', reject);
1697
+
1698
+ // Write header + opening array bracket
1699
+ const headerStr = JSON.stringify(header).slice(0, -1) + ',"documents":[';
1700
+ ws.write(headerStr);
1701
+ bytesWritten += headerStr.length;
1702
+
1703
+ let first = true;
1704
+ for (const [id, doc] of this.documents) {
1705
+ const obj = {
1706
+ id,
1707
+ tokens: Array.from(doc.tokens),
1708
+ numTokens: doc.numTokens,
1709
+ dim: doc.dim,
1710
+ metadata: doc.metadata,
1711
+ };
1712
+ if (doc.quantBits === 4) obj.quantBits = 4;
1713
+ if (doc.tokenNorms) obj.tokenNorms = Array.from(doc.tokenNorms);
1714
+ if (doc.preNorms) obj.preNorms = Array.from(doc.preNorms);
1715
+ // Per-token quant (Phase 1/4) vs per-doc quant (legacy)
1716
+ if (doc.minArray) {
1717
+ obj.minArray = Array.from(doc.minArray);
1718
+ obj.scaleArray = Array.from(doc.scaleArray);
1719
+ } else {
1720
+ obj.min = doc.min;
1721
+ obj.scale = doc.scale;
1722
+ }
1723
+ const entry = JSON.stringify(obj);
1724
+
1725
+ const chunk = first ? entry : ',' + entry;
1726
+ first = false;
1727
+ ws.write(chunk);
1728
+ bytesWritten += chunk.length;
1729
+ }
1730
+
1731
+ ws.end(']}', () => {
1732
+ bytesWritten += 2;
1733
+ resolve();
1734
+ });
1735
+ });
1736
+
1737
+ // CRA-3: Persist WUSH calibration alongside legacy single-file index
1738
+ if (this._wushCalibration) {
1739
+ const cal = {
1740
+ eigenVecs: Array.from(this._wushCalibration.eigenVecs),
1741
+ invSqrtEigenVals: Array.from(this._wushCalibration.invSqrtEigenVals),
1742
+ dim: this.tokenDim,
1743
+ };
1744
+ const wushPath = this.indexPath + '.wush-calibration.json';
1745
+ await fs.writeFile(wushPath, JSON.stringify(cal));
1746
+ }
1747
+
1748
+ await this._saveAliasSidecar();
1749
+
1750
+ const sizeMB = (bytesWritten / 1024 / 1024).toFixed(2);
1751
+ console.log(`LateInteraction: Saved ${this.documents.size} documents (${sizeMB} MB)`);
1752
+ }
1753
+
1754
+ /**
1755
+ * Load index from disk.
1756
+ *
1757
+ * Uses streaming parse to avoid V8's ~512MB string length limit on large
1758
+ * indexes. Reads the file as a stream, extracts the header, then parses
1759
+ * each document object individually.
1760
+ */
1761
+ async load() {
1762
+ try {
1763
+ const stat = await fs.stat(this.indexPath);
1764
+
1765
+ // Check if this is a segmented index (v3.0)
1766
+ let state;
1767
+ if (stat.size < 1024) {
1768
+ // Small file — likely a segment stub
1769
+ const data = await fs.readFile(this.indexPath, 'utf-8');
1770
+ state = JSON.parse(data);
1771
+ if (state.format === 'segmented') {
1772
+ // segmentDir may be stored as:
1773
+ // - basename (new format, stage-and-swap safe): "foo.db.segments"
1774
+ // - absolute path (legacy or pre-fix-state): "/abs/.../foo.db.segments"
1775
+ // - absolute path with .tmp suffix (pre-fix broken state):
1776
+ // "/abs/.../foo.db.tmp.segments" — self-heal by migrating the
1777
+ // orphaned directory to the canonical name and rewriting the stub.
1778
+ let segDirAbs;
1779
+ if (path.isAbsolute(state.segmentDir)) {
1780
+ segDirAbs = state.segmentDir;
1781
+ } else {
1782
+ segDirAbs = path.join(path.dirname(this.indexPath), state.segmentDir);
1783
+ }
1784
+
1785
+ // Self-heal: the pre-fix staged-save bug wrote an absolute
1786
+ // segmentDir with the `.tmp.segments` suffix into the promoted
1787
+ // stub. Detect that pattern and migrate the directory to the
1788
+ // canonical {indexPath}.segments name so the stub can be rewritten
1789
+ // as a stable basename. Race-safe: two concurrent processes may
1790
+ // both reach this branch; fs.rename atomicity ensures only one
1791
+ // succeeds, and we tolerate the loser's ENOENT / EEXIST.
1792
+ const canonicalSegDir = this.indexPath + '.segments';
1793
+ const maybeMigrate = async (from, to) => {
1794
+ try {
1795
+ await fs.rename(from, to);
1796
+ return true;
1797
+ } catch (err) {
1798
+ // ENOENT: another process already migrated it (source gone).
1799
+ // EEXIST / ENOTEMPTY: destination raced us (target populated).
1800
+ // EPERM: atomic rename may fall through on some platforms;
1801
+ // treat as a best-effort miss and let downstream decide.
1802
+ if (err && (err.code === 'ENOENT' || err.code === 'EEXIST'
1803
+ || err.code === 'ENOTEMPTY' || err.code === 'EPERM')) {
1804
+ return false;
1805
+ }
1806
+ throw err;
1807
+ }
1808
+ };
1809
+
1810
+ // Atomic stub rewrite via .tmp + rename, so a crash mid-heal cannot
1811
+ // leave a truncated stub file.
1812
+ const writeStubAtomic = async (stubContent) => {
1813
+ const stubTmp = this.indexPath + '.selfheal.tmp';
1814
+ await fs.writeFile(stubTmp, JSON.stringify(stubContent));
1815
+ try {
1816
+ await fs.rename(stubTmp, this.indexPath);
1817
+ } catch (err) {
1818
+ try { await fs.unlink(stubTmp); } catch (_e) { /* best effort */ }
1819
+ throw err;
1820
+ }
1821
+ };
1822
+
1823
+ const looksLikeBrokenTmpState = segDirAbs !== canonicalSegDir
1824
+ && segDirAbs.endsWith('.tmp.segments')
1825
+ && path.dirname(segDirAbs) === path.dirname(canonicalSegDir);
1826
+ if (looksLikeBrokenTmpState) {
1827
+ if (existsSync(segDirAbs) && !existsSync(canonicalSegDir)) {
1828
+ console.warn(`[LateInteraction] Self-heal: migrating orphaned ${path.basename(segDirAbs)} to ${path.basename(canonicalSegDir)}`);
1829
+ await maybeMigrate(segDirAbs, canonicalSegDir);
1830
+ }
1831
+ // Only rewrite the stub if the canonical directory actually
1832
+ // exists after the migration attempt. Otherwise we'd point
1833
+ // a newly-rewritten stub at nothing and load would fail.
1834
+ if (existsSync(canonicalSegDir)) {
1835
+ await writeStubAtomic({
1836
+ version: '3.0',
1837
+ format: 'segmented',
1838
+ segmentDir: path.basename(canonicalSegDir),
1839
+ });
1840
+ segDirAbs = canonicalSegDir;
1841
+ }
1842
+ }
1843
+
1844
+ // Second self-heal path: stub points at a non-existent segments dir
1845
+ // but an orphaned `.tmp.segments` exists nearby — migrate in place.
1846
+ if (!existsSync(segDirAbs)) {
1847
+ const tmpSegDir = this.indexPath + '.tmp.segments';
1848
+ if (existsSync(tmpSegDir) && !existsSync(canonicalSegDir)) {
1849
+ console.warn(`[LateInteraction] Self-heal: migrating orphaned ${path.basename(tmpSegDir)} to ${path.basename(canonicalSegDir)}`);
1850
+ await maybeMigrate(tmpSegDir, canonicalSegDir);
1851
+ if (existsSync(canonicalSegDir)) {
1852
+ await writeStubAtomic({
1853
+ version: '3.0',
1854
+ format: 'segmented',
1855
+ segmentDir: path.basename(canonicalSegDir),
1856
+ });
1857
+ segDirAbs = canonicalSegDir;
1858
+ }
1859
+ }
1860
+ }
1861
+ await this._loadSegmented(segDirAbs);
1862
+ await this._loadAliasSidecar();
1863
+ return;
1864
+ }
1865
+ }
1866
+
1867
+ const useStreaming = stat.size > 256 * 1024 * 1024; // >256MB → stream
1868
+
1869
+ if (!state) {
1870
+ if (useStreaming) {
1871
+ state = await this._loadStreaming();
1872
+ } else {
1873
+ const data = await fs.readFile(this.indexPath, 'utf-8');
1874
+ state = JSON.parse(data);
1875
+ }
1876
+ }
1877
+
1878
+ // Model consistency check (v2.0+)
1879
+ if (state.modelId && this.modelId && state.modelId !== this.modelId) {
1880
+ const indexDim = state.tokenDim;
1881
+ const configDim = this.tokenDim;
1882
+ console.warn(`[LateInteraction] Index built with ${state.modelId} (${indexDim}d) but config says ${this.modelId} (${configDim}d).`);
1883
+ console.warn(` Skipping late interaction scoring. Re-index to use the new model.`);
1884
+ this.modelMismatch = true;
1885
+ return;
1886
+ }
1887
+
1888
+ this.tokenDim = state.tokenDim;
1889
+ this.matryoshkaDim = state.matryoshkaDim || 0;
1890
+ this.maxTokens = state.maxTokens;
1891
+ this.useInt8 = state.useInt8;
1892
+ this.quantBits = state.quantBits || (state.useInt8 ? 8 : 32);
1893
+ this.poolFactor = state.poolFactor || 1;
1894
+ if (state.whtSeed !== undefined) this.whtSeed = state.whtSeed;
1895
+ this.whtOrdering = state.whtOrdering || 'natural';
1896
+ if (state.modelId) this.modelId = state.modelId;
1897
+
1898
+ // Documents may already be loaded by streaming path
1899
+ if (state.documents && !this._streamLoaded) {
1900
+ this.documents.clear();
1901
+ for (const doc of state.documents) {
1902
+ // 4-bit docs store packed nibbles as unsigned bytes (0-255);
1903
+ // int8 docs store signed values (-128..127); float32 uses f32.
1904
+ const is4bit = doc.quantBits === 4;
1905
+ const tokens = is4bit
1906
+ ? new Uint8Array(doc.tokens)
1907
+ : (this.useInt8 ? new Int8Array(doc.tokens) : new Float32Array(doc.tokens));
1908
+
1909
+ const entry = {
1910
+ tokens,
1911
+ numTokens: doc.numTokens,
1912
+ dim: doc.dim,
1913
+ metadata: doc.metadata,
1914
+ };
1915
+ if (is4bit) entry.quantBits = 4;
1916
+ if (doc.tokenNorms) entry.tokenNorms = new Float32Array(doc.tokenNorms);
1917
+ if (doc.preNorms) entry.preNorms = new Float32Array(doc.preNorms);
1918
+
1919
+ // Per-token quant (Phase 1/4) vs per-doc quant (legacy)
1920
+ if (doc.minArray) {
1921
+ entry.minArray = new Float32Array(doc.minArray);
1922
+ entry.scaleArray = new Float32Array(doc.scaleArray);
1923
+ } else {
1924
+ entry.min = doc.min;
1925
+ entry.scale = doc.scale;
1926
+ }
1927
+
1928
+ this.documents.set(doc.id, entry);
1929
+ }
1930
+ }
1931
+ this._streamLoaded = false;
1932
+ // Detect per-token quant from first doc — O(1) vs scanning all docs
1933
+ for (const doc of this.documents.values()) {
1934
+ this._hasPerTokenQuant = !!doc.minArray;
1935
+ break;
1936
+ }
1937
+
1938
+ // Rebuild norms only for docs that don't have persisted norms
1939
+ this._rebuildTokenNorms();
1940
+
1941
+ // CRA-3: Load WUSH calibration for legacy single-file indexes
1942
+ const wushPath = this.indexPath + '.wush-calibration.json';
1943
+ if (existsSync(wushPath)) {
1944
+ try {
1945
+ const cal = JSON.parse(await fs.readFile(wushPath, 'utf-8'));
1946
+ this._wushCalibration = {
1947
+ eigenVecs: new Float64Array(cal.eigenVecs),
1948
+ invSqrtEigenVals: new Float64Array(cal.invSqrtEigenVals),
1949
+ };
1950
+ this.wushCalibrate = true;
1951
+ } catch { /* calibration missing or corrupt — fall back to bare WHT */ }
1952
+ }
1953
+
1954
+ await this._loadAliasSidecar();
1955
+
1956
+ console.log(`LateInteraction: Loaded ${this.documents.size} documents (model: ${this.modelId || 'legacy'}, ${this.tokenDim}d)${this.aliasPointers.size > 0 ? `, ${this.aliasPointers.size} aliases` : ''}`);
1957
+ } catch (err) {
1958
+ if (err.code === 'ENOENT') {
1959
+ console.log('LateInteraction: No existing index found');
1960
+ } else {
1961
+ console.error(`LateInteraction: Failed to load index: ${err.message}`);
1962
+ }
1963
+ }
1964
+ }
1965
+
1966
+ /**
1967
+ * Load segmented index (Option 3: flatten all segments into this.documents).
1968
+ * All segment data is loaded into memory at search init — same search semantics
1969
+ * as the legacy single-file format. Memory savings come from indexing time only.
1970
+ */
1971
+ async _loadSegmented(segmentDir) {
1972
+ const manifestPath = path.join(segmentDir, 'manifest.json');
1973
+ const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf-8'));
1974
+
1975
+ // Model consistency check
1976
+ if (manifest.modelId && this.modelId && manifest.modelId !== this.modelId) {
1977
+ console.warn(`[LateInteraction] Segmented index built with ${manifest.modelId} but config says ${this.modelId}.`);
1978
+ console.warn(` Skipping late interaction scoring. Re-index to use the new model.`);
1979
+ this.modelMismatch = true;
1980
+ return;
1981
+ }
1982
+
1983
+ this.tokenDim = manifest.tokenDim;
1984
+ this.matryoshkaDim = manifest.matryoshkaDim || 0;
1985
+ this.maxTokens = manifest.maxTokens;
1986
+ this.useInt8 = manifest.useInt8;
1987
+ this.quantBits = manifest.quantBits || (manifest.useInt8 ? 8 : 32);
1988
+ this.poolFactor = manifest.poolFactor || 1;
1989
+ if (manifest.whtSeed !== undefined) this.whtSeed = manifest.whtSeed;
1990
+ this.whtOrdering = manifest.whtOrdering || 'natural';
1991
+ if (manifest.modelId) this.modelId = manifest.modelId;
1992
+
1993
+ this.documents.clear();
1994
+
1995
+ const isSSLX = manifest.format === 'sslx-v3';
1996
+
1997
+ for (const seg of manifest.segments) {
1998
+ const segPath = path.join(segmentDir, seg.path);
1999
+ const docs = await this._readSegmentFile(segPath);
2000
+
2001
+ for (const doc of docs) {
2002
+ // SSLX reader returns typed arrays directly; legacy LISE returns plain arrays
2003
+ const tokens = (doc.tokens instanceof Int8Array || doc.tokens instanceof Float32Array || doc.tokens instanceof Uint8Array)
2004
+ ? doc.tokens
2005
+ : (this.useInt8 ? new Int8Array(doc.tokens) : new Float32Array(doc.tokens));
2006
+
2007
+ const entry = {
2008
+ tokens,
2009
+ numTokens: doc.numTokens,
2010
+ dim: doc.dim,
2011
+ metadata: doc.metadata,
2012
+ };
2013
+ if (doc.quantBits) entry.quantBits = doc.quantBits;
2014
+
2015
+ // Per-token quant (Phase 1/4) vs per-doc quant (legacy)
2016
+ if (doc.minArray) {
2017
+ entry.minArray = doc.minArray;
2018
+ entry.scaleArray = doc.scaleArray;
2019
+ } else {
2020
+ entry.min = doc.min;
2021
+ entry.scale = doc.scale;
2022
+ }
2023
+
2024
+ // SSLX segments include persisted tokenNorms — skip expensive rebuild
2025
+ if (doc.tokenNorms) entry.tokenNorms = doc.tokenNorms;
2026
+ // CRA-6: Restore pre-normalization norms for token importance weighting
2027
+ if (doc.preNorms) entry.preNorms = doc.preNorms;
2028
+
2029
+ this.documents.set(doc.id, entry);
2030
+ }
2031
+ }
2032
+
2033
+ for (const doc of this.documents.values()) {
2034
+ this._hasPerTokenQuant = !!doc.minArray;
2035
+ break;
2036
+ }
2037
+ this._rebuildTokenNorms();
2038
+ this._segmentDir = segmentDir;
2039
+ this._segments = manifest.segments.map(s => ({ path: path.join(segmentDir, s.path), count: s.count }));
2040
+
2041
+ // CRA-3: Load WUSH calibration if it exists alongside segments
2042
+ const wushPath = path.join(segmentDir, 'wush-calibration.json');
2043
+ if (existsSync(wushPath)) {
2044
+ try {
2045
+ const cal = JSON.parse(await fs.readFile(wushPath, 'utf-8'));
2046
+ this._wushCalibration = {
2047
+ eigenVecs: new Float64Array(cal.eigenVecs),
2048
+ invSqrtEigenVals: new Float64Array(cal.invSqrtEigenVals),
2049
+ };
2050
+ this.wushCalibrate = true;
2051
+ } catch { /* calibration missing or corrupt — fall back to bare WHT */ }
2052
+ }
2053
+
2054
+ console.log(`LateInteraction: Loaded ${this.documents.size} documents from ${manifest.segments.length} segments (model: ${this.modelId || 'legacy'}, ${this.tokenDim}d)`);
2055
+ }
2056
+
2057
+ /**
2058
+ * Stream-parse a large LI index file.
2059
+ *
2060
+ * Strategy: read the file as a Buffer (no V8 string limit), find the
2061
+ * `"documents":[` marker, then extract each document JSON object by
2062
+ * tracking brace depth. Each document is parsed individually (small
2063
+ * string, well under limits).
2064
+ *
2065
+ * @returns {Object} Header fields (version, modelId, tokenDim, etc.) — documents are loaded directly into this.documents
2066
+ */
2067
+ async _loadStreaming() {
2068
+ const marker = Buffer.from('"documents":[');
2069
+ const stream = createReadStream(this.indexPath, { highWaterMark: this.streamChunkSize });
2070
+
2071
+ let header = null;
2072
+ let headerParts = [];
2073
+ let pending = Buffer.alloc(0);
2074
+ let docStart = -1;
2075
+ let depth = 0;
2076
+ let inString = false;
2077
+ let escapeNext = false;
2078
+ let docCount = 0;
2079
+
2080
+ this.documents.clear();
2081
+
2082
+ const parseDocument = (docBuf) => {
2083
+ const doc = JSON.parse(docBuf.toString('utf-8'));
2084
+ const is4bit = doc.quantBits === 4;
2085
+ const tokens = is4bit
2086
+ ? new Uint8Array(doc.tokens)
2087
+ : (header.useInt8 ? new Int8Array(doc.tokens) : new Float32Array(doc.tokens));
2088
+
2089
+ const entry = {
2090
+ tokens,
2091
+ numTokens: doc.numTokens,
2092
+ dim: doc.dim,
2093
+ metadata: doc.metadata,
2094
+ };
2095
+ if (is4bit) entry.quantBits = 4;
2096
+ if (doc.tokenNorms) entry.tokenNorms = new Float32Array(doc.tokenNorms);
2097
+ if (doc.preNorms) entry.preNorms = new Float32Array(doc.preNorms);
2098
+
2099
+ if (doc.minArray) {
2100
+ entry.minArray = new Float32Array(doc.minArray);
2101
+ entry.scaleArray = new Float32Array(doc.scaleArray);
2102
+ } else {
2103
+ entry.min = doc.min;
2104
+ entry.scale = doc.scale;
2105
+ }
2106
+
2107
+ this.documents.set(doc.id, entry);
2108
+
2109
+ docCount++;
2110
+ if (docCount % 5000 === 0) {
2111
+ console.log(`LateInteraction: Streaming load ${docCount} documents...`);
2112
+ }
2113
+ };
2114
+
2115
+ for await (const chunk of stream) {
2116
+ pending = pending.length > 0 ? Buffer.concat([pending, chunk]) : chunk;
2117
+
2118
+ if (!header) {
2119
+ const markerIdx = pending.indexOf(marker);
2120
+ if (markerIdx === -1) {
2121
+ const keep = Math.min(marker.length - 1, pending.length);
2122
+ const flushLen = pending.length - keep;
2123
+ if (flushLen > 0) {
2124
+ headerParts.push(pending.subarray(0, flushLen));
2125
+ pending = pending.subarray(flushLen);
2126
+ }
2127
+ continue;
2128
+ }
2129
+
2130
+ headerParts.push(pending.subarray(0, markerIdx));
2131
+ const headerStr = Buffer.concat(headerParts).toString('utf-8') + '"_":0}';
2132
+ header = JSON.parse(headerStr);
2133
+ headerParts = [];
2134
+ pending = pending.subarray(markerIdx + marker.length);
2135
+ }
2136
+
2137
+ let i = 0;
2138
+ while (i < pending.length) {
2139
+ const byte = pending[i];
2140
+
2141
+ if (docStart === -1) {
2142
+ if (byte === 0x20 || byte === 0x0A || byte === 0x0D || byte === 0x09 || byte === 0x2C) {
2143
+ i++;
2144
+ continue;
2145
+ }
2146
+ if (byte === 0x5D) {
2147
+ pending = Buffer.alloc(0);
2148
+ i = pending.length;
2149
+ break;
2150
+ }
2151
+ if (byte !== 0x7B) {
2152
+ i++;
2153
+ continue;
2154
+ }
2155
+
2156
+ docStart = i;
2157
+ depth = 1;
2158
+ inString = false;
2159
+ escapeNext = false;
2160
+ i++;
2161
+ continue;
2162
+ }
2163
+
2164
+ if (inString) {
2165
+ if (escapeNext) {
2166
+ escapeNext = false;
2167
+ } else if (byte === 0x5C) {
2168
+ escapeNext = true;
2169
+ } else if (byte === 0x22) {
2170
+ inString = false;
2171
+ }
2172
+ i++;
2173
+ continue;
2174
+ }
2175
+
2176
+ if (byte === 0x22) {
2177
+ inString = true;
2178
+ i++;
2179
+ continue;
2180
+ }
2181
+ if (byte === 0x7B) {
2182
+ depth++;
2183
+ i++;
2184
+ continue;
2185
+ }
2186
+ if (byte === 0x7D) {
2187
+ depth--;
2188
+ i++;
2189
+ if (depth === 0) {
2190
+ parseDocument(pending.subarray(docStart, i));
2191
+ pending = pending.subarray(i);
2192
+ i = 0;
2193
+ docStart = -1;
2194
+ }
2195
+ continue;
2196
+ }
2197
+
2198
+ i++;
2199
+ }
2200
+
2201
+ if (docStart >= 0) {
2202
+ // Carry over incomplete document: slice from docStart so the opening
2203
+ // '{' is at position 0. Reset parser state so the re-scan from the
2204
+ // opening brace correctly re-establishes depth/string tracking.
2205
+ pending = pending.subarray(docStart);
2206
+ docStart = -1;
2207
+ depth = 0;
2208
+ inString = false;
2209
+ escapeNext = false;
2210
+ } else if (docStart === -1 && pending.length > 0) {
2211
+ pending = Buffer.alloc(0);
2212
+ }
2213
+ }
2214
+
2215
+ if (!header) {
2216
+ throw new Error('Invalid LI index format: no documents array');
2217
+ }
2218
+ if (pending.length > 0) {
2219
+ throw new Error('Invalid LI index format: truncated document payload');
2220
+ }
2221
+
2222
+ this._streamLoaded = true;
2223
+ return header;
2224
+ }
2225
+
2226
+ /**
2227
+ * Reconstruct per-token L2 norms for all loaded documents.
2228
+ * Called after load() since tokenNorms are not persisted in the index file.
2229
+ * @private
2230
+ */
2231
+ _rebuildTokenNorms() {
2232
+ for (const [, doc] of this.documents) {
2233
+ if (doc.tokenNorms) continue; // already has norms (e.g., from add() or SSLX)
2234
+
2235
+ let flat;
2236
+ if (doc.quantBits === 4 && doc.minArray) {
2237
+ // Phase 4: dequantize 4-bit nibble-packed data before computing norms
2238
+ flat = dequantizeFromInt4PerToken(doc.tokens, doc.minArray, doc.scaleArray, doc.numTokens, doc.dim);
2239
+ } else if (this.useInt8 && doc.minArray) {
2240
+ flat = this._dequantPerToken(doc);
2241
+ } else if (this.useInt8 && doc.min !== undefined) {
2242
+ flat = dequantizeFromInt8(doc.tokens, doc.min, doc.scale);
2243
+ } else {
2244
+ flat = doc.tokens;
2245
+ }
2246
+
2247
+ const norms = new Float32Array(doc.numTokens);
2248
+ for (let t = 0; t < doc.numTokens; t++) {
2249
+ const offset = t * doc.dim;
2250
+ let normSq = 0;
2251
+ for (let d = 0; d < doc.dim; d++) {
2252
+ normSq += flat[offset + d] * flat[offset + d];
2253
+ }
2254
+ norms[t] = Math.sqrt(normSq);
2255
+ }
2256
+ doc.tokenNorms = norms;
2257
+ }
2258
+ }
2259
+
2260
+ /**
2261
+ * Check which chunk IDs have pre-indexed token vectors.
2262
+ * Synchronous O(n) Map lookup — no SQL, no async needed.
2263
+ *
2264
+ * @param {Iterable<string>} chunkIds
2265
+ * @returns {Set<string>} IDs that have tokens in the index
2266
+ */
2267
+ hasTokens(chunkIds) {
2268
+ const available = new Set();
2269
+ for (const id of chunkIds) {
2270
+ if (this.documents.has(id)) { available.add(id); continue; }
2271
+ const ptr = this.aliasPointers.get(id);
2272
+ if (ptr && this.documents.has(ptr.exemplarId)) available.add(id);
2273
+ }
2274
+ return available;
2275
+ }
2276
+
2277
+ /**
2278
+ * Get index statistics
2279
+ */
2280
+ getStats() {
2281
+ let totalTokens = 0;
2282
+ for (const doc of this.documents.values()) {
2283
+ totalTokens += doc.numTokens;
2284
+ }
2285
+
2286
+ const avgTokens = this.documents.size > 0 ?
2287
+ (totalTokens / this.documents.size).toFixed(1) : 0;
2288
+
2289
+ let bytesPerToken;
2290
+ if (this.quantBits === 4) {
2291
+ bytesPerToken = Math.ceil(this.tokenDim / 2) + 12;
2292
+ } else if (this.useInt8) {
2293
+ bytesPerToken = this.tokenDim + (this._hasPerTokenQuant ? 12 : 4);
2294
+ } else {
2295
+ bytesPerToken = this.tokenDim * 4 + 4;
2296
+ }
2297
+ const estimatedMB = (totalTokens * bytesPerToken / 1024 / 1024).toFixed(2);
2298
+
2299
+ return {
2300
+ documents: this.documents.size,
2301
+ totalTokens,
2302
+ avgTokensPerDoc: avgTokens,
2303
+ tokenDim: this.tokenDim,
2304
+ useInt8: this.useInt8,
2305
+ quantBits: this.quantBits,
2306
+ estimatedSizeMB: estimatedMB,
2307
+ modelId: this.modelId,
2308
+ poolFactor: this.poolFactor,
2309
+ };
2310
+ }
2311
+ }
2312
+
2313
+ // CLI interface
2314
+ if (import.meta.url === `file://${process.argv[1]}`) {
2315
+ const args = process.argv.slice(2);
2316
+
2317
+ if (args.includes('--test')) {
2318
+ console.log('Testing Late Interaction Index...\n');
2319
+
2320
+ const index = new LateInteractionIndex();
2321
+ await index.init();
2322
+
2323
+ // Add test documents with fake token embeddings
2324
+ const doc1Tokens = Array(50).fill(null).map(() =>
2325
+ Array(64).fill(0).map(() => Math.random())
2326
+ );
2327
+ const doc2Tokens = Array(30).fill(null).map(() =>
2328
+ Array(64).fill(0).map(() => Math.random())
2329
+ );
2330
+
2331
+ await index.add('doc1', doc1Tokens, { file: 'test1.js' });
2332
+ await index.add('doc2', doc2Tokens, { file: 'test2.js' });
2333
+
2334
+ console.log('Stats:', index.getStats());
2335
+
2336
+ // Test MaxSim scoring
2337
+ const queryTokens = Array(5).fill(null).map(() =>
2338
+ Array(64).fill(0).map(() => Math.random())
2339
+ );
2340
+
2341
+ const candidates = [{ id: 'doc1', score: 0.5 }, { id: 'doc2', score: 0.6 }];
2342
+ const scored = await index.scoreWithLateInteraction(queryTokens, candidates);
2343
+
2344
+ console.log('\nMaxSim Scores:');
2345
+ for (const s of scored) {
2346
+ console.log(` ${s.id}: ${s.lateInteractionScore.toFixed(4)}`);
2347
+ }
2348
+
2349
+ // Save and reload
2350
+ await index.save();
2351
+ console.log('\nSaved and reloading...');
2352
+
2353
+ const index2 = new LateInteractionIndex();
2354
+ await index2.init();
2355
+ console.log('Reloaded stats:', index2.getStats());
2356
+
2357
+ } else if (args.includes('--stats')) {
2358
+ const index = new LateInteractionIndex();
2359
+ await index.init();
2360
+ console.log('Late Interaction Index Stats:', index.getStats());
2361
+
2362
+ } else {
2363
+ console.log(`
2364
+ Late Interaction Index
2365
+
2366
+ Usage:
2367
+ node late-interaction-index.js --test Run test with fake data
2368
+ node late-interaction-index.js --stats Show index statistics
2369
+
2370
+ Storage Optimization:
2371
+ - 64-dim tokens (vs 128): 50% smaller, 1.5% accuracy loss
2372
+ - int8 quantization: 4x compression
2373
+ - Expected: ~200-700MB for 11k chunks (not 25GB!)
2374
+
2375
+ How it works:
2376
+ 1. Store token-level embeddings at indexing time
2377
+ 2. At query time, compute MaxSim between query & doc tokens
2378
+ 3. MaxSim approximates cross-encoder quality at bi-encoder speed
2379
+ `);
2380
+ }
2381
+ }
2382
+
2383
+ export default LateInteractionIndex;