@hviana/sema 0.1.5 → 0.1.7
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/AGENTS.md +6 -5
- package/CITATION.cff +49 -0
- package/HOW_IT_WORKS.md +11 -12
- package/README.md +7 -5
- package/dist/example/demo.js +25 -25
- package/dist/example/train_base.d.ts +19 -21
- package/dist/example/train_base.js +1700 -1952
- package/dist/src/alphabet.d.ts +3 -3
- package/dist/src/alphabet.js +24 -27
- package/dist/src/alu/src/alu.d.ts +169 -194
- package/dist/src/alu/src/alu.js +374 -414
- package/dist/src/alu/src/expr.d.ts +36 -43
- package/dist/src/alu/src/expr.js +252 -278
- package/dist/src/alu/src/index.d.ts +9 -98
- package/dist/src/alu/src/index.js +10 -64
- package/dist/src/alu/src/kernel-arith.d.ts +1 -5
- package/dist/src/alu/src/kernel-arith.js +195 -285
- package/dist/src/alu/src/kernel-bits.d.ts +1 -5
- package/dist/src/alu/src/kernel-bits.js +81 -115
- package/dist/src/alu/src/kernel-logic.js +33 -63
- package/dist/src/alu/src/kernel-nd.js +155 -195
- package/dist/src/alu/src/kernel-numeric.d.ts +16 -80
- package/dist/src/alu/src/kernel-numeric.js +259 -331
- package/dist/src/alu/src/operation.d.ts +112 -132
- package/dist/src/alu/src/operation.js +149 -163
- package/dist/src/alu/src/parser.d.ts +168 -175
- package/dist/src/alu/src/parser.js +382 -421
- package/dist/src/alu/src/resonance.d.ts +16 -25
- package/dist/src/alu/src/resonance.js +49 -56
- package/dist/src/alu/src/text.d.ts +3 -7
- package/dist/src/alu/src/text.js +31 -37
- package/dist/src/alu/src/value.d.ts +14 -14
- package/dist/src/alu/src/value.js +150 -160
- package/dist/src/alu/test/alu.test.js +543 -656
- package/dist/src/bytes.d.ts +1 -5
- package/dist/src/bytes.js +33 -40
- package/dist/src/config.d.ts +99 -102
- package/dist/src/config.js +78 -83
- package/dist/src/derive/src/deduction.d.ts +60 -62
- package/dist/src/derive/src/deduction.js +103 -109
- package/dist/src/derive/src/index.d.ts +1 -7
- package/dist/src/derive/src/priority-queue.d.ts +8 -8
- package/dist/src/derive/src/priority-queue.js +57 -61
- package/dist/src/derive/src/rewrite.d.ts +15 -18
- package/dist/src/derive/src/rewrite.js +59 -67
- package/dist/src/derive/src/trie.d.ts +56 -56
- package/dist/src/derive/src/trie.js +175 -179
- package/dist/src/derive/test/derive.test.js +96 -100
- package/dist/src/extension.d.ts +13 -16
- package/dist/src/geometry.d.ts +29 -43
- package/dist/src/geometry.js +324 -268
- package/dist/src/index.d.ts +2 -17
- package/dist/src/index.js +2 -12
- package/dist/src/ingest-cache.d.ts +25 -30
- package/dist/src/ingest-cache.js +108 -127
- package/dist/src/mind/articulation.d.ts +1 -5
- package/dist/src/mind/articulation.js +77 -112
- package/dist/src/mind/attention.d.ts +40 -108
- package/dist/src/mind/attention.js +754 -872
- package/dist/src/mind/canonical.d.ts +4 -19
- package/dist/src/mind/canonical.js +27 -31
- package/dist/src/mind/graph-search.d.ts +201 -225
- package/dist/src/mind/graph-search.js +742 -821
- package/dist/src/mind/index.d.ts +2 -10
- package/dist/src/mind/junction.d.ts +31 -58
- package/dist/src/mind/junction.js +172 -237
- package/dist/src/mind/learning.d.ts +16 -52
- package/dist/src/mind/learning.js +143 -165
- package/dist/src/mind/match.d.ts +20 -69
- package/dist/src/mind/match.js +259 -318
- package/dist/src/mind/mechanisms/alu.js +16 -16
- package/dist/src/mind/mechanisms/cast.d.ts +9 -13
- package/dist/src/mind/mechanisms/cast.js +363 -456
- package/dist/src/mind/mechanisms/confluence.d.ts +8 -12
- package/dist/src/mind/mechanisms/confluence.js +152 -183
- package/dist/src/mind/mechanisms/cover.d.ts +2 -8
- package/dist/src/mind/mechanisms/cover.js +148 -210
- package/dist/src/mind/mechanisms/extraction.d.ts +8 -34
- package/dist/src/mind/mechanisms/extraction.js +234 -288
- package/dist/src/mind/mechanisms/recall.d.ts +6 -10
- package/dist/src/mind/mechanisms/recall.js +141 -185
- package/dist/src/mind/mind.d.ts +194 -155
- package/dist/src/mind/mind.js +392 -289
- package/dist/src/mind/pipeline-mechanism.d.ts +112 -124
- package/dist/src/mind/pipeline-mechanism.js +161 -172
- package/dist/src/mind/pipeline.d.ts +4 -14
- package/dist/src/mind/pipeline.js +125 -189
- package/dist/src/mind/primitives.d.ts +20 -33
- package/dist/src/mind/primitives.js +124 -120
- package/dist/src/mind/rationale.d.ts +86 -98
- package/dist/src/mind/rationale.js +113 -121
- package/dist/src/mind/reasoning.d.ts +2 -13
- package/dist/src/mind/reasoning.js +129 -166
- package/dist/src/mind/recognition.d.ts +1 -4
- package/dist/src/mind/recognition.js +198 -210
- package/dist/src/mind/resonance.d.ts +5 -22
- package/dist/src/mind/resonance.js +0 -0
- package/dist/src/mind/trace.d.ts +6 -25
- package/dist/src/mind/trace.js +50 -58
- package/dist/src/mind/traverse.d.ts +29 -58
- package/dist/src/mind/traverse.js +344 -356
- package/dist/src/mind/types.d.ts +122 -127
- package/dist/src/mind/types.js +60 -69
- package/dist/src/rabitq-ivf/src/database.d.ts +113 -0
- package/dist/src/rabitq-ivf/src/database.js +201 -0
- package/dist/src/rabitq-ivf/src/index.d.ts +7 -0
- package/dist/src/{rabitq-hnsw → rabitq-ivf}/src/index.js +1 -3
- package/dist/src/rabitq-ivf/src/ivf.d.ts +200 -0
- package/dist/src/rabitq-ivf/src/ivf.js +1165 -0
- package/dist/src/rabitq-ivf/src/prng.d.ts +19 -0
- package/dist/src/rabitq-ivf/src/prng.js +36 -0
- package/dist/src/rabitq-ivf/src/rabitq.d.ts +95 -0
- package/dist/src/rabitq-ivf/src/rabitq.js +283 -0
- package/dist/src/sema.d.ts +9 -13
- package/dist/src/sema.js +26 -40
- package/dist/src/store-sqlite.d.ts +171 -166
- package/dist/src/store-sqlite.js +768 -662
- package/dist/src/store.d.ts +597 -630
- package/dist/src/store.js +1434 -1581
- package/dist/src/vec.d.ts +5 -9
- package/dist/src/vec.js +61 -73
- package/example/train_base.ts +33 -15
- package/index.html +65 -0
- package/package.json +1 -1
- package/src/alu/README.md +1 -1
- package/src/alu/src/index.ts +1 -1
- package/src/config.ts +19 -27
- package/src/geometry.ts +93 -1
- package/src/index.ts +6 -11
- package/src/mind/attention.ts +11 -6
- package/src/mind/mechanisms/recall.ts +29 -13
- package/src/mind/mind.ts +250 -19
- package/src/mind/primitives.ts +43 -6
- package/src/mind/recognition.ts +26 -8
- package/src/mind/traverse.ts +47 -0
- package/src/mind/types.ts +20 -21
- package/src/rabitq-ivf/README.md +56 -0
- package/src/rabitq-ivf/src/database.ts +276 -0
- package/src/{rabitq-hnsw → rabitq-ivf}/src/index.ts +2 -5
- package/src/rabitq-ivf/src/ivf.ts +1330 -0
- package/src/{rabitq-hnsw → rabitq-ivf}/src/prng.ts +1 -1
- package/src/store-sqlite.ts +204 -9
- package/src/store.ts +27 -34
- package/test/08-storage.test.mjs +3 -3
- package/test/13-conversation.test.mjs +190 -20
- package/test/14-scaling.test.mjs +2 -2
- package/test/35-ivf.test.mjs +263 -0
- package/test/36-bloom.test.mjs +123 -0
- package/dist/src/rabitq-hnsw/src/database.d.ts +0 -202
- package/dist/src/rabitq-hnsw/src/database.js +0 -405
- package/dist/src/rabitq-hnsw/src/heap.d.ts +0 -22
- package/dist/src/rabitq-hnsw/src/heap.js +0 -94
- package/dist/src/rabitq-hnsw/src/hnsw.d.ts +0 -125
- package/dist/src/rabitq-hnsw/src/hnsw.js +0 -500
- package/dist/src/rabitq-hnsw/src/index.d.ts +0 -15
- package/dist/src/rabitq-hnsw/src/prng.d.ts +0 -19
- package/dist/src/rabitq-hnsw/src/prng.js +0 -38
- package/dist/src/rabitq-hnsw/src/rabitq.d.ts +0 -95
- package/dist/src/rabitq-hnsw/src/rabitq.js +0 -299
- package/dist/src/rabitq-hnsw/src/store.d.ts +0 -162
- package/dist/src/rabitq-hnsw/src/store.js +0 -916
- package/dist/src/rabitq-hnsw/test/hnsw.test.d.ts +0 -1
- package/dist/src/rabitq-hnsw/test/hnsw.test.js +0 -1197
- package/src/rabitq-hnsw/README.md +0 -303
- package/src/rabitq-hnsw/src/database.ts +0 -492
- package/src/rabitq-hnsw/src/heap.ts +0 -90
- package/src/rabitq-hnsw/src/hnsw.ts +0 -514
- package/src/rabitq-hnsw/src/store.ts +0 -994
- package/src/rabitq-hnsw/test/hnsw.test.ts +0 -1213
- /package/src/{rabitq-hnsw → rabitq-ivf}/src/rabitq.ts +0 -0
package/src/mind/traverse.ts
CHANGED
|
@@ -115,6 +115,30 @@ export function edgeAncestors(
|
|
|
115
115
|
const hit = memo?.get(id);
|
|
116
116
|
if (hit !== undefined) return hit;
|
|
117
117
|
|
|
118
|
+
// BYTE-ATOM COMMONALITY. A single-byte leaf (implicit negative id) has no
|
|
119
|
+
// structural parents BY CONSTRUCTION — atoms are never linked into the kid
|
|
120
|
+
// or contain tables — so this climb cannot observe its containment at all.
|
|
121
|
+
// The walk below would see only the atom's own direct edges and report
|
|
122
|
+
// contextsReached ≈ 1, turning the MOST common content in the store into
|
|
123
|
+
// the MOST discriminative voter (observed on a 325K-context store: every
|
|
124
|
+
// recognised single-letter site voted full ln N for the one fact whose
|
|
125
|
+
// continuation is that letter, and their pooled sum out-voted every
|
|
126
|
+
// genuine anchor). An unmeasurable containment must not default to
|
|
127
|
+
// "maximally rare": it is bounded below by the uniform expectation over
|
|
128
|
+
// the byte alphabet — N contexts, each at least one chunk of up to W of
|
|
129
|
+
// the 256 possible atoms, reach ≥ N·W/256 contexts per atom on average
|
|
130
|
+
// (see {@link atomReach}). When that floor itself exceeds the hub bound
|
|
131
|
+
// √N the atom is a hub at this corpus scale and the climb abstains
|
|
132
|
+
// (saturated) — the atom's own edges remain fully traversable (tier-0
|
|
133
|
+
// exact recall, chooseNext, project); only its say as a consensus voter
|
|
134
|
+
// is withdrawn. On a small store the floor stays ≤ √N and the atom
|
|
135
|
+
// climbs exactly as before, so single-letter facts keep working.
|
|
136
|
+
if (id < 0 && atomIsHub(ctx, contextCount)) {
|
|
137
|
+
const reach = { roots: [], contextsReached: 0, saturated: true };
|
|
138
|
+
memo?.set(id, reach);
|
|
139
|
+
return reach;
|
|
140
|
+
}
|
|
141
|
+
|
|
118
142
|
const bound = Math.ceil(Math.sqrt(contextCount));
|
|
119
143
|
const roots: number[] = [];
|
|
120
144
|
const seen = new Set<number>([id]);
|
|
@@ -257,6 +281,29 @@ export function prevOf(ctx: MindContext, id: number): number[] {
|
|
|
257
281
|
return ctx.store.prev(id);
|
|
258
282
|
}
|
|
259
283
|
|
|
284
|
+
/** The uniform-expectation floor on a byte atom's corpus commonality: N
|
|
285
|
+
* learnt contexts, each at least one perception chunk of up to W of the 256
|
|
286
|
+
* possible byte values, contain a given atom in ≥ N·W/256 contexts on
|
|
287
|
+
* average. An atom's TRUE containment is unmeasurable (atoms carry no
|
|
288
|
+
* kid/contain links by construction), so this floor is the honest stand-in:
|
|
289
|
+
* derived entirely from the corpus scale N, the perception window W, and
|
|
290
|
+
* the alphabet size — never tuned. */
|
|
291
|
+
export function atomReach(ctx: MindContext, contextCount: number): number {
|
|
292
|
+
return Math.max(
|
|
293
|
+
1,
|
|
294
|
+
Math.ceil((contextCount * ctx.space.maxGroup) / 256),
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** Whether a byte atom is a hub at this corpus scale — its commonality floor
|
|
299
|
+
* {@link atomReach} exceeds the hub bound √N. Below it (small stores) an
|
|
300
|
+
* atom votes and is recognised exactly as any stored form; above it the
|
|
301
|
+
* alphabet is scaffolding everywhere and abstains. */
|
|
302
|
+
export function atomIsHub(ctx: MindContext, contextCount: number): boolean {
|
|
303
|
+
return atomReach(ctx, contextCount) >
|
|
304
|
+
Math.ceil(Math.sqrt(Math.max(2, contextCount)));
|
|
305
|
+
}
|
|
306
|
+
|
|
260
307
|
/** Whether a node LEADS SOMEWHERE — it bears a continuation edge or a halo.
|
|
261
308
|
* The admission predicate recognition filters sites with (HOW_IT_WORKS
|
|
262
309
|
* §15.3): a form that leads nowhere contributes nothing to any derivation.
|
package/src/mind/types.ts
CHANGED
|
@@ -144,28 +144,27 @@ export interface MindContext extends GraphSearchHost {
|
|
|
144
144
|
cfg: MindConfig;
|
|
145
145
|
search: GraphSearch;
|
|
146
146
|
trace: Rationale | null;
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
* (
|
|
150
|
-
*
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
*
|
|
160
|
-
* a pure function of bytes; only inference-shaped calls (plain Uint8Array,
|
|
161
|
-
* no leafAt/lookup capabilities) are memoised, so the deposit path never
|
|
162
|
-
* sees it. Keyed by CONTENT (latin1 of the bytes), not object identity —
|
|
163
|
-
* mechanisms materialise the same span in fresh subarrays constantly
|
|
164
|
-
* (measured on a trained store: 46% of one response's perceptions were
|
|
165
|
-
* byte-identical repeats an identity key missed). NOT bypassed under
|
|
166
|
-
* trace — perception emits no rationale steps, so there is nothing a memo
|
|
167
|
-
* hit could swallow. Null outside respond(). */
|
|
147
|
+
/** Memo of the consensus climb — content-keyed (latin1) so results
|
|
148
|
+
* persist across conversation turns where the same byte spans recur.
|
|
149
|
+
* Null outside respond(); during respondTurn() the conversation's
|
|
150
|
+
* persistent map is swapped in. */
|
|
151
|
+
climbMemo: Map<string, Map<string, AttentionRead>> | null;
|
|
152
|
+
/** Memo of {@link recognise} — content-keyed (latin1) so recognised
|
|
153
|
+
* forms carry forward across conversation turns. Bypassed while a
|
|
154
|
+
* trace is attached. Null outside respond(). */
|
|
155
|
+
recogniseMemo: Map<string, Recognition> | null;
|
|
156
|
+
/** Memo of {@link perceive} — content-keyed (latin1). The general
|
|
157
|
+
* cache the result-level memos each partially compensate for. NOT
|
|
158
|
+
* bypassed under trace — perception emits no rationale steps.
|
|
159
|
+
* Null outside respond(). */
|
|
168
160
|
perceiveMemo: Map<string, Sema> | null;
|
|
161
|
+
/** Subtree-resolution cache: Sema node → its store id and byte length.
|
|
162
|
+
* Populated by {@link foldTree} during inference; checked before
|
|
163
|
+
* walking children. When a conversation's pyramid reuses prefix
|
|
164
|
+
* subtrees, this cache lets {@link recognise} skip them entirely —
|
|
165
|
+
* O(suffix) instead of O(context). Mind-lifetime (WeakMap keys are
|
|
166
|
+
* the Sema objects the pyramid keeps alive). */
|
|
167
|
+
_resolvedSubtrees: WeakMap<Sema, { id: number; len: number }> | null;
|
|
169
168
|
_edgeGuide: Vec | null;
|
|
170
169
|
_edgeChoice: Map<number, number>;
|
|
171
170
|
_prevSeen: Set<number> | null;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# rabitq-ivf
|
|
2
|
+
|
|
3
|
+
A small TypeScript library for **approximate nearest-neighbour search** using an
|
|
4
|
+
**adaptive partitioned (IVF) index** over **1-bit RaBitQ codes**, with
|
|
5
|
+
**cosine** distance. The entire index lives in **SQLite** — the durable copy of
|
|
6
|
+
every code is on disk, resident memory is bounded (the pivot table plus a capped
|
|
7
|
+
page cache, never the whole collection), and the index survives process
|
|
8
|
+
restarts.
|
|
9
|
+
|
|
10
|
+
The vectors are stored **only** as 1-bit RaBitQ codes — the original vectors are
|
|
11
|
+
never kept — so the index is both fast _and_ tiny on disk:
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
a 1024-d vector:
|
|
15
|
+
Float32 : 1024 × 4 bytes = 4096 bytes
|
|
16
|
+
RaBitQ 1-bit (code) : 1024 × 1 bit = 128 bytes (32× smaller)
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
The code _is_ the entire per-vector representation. `get` returns the bare code,
|
|
20
|
+
and `insert`, `update`, and `query` all accept either a raw vector or a code —
|
|
21
|
+
detected by length.
|
|
22
|
+
|
|
23
|
+
## The index
|
|
24
|
+
|
|
25
|
+
- **Clusters, not a graph.** The collection is partitioned into clusters, each
|
|
26
|
+
with a binary pivot code and its member codes packed in fixed-size chunk
|
|
27
|
+
blobs.
|
|
28
|
+
- **Insert = route + append.** Find the nearest pivot (one linear Hamming scan
|
|
29
|
+
of the RAM-resident pivot table) and append to that cluster's tail chunk. No
|
|
30
|
+
beam search, no neighbour rewiring — per-insert cost is essentially flat in
|
|
31
|
+
collection size.
|
|
32
|
+
- **Query = probe + scan.** Rank all pivots with the accurate RaBitQ estimator,
|
|
33
|
+
scan the `ceil(efSearch/4)` nearest clusters with the same estimator, keep the
|
|
34
|
+
top k. Per-query storage reads are bounded by nprobe × chunks-per-cluster —
|
|
35
|
+
constant once the collection has split.
|
|
36
|
+
- **Adaptive, deterministic splits.** A cluster reaching 4096 entries is
|
|
37
|
+
median-split on the margin between two farthest-point seeds (two exact halves,
|
|
38
|
+
cascade-proof), and both halves get fresh majority-bit pivots. There is no
|
|
39
|
+
RNG: the index is a pure function of the insertion sequence.
|
|
40
|
+
- **Same durability discipline as the rest of Sema**: WAL, batched caller-owned
|
|
41
|
+
transactions (`upsertMany`), 1 KiB pages, 64 MiB WAL autocheckpoint.
|
|
42
|
+
|
|
43
|
+
## Usage
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
import { VectorDatabase } from "./src/index.js";
|
|
47
|
+
|
|
48
|
+
const db = new VectorDatabase({ dbPath: "vectors.db", dim: 1024 });
|
|
49
|
+
db.upsertMany([{ id: 1, vector: v1 }, { id: 2, vector: v2 }]);
|
|
50
|
+
const hits = db.query(q, 10); // [{ id, distance }]
|
|
51
|
+
db.close();
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
`lastQueryStorageReads` reports the cache-independent storage-read count of the
|
|
55
|
+
most recent query — the honest scalability witness the test suite asserts on
|
|
56
|
+
(`test/35-ivf.test.mjs` at the repo root).
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { IvfHit, IvfIndex } from "./ivf.js";
|
|
2
|
+
|
|
3
|
+
/** External ids are integers only. */
|
|
4
|
+
export type ExternalId = number;
|
|
5
|
+
|
|
6
|
+
export interface DatabaseOptions {
|
|
7
|
+
/** Path to the SQLite database file (use ":memory:" for a transient store). */
|
|
8
|
+
dbPath: string;
|
|
9
|
+
/** Vector dimensionality. Required for a new database; ignored when reopening. */
|
|
10
|
+
dim?: number;
|
|
11
|
+
/** Query breadth: clusters probed per query = ceil(efSearch / 4). Default
|
|
12
|
+
* 64 (16 probes). Tunable at runtime and at reopen. */
|
|
13
|
+
efSearch?: number;
|
|
14
|
+
/** Bits used to scalar-quantise the query for the RaBitQ estimator.
|
|
15
|
+
* Query-side only (stored codes are independent of it), so it is tunable
|
|
16
|
+
* at reopen, like efSearch. Default 8. */
|
|
17
|
+
queryBits?: number;
|
|
18
|
+
/** Number of sign-flip + Hadamard rounds in the random rotation. Default 3. */
|
|
19
|
+
rotationRounds?: number;
|
|
20
|
+
/** Seed for the rotation. Default fixed. */
|
|
21
|
+
seed?: number;
|
|
22
|
+
/** Optional centroid the vectors are centered by before quantisation. */
|
|
23
|
+
centroid?: ArrayLike<number>;
|
|
24
|
+
/** SQLite page-cache budget in MiB. Purely a latency knob: correctness and
|
|
25
|
+
* the per-operation storage-read count are identical with it at 0. */
|
|
26
|
+
cacheSizeMb?: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface QueryResult {
|
|
30
|
+
id: ExternalId;
|
|
31
|
+
/** Estimated cosine distance (1 - cosine). */
|
|
32
|
+
distance: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface StorageStats {
|
|
36
|
+
float32BytesPerVector: number;
|
|
37
|
+
codeBytesPerVector: number;
|
|
38
|
+
bytesPerVector: number;
|
|
39
|
+
compressionRatio: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* A persistent vector database: an adaptive PARTITIONED (IVF) index over
|
|
44
|
+
* 1-bit RaBitQ codes (cosine), stored entirely in SQLite at `dbPath`. See
|
|
45
|
+
* ivf.ts for the index design. The original float vectors are never
|
|
46
|
+
* retained — only the sign codes — so a 1024-d vector costs 128 bytes.
|
|
47
|
+
*
|
|
48
|
+
* Inserting is route-and-append: cost is essentially FLAT in collection size
|
|
49
|
+
* (one RAM scan of the pivot table + one chunk append — no per-insert graph
|
|
50
|
+
* walk). Query cost is bounded by nprobe × cluster size — constant in N
|
|
51
|
+
* once the collection is past its first splits.
|
|
52
|
+
*/
|
|
53
|
+
export class VectorDatabase {
|
|
54
|
+
private readonly index: IvfIndex;
|
|
55
|
+
|
|
56
|
+
constructor(options: DatabaseOptions) {
|
|
57
|
+
if (
|
|
58
|
+
!options || typeof options.dbPath !== "string" ||
|
|
59
|
+
options.dbPath.length === 0
|
|
60
|
+
) {
|
|
61
|
+
throw new Error("DatabaseOptions.dbPath (string) is required");
|
|
62
|
+
}
|
|
63
|
+
this.index = new IvfIndex(options.dbPath, options);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
get dim(): number {
|
|
67
|
+
return this.index.dim;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Number of live (non-deleted) vectors. */
|
|
71
|
+
get size(): number {
|
|
72
|
+
return this.index.size;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Physical slot count including tombstones from deletes/updates. */
|
|
76
|
+
get physicalSize(): number {
|
|
77
|
+
return this.index.physicalSize;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Number of clusters (partitions) currently in the index. */
|
|
81
|
+
get clusterCount(): number {
|
|
82
|
+
return this.index.clusterCount;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
get efSearch(): number {
|
|
86
|
+
return this.index.efSearch;
|
|
87
|
+
}
|
|
88
|
+
set efSearch(value: number) {
|
|
89
|
+
this.index.efSearch = value;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Distance computations performed during the most recent query. */
|
|
93
|
+
get lastQueryDistanceComputations(): number {
|
|
94
|
+
return this.index.lastQueryDistComps;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Storage row reads issued by the most recent query — the honest,
|
|
98
|
+
* cache-independent scalability metric. */
|
|
99
|
+
get lastQueryStorageReads(): number {
|
|
100
|
+
return this.index.lastQueryStorageReads;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
get storage(): StorageStats {
|
|
104
|
+
const f32 = this.dim * 4;
|
|
105
|
+
const bpv = this.index.bytesPerVector;
|
|
106
|
+
return {
|
|
107
|
+
float32BytesPerVector: f32,
|
|
108
|
+
codeBytesPerVector: bpv,
|
|
109
|
+
bytesPerVector: bpv,
|
|
110
|
+
compressionRatio: f32 / bpv,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
private checkId(id: ExternalId): number {
|
|
115
|
+
// 32-bit signed range: chunk blobs store exts as int32 — a wider id
|
|
116
|
+
// would silently truncate, so it is rejected at the door instead.
|
|
117
|
+
if (!Number.isInteger(id) || id > 0x7fffffff || id < -0x80000000) {
|
|
118
|
+
throw new Error(`External id must be a 32-bit integer, got ${id}`);
|
|
119
|
+
}
|
|
120
|
+
return id;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Convert a value to code bytes, selecting by length: `codeWords`
|
|
124
|
+
* elements → an existing 1-bit code; otherwise a raw `dim`-vector. */
|
|
125
|
+
private toCodeBytes(value: ArrayLike<number>): Uint8Array {
|
|
126
|
+
if (value.length === this.index.codeWords) {
|
|
127
|
+
return this.index.codeToBytes(value);
|
|
128
|
+
}
|
|
129
|
+
if (value.length !== this.dim) {
|
|
130
|
+
throw new Error(
|
|
131
|
+
`Vector dimension mismatch: expected ${this.dim}, got ${value.length}`,
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
return this.index.encodeToBytes(value);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
has(id: ExternalId): boolean {
|
|
138
|
+
return this.index.has(this.checkId(id));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Stream every live external id (bounded memory). */
|
|
142
|
+
*keys(): IterableIterator<ExternalId> {
|
|
143
|
+
yield* this.index.keys();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Stream live entries whose INTERNAL id is > `after` — a durable
|
|
147
|
+
* incremental watermark (internal ids are monotone at insert and preserved
|
|
148
|
+
* by update and compact). */
|
|
149
|
+
*keysSince(
|
|
150
|
+
after: number,
|
|
151
|
+
): IterableIterator<{ ext: ExternalId; internal: number }> {
|
|
152
|
+
yield* this.index.keysSince(after);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Read the stored 1-bit code for an id (a copy as a Uint32Array), or null. */
|
|
156
|
+
get(id: ExternalId): Uint32Array | null {
|
|
157
|
+
const bytes = this.index.codeOf(this.checkId(id));
|
|
158
|
+
return bytes ? this.index.bytesToCode(bytes) : null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
insert(id: ExternalId, value: ArrayLike<number>): void {
|
|
162
|
+
this.index.begin();
|
|
163
|
+
try {
|
|
164
|
+
this.index.insert(this.checkId(id), this.toCodeBytes(value));
|
|
165
|
+
this.index.commit();
|
|
166
|
+
} catch (e) {
|
|
167
|
+
this.index.rollback();
|
|
168
|
+
throw e;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
update(id: ExternalId, value: ArrayLike<number>): void {
|
|
173
|
+
this.index.begin();
|
|
174
|
+
try {
|
|
175
|
+
this.index.update(this.checkId(id), this.toCodeBytes(value));
|
|
176
|
+
this.index.commit();
|
|
177
|
+
} catch (e) {
|
|
178
|
+
this.index.rollback();
|
|
179
|
+
throw e;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
upsert(id: ExternalId, value: ArrayLike<number>): void {
|
|
184
|
+
this.upsertMany([{ id, vector: value }]);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Upsert many vectors under ONE transaction — one WAL commit for the whole
|
|
189
|
+
* batch instead of one per vector, with chunk appends to the same cluster
|
|
190
|
+
* coalesced in the index's write-back buffer.
|
|
191
|
+
*/
|
|
192
|
+
upsertMany(
|
|
193
|
+
entries: Array<{ id: ExternalId; vector: ArrayLike<number> }>,
|
|
194
|
+
): void {
|
|
195
|
+
if (entries.length === 0) return;
|
|
196
|
+
this.index.begin();
|
|
197
|
+
try {
|
|
198
|
+
for (const e of entries) {
|
|
199
|
+
// One shared vmap probe decides insert vs update — the per-row point
|
|
200
|
+
// query is the dominant bulk-load cost, so it is never duplicated.
|
|
201
|
+
this.index.upsert(this.checkId(e.id), this.toCodeBytes(e.vector));
|
|
202
|
+
}
|
|
203
|
+
this.index.commit();
|
|
204
|
+
} catch (e) {
|
|
205
|
+
this.index.rollback();
|
|
206
|
+
throw e;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Delete many ids under ONE transaction. Absent ids are skipped.
|
|
211
|
+
* Returns the number of vectors actually removed. */
|
|
212
|
+
deleteMany(ids: ExternalId[]): number {
|
|
213
|
+
if (ids.length === 0) return 0;
|
|
214
|
+
let removed = 0;
|
|
215
|
+
this.index.begin();
|
|
216
|
+
try {
|
|
217
|
+
for (const id of ids) {
|
|
218
|
+
if (this.index.remove(this.checkId(id))) removed++;
|
|
219
|
+
}
|
|
220
|
+
this.index.commit();
|
|
221
|
+
} catch (e) {
|
|
222
|
+
this.index.rollback();
|
|
223
|
+
throw e;
|
|
224
|
+
}
|
|
225
|
+
return removed;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Delete the vector bound to an id. Returns false if absent. */
|
|
229
|
+
delete(id: ExternalId): boolean {
|
|
230
|
+
return this.deleteMany([id]) === 1;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Heat the SQLite page cache with sequential scans (latency only). */
|
|
234
|
+
warmCache(): number {
|
|
235
|
+
return this.index.warmCache();
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Reclaim tombstoned slots by rewriting the clusters that carry any, then
|
|
239
|
+
* VACUUM. Internal ids and cluster assignment are preserved. */
|
|
240
|
+
compact(): void {
|
|
241
|
+
this.index.compact();
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* k-NN search. The argument's length selects the mode:
|
|
246
|
+
* - `dim` elements → a raw vector (accurate estimator)
|
|
247
|
+
* - `codeWords` elements → a 1-bit code, by sign-bit Hamming
|
|
248
|
+
*/
|
|
249
|
+
query(
|
|
250
|
+
query: ArrayLike<number>,
|
|
251
|
+
k = 10,
|
|
252
|
+
opts?: { ef?: number },
|
|
253
|
+
): QueryResult[] {
|
|
254
|
+
let hits: IvfHit[];
|
|
255
|
+
if (query.length === this.index.codeWords) {
|
|
256
|
+
hits = this.index.searchKnnByCode(
|
|
257
|
+
this.index.codeToBytes(query),
|
|
258
|
+
k,
|
|
259
|
+
opts?.ef,
|
|
260
|
+
);
|
|
261
|
+
} else {
|
|
262
|
+
if (query.length !== this.dim) {
|
|
263
|
+
throw new Error(
|
|
264
|
+
`Vector dimension mismatch: expected ${this.dim}, got ${query.length}`,
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
hits = this.index.searchKnn(query, k, opts?.ef);
|
|
268
|
+
}
|
|
269
|
+
return hits;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Close the underlying database. The instance must not be used afterwards. */
|
|
273
|
+
close(): void {
|
|
274
|
+
this.index.close();
|
|
275
|
+
}
|
|
276
|
+
}
|
|
@@ -5,11 +5,8 @@ export type {
|
|
|
5
5
|
QueryResult,
|
|
6
6
|
StorageStats,
|
|
7
7
|
} from "./database.js";
|
|
8
|
-
export {
|
|
9
|
-
export type {
|
|
8
|
+
export { IvfIndex } from "./ivf.js";
|
|
9
|
+
export type { IvfConfig, IvfHit } from "./ivf.js";
|
|
10
10
|
export { RaBitQuantizer } from "./rabitq.js";
|
|
11
11
|
export type { QueryContext, RaBitQOptions } from "./rabitq.js";
|
|
12
|
-
export { Store } from "./store.js";
|
|
13
|
-
export type { GlobalState, NodeRec, StoreConfig } from "./store.js";
|
|
14
|
-
export { Heap } from "./heap.js";
|
|
15
12
|
export { Prng } from "./prng.js";
|