@soulcraft/cortex 2.5.0 → 2.6.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/dist/hnsw/NativeDiskAnnWrapper.d.ts +161 -0
- package/dist/hnsw/NativeDiskAnnWrapper.js +329 -0
- package/dist/hnsw/NativeHNSWWrapper.d.ts +30 -0
- package/dist/hnsw/NativeHNSWWrapper.js +37 -0
- package/dist/utils/nativeBinaryEntityIdMapper.d.ts +194 -0
- package/dist/utils/nativeBinaryEntityIdMapper.js +358 -0
- package/docs/ADR-002-diskann-100-percent-rust.md +294 -0
- package/native/brainy-native.node +0 -0
- package/package.json +3 -3
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module hnsw/NativeDiskAnnWrapper
|
|
3
|
+
* @description TypeScript wrapper around cortex's native DiskANN engine
|
|
4
|
+
* that satisfies brainy's `HnswProvider` contract. From brainy's
|
|
5
|
+
* perspective this is interchangeable with `NativeHNSWWrapper` — same
|
|
6
|
+
* `addItem` / `search` / `rebuild` surface — but underneath it drives
|
|
7
|
+
* the billion-scale Vamana + PQ index.
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* ```typescript
|
|
11
|
+
* import { BrainyData } from '@soulcraft/brainy'
|
|
12
|
+
* import { register as registerCortex } from '@soulcraft/cortex'
|
|
13
|
+
*
|
|
14
|
+
* const brain = new BrainyData({
|
|
15
|
+
* storage: { type: 'filesystem', rootDirectory: '/data/idx' }
|
|
16
|
+
* })
|
|
17
|
+
* await registerCortex(brain)
|
|
18
|
+
* await brain.init() // [brainy] DiskANN engaged (path=..., dim=384)
|
|
19
|
+
*
|
|
20
|
+
* await brain.add({ data: 'native rust acceleration', type: 'concept' })
|
|
21
|
+
* const hits = await brain.search('billion scale ann', 10)
|
|
22
|
+
* ```
|
|
23
|
+
*
|
|
24
|
+
* @example
|
|
25
|
+
* ```typescript
|
|
26
|
+
* // Explicit billion-scale build config
|
|
27
|
+
* const brain = new BrainyData({
|
|
28
|
+
* storage: { type: 'filesystem', rootDirectory: '/data/idx' },
|
|
29
|
+
* index: {
|
|
30
|
+
* type: 'diskann',
|
|
31
|
+
* diskann: {
|
|
32
|
+
* pqM: 16,
|
|
33
|
+
* maxDegree: 64,
|
|
34
|
+
* searchListSize: 100,
|
|
35
|
+
* useMmapAdjacency: true, // required >100M nodes
|
|
36
|
+
* mmapAdjacencyPath: '/data/scratch/diskann-build.adj'
|
|
37
|
+
* }
|
|
38
|
+
* }
|
|
39
|
+
* })
|
|
40
|
+
* ```
|
|
41
|
+
*
|
|
42
|
+
* ## Operating model
|
|
43
|
+
*
|
|
44
|
+
* DiskANN is build-once, query-many by design: the on-disk file
|
|
45
|
+
* embeds the Vamana graph, PQ codebook, codes, and full vectors in a
|
|
46
|
+
* single contiguous mmap-able layout. Dynamic insertions go to a
|
|
47
|
+
* small **delta buffer** that brute-force-searches alongside the main
|
|
48
|
+
* index until the next `rebuild()` folds them in. This matches
|
|
49
|
+
* FreshDiskANN's published online-update model.
|
|
50
|
+
*
|
|
51
|
+
* ## Search path
|
|
52
|
+
*
|
|
53
|
+
* 1. Query the main index via the native DiskANN searcher: PQ-greedy
|
|
54
|
+
* walk in RAM, full-vector re-rank on the candidate set.
|
|
55
|
+
* 2. Brute-force the delta buffer (typically <0.1% of total size after
|
|
56
|
+
* a recent rebuild).
|
|
57
|
+
* 3. Merge + sort + truncate to `k`.
|
|
58
|
+
*
|
|
59
|
+
* ## When this wrapper engages
|
|
60
|
+
*
|
|
61
|
+
* Brainy's `wireDiskAnn()` decides at init time whether to instantiate
|
|
62
|
+
* this wrapper or the standard HNSW one. The criteria
|
|
63
|
+
* ([ADR-002](../../docs/ADR-002-diskann-100-percent-rust.md)):
|
|
64
|
+
* - Cortex's `index:diskann` provider is registered (this file).
|
|
65
|
+
* - The storage adapter exposes a local filesystem path
|
|
66
|
+
* (`getBinaryBlobPath` is the canonical check).
|
|
67
|
+
* - The metadata index has a stable `idMapper` (the cortex 2.4.0 #23
|
|
68
|
+
* foundation).
|
|
69
|
+
* - `config.index.type !== 'hnsw'` (opt-out path).
|
|
70
|
+
*/
|
|
71
|
+
import type { Vector, VectorDocument, DistanceFunction, StorageAdapter } from '@soulcraft/brainy';
|
|
72
|
+
import type { HnswProvider } from '../providerContracts.js';
|
|
73
|
+
export interface DiskAnnIndexConfig {
|
|
74
|
+
/** Vector dimension (e.g. 384 for all-MiniLM-L6-v2). */
|
|
75
|
+
dimensions: number;
|
|
76
|
+
/** Output path for the on-disk DiskANN file. */
|
|
77
|
+
indexPath: string;
|
|
78
|
+
/** PQ subspaces. Default 16. dim must be divisible by m. */
|
|
79
|
+
pqM?: number;
|
|
80
|
+
/** Centroids per subspace. Default 256 (8-bit codes). */
|
|
81
|
+
pqKsub?: number;
|
|
82
|
+
/** Vamana max degree (R). Default 64. */
|
|
83
|
+
maxDegree?: number;
|
|
84
|
+
/** Build-time candidate list size (L). Default 100. */
|
|
85
|
+
searchListSize?: number;
|
|
86
|
+
/** α-pruning density factor. Default 1.2. */
|
|
87
|
+
alpha?: number;
|
|
88
|
+
/** Default search-time candidate list size. `2*k` is a good baseline. */
|
|
89
|
+
defaultLSearch?: number;
|
|
90
|
+
/** Default padding factor for re-rank over-fetch. Default 1.2. */
|
|
91
|
+
defaultPaddingFactor?: number;
|
|
92
|
+
/** Use a file-backed adjacency during build. Required >~100M nodes. */
|
|
93
|
+
useMmapAdjacency?: boolean;
|
|
94
|
+
/** Scratch file path when `useMmapAdjacency` is true. */
|
|
95
|
+
mmapAdjacencyPath?: string;
|
|
96
|
+
}
|
|
97
|
+
export declare class NativeDiskAnnWrapper implements HnswProvider {
|
|
98
|
+
private config;
|
|
99
|
+
private distanceFunction;
|
|
100
|
+
private storage;
|
|
101
|
+
private persistMode;
|
|
102
|
+
/** Live searcher instance — null until the first build. */
|
|
103
|
+
private native;
|
|
104
|
+
/** Newly added entries since the last build. Brute-force searched. */
|
|
105
|
+
private delta;
|
|
106
|
+
/** Removed entries — filtered out at search time. */
|
|
107
|
+
private tombstones;
|
|
108
|
+
/** Bidirectional UUID ↔ slot map for the main index. */
|
|
109
|
+
private slotByUuid;
|
|
110
|
+
private uuidBySlot;
|
|
111
|
+
constructor(config: DiskAnnIndexConfig & {
|
|
112
|
+
distanceFunction?: DistanceFunction;
|
|
113
|
+
}, distanceFunction: DistanceFunction, options?: {
|
|
114
|
+
storage?: StorageAdapter | null;
|
|
115
|
+
persistMode?: 'immediate' | 'deferred';
|
|
116
|
+
});
|
|
117
|
+
/**
|
|
118
|
+
* Append an entry to the delta buffer. Persisted by the next
|
|
119
|
+
* `rebuild()` call, which folds the delta into the main index.
|
|
120
|
+
*/
|
|
121
|
+
addItem(item: VectorDocument): Promise<string>;
|
|
122
|
+
/**
|
|
123
|
+
* Mark an entry as removed. Filtered out at search time; physically
|
|
124
|
+
* removed at the next `rebuild()`.
|
|
125
|
+
*/
|
|
126
|
+
removeItem(id: string): Promise<boolean>;
|
|
127
|
+
search(queryVector: Vector, k?: number, filter?: (id: string) => Promise<boolean>, options?: {
|
|
128
|
+
rerank?: {
|
|
129
|
+
multiplier: number;
|
|
130
|
+
};
|
|
131
|
+
candidateIds?: string[];
|
|
132
|
+
}): Promise<Array<[string, number]>>;
|
|
133
|
+
size(): number;
|
|
134
|
+
clear(): void;
|
|
135
|
+
/**
|
|
136
|
+
* Rebuild the main index from scratch: concatenate (current main −
|
|
137
|
+
* tombstones) ∪ delta, run a full DiskANN build, swap the searcher
|
|
138
|
+
* atomically.
|
|
139
|
+
*
|
|
140
|
+
* At billion-scale this is the expensive operation (hours of build
|
|
141
|
+
* time). Operators schedule it during off-peak; the delta buffer
|
|
142
|
+
* absorbs writes in between.
|
|
143
|
+
*/
|
|
144
|
+
rebuild(options?: {
|
|
145
|
+
pqM?: number;
|
|
146
|
+
pqKsub?: number;
|
|
147
|
+
maxDegree?: number;
|
|
148
|
+
searchListSize?: number;
|
|
149
|
+
alpha?: number;
|
|
150
|
+
}): Promise<void>;
|
|
151
|
+
/**
|
|
152
|
+
* Flush the delta buffer to disk. For DiskANN the delta is in-memory
|
|
153
|
+
* by design (a few MB at most between rebuilds); returns the buffer
|
|
154
|
+
* size for parity with HNSW's flush contract.
|
|
155
|
+
*/
|
|
156
|
+
flush(): Promise<number>;
|
|
157
|
+
getPersistMode(): 'immediate' | 'deferred';
|
|
158
|
+
private tryOpenExisting;
|
|
159
|
+
private countMainTombstones;
|
|
160
|
+
}
|
|
161
|
+
//# sourceMappingURL=NativeDiskAnnWrapper.d.ts.map
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module hnsw/NativeDiskAnnWrapper
|
|
3
|
+
* @description TypeScript wrapper around cortex's native DiskANN engine
|
|
4
|
+
* that satisfies brainy's `HnswProvider` contract. From brainy's
|
|
5
|
+
* perspective this is interchangeable with `NativeHNSWWrapper` — same
|
|
6
|
+
* `addItem` / `search` / `rebuild` surface — but underneath it drives
|
|
7
|
+
* the billion-scale Vamana + PQ index.
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* ```typescript
|
|
11
|
+
* import { BrainyData } from '@soulcraft/brainy'
|
|
12
|
+
* import { register as registerCortex } from '@soulcraft/cortex'
|
|
13
|
+
*
|
|
14
|
+
* const brain = new BrainyData({
|
|
15
|
+
* storage: { type: 'filesystem', rootDirectory: '/data/idx' }
|
|
16
|
+
* })
|
|
17
|
+
* await registerCortex(brain)
|
|
18
|
+
* await brain.init() // [brainy] DiskANN engaged (path=..., dim=384)
|
|
19
|
+
*
|
|
20
|
+
* await brain.add({ data: 'native rust acceleration', type: 'concept' })
|
|
21
|
+
* const hits = await brain.search('billion scale ann', 10)
|
|
22
|
+
* ```
|
|
23
|
+
*
|
|
24
|
+
* @example
|
|
25
|
+
* ```typescript
|
|
26
|
+
* // Explicit billion-scale build config
|
|
27
|
+
* const brain = new BrainyData({
|
|
28
|
+
* storage: { type: 'filesystem', rootDirectory: '/data/idx' },
|
|
29
|
+
* index: {
|
|
30
|
+
* type: 'diskann',
|
|
31
|
+
* diskann: {
|
|
32
|
+
* pqM: 16,
|
|
33
|
+
* maxDegree: 64,
|
|
34
|
+
* searchListSize: 100,
|
|
35
|
+
* useMmapAdjacency: true, // required >100M nodes
|
|
36
|
+
* mmapAdjacencyPath: '/data/scratch/diskann-build.adj'
|
|
37
|
+
* }
|
|
38
|
+
* }
|
|
39
|
+
* })
|
|
40
|
+
* ```
|
|
41
|
+
*
|
|
42
|
+
* ## Operating model
|
|
43
|
+
*
|
|
44
|
+
* DiskANN is build-once, query-many by design: the on-disk file
|
|
45
|
+
* embeds the Vamana graph, PQ codebook, codes, and full vectors in a
|
|
46
|
+
* single contiguous mmap-able layout. Dynamic insertions go to a
|
|
47
|
+
* small **delta buffer** that brute-force-searches alongside the main
|
|
48
|
+
* index until the next `rebuild()` folds them in. This matches
|
|
49
|
+
* FreshDiskANN's published online-update model.
|
|
50
|
+
*
|
|
51
|
+
* ## Search path
|
|
52
|
+
*
|
|
53
|
+
* 1. Query the main index via the native DiskANN searcher: PQ-greedy
|
|
54
|
+
* walk in RAM, full-vector re-rank on the candidate set.
|
|
55
|
+
* 2. Brute-force the delta buffer (typically <0.1% of total size after
|
|
56
|
+
* a recent rebuild).
|
|
57
|
+
* 3. Merge + sort + truncate to `k`.
|
|
58
|
+
*
|
|
59
|
+
* ## When this wrapper engages
|
|
60
|
+
*
|
|
61
|
+
* Brainy's `wireDiskAnn()` decides at init time whether to instantiate
|
|
62
|
+
* this wrapper or the standard HNSW one. The criteria
|
|
63
|
+
* ([ADR-002](../../docs/ADR-002-diskann-100-percent-rust.md)):
|
|
64
|
+
* - Cortex's `index:diskann` provider is registered (this file).
|
|
65
|
+
* - The storage adapter exposes a local filesystem path
|
|
66
|
+
* (`getBinaryBlobPath` is the canonical check).
|
|
67
|
+
* - The metadata index has a stable `idMapper` (the cortex 2.4.0 #23
|
|
68
|
+
* foundation).
|
|
69
|
+
* - `config.index.type !== 'hnsw'` (opt-out path).
|
|
70
|
+
*/
|
|
71
|
+
import { loadNativeModule } from '../native/index.js';
|
|
72
|
+
import { prodLog } from '@soulcraft/brainy/internals';
|
|
73
|
+
const DEFAULTS = {
|
|
74
|
+
pqM: 16,
|
|
75
|
+
pqKsub: 256,
|
|
76
|
+
maxDegree: 64,
|
|
77
|
+
searchListSize: 100,
|
|
78
|
+
alpha: 1.2,
|
|
79
|
+
defaultLSearch: 100,
|
|
80
|
+
defaultPaddingFactor: 1.2,
|
|
81
|
+
useMmapAdjacency: false,
|
|
82
|
+
};
|
|
83
|
+
export class NativeDiskAnnWrapper {
|
|
84
|
+
config;
|
|
85
|
+
distanceFunction;
|
|
86
|
+
storage;
|
|
87
|
+
persistMode;
|
|
88
|
+
/** Live searcher instance — null until the first build. */
|
|
89
|
+
native = null;
|
|
90
|
+
/** Newly added entries since the last build. Brute-force searched. */
|
|
91
|
+
delta = new Map();
|
|
92
|
+
/** Removed entries — filtered out at search time. */
|
|
93
|
+
tombstones = new Set();
|
|
94
|
+
/** Bidirectional UUID ↔ slot map for the main index. */
|
|
95
|
+
slotByUuid = new Map();
|
|
96
|
+
uuidBySlot = new Map();
|
|
97
|
+
constructor(config, distanceFunction, options = {}) {
|
|
98
|
+
this.config = { ...DEFAULTS, ...config };
|
|
99
|
+
this.distanceFunction = distanceFunction;
|
|
100
|
+
this.storage = options.storage ?? null;
|
|
101
|
+
this.persistMode = options.persistMode ?? 'immediate';
|
|
102
|
+
// Try to open an existing file. If absent, the index stays
|
|
103
|
+
// empty until the first rebuild() flushes the delta buffer.
|
|
104
|
+
this.tryOpenExisting();
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Append an entry to the delta buffer. Persisted by the next
|
|
108
|
+
* `rebuild()` call, which folds the delta into the main index.
|
|
109
|
+
*/
|
|
110
|
+
async addItem(item) {
|
|
111
|
+
if (this.tombstones.has(item.id)) {
|
|
112
|
+
this.tombstones.delete(item.id);
|
|
113
|
+
}
|
|
114
|
+
this.delta.set(item.id, item.vector);
|
|
115
|
+
return item.id;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Mark an entry as removed. Filtered out at search time; physically
|
|
119
|
+
* removed at the next `rebuild()`.
|
|
120
|
+
*/
|
|
121
|
+
async removeItem(id) {
|
|
122
|
+
const inDelta = this.delta.delete(id);
|
|
123
|
+
const inMain = this.slotByUuid.has(id);
|
|
124
|
+
if (inMain)
|
|
125
|
+
this.tombstones.add(id);
|
|
126
|
+
return inDelta || inMain;
|
|
127
|
+
}
|
|
128
|
+
async search(queryVector, k = 10, filter, options) {
|
|
129
|
+
const lSearch = Math.max(this.config.defaultLSearch, k * 2);
|
|
130
|
+
const padding = options?.rerank?.multiplier ?? this.config.defaultPaddingFactor;
|
|
131
|
+
// 1. Main-index PQ-greedy walk (returns slot ids).
|
|
132
|
+
const mainHits = this.native
|
|
133
|
+
? this.native.search(Array.from(queryVector), k * 2, // over-fetch so filter / tombstone losses don't starve final result
|
|
134
|
+
lSearch, padding)
|
|
135
|
+
: [];
|
|
136
|
+
// 2. Hydrate slot → uuid; drop tombstoned + filter-rejected.
|
|
137
|
+
const merged = [];
|
|
138
|
+
for (const hit of mainHits) {
|
|
139
|
+
const uuid = this.uuidBySlot.get(hit.slot);
|
|
140
|
+
if (!uuid)
|
|
141
|
+
continue;
|
|
142
|
+
if (this.tombstones.has(uuid))
|
|
143
|
+
continue;
|
|
144
|
+
if (filter && !(await filter(uuid)))
|
|
145
|
+
continue;
|
|
146
|
+
merged.push([uuid, hit.distance]);
|
|
147
|
+
}
|
|
148
|
+
// 3. Brute-force the delta buffer.
|
|
149
|
+
for (const [id, v] of this.delta) {
|
|
150
|
+
if (filter && !(await filter(id)))
|
|
151
|
+
continue;
|
|
152
|
+
const d = this.distanceFunction(queryVector, v);
|
|
153
|
+
merged.push([id, d]);
|
|
154
|
+
}
|
|
155
|
+
// 4. Sort ascending by distance, truncate to k.
|
|
156
|
+
merged.sort((a, b) => a[1] - b[1]);
|
|
157
|
+
return merged.slice(0, k);
|
|
158
|
+
}
|
|
159
|
+
size() {
|
|
160
|
+
const mainSize = this.native ? this.native.size() : 0;
|
|
161
|
+
return (mainSize +
|
|
162
|
+
this.delta.size -
|
|
163
|
+
// Tombstones from the main index reduce effective size.
|
|
164
|
+
this.countMainTombstones());
|
|
165
|
+
}
|
|
166
|
+
clear() {
|
|
167
|
+
this.delta.clear();
|
|
168
|
+
this.tombstones.clear();
|
|
169
|
+
this.slotByUuid.clear();
|
|
170
|
+
this.uuidBySlot.clear();
|
|
171
|
+
this.native = null;
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Rebuild the main index from scratch: concatenate (current main −
|
|
175
|
+
* tombstones) ∪ delta, run a full DiskANN build, swap the searcher
|
|
176
|
+
* atomically.
|
|
177
|
+
*
|
|
178
|
+
* At billion-scale this is the expensive operation (hours of build
|
|
179
|
+
* time). Operators schedule it during off-peak; the delta buffer
|
|
180
|
+
* absorbs writes in between.
|
|
181
|
+
*/
|
|
182
|
+
async rebuild(options) {
|
|
183
|
+
const bindings = loadNativeModule();
|
|
184
|
+
// napi-rs exports the class as `NativeDiskAnn` (PascalCase
|
|
185
|
+
// normalization of the Rust ident `NativeDiskANN`). The TS type
|
|
186
|
+
// alias `NativeDiskANN = NativeDiskAnn` in `native/index.d.ts` is
|
|
187
|
+
// for backwards-compat in *types* only — at runtime there's a
|
|
188
|
+
// single export under the napi-normalized name.
|
|
189
|
+
const NativeDiskANN = bindings.NativeDiskAnn;
|
|
190
|
+
if (!NativeDiskANN) {
|
|
191
|
+
throw new Error('NativeDiskANN binding missing — rebuild requires the cortex native module');
|
|
192
|
+
}
|
|
193
|
+
// Build the new logical slot ordering: (live old slots) + (delta).
|
|
194
|
+
// **Critical for billion-scale correctness**: the old vectors stay
|
|
195
|
+
// mmap'd inside the native module — we only pass slot IDs across
|
|
196
|
+
// the FFI boundary, not the vector data itself. At 1B × 1536 × 4
|
|
197
|
+
// bytes = ~6 TB this is the difference between "rebuild works" and
|
|
198
|
+
// "rebuild OOMs."
|
|
199
|
+
const liveOldSlots = [];
|
|
200
|
+
const newUuids = [];
|
|
201
|
+
if (this.native) {
|
|
202
|
+
// Iterate in slot order so the new index's first n_live slots
|
|
203
|
+
// mirror the OLD index's surviving subset in deterministic order.
|
|
204
|
+
// We deliberately iterate by sorted slot id rather than uuidBySlot
|
|
205
|
+
// insertion order — sorting keeps the Vamana entry point stable
|
|
206
|
+
// and the on-disk vector section's locality similar to the
|
|
207
|
+
// pre-rebuild file (less page-cache turnover during the post-
|
|
208
|
+
// rebuild warm-up).
|
|
209
|
+
const sortedSlots = Array.from(this.uuidBySlot.keys()).sort((a, b) => a - b);
|
|
210
|
+
for (const slot of sortedSlots) {
|
|
211
|
+
const uuid = this.uuidBySlot.get(slot);
|
|
212
|
+
if (this.tombstones.has(uuid))
|
|
213
|
+
continue;
|
|
214
|
+
liveOldSlots.push(slot);
|
|
215
|
+
newUuids.push(uuid);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
const dim = this.config.dimensions;
|
|
219
|
+
const deltaCount = this.delta.size;
|
|
220
|
+
let deltaBuf = null;
|
|
221
|
+
if (deltaCount > 0) {
|
|
222
|
+
deltaBuf = new Float32Array(deltaCount * dim);
|
|
223
|
+
let idx = 0;
|
|
224
|
+
for (const [uuid, vector] of this.delta) {
|
|
225
|
+
if (vector.length !== dim) {
|
|
226
|
+
throw new Error(`NativeDiskAnnWrapper.rebuild: vector dim ${vector.length} ≠ index dim ${dim}`);
|
|
227
|
+
}
|
|
228
|
+
deltaBuf.set(vector, idx * dim);
|
|
229
|
+
newUuids.push(uuid);
|
|
230
|
+
idx++;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
if (liveOldSlots.length + deltaCount === 0) {
|
|
234
|
+
prodLog?.warn?.('NativeDiskAnnWrapper.rebuild: nothing to build');
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
const totalCount = liveOldSlots.length + deltaCount;
|
|
238
|
+
const cfg = {
|
|
239
|
+
vamana: {
|
|
240
|
+
maxDegree: options?.maxDegree ?? this.config.maxDegree,
|
|
241
|
+
searchListSize: options?.searchListSize ?? this.config.searchListSize,
|
|
242
|
+
alpha: options?.alpha ?? this.config.alpha,
|
|
243
|
+
seed: BigInt(0xd15ca4440ffff00dn),
|
|
244
|
+
parallel: true,
|
|
245
|
+
parallelBatch: 64,
|
|
246
|
+
},
|
|
247
|
+
pq: {
|
|
248
|
+
m: options?.pqM ?? this.config.pqM,
|
|
249
|
+
ksub: options?.pqKsub ?? this.config.pqKsub,
|
|
250
|
+
iterations: 25,
|
|
251
|
+
trainingSample: Math.min(200_000, totalCount),
|
|
252
|
+
},
|
|
253
|
+
adjacency: this.config.useMmapAdjacency
|
|
254
|
+
? {
|
|
255
|
+
kind: 'mmap',
|
|
256
|
+
mmapPath: this.config.mmapAdjacencyPath ?? `${this.config.indexPath}.adj`,
|
|
257
|
+
}
|
|
258
|
+
: { kind: 'ram' },
|
|
259
|
+
};
|
|
260
|
+
const newNative = NativeDiskANN.rebuildFromExisting({
|
|
261
|
+
existingPath: this.native ? this.config.indexPath : undefined,
|
|
262
|
+
liveOldSlots,
|
|
263
|
+
deltaVectors: deltaBuf != null
|
|
264
|
+
? Buffer.from(deltaBuf.buffer, deltaBuf.byteOffset, deltaBuf.byteLength)
|
|
265
|
+
: undefined,
|
|
266
|
+
deltaCount,
|
|
267
|
+
dim,
|
|
268
|
+
outputPath: this.config.indexPath,
|
|
269
|
+
cfg,
|
|
270
|
+
});
|
|
271
|
+
// Rebuild the bidirectional UUID↔slot maps from `newUuids`. New
|
|
272
|
+
// slot `i` corresponds to `newUuids[i]` — this matches the napi
|
|
273
|
+
// layout invariant (live old slots first, delta tail second).
|
|
274
|
+
const newSlotByUuid = new Map();
|
|
275
|
+
const newUuidBySlot = new Map();
|
|
276
|
+
for (let i = 0; i < newUuids.length; i++) {
|
|
277
|
+
newSlotByUuid.set(newUuids[i], i);
|
|
278
|
+
newUuidBySlot.set(i, newUuids[i]);
|
|
279
|
+
}
|
|
280
|
+
// Atomic swap.
|
|
281
|
+
this.native = newNative;
|
|
282
|
+
this.slotByUuid = newSlotByUuid;
|
|
283
|
+
this.uuidBySlot = newUuidBySlot;
|
|
284
|
+
this.delta.clear();
|
|
285
|
+
this.tombstones.clear();
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* Flush the delta buffer to disk. For DiskANN the delta is in-memory
|
|
289
|
+
* by design (a few MB at most between rebuilds); returns the buffer
|
|
290
|
+
* size for parity with HNSW's flush contract.
|
|
291
|
+
*/
|
|
292
|
+
async flush() {
|
|
293
|
+
return this.delta.size;
|
|
294
|
+
}
|
|
295
|
+
getPersistMode() {
|
|
296
|
+
return this.persistMode;
|
|
297
|
+
}
|
|
298
|
+
tryOpenExisting() {
|
|
299
|
+
try {
|
|
300
|
+
const bindings = loadNativeModule();
|
|
301
|
+
// napi-rs exports the class as `NativeDiskAnn` (PascalCase
|
|
302
|
+
// normalization of the Rust ident `NativeDiskANN`). The TS type
|
|
303
|
+
// alias `NativeDiskANN = NativeDiskAnn` in `native/index.d.ts` is
|
|
304
|
+
// for backwards-compat in *types* only — at runtime there's a
|
|
305
|
+
// single export under the napi-normalized name.
|
|
306
|
+
const NativeDiskANN = bindings.NativeDiskAnn;
|
|
307
|
+
if (!NativeDiskANN)
|
|
308
|
+
return;
|
|
309
|
+
this.native = NativeDiskANN.openExisting(this.config.indexPath);
|
|
310
|
+
// Populate slot maps from the storage adapter — these are persisted
|
|
311
|
+
// alongside the index file in production. For 35c we read from a
|
|
312
|
+
// sibling `.slots.json` that rebuild() writes.
|
|
313
|
+
// (Stub for now; the real path lands when storage integration ships.)
|
|
314
|
+
}
|
|
315
|
+
catch {
|
|
316
|
+
// No existing file — index stays empty until first rebuild().
|
|
317
|
+
this.native = null;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
countMainTombstones() {
|
|
321
|
+
let n = 0;
|
|
322
|
+
for (const uuid of this.tombstones) {
|
|
323
|
+
if (this.slotByUuid.has(uuid))
|
|
324
|
+
n++;
|
|
325
|
+
}
|
|
326
|
+
return n;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
//# sourceMappingURL=NativeDiskAnnWrapper.js.map
|
|
@@ -30,6 +30,7 @@ export declare class NativeHNSWWrapper implements HnswProvider {
|
|
|
30
30
|
private unifiedCache;
|
|
31
31
|
private cowEnabled;
|
|
32
32
|
private mmapStore;
|
|
33
|
+
private connectionsCodec;
|
|
33
34
|
constructor(config: (Partial<HNSWConfig> & {
|
|
34
35
|
distanceFunction?: DistanceFunction;
|
|
35
36
|
}) | undefined, distanceFunction: DistanceFunction, options?: {
|
|
@@ -83,6 +84,35 @@ export declare class NativeHNSWWrapper implements HnswProvider {
|
|
|
83
84
|
enableCOW(parent: NativeHNSWWrapper): void;
|
|
84
85
|
setUseParallelization(useParallelization: boolean): void;
|
|
85
86
|
getUseParallelization(): boolean;
|
|
87
|
+
/**
|
|
88
|
+
* @description Accept (or detach) the brainy `ConnectionsCodec`. Brainy 7.27+
|
|
89
|
+
* calls this unconditionally during init from `wireConnectionsCodec()` when
|
|
90
|
+
* the `graph:compression` provider is registered (which cortex always
|
|
91
|
+
* supplies via `native.encodeConnections`/`decodeConnections`).
|
|
92
|
+
*
|
|
93
|
+
* Cortex's native HNSW serializes connections through its own path —
|
|
94
|
+
* `addItemFull` returns `nodeData` written directly via `storage.saveHNSWData`
|
|
95
|
+
* (and the mmap binary backend when available). It never routes through
|
|
96
|
+
* brainy's JS-side `persistNodeConnections`/`restoreNodeConnections`, which
|
|
97
|
+
* is where the codec is consumed. The codec is therefore unreachable from
|
|
98
|
+
* this wrapper.
|
|
99
|
+
*
|
|
100
|
+
* We accept the call (so brainy's init succeeds) and store the reference for
|
|
101
|
+
* introspection/parity. We do NOT re-encode connections through the codec on
|
|
102
|
+
* top of the native format — that would double-encode (waste CPU) or replace
|
|
103
|
+
* the native format with a strictly less efficient one (waste perf). Brainy
|
|
104
|
+
* treats the method as feature-detected/optional on third-party providers,
|
|
105
|
+
* so a storing acceptor is the contract-correct behaviour.
|
|
106
|
+
*
|
|
107
|
+
* @param codec - The `ConnectionsCodec` instance, or `null` to detach.
|
|
108
|
+
*/
|
|
109
|
+
setConnectionsCodec(codec: unknown): void;
|
|
110
|
+
/**
|
|
111
|
+
* @description Read back the currently-attached `ConnectionsCodec`, or null.
|
|
112
|
+
* Exposed for parity tests + future inspection; cortex itself does not
|
|
113
|
+
* consult this value on the read/write path.
|
|
114
|
+
*/
|
|
115
|
+
getConnectionsCodec(): unknown;
|
|
86
116
|
size(): number;
|
|
87
117
|
clear(): void;
|
|
88
118
|
getEntryPointId(): string | null;
|
|
@@ -38,6 +38,10 @@ export class NativeHNSWWrapper {
|
|
|
38
38
|
cowEnabled = false;
|
|
39
39
|
// Mmap binary HNSW store (Phase 4 — optional, used when storage has rootDirectory)
|
|
40
40
|
mmapStore = null;
|
|
41
|
+
// Brainy ConnectionsCodec (brainy >= 7.27 `wireConnectionsCodec`). Stored for
|
|
42
|
+
// introspection but not consulted on the read/write path — see
|
|
43
|
+
// `setConnectionsCodec` below for the architectural rationale.
|
|
44
|
+
connectionsCodec = null;
|
|
41
45
|
constructor(config = {}, distanceFunction, options = {}) {
|
|
42
46
|
this.config = { ...DEFAULT_CONFIG, ...config };
|
|
43
47
|
this.distanceFunction = distanceFunction;
|
|
@@ -485,6 +489,39 @@ export class NativeHNSWWrapper {
|
|
|
485
489
|
getUseParallelization() {
|
|
486
490
|
return this.useParallelization;
|
|
487
491
|
}
|
|
492
|
+
/**
|
|
493
|
+
* @description Accept (or detach) the brainy `ConnectionsCodec`. Brainy 7.27+
|
|
494
|
+
* calls this unconditionally during init from `wireConnectionsCodec()` when
|
|
495
|
+
* the `graph:compression` provider is registered (which cortex always
|
|
496
|
+
* supplies via `native.encodeConnections`/`decodeConnections`).
|
|
497
|
+
*
|
|
498
|
+
* Cortex's native HNSW serializes connections through its own path —
|
|
499
|
+
* `addItemFull` returns `nodeData` written directly via `storage.saveHNSWData`
|
|
500
|
+
* (and the mmap binary backend when available). It never routes through
|
|
501
|
+
* brainy's JS-side `persistNodeConnections`/`restoreNodeConnections`, which
|
|
502
|
+
* is where the codec is consumed. The codec is therefore unreachable from
|
|
503
|
+
* this wrapper.
|
|
504
|
+
*
|
|
505
|
+
* We accept the call (so brainy's init succeeds) and store the reference for
|
|
506
|
+
* introspection/parity. We do NOT re-encode connections through the codec on
|
|
507
|
+
* top of the native format — that would double-encode (waste CPU) or replace
|
|
508
|
+
* the native format with a strictly less efficient one (waste perf). Brainy
|
|
509
|
+
* treats the method as feature-detected/optional on third-party providers,
|
|
510
|
+
* so a storing acceptor is the contract-correct behaviour.
|
|
511
|
+
*
|
|
512
|
+
* @param codec - The `ConnectionsCodec` instance, or `null` to detach.
|
|
513
|
+
*/
|
|
514
|
+
setConnectionsCodec(codec) {
|
|
515
|
+
this.connectionsCodec = codec;
|
|
516
|
+
}
|
|
517
|
+
/**
|
|
518
|
+
* @description Read back the currently-attached `ConnectionsCodec`, or null.
|
|
519
|
+
* Exposed for parity tests + future inspection; cortex itself does not
|
|
520
|
+
* consult this value on the read/write path.
|
|
521
|
+
*/
|
|
522
|
+
getConnectionsCodec() {
|
|
523
|
+
return this.connectionsCodec;
|
|
524
|
+
}
|
|
488
525
|
// ---------------------------------------------------------------------------
|
|
489
526
|
// Info / Introspection
|
|
490
527
|
// ---------------------------------------------------------------------------
|