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.
- package/LICENSE +190 -0
- package/NOTICE +23 -0
- package/core/cli.js +51 -0
- package/core/config.js +27 -0
- package/core/embedding/embedding-cache.js +467 -0
- package/core/embedding/embedding-local-model.js +845 -0
- package/core/embedding/embedding-remote.js +492 -0
- package/core/embedding/embedding-service.js +712 -0
- package/core/embedding/embedding-telemetry.js +219 -0
- package/core/embedding/index.js +40 -0
- package/core/graph/community-detector.js +294 -0
- package/core/graph/graph-expansion.js +839 -0
- package/core/graph/graph-extractor.js +2304 -0
- package/core/graph/graph-search.js +2148 -0
- package/core/graph/hcgs-generator.js +666 -0
- package/core/graph/index.js +16 -0
- package/core/graph/leiden-algorithm.js +547 -0
- package/core/graph/relationship-resolver.js +366 -0
- package/core/graph/repo-map.js +408 -0
- package/core/graph/summary-manager.js +549 -0
- package/core/indexing/artifact-builder.js +1054 -0
- package/core/indexing/ast-chunker.js +709 -0
- package/core/indexing/chunking/chunk-builder.js +170 -0
- package/core/indexing/chunking/markdown-chunker.js +503 -0
- package/core/indexing/chunking/plaintext-chunker.js +104 -0
- package/core/indexing/dedup/dedup-phase.js +159 -0
- package/core/indexing/dedup/exemplar-selector.js +65 -0
- package/core/indexing/document-chunker.js +56 -0
- package/core/indexing/incremental-parser.js +390 -0
- package/core/indexing/incremental-tracker.js +761 -0
- package/core/indexing/index-codebase-v21.js +472 -0
- package/core/indexing/index-maintainer.mjs +1674 -0
- package/core/indexing/index.js +90 -0
- package/core/indexing/indexer-ann.js +1077 -0
- package/core/indexing/indexer-build.js +742 -0
- package/core/indexing/indexer-phases.js +800 -0
- package/core/indexing/indexer-pool.js +764 -0
- package/core/indexing/indexer-sparse-gram.js +98 -0
- package/core/indexing/indexer-utils.js +536 -0
- package/core/indexing/indexer-worker.js +148 -0
- package/core/indexing/li-skip-policy.js +225 -0
- package/core/indexing/merkle-tracker.js +244 -0
- package/core/indexing/model-pool.js +166 -0
- package/core/infrastructure/code-graph-repository.js +120 -0
- package/core/infrastructure/codebase-repository.js +131 -0
- package/core/infrastructure/config/dedup.js +54 -0
- package/core/infrastructure/config/embedding.js +298 -0
- package/core/infrastructure/config/graph.js +80 -0
- package/core/infrastructure/config/index.js +82 -0
- package/core/infrastructure/config/indexing.js +8 -0
- package/core/infrastructure/config/platform.js +254 -0
- package/core/infrastructure/config/ranking.js +221 -0
- package/core/infrastructure/config/search.js +396 -0
- package/core/infrastructure/config/translation.js +89 -0
- package/core/infrastructure/config/vector-store.js +114 -0
- package/core/infrastructure/constants.js +86 -0
- package/core/infrastructure/coreml-cascade.js +909 -0
- package/core/infrastructure/coreml-cascade.json +46 -0
- package/core/infrastructure/coreml-provider.js +81 -0
- package/core/infrastructure/db-utils.js +69 -0
- package/core/infrastructure/dedup-hashing.js +83 -0
- package/core/infrastructure/hardware-capability.js +332 -0
- package/core/infrastructure/index.js +104 -0
- package/core/infrastructure/language-patterns/maps.js +121 -0
- package/core/infrastructure/language-patterns/registry-core.js +323 -0
- package/core/infrastructure/language-patterns/registry-data-query.js +155 -0
- package/core/infrastructure/language-patterns/registry-object-oriented.js +285 -0
- package/core/infrastructure/language-patterns/registry-tooling.js +240 -0
- package/core/infrastructure/language-patterns/registry-web-style.js +143 -0
- package/core/infrastructure/language-patterns/registry.js +19 -0
- package/core/infrastructure/language-patterns.js +141 -0
- package/core/infrastructure/llm-provider.js +733 -0
- package/core/infrastructure/manifest.json +46 -0
- package/core/infrastructure/maxsim.wasm +0 -0
- package/core/infrastructure/model-fetcher.js +423 -0
- package/core/infrastructure/model-registry.js +214 -0
- package/core/infrastructure/native-inference.js +587 -0
- package/core/infrastructure/native-resolver.js +187 -0
- package/core/infrastructure/native-sparse-gram.js +257 -0
- package/core/infrastructure/native-tokenizer.js +160 -0
- package/core/infrastructure/onnx-mutex.js +45 -0
- package/core/infrastructure/onnx-session-utils.js +261 -0
- package/core/infrastructure/ort-pipeline.js +111 -0
- package/core/infrastructure/project-detector.js +102 -0
- package/core/infrastructure/quantization.js +410 -0
- package/core/infrastructure/simd-distance.js +502 -0
- package/core/infrastructure/simd-distance.wasm +0 -0
- package/core/infrastructure/tree-sitter-provider.js +665 -0
- package/core/infrastructure/webgpu-maxsim.js +222 -0
- package/core/query/index.js +35 -0
- package/core/query/intent-detector.js +201 -0
- package/core/query/intent-router.js +156 -0
- package/core/query/query-router-catboost.js +222 -0
- package/core/query/query-router-ml.js +266 -0
- package/core/query/query-router.js +213 -0
- package/core/ranking/cascaded-scorer.js +379 -0
- package/core/ranking/flashrank.js +810 -0
- package/core/ranking/index.js +49 -0
- package/core/ranking/late-interaction-index.js +2383 -0
- package/core/ranking/late-interaction-model.js +812 -0
- package/core/ranking/local-reranker.js +374 -0
- package/core/ranking/mmr.js +379 -0
- package/core/ranking/quality-scorer.js +363 -0
- package/core/search/context-expander.js +1167 -0
- package/core/search/dedup/sibling-expander.js +327 -0
- package/core/search/index.js +16 -0
- package/core/search/search-boost.js +259 -0
- package/core/search/search-cli.js +544 -0
- package/core/search/search-format.js +282 -0
- package/core/search/search-fusion.js +327 -0
- package/core/search/search-hybrid.js +204 -0
- package/core/search/search-pattern-chunks.js +337 -0
- package/core/search/search-pattern-planner.js +439 -0
- package/core/search/search-pattern-prefilter.js +412 -0
- package/core/search/search-pattern-ripgrep.js +663 -0
- package/core/search/search-pattern.js +463 -0
- package/core/search/search-postprocess.js +452 -0
- package/core/search/search-semantic.js +706 -0
- package/core/search/search-server.js +554 -0
- package/core/search/session-daemon-prewarm.mjs +164 -0
- package/core/search/session-warmup.js +595 -0
- package/core/search/sweet-search.js +632 -0
- package/core/search/warmup-metrics.js +532 -0
- package/core/start-server.js +6 -0
- package/core/training/query-router/features/extractor.js +762 -0
- package/core/training/query-router/features/multilingual-patterns.js +431 -0
- package/core/training/query-router/features/text-segmenter.js +303 -0
- package/core/training/query-router/features/unicode-utils.js +383 -0
- package/core/training/query-router/output/v45_router_d4.js +11521 -0
- package/core/training/query-router/output/v46_router_d4.js +11498 -0
- package/core/vector-store/binary-heap.js +227 -0
- package/core/vector-store/binary-hnsw-index.js +1004 -0
- package/core/vector-store/float-vector-store.js +234 -0
- package/core/vector-store/hnsw-index.js +580 -0
- package/core/vector-store/index.js +39 -0
- package/core/vector-store/seismic-index.js +498 -0
- package/core/vocabulary/index.js +84 -0
- package/core/vocabulary/vocab-constants.js +20 -0
- package/core/vocabulary/vocab-miner-extractors.js +375 -0
- package/core/vocabulary/vocab-miner-nl.js +404 -0
- package/core/vocabulary/vocab-miner-utils.js +146 -0
- package/core/vocabulary/vocab-miner.js +574 -0
- package/core/vocabulary/vocab-prewarm-cli.js +110 -0
- package/core/vocabulary/vocab-ranker.js +492 -0
- package/core/vocabulary/vocab-warmer.js +523 -0
- package/core/vocabulary/vocab-warmup-orchestrator.js +425 -0
- package/core/vocabulary/vocabulary-utils.js +704 -0
- package/crates/wasm-router/pkg/package.json +13 -0
- package/crates/wasm-router/pkg/query_router_wasm.d.ts +36 -0
- package/crates/wasm-router/pkg/query_router_wasm.js +271 -0
- package/crates/wasm-router/pkg/query_router_wasm_bg.wasm +0 -0
- package/crates/wasm-router/pkg/query_router_wasm_bg.wasm.d.ts +19 -0
- package/mcp/config-gen.js +121 -0
- package/mcp/server.js +335 -0
- package/mcp/tool-handlers.js +476 -0
- package/package.json +131 -9
- package/scripts/benchmark-harness.js +794 -0
- package/scripts/init.js +1058 -0
- package/scripts/smoke-test.js +435 -0
- package/scripts/uninstall.js +478 -0
- 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;
|