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,1004 @@
|
|
|
1
|
+
|
|
2
|
+
/**
|
|
3
|
+
* Binary HNSW Index (P0: 32x memory reduction, 10x faster search)
|
|
4
|
+
*
|
|
5
|
+
* Specialized HNSW index for binary vectors using Hamming distance.
|
|
6
|
+
* Part of the 3-stage retrieval pipeline:
|
|
7
|
+
* Stage 1: Binary HNSW (1000 candidates, ~100μs)
|
|
8
|
+
* Stage 2: Int8 rescore (100 candidates, ~1ms)
|
|
9
|
+
* Stage 3: Rerank (top 20 → k)
|
|
10
|
+
*
|
|
11
|
+
* CANONICAL INT8 STORAGE (Workstream H resolution):
|
|
12
|
+
* Int8 vectors for stage-2 rescoring are stored in this index's .int8.json sidecar.
|
|
13
|
+
* This file is saved/loaded alongside the binary HNSW index artifacts.
|
|
14
|
+
* - save(): Writes .int8.json with { id: Int8Array[], ... } format
|
|
15
|
+
* - load(): Populates this.int8Vectors Map from .int8.json
|
|
16
|
+
* - getInt8Vector(id): O(1) lookup used by sweet-search.js during stage-2
|
|
17
|
+
*
|
|
18
|
+
* This is the ONLY source of int8 vectors. The SQLite approach (codebase-int8.db)
|
|
19
|
+
* was removed as redundant - it was created but never used by search.
|
|
20
|
+
*
|
|
21
|
+
* Reference: https://huggingface.co/blog/embedding-quantization
|
|
22
|
+
*
|
|
23
|
+
* Performance:
|
|
24
|
+
* - Memory: 32x smaller than float HNSW (512d → 64 bytes)
|
|
25
|
+
* - Search: ~10x faster (Hamming distance via SIMD-friendly popcount)
|
|
26
|
+
* - Throughput: ~10,000 queries/sec on 100k vectors
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import fs from 'fs/promises';
|
|
30
|
+
import { existsSync } from 'fs';
|
|
31
|
+
import path from 'path';
|
|
32
|
+
import { BINARY_HNSW_CONFIG, DB_PATHS } from '../infrastructure/config/index.js';
|
|
33
|
+
import {
|
|
34
|
+
floatToBinary,
|
|
35
|
+
asymmetricDocEncode, asymmetricQueryEncode,
|
|
36
|
+
computeCentroid, generateSignVector,
|
|
37
|
+
} from '../infrastructure/quantization.js';
|
|
38
|
+
import { wasmHammingDistance as hammingDistance } from '../infrastructure/simd-distance.js';
|
|
39
|
+
import { TypedMinHeap, TypedMaxHeap, VisitedList } from './binary-heap.js';
|
|
40
|
+
|
|
41
|
+
// Current quantization pipeline version. Bump when the encoding pipeline changes
|
|
42
|
+
// (centroid subtraction, rotation, quantization scheme). Indexes built with a
|
|
43
|
+
// different version are incompatible and must be rebuilt.
|
|
44
|
+
const PIPELINE_VERSION = 2;
|
|
45
|
+
|
|
46
|
+
// =============================================================================
|
|
47
|
+
// BINARY HNSW INDEX CLASS
|
|
48
|
+
// =============================================================================
|
|
49
|
+
|
|
50
|
+
export class BinaryHNSWIndex {
|
|
51
|
+
constructor(options = {}) {
|
|
52
|
+
// Binary dimension (bytes, not bits)
|
|
53
|
+
this.dimension = options.dimension || BINARY_HNSW_CONFIG.dimension;
|
|
54
|
+
this.floatDimension = options.floatDimension || BINARY_HNSW_CONFIG.floatDimension;
|
|
55
|
+
|
|
56
|
+
// HNSW parameters (more aggressive since Hamming is cheap)
|
|
57
|
+
this.M = options.M || BINARY_HNSW_CONFIG.M;
|
|
58
|
+
this.efConstruction = options.efConstruction || BINARY_HNSW_CONFIG.efConstruction;
|
|
59
|
+
this.efSearch = options.efSearch || BINARY_HNSW_CONFIG.efSearch;
|
|
60
|
+
this.maxElements = options.maxElements || BINARY_HNSW_CONFIG.maxElements;
|
|
61
|
+
|
|
62
|
+
this.indexPath = options.indexPath || DB_PATHS.binaryHnswIndex;
|
|
63
|
+
|
|
64
|
+
// Storage
|
|
65
|
+
this.vectors = []; // Array of { id, binary: Uint8Array, metadata }
|
|
66
|
+
this.idToIndex = new Map(); // id → array index
|
|
67
|
+
this.initialized = false;
|
|
68
|
+
|
|
69
|
+
// For int8 rescoring
|
|
70
|
+
this.int8Vectors = new Map(); // id → Int8Array
|
|
71
|
+
|
|
72
|
+
// Graph structure (simplified HNSW for pure JS)
|
|
73
|
+
this.graph = []; // Array of neighbor lists per level
|
|
74
|
+
this.entryPoint = -1;
|
|
75
|
+
this.maxLevel = 0;
|
|
76
|
+
|
|
77
|
+
// Pre-allocated visited list (generation-stamped, reused across searches)
|
|
78
|
+
this._visitedList = new VisitedList(this.maxElements);
|
|
79
|
+
|
|
80
|
+
// Asymmetric binary quantization calibration data
|
|
81
|
+
this.centroid = null; // Float32Array — dataset centroid
|
|
82
|
+
this.signVector = null; // Float32Array — random ±1 for WHT rotation
|
|
83
|
+
this.useAsymmetric = false; // Enabled after calibration
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Reset to empty state for a fresh build (skips loading from disk). */
|
|
87
|
+
resetForBuild() {
|
|
88
|
+
this.vectors = [];
|
|
89
|
+
this.idToIndex = new Map();
|
|
90
|
+
this.graph = [];
|
|
91
|
+
this.entryPoint = -1;
|
|
92
|
+
this.maxLevel = 0;
|
|
93
|
+
this.centroid = null;
|
|
94
|
+
this.signVector = null;
|
|
95
|
+
this.useAsymmetric = false;
|
|
96
|
+
this.initialized = true;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Initialize the index
|
|
101
|
+
*/
|
|
102
|
+
async init() {
|
|
103
|
+
if (this.initialized) return;
|
|
104
|
+
|
|
105
|
+
// Try to load existing index
|
|
106
|
+
const metaPath = this.indexPath.replace('.idx', '.meta.json');
|
|
107
|
+
if (existsSync(metaPath)) {
|
|
108
|
+
try {
|
|
109
|
+
await this.load();
|
|
110
|
+
this.initialized = true;
|
|
111
|
+
return;
|
|
112
|
+
} catch (err) {
|
|
113
|
+
console.log(`BinaryHNSW: Failed to load, creating new index: ${err.message}`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
this.vectors = [];
|
|
118
|
+
this.idToIndex = new Map();
|
|
119
|
+
this.graph = [];
|
|
120
|
+
this.entryPoint = -1;
|
|
121
|
+
this.maxLevel = 0;
|
|
122
|
+
this.initialized = true;
|
|
123
|
+
|
|
124
|
+
console.log(`BinaryHNSW: Initialized (${this.dimension} bytes, M=${this.M})`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Calculate random level for new node (exponential distribution)
|
|
129
|
+
*/
|
|
130
|
+
getRandomLevel() {
|
|
131
|
+
const mL = 1 / Math.log(this.M);
|
|
132
|
+
let level = Math.floor(-Math.log(Math.random()) * mL);
|
|
133
|
+
return Math.min(level, 10); // Cap at 10 levels
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Add a vector to the index
|
|
138
|
+
*
|
|
139
|
+
* @param {string} id - Unique identifier
|
|
140
|
+
* @param {Uint8Array|number[]} binaryVector - Binary vector (or float to convert)
|
|
141
|
+
* @param {object} metadata - Optional metadata
|
|
142
|
+
* @param {Int8Array} int8Vector - Optional int8 vector for rescoring
|
|
143
|
+
*/
|
|
144
|
+
async add(id, binaryVector, metadata = {}, int8Vector = null) {
|
|
145
|
+
await this.init();
|
|
146
|
+
|
|
147
|
+
// Convert float to binary if needed
|
|
148
|
+
let binary;
|
|
149
|
+
if (binaryVector instanceof Uint8Array) {
|
|
150
|
+
binary = binaryVector;
|
|
151
|
+
} else if (Array.isArray(binaryVector) && binaryVector.length > this.dimension) {
|
|
152
|
+
// Assume it's a float vector, convert to binary
|
|
153
|
+
binary = floatToBinary(binaryVector.slice(0, this.floatDimension));
|
|
154
|
+
} else {
|
|
155
|
+
binary = new Uint8Array(binaryVector);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Check if already exists
|
|
159
|
+
if (this.idToIndex.has(id)) {
|
|
160
|
+
const idx = this.idToIndex.get(id);
|
|
161
|
+
this.vectors[idx] = { id, binary, metadata };
|
|
162
|
+
if (int8Vector) {
|
|
163
|
+
this.int8Vectors.set(id, int8Vector);
|
|
164
|
+
}
|
|
165
|
+
return idx;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Add new vector
|
|
169
|
+
const idx = this.vectors.length;
|
|
170
|
+
this.vectors.push({ id, binary, metadata });
|
|
171
|
+
this.idToIndex.set(id, idx);
|
|
172
|
+
|
|
173
|
+
if (int8Vector) {
|
|
174
|
+
this.int8Vectors.set(id, int8Vector);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Add to HNSW graph
|
|
178
|
+
const level = this.getRandomLevel();
|
|
179
|
+
this.addToGraph(idx, level);
|
|
180
|
+
|
|
181
|
+
return idx;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Level-aware max degree: M0=2*M on layer 0, M on higher layers.
|
|
186
|
+
*/
|
|
187
|
+
getMaxM(level) {
|
|
188
|
+
return level === 0 ? this.M * 2 : this.M;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Heuristic neighbor selection (Algorithm 4, Malkov & Yashunin 2016).
|
|
193
|
+
* Selects diverse neighbors that cover different angular directions,
|
|
194
|
+
* avoiding clusters of nearby nodes pointing at each other.
|
|
195
|
+
*/
|
|
196
|
+
selectNeighborsHeuristic(nodeIdx, candidates, maxM) {
|
|
197
|
+
const selected = [];
|
|
198
|
+
const nodeBinary = this.vectors[nodeIdx].binary;
|
|
199
|
+
|
|
200
|
+
// Sort candidates by distance ascending
|
|
201
|
+
candidates.sort((a, b) => a.dist - b.dist);
|
|
202
|
+
|
|
203
|
+
for (const candidate of candidates) {
|
|
204
|
+
if (selected.length >= maxM) break;
|
|
205
|
+
|
|
206
|
+
// Check if this candidate is closer to any already-selected neighbor
|
|
207
|
+
// than it is to the node itself — if so, it's redundant (same direction)
|
|
208
|
+
let tooClose = false;
|
|
209
|
+
for (const s of selected) {
|
|
210
|
+
const interDist = hammingDistance(
|
|
211
|
+
this.vectors[candidate.idx].binary,
|
|
212
|
+
this.vectors[s.idx].binary
|
|
213
|
+
);
|
|
214
|
+
if (interDist < candidate.dist) {
|
|
215
|
+
tooClose = true;
|
|
216
|
+
break;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (!tooClose) {
|
|
221
|
+
selected.push(candidate);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Backfill with closest if heuristic was too aggressive
|
|
226
|
+
if (selected.length < maxM) {
|
|
227
|
+
const selectedSet = new Set(selected.map(s => s.idx));
|
|
228
|
+
for (const candidate of candidates) {
|
|
229
|
+
if (selected.length >= maxM) break;
|
|
230
|
+
if (!selectedSet.has(candidate.idx)) {
|
|
231
|
+
selected.push(candidate);
|
|
232
|
+
selectedSet.add(candidate.idx);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return selected;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Add node to HNSW graph.
|
|
242
|
+
* Uses heuristic selection + level-aware M0=2*M.
|
|
243
|
+
*/
|
|
244
|
+
addToGraph(idx, level) {
|
|
245
|
+
// Ensure graph has enough levels
|
|
246
|
+
while (this.graph.length <= level) {
|
|
247
|
+
this.graph.push([]);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Initialize neighbor lists for new node
|
|
251
|
+
for (let l = 0; l <= level; l++) {
|
|
252
|
+
if (!this.graph[l][idx]) {
|
|
253
|
+
this.graph[l][idx] = [];
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// If first node, set as entry point
|
|
258
|
+
if (this.entryPoint === -1) {
|
|
259
|
+
this.entryPoint = idx;
|
|
260
|
+
this.maxLevel = level;
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Find neighbors at each level and connect
|
|
265
|
+
let currentNode = this.entryPoint;
|
|
266
|
+
|
|
267
|
+
// Traverse from top to target level
|
|
268
|
+
for (let l = this.maxLevel; l > level; l--) {
|
|
269
|
+
currentNode = this.greedySearch(currentNode, idx, l);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// At each level from level down to 0, find neighbors and connect
|
|
273
|
+
for (let l = level; l >= 0; l--) {
|
|
274
|
+
const maxM = this.getMaxM(l);
|
|
275
|
+
const neighbors = this.searchLayer(currentNode, idx, this.efConstruction, l);
|
|
276
|
+
|
|
277
|
+
// Heuristic selection (Algorithm 4) for angular diversity
|
|
278
|
+
const selectedNeighbors = this.selectNeighborsHeuristic(idx, neighbors, maxM);
|
|
279
|
+
|
|
280
|
+
// Connect both directions
|
|
281
|
+
this.graph[l][idx] = selectedNeighbors.map(n => n.idx);
|
|
282
|
+
|
|
283
|
+
for (const neighbor of selectedNeighbors) {
|
|
284
|
+
if (!this.graph[l][neighbor.idx]) {
|
|
285
|
+
this.graph[l][neighbor.idx] = [];
|
|
286
|
+
}
|
|
287
|
+
if (!this.graph[l][neighbor.idx].includes(idx)) {
|
|
288
|
+
this.graph[l][neighbor.idx].push(idx);
|
|
289
|
+
|
|
290
|
+
// Prune if too many neighbors (threshold = 2 * maxM for the level)
|
|
291
|
+
if (this.graph[l][neighbor.idx].length > maxM * 2) {
|
|
292
|
+
this.pruneNeighbors(neighbor.idx, l);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (selectedNeighbors.length > 0) {
|
|
298
|
+
currentNode = selectedNeighbors[0].idx;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Update entry point if new level is higher
|
|
303
|
+
if (level > this.maxLevel) {
|
|
304
|
+
this.entryPoint = idx;
|
|
305
|
+
this.maxLevel = level;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Greedy search to find closest node at a given level
|
|
311
|
+
*/
|
|
312
|
+
greedySearch(startNode, targetIdx, level) {
|
|
313
|
+
const targetBinary = this.vectors[targetIdx].binary;
|
|
314
|
+
let currentNode = startNode;
|
|
315
|
+
let currentDist = hammingDistance(this.vectors[currentNode].binary, targetBinary);
|
|
316
|
+
|
|
317
|
+
let improved = true;
|
|
318
|
+
while (improved) {
|
|
319
|
+
improved = false;
|
|
320
|
+
const neighbors = this.graph[level]?.[currentNode] || [];
|
|
321
|
+
|
|
322
|
+
for (const neighborIdx of neighbors) {
|
|
323
|
+
const neighborDist = hammingDistance(this.vectors[neighborIdx].binary, targetBinary);
|
|
324
|
+
if (neighborDist < currentDist) {
|
|
325
|
+
currentNode = neighborIdx;
|
|
326
|
+
currentDist = neighborDist;
|
|
327
|
+
improved = true;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
return currentNode;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Search layer returning ef closest candidates (construction-time).
|
|
337
|
+
* Heap-based with generation-stamped visited list.
|
|
338
|
+
*/
|
|
339
|
+
searchLayer(startNode, targetIdx, ef, level) {
|
|
340
|
+
const targetBinary = this.vectors[targetIdx].binary;
|
|
341
|
+
const visited = this._visitedList;
|
|
342
|
+
visited.ensureCapacity(this.vectors.length);
|
|
343
|
+
visited.reset();
|
|
344
|
+
visited.mark(startNode);
|
|
345
|
+
|
|
346
|
+
const startDist = hammingDistance(this.vectors[startNode].binary, targetBinary);
|
|
347
|
+
|
|
348
|
+
// candidates = min-heap (explore closest first)
|
|
349
|
+
const candidates = new TypedMinHeap(ef * 4);
|
|
350
|
+
candidates.insert(startNode, startDist);
|
|
351
|
+
|
|
352
|
+
// results = max-heap of size ef (peek furthest, replaceMax when closer found)
|
|
353
|
+
const results = new TypedMaxHeap(ef + 1);
|
|
354
|
+
results.insert(startNode, startDist);
|
|
355
|
+
|
|
356
|
+
while (candidates.size > 0) {
|
|
357
|
+
const currentDist = candidates.peekVal();
|
|
358
|
+
|
|
359
|
+
// If closest candidate is further than furthest result, stop
|
|
360
|
+
if (results.size >= ef && currentDist > results.peekVal()) {
|
|
361
|
+
break;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const currentIdx = candidates.extractMin();
|
|
365
|
+
|
|
366
|
+
const neighbors = this.graph[level]?.[currentIdx] || [];
|
|
367
|
+
for (let i = 0; i < neighbors.length; i++) {
|
|
368
|
+
const neighborIdx = neighbors[i];
|
|
369
|
+
if (visited.isVisited(neighborIdx)) continue;
|
|
370
|
+
visited.mark(neighborIdx);
|
|
371
|
+
|
|
372
|
+
const dist = hammingDistance(this.vectors[neighborIdx].binary, targetBinary);
|
|
373
|
+
|
|
374
|
+
if (results.size < ef) {
|
|
375
|
+
candidates.insert(neighborIdx, dist);
|
|
376
|
+
results.insert(neighborIdx, dist);
|
|
377
|
+
} else if (dist < results.peekVal()) {
|
|
378
|
+
candidates.insert(neighborIdx, dist);
|
|
379
|
+
results.replaceMax(neighborIdx, dist);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Drain results heap into sorted array (ascending by distance)
|
|
385
|
+
const sorted = results.drainSorted();
|
|
386
|
+
const out = [];
|
|
387
|
+
for (let i = 0; i < sorted.length; i++) {
|
|
388
|
+
out.push({ idx: sorted.keys[i], dist: sorted.vals[i] });
|
|
389
|
+
}
|
|
390
|
+
return out;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Prune neighbors using heuristic selection.
|
|
395
|
+
* Level-aware: M0=2*M on layer 0, M on higher layers.
|
|
396
|
+
*/
|
|
397
|
+
pruneNeighbors(nodeIdx, level) {
|
|
398
|
+
const neighbors = this.graph[level][nodeIdx];
|
|
399
|
+
const nodeBinary = this.vectors[nodeIdx].binary;
|
|
400
|
+
const maxM = this.getMaxM(level);
|
|
401
|
+
|
|
402
|
+
const withDist = neighbors.map(idx => ({
|
|
403
|
+
idx,
|
|
404
|
+
dist: hammingDistance(this.vectors[idx].binary, nodeBinary),
|
|
405
|
+
}));
|
|
406
|
+
|
|
407
|
+
const selected = this.selectNeighborsHeuristic(nodeIdx, withDist, maxM);
|
|
408
|
+
this.graph[level][nodeIdx] = selected.map(n => n.idx);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Search for k nearest neighbors.
|
|
413
|
+
* Supports asymmetric binary quantization.
|
|
414
|
+
* Fix 7: Adaptive ef based on greedy descent quality.
|
|
415
|
+
*
|
|
416
|
+
* @param {Uint8Array|number[]} queryVector - Binary query vector (or float to convert)
|
|
417
|
+
* @param {number} k - Number of results
|
|
418
|
+
* @param {object} opts - Optional { floatQuery: Float32Array } for asymmetric mode
|
|
419
|
+
* @returns {Promise<{results: Array, latency_us: number}>}
|
|
420
|
+
*/
|
|
421
|
+
async search(queryVector, k = 10, opts = {}) {
|
|
422
|
+
await this.init();
|
|
423
|
+
|
|
424
|
+
const start = performance.now();
|
|
425
|
+
|
|
426
|
+
if (this.vectors.length === 0) {
|
|
427
|
+
return { results: [], latency_us: 0, k, total: 0 };
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// Convert float to binary if needed
|
|
431
|
+
let queryBinary;
|
|
432
|
+
if (queryVector instanceof Uint8Array) {
|
|
433
|
+
queryBinary = queryVector;
|
|
434
|
+
} else if (Array.isArray(queryVector) && queryVector.length > this.dimension) {
|
|
435
|
+
queryBinary = floatToBinary(queryVector.slice(0, this.floatDimension));
|
|
436
|
+
} else {
|
|
437
|
+
queryBinary = new Uint8Array(queryVector);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// When asymmetric mode is active (future: 1024d+ providers), re-encode
|
|
441
|
+
// query binary through center→rotate→sign-bit for Hamming consistency.
|
|
442
|
+
if (this.useAsymmetric && opts.floatQuery) {
|
|
443
|
+
queryBinary = this.encodeDocument(opts.floatQuery);
|
|
444
|
+
}
|
|
445
|
+
let currentNode = this.entryPoint;
|
|
446
|
+
|
|
447
|
+
for (let l = this.maxLevel; l >= 1; l--) {
|
|
448
|
+
currentNode = this.greedySearchQuery(currentNode, queryBinary, l);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// Adaptive ef: easy queries get a smaller budget, hard queries get more
|
|
452
|
+
let ef = Math.max(k, this.efSearch);
|
|
453
|
+
const greedyDist = hammingDistance(this.vectors[currentNode].binary, queryBinary);
|
|
454
|
+
const maxDist = this.dimension * 8;
|
|
455
|
+
const greedyQuality = 1 - (greedyDist / maxDist);
|
|
456
|
+
if (greedyQuality > 0.85) {
|
|
457
|
+
ef = Math.max(k, Math.round(ef * 0.6));
|
|
458
|
+
} else if (greedyQuality < 0.55) {
|
|
459
|
+
ef = Math.round(ef * 1.5);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// Level 0 search — pure Hamming, no asymmetric in the traversal loop
|
|
463
|
+
const searchResult = this.searchLayerQuery(currentNode, queryBinary, ef, 0);
|
|
464
|
+
let candidates = searchResult.candidates;
|
|
465
|
+
|
|
466
|
+
// Return top k
|
|
467
|
+
const results = candidates.slice(0, k).map(c => ({
|
|
468
|
+
id: this.vectors[c.idx].id,
|
|
469
|
+
score: 1 - (c.dist / maxDist),
|
|
470
|
+
hammingDistance: c.dist,
|
|
471
|
+
metadata: this.vectors[c.idx].metadata,
|
|
472
|
+
}));
|
|
473
|
+
|
|
474
|
+
const latency = performance.now() - start;
|
|
475
|
+
|
|
476
|
+
return {
|
|
477
|
+
results,
|
|
478
|
+
latency_us: Math.round(latency * 1000),
|
|
479
|
+
latency_ms: latency.toFixed(3),
|
|
480
|
+
k,
|
|
481
|
+
total: this.vectors.length,
|
|
482
|
+
visitedNodes: searchResult.visitedCount,
|
|
483
|
+
adaptiveEf: ef,
|
|
484
|
+
useAsymmetric: this.useAsymmetric,
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Greedy search for query vector
|
|
490
|
+
*/
|
|
491
|
+
greedySearchQuery(startNode, queryBinary, level) {
|
|
492
|
+
let currentNode = startNode;
|
|
493
|
+
let currentDist = hammingDistance(this.vectors[currentNode].binary, queryBinary);
|
|
494
|
+
|
|
495
|
+
let improved = true;
|
|
496
|
+
while (improved) {
|
|
497
|
+
improved = false;
|
|
498
|
+
const neighbors = this.graph[level]?.[currentNode] || [];
|
|
499
|
+
|
|
500
|
+
for (const neighborIdx of neighbors) {
|
|
501
|
+
const neighborDist = hammingDistance(this.vectors[neighborIdx].binary, queryBinary);
|
|
502
|
+
if (neighborDist < currentDist) {
|
|
503
|
+
currentNode = neighborIdx;
|
|
504
|
+
currentDist = neighborDist;
|
|
505
|
+
improved = true;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
return currentNode;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* Search layer for query vector.
|
|
515
|
+
* Pure Hamming distance — heaps are integer-native, no float packing.
|
|
516
|
+
* Asymmetric rescoring happens in search() after candidates are returned.
|
|
517
|
+
*/
|
|
518
|
+
searchLayerQuery(startNode, queryBinary, ef, level) {
|
|
519
|
+
const visited = this._visitedList;
|
|
520
|
+
visited.ensureCapacity(this.vectors.length);
|
|
521
|
+
visited.reset();
|
|
522
|
+
visited.mark(startNode);
|
|
523
|
+
|
|
524
|
+
const startDist = hammingDistance(this.vectors[startNode].binary, queryBinary);
|
|
525
|
+
const candidates = new TypedMinHeap(ef * 4);
|
|
526
|
+
const results = new TypedMaxHeap(ef + 1);
|
|
527
|
+
candidates.insert(startNode, startDist);
|
|
528
|
+
results.insert(startNode, startDist);
|
|
529
|
+
|
|
530
|
+
const etConfig = BINARY_HNSW_CONFIG.earlyTermination || {};
|
|
531
|
+
const windowSize = etConfig.windowSize || 16;
|
|
532
|
+
const etThresholds = etConfig.thresholds || [[0.3, 0.05], [0.6, 0.10]];
|
|
533
|
+
let visitedCount = 0;
|
|
534
|
+
let recentDiscoveries = 0;
|
|
535
|
+
let recentVisits = 0;
|
|
536
|
+
|
|
537
|
+
while (candidates.size > 0) {
|
|
538
|
+
if (results.size >= ef && candidates.peekVal() > results.peekVal()) break;
|
|
539
|
+
|
|
540
|
+
const currentIdx = candidates.extractMin();
|
|
541
|
+
visitedCount++;
|
|
542
|
+
|
|
543
|
+
const neighbors = this.graph[level]?.[currentIdx] || [];
|
|
544
|
+
let foundNew = false;
|
|
545
|
+
for (let i = 0; i < neighbors.length; i++) {
|
|
546
|
+
const neighborIdx = neighbors[i];
|
|
547
|
+
if (visited.isVisited(neighborIdx)) continue;
|
|
548
|
+
visited.mark(neighborIdx);
|
|
549
|
+
|
|
550
|
+
const dist = hammingDistance(this.vectors[neighborIdx].binary, queryBinary);
|
|
551
|
+
|
|
552
|
+
if (results.size < ef) {
|
|
553
|
+
candidates.insert(neighborIdx, dist);
|
|
554
|
+
results.insert(neighborIdx, dist);
|
|
555
|
+
foundNew = true;
|
|
556
|
+
} else if (dist < results.peekVal()) {
|
|
557
|
+
candidates.insert(neighborIdx, dist);
|
|
558
|
+
results.replaceMax(neighborIdx, dist);
|
|
559
|
+
foundNew = true;
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
recentVisits++;
|
|
564
|
+
if (foundNew) recentDiscoveries++;
|
|
565
|
+
if (recentVisits > windowSize) {
|
|
566
|
+
recentVisits >>= 1;
|
|
567
|
+
recentDiscoveries >>= 1;
|
|
568
|
+
}
|
|
569
|
+
if (recentVisits >= windowSize) {
|
|
570
|
+
const progress = visitedCount / ef;
|
|
571
|
+
const rate = recentDiscoveries / recentVisits;
|
|
572
|
+
if (etThresholds.some(([p, r]) => progress > p && rate < r)) break;
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
const sorted = results.drainSorted();
|
|
577
|
+
const out = new Array(sorted.length);
|
|
578
|
+
for (let i = 0; i < sorted.length; i++) {
|
|
579
|
+
out[i] = { idx: sorted.keys[i], dist: sorted.vals[i] };
|
|
580
|
+
}
|
|
581
|
+
return { candidates: out, visitedCount };
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* Calibrate asymmetric quantization from float embeddings.
|
|
586
|
+
* Must be called once per index build, before adding vectors.
|
|
587
|
+
* Computes centroid and generates sign vector for WHT rotation.
|
|
588
|
+
*/
|
|
589
|
+
calibrateAsymmetric(floatEmbeddings) {
|
|
590
|
+
if (!floatEmbeddings || floatEmbeddings.length === 0) return;
|
|
591
|
+
this.centroid = computeCentroid(floatEmbeddings);
|
|
592
|
+
this.signVector = generateSignVector(this.centroid.length);
|
|
593
|
+
this.useAsymmetric = true;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/**
|
|
597
|
+
* Encode a float embedding using the asymmetric pipeline (center→rotate→binarize).
|
|
598
|
+
* Falls back to simple sign-bit if not calibrated.
|
|
599
|
+
*/
|
|
600
|
+
encodeDocument(floatEmbedding) {
|
|
601
|
+
if (this.useAsymmetric && this.centroid && this.signVector) {
|
|
602
|
+
return asymmetricDocEncode(floatEmbedding, this.centroid, this.signVector);
|
|
603
|
+
}
|
|
604
|
+
return floatToBinary(floatEmbedding);
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* Encode a query using the asymmetric pipeline (center→rotate→int4).
|
|
609
|
+
* Returns { int4, norm } for asymmetric distance, or null if not calibrated.
|
|
610
|
+
*/
|
|
611
|
+
encodeQuery(floatEmbedding) {
|
|
612
|
+
if (this.useAsymmetric && this.centroid && this.signVector) {
|
|
613
|
+
return asymmetricQueryEncode(floatEmbedding, this.centroid, this.signVector);
|
|
614
|
+
}
|
|
615
|
+
return null;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* Batch add vectors
|
|
620
|
+
*/
|
|
621
|
+
async addBatch(items) {
|
|
622
|
+
await this.init();
|
|
623
|
+
|
|
624
|
+
const results = [];
|
|
625
|
+
for (const item of items) {
|
|
626
|
+
const idx = await this.add(item.id, item.binary || item.vector, item.metadata, item.int8);
|
|
627
|
+
results.push(idx);
|
|
628
|
+
}
|
|
629
|
+
return results;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* Get int8 vector for rescoring
|
|
634
|
+
*/
|
|
635
|
+
getInt8Vector(id) {
|
|
636
|
+
return this.int8Vectors.get(id);
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/**
|
|
640
|
+
* Save index to disk
|
|
641
|
+
*/
|
|
642
|
+
async save(indexPath = this.indexPath) {
|
|
643
|
+
await fs.mkdir(path.dirname(indexPath), { recursive: true });
|
|
644
|
+
|
|
645
|
+
// Save metadata
|
|
646
|
+
const meta = {
|
|
647
|
+
dimension: this.dimension,
|
|
648
|
+
floatDimension: this.floatDimension,
|
|
649
|
+
M: this.M,
|
|
650
|
+
efConstruction: this.efConstruction,
|
|
651
|
+
efSearch: this.efSearch,
|
|
652
|
+
maxElements: this.maxElements,
|
|
653
|
+
vectorCount: this.vectors.length,
|
|
654
|
+
maxLevel: this.maxLevel,
|
|
655
|
+
entryPoint: this.entryPoint,
|
|
656
|
+
useAsymmetric: this.useAsymmetric,
|
|
657
|
+
pipelineVersion: PIPELINE_VERSION,
|
|
658
|
+
savedAt: new Date().toISOString(),
|
|
659
|
+
};
|
|
660
|
+
|
|
661
|
+
const metaPath = indexPath.replace('.idx', '.meta.json');
|
|
662
|
+
await fs.writeFile(metaPath, JSON.stringify(meta, null, 2));
|
|
663
|
+
|
|
664
|
+
// Save vectors (binary + metadata)
|
|
665
|
+
const vectorsData = this.vectors.map(v => ({
|
|
666
|
+
id: v.id,
|
|
667
|
+
binary: Array.from(v.binary),
|
|
668
|
+
metadata: v.metadata,
|
|
669
|
+
}));
|
|
670
|
+
|
|
671
|
+
const vectorsPath = indexPath.replace('.idx', '.vectors.json');
|
|
672
|
+
await fs.writeFile(vectorsPath, JSON.stringify(vectorsData));
|
|
673
|
+
|
|
674
|
+
// Save graph structure
|
|
675
|
+
const graphPath = indexPath.replace('.idx', '.graph.json');
|
|
676
|
+
await fs.writeFile(graphPath, JSON.stringify(this.graph));
|
|
677
|
+
|
|
678
|
+
// Save int8 vectors if any
|
|
679
|
+
if (this.int8Vectors.size > 0) {
|
|
680
|
+
const int8Data = {};
|
|
681
|
+
for (const [id, vec] of this.int8Vectors) {
|
|
682
|
+
int8Data[id] = Array.from(vec);
|
|
683
|
+
}
|
|
684
|
+
const int8Path = indexPath.replace('.idx', '.int8.json');
|
|
685
|
+
await fs.writeFile(int8Path, JSON.stringify(int8Data));
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// Save asymmetric calibration data (centroid + rotation signs)
|
|
689
|
+
if (this.useAsymmetric && this.centroid && this.signVector) {
|
|
690
|
+
const calibPath = indexPath.replace('.idx', '.calibration.json');
|
|
691
|
+
await fs.writeFile(calibPath, JSON.stringify({
|
|
692
|
+
centroid: Array.from(this.centroid),
|
|
693
|
+
signVector: Array.from(this.signVector),
|
|
694
|
+
}));
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
console.log(`BinaryHNSW: Saved ${this.vectors.length} vectors to ${indexPath} (asymmetric=${this.useAsymmetric})`);
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/**
|
|
701
|
+
* Load index from disk
|
|
702
|
+
*/
|
|
703
|
+
async load(indexPath = this.indexPath) {
|
|
704
|
+
const metaPath = indexPath.replace('.idx', '.meta.json');
|
|
705
|
+
const vectorsPath = indexPath.replace('.idx', '.vectors.json');
|
|
706
|
+
const graphPath = indexPath.replace('.idx', '.graph.json');
|
|
707
|
+
const int8Path = indexPath.replace('.idx', '.int8.json');
|
|
708
|
+
|
|
709
|
+
if (!existsSync(metaPath)) {
|
|
710
|
+
throw new Error(`Index metadata not found: ${metaPath}`);
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
// Load metadata
|
|
714
|
+
const meta = JSON.parse(await fs.readFile(metaPath, 'utf-8'));
|
|
715
|
+
|
|
716
|
+
// Validate pipeline version — mismatched indexes must be rebuilt
|
|
717
|
+
const storedVersion = meta.pipelineVersion || 1;
|
|
718
|
+
if (storedVersion !== PIPELINE_VERSION) {
|
|
719
|
+
throw new Error(
|
|
720
|
+
`Pipeline version mismatch: index=${storedVersion}, current=${PIPELINE_VERSION}. ` +
|
|
721
|
+
`Index must be rebuilt (quantization pipeline changed).`
|
|
722
|
+
);
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
this.dimension = meta.dimension;
|
|
726
|
+
this.floatDimension = meta.floatDimension;
|
|
727
|
+
this.M = meta.M;
|
|
728
|
+
this.efConstruction = meta.efConstruction;
|
|
729
|
+
this.efSearch = meta.efSearch;
|
|
730
|
+
this.maxLevel = meta.maxLevel;
|
|
731
|
+
this.entryPoint = meta.entryPoint;
|
|
732
|
+
this.useAsymmetric = meta.useAsymmetric || false;
|
|
733
|
+
|
|
734
|
+
// Load vectors
|
|
735
|
+
const vectorsData = JSON.parse(await fs.readFile(vectorsPath, 'utf-8'));
|
|
736
|
+
this.vectors = vectorsData.map(v => ({
|
|
737
|
+
id: v.id,
|
|
738
|
+
binary: new Uint8Array(v.binary),
|
|
739
|
+
metadata: v.metadata,
|
|
740
|
+
}));
|
|
741
|
+
|
|
742
|
+
// Rebuild id map
|
|
743
|
+
this.idToIndex.clear();
|
|
744
|
+
for (let i = 0; i < this.vectors.length; i++) {
|
|
745
|
+
this.idToIndex.set(this.vectors[i].id, i);
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
// Load graph
|
|
749
|
+
this.graph = JSON.parse(await fs.readFile(graphPath, 'utf-8'));
|
|
750
|
+
|
|
751
|
+
// Load int8 vectors if available
|
|
752
|
+
if (existsSync(int8Path)) {
|
|
753
|
+
const int8Data = JSON.parse(await fs.readFile(int8Path, 'utf-8'));
|
|
754
|
+
this.int8Vectors.clear();
|
|
755
|
+
for (const [id, vec] of Object.entries(int8Data)) {
|
|
756
|
+
this.int8Vectors.set(id, new Int8Array(vec));
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
// Load asymmetric calibration data
|
|
761
|
+
const calibPath = indexPath.replace('.idx', '.calibration.json');
|
|
762
|
+
if (this.useAsymmetric && existsSync(calibPath)) {
|
|
763
|
+
const calibData = JSON.parse(await fs.readFile(calibPath, 'utf-8'));
|
|
764
|
+
this.centroid = new Float32Array(calibData.centroid);
|
|
765
|
+
this.signVector = new Float32Array(calibData.signVector);
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
// Resize visited list to actual vector count
|
|
769
|
+
this._visitedList.ensureCapacity(this.vectors.length);
|
|
770
|
+
|
|
771
|
+
this.initialized = true;
|
|
772
|
+
console.log(`BinaryHNSW: Loaded ${this.vectors.length} vectors from ${indexPath} (asymmetric=${this.useAsymmetric})`);
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
/**
|
|
776
|
+
* Get index statistics
|
|
777
|
+
*/
|
|
778
|
+
getStats() {
|
|
779
|
+
const graphNodes = this.graph.reduce((sum, level) => sum + level.filter(n => n).length, 0);
|
|
780
|
+
const graphEdges = this.graph.reduce((sum, level) =>
|
|
781
|
+
sum + level.reduce((s, neighbors) => s + (neighbors?.length || 0), 0), 0);
|
|
782
|
+
|
|
783
|
+
return {
|
|
784
|
+
dimension: this.dimension,
|
|
785
|
+
floatDimension: this.floatDimension,
|
|
786
|
+
totalVectors: this.vectors.length,
|
|
787
|
+
maxElements: this.maxElements,
|
|
788
|
+
M: this.M,
|
|
789
|
+
efConstruction: this.efConstruction,
|
|
790
|
+
efSearch: this.efSearch,
|
|
791
|
+
maxLevel: this.maxLevel,
|
|
792
|
+
graphLevels: this.graph.length,
|
|
793
|
+
graphNodes,
|
|
794
|
+
graphEdges,
|
|
795
|
+
int8VectorCount: this.int8Vectors.size,
|
|
796
|
+
memorySizeBytes: this.vectors.length * this.dimension, // Just binary vectors
|
|
797
|
+
memorySizeMB: (this.vectors.length * this.dimension / 1024 / 1024).toFixed(2),
|
|
798
|
+
};
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
/**
|
|
802
|
+
* Clear all data
|
|
803
|
+
*/
|
|
804
|
+
async clear() {
|
|
805
|
+
this.vectors = [];
|
|
806
|
+
this.idToIndex.clear();
|
|
807
|
+
this.int8Vectors.clear();
|
|
808
|
+
this.graph = [];
|
|
809
|
+
this.entryPoint = -1;
|
|
810
|
+
this.maxLevel = 0;
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
// =============================================================================
|
|
815
|
+
// FACTORY FUNCTION
|
|
816
|
+
// =============================================================================
|
|
817
|
+
|
|
818
|
+
export async function createBinaryHNSWIndex(options = {}) {
|
|
819
|
+
const index = new BinaryHNSWIndex(options);
|
|
820
|
+
|
|
821
|
+
if (options.load !== false && existsSync(options.indexPath || DB_PATHS.binaryHnswIndex)) {
|
|
822
|
+
try {
|
|
823
|
+
await index.load(options.indexPath);
|
|
824
|
+
} catch (err) {
|
|
825
|
+
console.log(`BinaryHNSW: Could not load existing index: ${err.message}`);
|
|
826
|
+
await index.init();
|
|
827
|
+
}
|
|
828
|
+
} else {
|
|
829
|
+
await index.init();
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
return index;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
// =============================================================================
|
|
836
|
+
// CLI
|
|
837
|
+
// =============================================================================
|
|
838
|
+
|
|
839
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
840
|
+
const args = process.argv.slice(2);
|
|
841
|
+
|
|
842
|
+
console.log(`
|
|
843
|
+
Binary HNSW Index CLI
|
|
844
|
+
|
|
845
|
+
Usage:
|
|
846
|
+
binary-hnsw-index.js stats Show index statistics
|
|
847
|
+
binary-hnsw-index.js test Run performance benchmark
|
|
848
|
+
binary-hnsw-index.js compare Compare binary vs float HNSW
|
|
849
|
+
|
|
850
|
+
Options:
|
|
851
|
+
--vectors <n> Number of test vectors (default: 10000)
|
|
852
|
+
--dim <n> Float dimension (default: 512)
|
|
853
|
+
`);
|
|
854
|
+
|
|
855
|
+
const command = args[0];
|
|
856
|
+
|
|
857
|
+
(async () => {
|
|
858
|
+
const index = new BinaryHNSWIndex();
|
|
859
|
+
|
|
860
|
+
try {
|
|
861
|
+
if (command === 'stats') {
|
|
862
|
+
await index.load();
|
|
863
|
+
console.log('\nBinary HNSW Index Statistics:');
|
|
864
|
+
console.log(JSON.stringify(index.getStats(), null, 2));
|
|
865
|
+
|
|
866
|
+
} else if (command === 'test' || command === 'benchmark') {
|
|
867
|
+
console.log('\n=== Binary HNSW Performance Benchmark ===\n');
|
|
868
|
+
|
|
869
|
+
const numVectors = parseInt(args.find((_, i) => args[i - 1] === '--vectors') || '10000', 10);
|
|
870
|
+
const floatDim = parseInt(args.find((_, i) => args[i - 1] === '--dim') || '512', 10);
|
|
871
|
+
const binaryDim = Math.ceil(floatDim / 8);
|
|
872
|
+
|
|
873
|
+
console.log(`Generating ${numVectors} random ${floatDim}-dim vectors...`);
|
|
874
|
+
console.log(`Binary dimension: ${binaryDim} bytes (${floatDim} bits)\n`);
|
|
875
|
+
|
|
876
|
+
// Generate random vectors
|
|
877
|
+
const vectors = [];
|
|
878
|
+
for (let i = 0; i < numVectors; i++) {
|
|
879
|
+
const float = new Array(floatDim).fill(0).map(() => Math.random() * 2 - 1);
|
|
880
|
+
const binary = floatToBinary(float);
|
|
881
|
+
vectors.push({ id: `vec-${i}`, float, binary });
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
// Add to index
|
|
885
|
+
console.log('Adding vectors to index...');
|
|
886
|
+
const addStart = performance.now();
|
|
887
|
+
for (const v of vectors) {
|
|
888
|
+
await index.add(v.id, v.binary, { index: parseInt(v.id.split('-')[1]) });
|
|
889
|
+
}
|
|
890
|
+
const addTime = performance.now() - addStart;
|
|
891
|
+
console.log(`Added ${numVectors} vectors in ${addTime.toFixed(2)}ms (${(numVectors / addTime * 1000).toFixed(0)} vec/s)\n`);
|
|
892
|
+
|
|
893
|
+
// Search benchmark
|
|
894
|
+
const numQueries = 100;
|
|
895
|
+
console.log(`Running ${numQueries} searches...`);
|
|
896
|
+
const latencies = [];
|
|
897
|
+
|
|
898
|
+
for (let i = 0; i < numQueries; i++) {
|
|
899
|
+
const queryFloat = new Array(floatDim).fill(0).map(() => Math.random() * 2 - 1);
|
|
900
|
+
const queryBinary = floatToBinary(queryFloat);
|
|
901
|
+
const result = await index.search(queryBinary, 10);
|
|
902
|
+
latencies.push(result.latency_us);
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
// Stats
|
|
906
|
+
latencies.sort((a, b) => a - b);
|
|
907
|
+
const p50 = latencies[Math.floor(numQueries * 0.5)];
|
|
908
|
+
const p95 = latencies[Math.floor(numQueries * 0.95)];
|
|
909
|
+
const p99 = latencies[Math.floor(numQueries * 0.99)];
|
|
910
|
+
const avg = latencies.reduce((a, b) => a + b, 0) / numQueries;
|
|
911
|
+
|
|
912
|
+
console.log(`\nSearch Latency (μs):`);
|
|
913
|
+
console.log(` p50: ${p50.toFixed(0)}μs`);
|
|
914
|
+
console.log(` p95: ${p95.toFixed(0)}μs`);
|
|
915
|
+
console.log(` p99: ${p99.toFixed(0)}μs`);
|
|
916
|
+
console.log(` avg: ${avg.toFixed(0)}μs`);
|
|
917
|
+
|
|
918
|
+
console.log(`\nMemory usage:`);
|
|
919
|
+
const stats = index.getStats();
|
|
920
|
+
console.log(` Binary vectors: ${stats.memorySizeMB} MB`);
|
|
921
|
+
console.log(` Equivalent float: ${(numVectors * floatDim * 4 / 1024 / 1024).toFixed(2)} MB`);
|
|
922
|
+
console.log(` Compression: ${(floatDim * 4 / binaryDim).toFixed(0)}x`);
|
|
923
|
+
|
|
924
|
+
} else if (command === 'compare') {
|
|
925
|
+
console.log('\n=== Binary vs Float HNSW Comparison ===\n');
|
|
926
|
+
|
|
927
|
+
const { HNSWIndex } = await import('./hnsw-index.js');
|
|
928
|
+
|
|
929
|
+
const numVectors = 5000;
|
|
930
|
+
const floatDim = 512;
|
|
931
|
+
|
|
932
|
+
console.log(`Testing with ${numVectors} vectors, ${floatDim} dimensions\n`);
|
|
933
|
+
|
|
934
|
+
// Generate vectors
|
|
935
|
+
const vectors = [];
|
|
936
|
+
for (let i = 0; i < numVectors; i++) {
|
|
937
|
+
const float = new Array(floatDim).fill(0).map(() => Math.random() * 2 - 1);
|
|
938
|
+
vectors.push({ id: `vec-${i}`, float });
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
// Binary index
|
|
942
|
+
const binaryIndex = new BinaryHNSWIndex({ dimension: Math.ceil(floatDim / 8), floatDimension: floatDim });
|
|
943
|
+
await binaryIndex.init();
|
|
944
|
+
|
|
945
|
+
console.log('Building binary index...');
|
|
946
|
+
let start = performance.now();
|
|
947
|
+
for (const v of vectors) {
|
|
948
|
+
await binaryIndex.add(v.id, floatToBinary(v.float));
|
|
949
|
+
}
|
|
950
|
+
const binaryBuildTime = performance.now() - start;
|
|
951
|
+
|
|
952
|
+
// Float index
|
|
953
|
+
const floatIndex = new HNSWIndex({ dimension: floatDim });
|
|
954
|
+
await floatIndex.init();
|
|
955
|
+
|
|
956
|
+
console.log('Building float index...');
|
|
957
|
+
start = performance.now();
|
|
958
|
+
for (const v of vectors) {
|
|
959
|
+
await floatIndex.add(v.id, v.float);
|
|
960
|
+
}
|
|
961
|
+
const floatBuildTime = performance.now() - start;
|
|
962
|
+
|
|
963
|
+
console.log(`\nBuild time: Binary ${binaryBuildTime.toFixed(0)}ms, Float ${floatBuildTime.toFixed(0)}ms`);
|
|
964
|
+
|
|
965
|
+
// Search comparison
|
|
966
|
+
const numQueries = 50;
|
|
967
|
+
const binaryLatencies = [];
|
|
968
|
+
const floatLatencies = [];
|
|
969
|
+
|
|
970
|
+
for (let i = 0; i < numQueries; i++) {
|
|
971
|
+
const queryFloat = new Array(floatDim).fill(0).map(() => Math.random() * 2 - 1);
|
|
972
|
+
|
|
973
|
+
const binaryResult = await binaryIndex.search(floatToBinary(queryFloat), 10);
|
|
974
|
+
binaryLatencies.push(binaryResult.latency_us);
|
|
975
|
+
|
|
976
|
+
const floatResult = await floatIndex.search(queryFloat, 10);
|
|
977
|
+
floatLatencies.push(floatResult.latency_us);
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
const binaryP50 = binaryLatencies.sort((a, b) => a - b)[Math.floor(numQueries * 0.5)];
|
|
981
|
+
const floatP50 = floatLatencies.sort((a, b) => a - b)[Math.floor(numQueries * 0.5)];
|
|
982
|
+
|
|
983
|
+
console.log(`\nSearch latency p50: Binary ${binaryP50}μs, Float ${floatP50}μs`);
|
|
984
|
+
console.log(`Speedup: ${(floatP50 / binaryP50).toFixed(1)}x`);
|
|
985
|
+
|
|
986
|
+
const binaryMem = numVectors * Math.ceil(floatDim / 8);
|
|
987
|
+
const floatMem = numVectors * floatDim * 4;
|
|
988
|
+
console.log(`\nMemory: Binary ${(binaryMem / 1024).toFixed(0)} KB, Float ${(floatMem / 1024).toFixed(0)} KB`);
|
|
989
|
+
console.log(`Compression: ${(floatMem / binaryMem).toFixed(0)}x`);
|
|
990
|
+
|
|
991
|
+
} else {
|
|
992
|
+
console.log('Unknown command. Use: stats, test, or compare');
|
|
993
|
+
}
|
|
994
|
+
} catch (err) {
|
|
995
|
+
console.error('Error:', err.message);
|
|
996
|
+
if (args.includes('-v') || args.includes('--verbose')) {
|
|
997
|
+
console.error(err.stack);
|
|
998
|
+
}
|
|
999
|
+
process.exit(1);
|
|
1000
|
+
}
|
|
1001
|
+
})();
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
export default BinaryHNSWIndex;
|