sliftutils 1.4.79 → 1.4.81
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/index.d.ts +45 -1
- package/package.json +1 -1
- package/spec.txt +38 -1
- package/storage/BulkDatabase2/BulkDatabaseBase.d.ts +4 -1
- package/storage/BulkDatabase2/BulkDatabaseBase.ts +23 -7
- package/storage/embeddingFormats.d.ts +5 -0
- package/storage/embeddingFormats.ts +33 -0
- package/storage/proxydatabase/ivfEmbeddingDatabase.d.ts +31 -0
- package/storage/proxydatabase/ivfEmbeddingDatabase.ts +227 -0
- package/storage/proxydatabase/transactionSet.d.ts +1 -0
- package/storage/proxydatabase/transactionSet.ts +18 -8
- package/storage/remoteFileServer.ts +7 -0
package/index.d.ts
CHANGED
|
@@ -963,8 +963,11 @@ declare module "sliftutils/storage/BulkDatabase2/BulkDatabaseBase" {
|
|
|
963
963
|
private storageFactory;
|
|
964
964
|
private config;
|
|
965
965
|
constructor(name: string, deps: ReactiveDeps, storageFactory: StorageFactory, config?: BulkDatabase2Config);
|
|
966
|
+
private _reader;
|
|
967
|
+
private get reader();
|
|
968
|
+
private activated;
|
|
969
|
+
private activate;
|
|
966
970
|
private setupVisibilityMergeCheck;
|
|
967
|
-
private reader;
|
|
968
971
|
private subCaches;
|
|
969
972
|
private pendingAppends;
|
|
970
973
|
private flushTimer;
|
|
@@ -2282,6 +2285,11 @@ declare module "sliftutils/storage/embeddingFormats" {
|
|
|
2282
2285
|
}): StoredEmbedding;
|
|
2283
2286
|
export declare function serializeStoredEmbedding(stored: StoredEmbedding): string;
|
|
2284
2287
|
export declare function deserializeStoredEmbedding(base64: string): StoredEmbedding;
|
|
2288
|
+
export declare function averageEmbeddings(embeddings: StoredEmbedding[], config: {
|
|
2289
|
+
format: EmbeddingFormat;
|
|
2290
|
+
model: string;
|
|
2291
|
+
}): StoredEmbedding;
|
|
2292
|
+
export declare function hashEmbedding(stored: StoredEmbedding): string;
|
|
2285
2293
|
export declare const getCloseness: (embedding1: Float32Array | StoredEmbedding, embedding2: Float32Array | StoredEmbedding) => number;
|
|
2286
2294
|
|
|
2287
2295
|
}
|
|
@@ -2311,12 +2319,48 @@ declare module "sliftutils/storage/proxydatabase/Database" {
|
|
|
2311
2319
|
|
|
2312
2320
|
}
|
|
2313
2321
|
|
|
2322
|
+
declare module "sliftutils/storage/proxydatabase/ivfEmbeddingDatabase" {
|
|
2323
|
+
import { Database } from "./Database";
|
|
2324
|
+
import { TransactionSetStore } from "./transactionSet";
|
|
2325
|
+
import { StoredEmbedding, EmbeddingFormat } from "../embeddingFormats";
|
|
2326
|
+
export type IvfConfig = {
|
|
2327
|
+
model: string;
|
|
2328
|
+
format: EmbeddingFormat;
|
|
2329
|
+
cellTarget: number;
|
|
2330
|
+
splitAt: number;
|
|
2331
|
+
};
|
|
2332
|
+
export type IvfEmbeddingRoot = {
|
|
2333
|
+
config: IvfConfig;
|
|
2334
|
+
centroids: TransactionSetStore;
|
|
2335
|
+
cells: {
|
|
2336
|
+
[cellId: string]: TransactionSetStore;
|
|
2337
|
+
};
|
|
2338
|
+
refIndex: TransactionSetStore;
|
|
2339
|
+
};
|
|
2340
|
+
export type EmbeddingInput = {
|
|
2341
|
+
ref: string;
|
|
2342
|
+
embedding: StoredEmbedding;
|
|
2343
|
+
};
|
|
2344
|
+
export type SearchHit = {
|
|
2345
|
+
ref: string;
|
|
2346
|
+
closeness: number;
|
|
2347
|
+
};
|
|
2348
|
+
export declare function searchEmbeddings(database: Database<IvfEmbeddingRoot>, query: StoredEmbedding, options: {
|
|
2349
|
+
probeBudget: number;
|
|
2350
|
+
resultCount: number;
|
|
2351
|
+
}): SearchHit[] | undefined;
|
|
2352
|
+
export declare function insertEmbeddings(database: Database<IvfEmbeddingRoot>, items: EmbeddingInput[]): true | undefined;
|
|
2353
|
+
export declare function removeEmbeddings(database: Database<IvfEmbeddingRoot>, refs: string[]): true | undefined;
|
|
2354
|
+
|
|
2355
|
+
}
|
|
2356
|
+
|
|
2314
2357
|
declare module "sliftutils/storage/proxydatabase/transactionSet" {
|
|
2315
2358
|
import { Database } from "./Database";
|
|
2316
2359
|
export type TransactionSetStore = {
|
|
2317
2360
|
[fileNumber: string]: Uint8Array;
|
|
2318
2361
|
};
|
|
2319
2362
|
export declare function transactionRead<Value>(database: Database<TransactionSetStore>): Map<string, Value> | undefined;
|
|
2363
|
+
export declare function replayTransactionStore<Value>(store: TransactionSetStore | undefined): Map<string, Value>;
|
|
2320
2364
|
export declare function transactionMutate<Value>(database: Database<TransactionSetStore>, transactions: {
|
|
2321
2365
|
key: string;
|
|
2322
2366
|
value: Value | undefined;
|
package/package.json
CHANGED
package/spec.txt
CHANGED
|
@@ -1,5 +1,42 @@
|
|
|
1
1
|
|
|
2
|
+
Searching
|
|
3
|
+
1) We want to create something that's database agnostic
|
|
4
|
+
- So I think we need like multiple phases for reading. And for applying it? We need to be able to know all of our reads ahead of time, which we should be able to roughly know. Or at least we'll have multiple stages. That should be fine. I guess we could do it so it actually understands our database. Like it reads, and then if it's not there, it returns. That might be fine too.
|
|
2
5
|
|
|
6
|
+
interface Database<Root> {
|
|
7
|
+
readData: <T>(fnc: (root: Root) => T) => T | undefined;
|
|
8
|
+
writeData: <T>(fnc: (root: Root) => T, newValue: T) => void;
|
|
9
|
+
}
|
|
10
|
+
I guess this works. We need multiple stages, so it's gonna have to read in the index, In parallel, and then once it has the full index, then it can do the next step in parallel, etc.
|
|
3
11
|
|
|
4
12
|
|
|
5
|
-
|
|
13
|
+
remaining
|
|
14
|
+
1) The code book creation. We kind of do want to recreate it sometimes. Maybe we can have something that does a full expensive recreation at certain early on points?
|
|
15
|
+
2) The searching there's a few different stages. Like, we want to search more IVF cells, but then even within the cells, we kind of want to download more of the final embeddings. because the codes aren't gonna to be perfect. So we have to do both at the same time?
|
|
16
|
+
|
|
17
|
+
parts
|
|
18
|
+
p8g8 format (q8), so we can store 2048 dimensions is a bit over 2KB
|
|
19
|
+
IVF cell size 32? PQ index reduces by 32X, and if IVF cells reduce by 32X... then 500K 2KB values (1GB) would still just be... 1MB IVF index, so... that's good.
|
|
20
|
+
0) In IVF, sample 32, then keep sampling more, up to 128 cells
|
|
21
|
+
1) Pq 64 index (byte values each, break into 64 groups, always... should work well, For face vectors, they're already really optimized. They have so few dimensions, and so we don't compress it by that much. But for text vectors, they're less compressed, so we compress it more. So it should work in all cases)
|
|
22
|
+
2) Retraining index on a subset
|
|
23
|
+
- Or... just don't. Freeze after a certain point, and... it might be fine? HMM... Although no, we could allow retraining. It should be cheap enough.
|
|
24
|
+
3) Split into transaction logs, so we can read the PQ index efficiently, but still update it
|
|
25
|
+
UGH... I mean basically you would just be replicating some of what we actually store on disk, but in our database, which is stupid, but I think we'll probably do it anyway. Basically, just a transaction log. We have to read everything every time, but it should help the speed a little bit...
|
|
26
|
+
- And I think it's fair because it's us saying, hey, we're going to read all this data every time, which the database can't know, so it can't optimize for that, but we can know, and then we can optimize for it
|
|
27
|
+
4) IVF the pq index to vastly decrease the initial access time
|
|
28
|
+
5.0) automatically split cells when they get big enough (2X their initial size)
|
|
29
|
+
5) occasionally regenerate the entire IVF.
|
|
30
|
+
5.1) Have something to store a subset of the data codes?
|
|
31
|
+
5.1) Have something that stores some of the data randomly so we can easily access a subset. We won't represent deletions in it. That's fine. This will allow us to rebalance the IVF fairly efficiently.
|
|
32
|
+
- And maybe even slowly move stuff over to the new IVF as we access stuff, we see if it's in the new IVF, and if not, we move it?
|
|
33
|
+
- 10% of the data? And... maybe just the pq index values?
|
|
34
|
+
- Should make retraining the IVF ten times faster and shouldn't affect the recall by too much, especially if we have more cells
|
|
35
|
+
- AND, Maybe we can actually sample less the more data we have. Ramping up to 10%.
|
|
36
|
+
unknown
|
|
37
|
+
exactly how we're going to update the IVF.
|
|
38
|
+
IVF pq index, so we can read part of it
|
|
39
|
+
mini transaction logs in each cell?
|
|
40
|
+
|
|
41
|
+
BUT... how do we update IVF
|
|
42
|
+
|
|
@@ -35,8 +35,11 @@ export declare class BulkDatabaseBase<T extends {
|
|
|
35
35
|
private storageFactory;
|
|
36
36
|
private config;
|
|
37
37
|
constructor(name: string, deps: ReactiveDeps, storageFactory: StorageFactory, config?: BulkDatabase2Config);
|
|
38
|
+
private _reader;
|
|
39
|
+
private get reader();
|
|
40
|
+
private activated;
|
|
41
|
+
private activate;
|
|
38
42
|
private setupVisibilityMergeCheck;
|
|
39
|
-
private reader;
|
|
40
43
|
private subCaches;
|
|
41
44
|
private pendingAppends;
|
|
42
45
|
private flushTimer;
|
|
@@ -148,14 +148,31 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
148
148
|
protected deps: ReactiveDeps,
|
|
149
149
|
private storageFactory: StorageFactory,
|
|
150
150
|
private config: BulkDatabase2Config = {},
|
|
151
|
-
) {
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
151
|
+
) { }
|
|
152
|
+
|
|
153
|
+
// The reader (and the background machinery that rides along with it) must NOT be set up just
|
|
154
|
+
// because the collection was constructed. Many collections are constructed and never touched, and
|
|
155
|
+
// in Node a merge tick would poke the storage factory (e.g. indexedDB) and throw. We build it
|
|
156
|
+
// lazily on first access to `this.reader`: every read, write, and merge goes through the reader,
|
|
157
|
+
// while pure construction never touches it.
|
|
158
|
+
private _reader: BulkDatabaseReader<T> | undefined;
|
|
159
|
+
private get reader(): BulkDatabaseReader<T> {
|
|
160
|
+
if (this._reader) return this._reader;
|
|
161
|
+
const reader = new BulkDatabaseReader<T>({
|
|
162
|
+
name: this.name,
|
|
163
|
+
deps: this.deps,
|
|
164
|
+
maxTriggerThrottleMs: this.config.maxTriggerThrottleMs,
|
|
156
165
|
});
|
|
157
|
-
|
|
166
|
+
reader.setEnsureIndex(() => this.ensureIndex());
|
|
167
|
+
this._reader = reader;
|
|
168
|
+
this.activate();
|
|
169
|
+
return reader;
|
|
170
|
+
}
|
|
158
171
|
|
|
172
|
+
private activated = false;
|
|
173
|
+
private activate(): void {
|
|
174
|
+
if (this.activated) return;
|
|
175
|
+
this.activated = true;
|
|
159
176
|
if (typeof window !== "undefined") {
|
|
160
177
|
try {
|
|
161
178
|
window.addEventListener("pagehide", () => void this.flushPending());
|
|
@@ -205,7 +222,6 @@ export class BulkDatabaseBase<T extends { key: string }> {
|
|
|
205
222
|
} catch { /* not in a DOM context */ }
|
|
206
223
|
}
|
|
207
224
|
|
|
208
|
-
private reader: BulkDatabaseReader<T>;
|
|
209
225
|
private subCaches: SubReaderCaches = { bulk: new Map(), stream: new Map() };
|
|
210
226
|
|
|
211
227
|
private pendingAppends: { framed: Buffer; rows: number }[] = [];
|
|
@@ -21,4 +21,9 @@ export declare function encodeEmbedding(config: {
|
|
|
21
21
|
}): StoredEmbedding;
|
|
22
22
|
export declare function serializeStoredEmbedding(stored: StoredEmbedding): string;
|
|
23
23
|
export declare function deserializeStoredEmbedding(base64: string): StoredEmbedding;
|
|
24
|
+
export declare function averageEmbeddings(embeddings: StoredEmbedding[], config: {
|
|
25
|
+
format: EmbeddingFormat;
|
|
26
|
+
model: string;
|
|
27
|
+
}): StoredEmbedding;
|
|
28
|
+
export declare function hashEmbedding(stored: StoredEmbedding): string;
|
|
24
29
|
export declare const getCloseness: (embedding1: Float32Array | StoredEmbedding, embedding2: Float32Array | StoredEmbedding) => number;
|
|
@@ -273,6 +273,39 @@ export function deserializeStoredEmbedding(base64: string): StoredEmbedding {
|
|
|
273
273
|
};
|
|
274
274
|
}
|
|
275
275
|
|
|
276
|
+
// Mean of several embeddings (decoded to their common length), re-encoded into the requested format
|
|
277
|
+
// (which renormalizes). Used to build and split IVF cell centroids.
|
|
278
|
+
export function averageEmbeddings(embeddings: StoredEmbedding[], config: { format: EmbeddingFormat; model: string }): StoredEmbedding {
|
|
279
|
+
let length = Infinity;
|
|
280
|
+
for (let embedding of embeddings) {
|
|
281
|
+
length = Math.min(length, embeddingLength(embedding));
|
|
282
|
+
}
|
|
283
|
+
let sum = new Float32Array(length);
|
|
284
|
+
for (let embedding of embeddings) {
|
|
285
|
+
let decoded = decodeToLength(embedding, length);
|
|
286
|
+
for (let dimension = 0; dimension < length; dimension++) {
|
|
287
|
+
sum[dimension] += decoded[dimension];
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
for (let dimension = 0; dimension < length; dimension++) {
|
|
291
|
+
sum[dimension] /= embeddings.length;
|
|
292
|
+
}
|
|
293
|
+
return encodeEmbedding({ input: sum, format: config.format, model: config.model });
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// A stable 16-byte (base64) content hash of an embedding, for use as a cell id derived from its centroid.
|
|
297
|
+
export function hashEmbedding(stored: StoredEmbedding): string {
|
|
298
|
+
let serialized = serializeStoredEmbedding(stored);
|
|
299
|
+
let lanes = new Uint32Array([2166136261, 2654435761, 40503, 3266489917]);
|
|
300
|
+
for (let charIndex = 0; charIndex < serialized.length; charIndex++) {
|
|
301
|
+
let code = serialized.charCodeAt(charIndex);
|
|
302
|
+
for (let lane = 0; lane < lanes.length; lane++) {
|
|
303
|
+
lanes[lane] = Math.imul(lanes[lane] ^ (code + lane * 131 + charIndex), 16777619);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
return Buffer.from(lanes.buffer).toString("base64");
|
|
307
|
+
}
|
|
308
|
+
|
|
276
309
|
// Compare two embeddings in any format / truncation. Both are decoded, truncated to their common
|
|
277
310
|
// length, and unit-normalized, then scored as 1 minus their euclidean distance.
|
|
278
311
|
export const getCloseness = measureWrap(function getCloseness(
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { Database } from "./Database";
|
|
2
|
+
import { TransactionSetStore } from "./transactionSet";
|
|
3
|
+
import { StoredEmbedding, EmbeddingFormat } from "../embeddingFormats";
|
|
4
|
+
export type IvfConfig = {
|
|
5
|
+
model: string;
|
|
6
|
+
format: EmbeddingFormat;
|
|
7
|
+
cellTarget: number;
|
|
8
|
+
splitAt: number;
|
|
9
|
+
};
|
|
10
|
+
export type IvfEmbeddingRoot = {
|
|
11
|
+
config: IvfConfig;
|
|
12
|
+
centroids: TransactionSetStore;
|
|
13
|
+
cells: {
|
|
14
|
+
[cellId: string]: TransactionSetStore;
|
|
15
|
+
};
|
|
16
|
+
refIndex: TransactionSetStore;
|
|
17
|
+
};
|
|
18
|
+
export type EmbeddingInput = {
|
|
19
|
+
ref: string;
|
|
20
|
+
embedding: StoredEmbedding;
|
|
21
|
+
};
|
|
22
|
+
export type SearchHit = {
|
|
23
|
+
ref: string;
|
|
24
|
+
closeness: number;
|
|
25
|
+
};
|
|
26
|
+
export declare function searchEmbeddings(database: Database<IvfEmbeddingRoot>, query: StoredEmbedding, options: {
|
|
27
|
+
probeBudget: number;
|
|
28
|
+
resultCount: number;
|
|
29
|
+
}): SearchHit[] | undefined;
|
|
30
|
+
export declare function insertEmbeddings(database: Database<IvfEmbeddingRoot>, items: EmbeddingInput[]): true | undefined;
|
|
31
|
+
export declare function removeEmbeddings(database: Database<IvfEmbeddingRoot>, refs: string[]): true | undefined;
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { Database, namespaceDatabase } from "./Database";
|
|
2
|
+
import { TransactionSetStore, transactionRead, transactionMutate, replayTransactionStore } from "./transactionSet";
|
|
3
|
+
import { StoredEmbedding, EmbeddingFormat, getCloseness, averageEmbeddings, hashEmbedding } from "../embeddingFormats";
|
|
4
|
+
|
|
5
|
+
export type IvfConfig = {
|
|
6
|
+
model: string;
|
|
7
|
+
format: EmbeddingFormat;
|
|
8
|
+
cellTarget: number;
|
|
9
|
+
splitAt: number;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
// Cell ids ARE the hash of the cell's centroid, so there's no id counter. centroids maps that id to the
|
|
13
|
+
// centroid embedding (read whole as the preload, ranked in RAM). Each cell is its own transaction set of
|
|
14
|
+
// ref → member embedding. refIndex maps a ref to the cell holding it, so a remove can find it.
|
|
15
|
+
export type IvfEmbeddingRoot = {
|
|
16
|
+
config: IvfConfig;
|
|
17
|
+
centroids: TransactionSetStore;
|
|
18
|
+
cells: { [cellId: string]: TransactionSetStore };
|
|
19
|
+
refIndex: TransactionSetStore;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type EmbeddingInput = { ref: string; embedding: StoredEmbedding };
|
|
23
|
+
export type SearchHit = { ref: string; closeness: number };
|
|
24
|
+
|
|
25
|
+
function centroidStore(database: Database<IvfEmbeddingRoot>): Database<TransactionSetStore> {
|
|
26
|
+
return namespaceDatabase(database, root => root.centroids);
|
|
27
|
+
}
|
|
28
|
+
function cellStore(database: Database<IvfEmbeddingRoot>, cellId: string): Database<TransactionSetStore> {
|
|
29
|
+
return namespaceDatabase(database, root => root.cells[cellId]);
|
|
30
|
+
}
|
|
31
|
+
function refIndexStore(database: Database<IvfEmbeddingRoot>): Database<TransactionSetStore> {
|
|
32
|
+
return namespaceDatabase(database, root => root.refIndex);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function nearestCell(embedding: StoredEmbedding, centroids: Map<string, StoredEmbedding>): string {
|
|
36
|
+
let bestCellId = "";
|
|
37
|
+
let bestCloseness = -Infinity;
|
|
38
|
+
for (const [cellId, centroid] of centroids) {
|
|
39
|
+
const closeness = getCloseness(embedding, centroid);
|
|
40
|
+
if (closeness > bestCloseness) {
|
|
41
|
+
bestCloseness = closeness;
|
|
42
|
+
bestCellId = cellId;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return bestCellId;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// 2-means on a cell's members so an overgrown cell can split in place. Seeds with one member and the one
|
|
49
|
+
// farthest from it, then alternates assign / recompute-centroid a few times.
|
|
50
|
+
function splitInTwo(
|
|
51
|
+
members: [string, StoredEmbedding][],
|
|
52
|
+
config: IvfConfig,
|
|
53
|
+
): { centroid: StoredEmbedding; members: [string, StoredEmbedding][] }[] {
|
|
54
|
+
let centroidA = members[0][1];
|
|
55
|
+
let centroidB = members[0][1];
|
|
56
|
+
let worst = Infinity;
|
|
57
|
+
for (const [, embedding] of members) {
|
|
58
|
+
const closeness = getCloseness(centroidA, embedding);
|
|
59
|
+
if (closeness < worst) {
|
|
60
|
+
worst = closeness;
|
|
61
|
+
centroidB = embedding;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
let groupA: [string, StoredEmbedding][] = [];
|
|
65
|
+
let groupB: [string, StoredEmbedding][] = [];
|
|
66
|
+
for (let iteration = 0; iteration < 5; iteration++) {
|
|
67
|
+
groupA = [];
|
|
68
|
+
groupB = [];
|
|
69
|
+
for (const member of members) {
|
|
70
|
+
if (getCloseness(centroidA, member[1]) >= getCloseness(centroidB, member[1])) {
|
|
71
|
+
groupA.push(member);
|
|
72
|
+
} else {
|
|
73
|
+
groupB.push(member);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (!groupA.length || !groupB.length) {
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
79
|
+
centroidA = averageEmbeddings(groupA.map(member => member[1]), config);
|
|
80
|
+
centroidB = averageEmbeddings(groupB.map(member => member[1]), config);
|
|
81
|
+
}
|
|
82
|
+
const result: { centroid: StoredEmbedding; members: [string, StoredEmbedding][] }[] = [];
|
|
83
|
+
if (groupA.length) {
|
|
84
|
+
result.push({ centroid: centroidA, members: groupA });
|
|
85
|
+
}
|
|
86
|
+
if (groupB.length) {
|
|
87
|
+
result.push({ centroid: centroidB, members: groupB });
|
|
88
|
+
}
|
|
89
|
+
return result;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Nearest cells until probeBudget members are covered, scored exactly against the query. undefined while
|
|
93
|
+
// not synced. All probed cells are fetched in one batched read so probing doesn't cascade per cell.
|
|
94
|
+
export function searchEmbeddings(
|
|
95
|
+
database: Database<IvfEmbeddingRoot>,
|
|
96
|
+
query: StoredEmbedding,
|
|
97
|
+
options: { probeBudget: number; resultCount: number },
|
|
98
|
+
): SearchHit[] | undefined {
|
|
99
|
+
const config = database.readData(root => root.config);
|
|
100
|
+
if (!config) return undefined;
|
|
101
|
+
const centroids = transactionRead<StoredEmbedding>(centroidStore(database));
|
|
102
|
+
if (!centroids) return undefined;
|
|
103
|
+
if (!centroids.size) return [];
|
|
104
|
+
|
|
105
|
+
const rankedCellIds = [...centroids.entries()]
|
|
106
|
+
.map(([cellId, centroid]) => ({ cellId, closeness: getCloseness(query, centroid) }))
|
|
107
|
+
.sort((left, right) => right.closeness - left.closeness)
|
|
108
|
+
.map(ranked => ranked.cellId);
|
|
109
|
+
|
|
110
|
+
const probeCellCount = Math.max(1, Math.ceil(options.probeBudget / config.cellTarget));
|
|
111
|
+
const probeCellIds = rankedCellIds.slice(0, probeCellCount);
|
|
112
|
+
const stores = database.readData(root => probeCellIds.map(cellId => root.cells[cellId]));
|
|
113
|
+
if (!stores) return undefined;
|
|
114
|
+
|
|
115
|
+
const hits: SearchHit[] = [];
|
|
116
|
+
for (const store of stores) {
|
|
117
|
+
const members = replayTransactionStore<StoredEmbedding>(store);
|
|
118
|
+
for (const [ref, embedding] of members) {
|
|
119
|
+
hits.push({ ref, closeness: getCloseness(query, embedding) });
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
hits.sort((left, right) => right.closeness - left.closeness);
|
|
123
|
+
return hits.slice(0, options.resultCount);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Assign each embedding to its nearest cell and add it. An empty index bootstraps a first cell from the
|
|
127
|
+
// inserted batch; a cell that crosses splitAt*cellTarget splits in place. undefined while not synced.
|
|
128
|
+
export function insertEmbeddings(
|
|
129
|
+
database: Database<IvfEmbeddingRoot>,
|
|
130
|
+
items: EmbeddingInput[],
|
|
131
|
+
): true | undefined {
|
|
132
|
+
if (!items.length) return true;
|
|
133
|
+
const config = database.readData(root => root.config);
|
|
134
|
+
if (!config) return undefined;
|
|
135
|
+
const centroids = transactionRead<StoredEmbedding>(centroidStore(database));
|
|
136
|
+
if (!centroids) return undefined;
|
|
137
|
+
|
|
138
|
+
const newCentroids: { key: string; value: StoredEmbedding | undefined }[] = [];
|
|
139
|
+
if (!centroids.size) {
|
|
140
|
+
const centroid = averageEmbeddings(items.map(item => item.embedding), config);
|
|
141
|
+
const cellId = hashEmbedding(centroid);
|
|
142
|
+
centroids.set(cellId, centroid);
|
|
143
|
+
newCentroids.push({ key: cellId, value: centroid });
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const itemsByCell = new Map<string, EmbeddingInput[]>();
|
|
147
|
+
for (const item of items) {
|
|
148
|
+
const cellId = nearestCell(item.embedding, centroids);
|
|
149
|
+
let group = itemsByCell.get(cellId);
|
|
150
|
+
if (!group) {
|
|
151
|
+
group = [];
|
|
152
|
+
itemsByCell.set(cellId, group);
|
|
153
|
+
}
|
|
154
|
+
group.push(item);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const targetCellIds = [...itemsByCell.keys()];
|
|
158
|
+
const existingStores = database.readData(root => targetCellIds.map(cellId => root.cells[cellId]));
|
|
159
|
+
if (!existingStores) return undefined;
|
|
160
|
+
|
|
161
|
+
const splitThreshold = config.splitAt * config.cellTarget;
|
|
162
|
+
const refWrites: { key: string; value: string | undefined }[] = [];
|
|
163
|
+
for (let cellIndex = 0; cellIndex < targetCellIds.length; cellIndex++) {
|
|
164
|
+
const cellId = targetCellIds[cellIndex];
|
|
165
|
+
const group = itemsByCell.get(cellId)!;
|
|
166
|
+
const combined = replayTransactionStore<StoredEmbedding>(existingStores[cellIndex]);
|
|
167
|
+
for (const item of group) {
|
|
168
|
+
combined.set(item.ref, item.embedding);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (combined.size <= splitThreshold) {
|
|
172
|
+
const memberWrites = group.map(item => ({ key: item.ref, value: item.embedding }));
|
|
173
|
+
if (!transactionMutate(cellStore(database, cellId), memberWrites)) return undefined;
|
|
174
|
+
for (const item of group) {
|
|
175
|
+
refWrites.push({ key: item.ref, value: cellId });
|
|
176
|
+
}
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const subCells = splitInTwo([...combined.entries()], config);
|
|
181
|
+
for (const subCell of subCells) {
|
|
182
|
+
const subCellId = hashEmbedding(subCell.centroid);
|
|
183
|
+
newCentroids.push({ key: subCellId, value: subCell.centroid });
|
|
184
|
+
const memberWrites = subCell.members.map(([ref, embedding]) => ({ key: ref, value: embedding }));
|
|
185
|
+
if (!transactionMutate(cellStore(database, subCellId), memberWrites)) return undefined;
|
|
186
|
+
for (const [ref] of subCell.members) {
|
|
187
|
+
refWrites.push({ key: ref, value: subCellId });
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
newCentroids.push({ key: cellId, value: undefined });
|
|
191
|
+
database.deleteData(root => root.cells[cellId]);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (newCentroids.length && !transactionMutate(centroidStore(database), newCentroids)) return undefined;
|
|
195
|
+
if (refWrites.length && !transactionMutate(refIndexStore(database), refWrites)) return undefined;
|
|
196
|
+
return true;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Tombstone each ref in its cell (found via refIndex) and drop it from the index. undefined while not synced.
|
|
200
|
+
export function removeEmbeddings(
|
|
201
|
+
database: Database<IvfEmbeddingRoot>,
|
|
202
|
+
refs: string[],
|
|
203
|
+
): true | undefined {
|
|
204
|
+
if (!refs.length) return true;
|
|
205
|
+
const refIndex = transactionRead<string>(refIndexStore(database));
|
|
206
|
+
if (!refIndex) return undefined;
|
|
207
|
+
|
|
208
|
+
const refsByCell = new Map<string, string[]>();
|
|
209
|
+
for (const ref of refs) {
|
|
210
|
+
const cellId = refIndex.get(ref);
|
|
211
|
+
if (!cellId) continue;
|
|
212
|
+
let group = refsByCell.get(cellId);
|
|
213
|
+
if (!group) {
|
|
214
|
+
group = [];
|
|
215
|
+
refsByCell.set(cellId, group);
|
|
216
|
+
}
|
|
217
|
+
group.push(ref);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
for (const [cellId, group] of refsByCell) {
|
|
221
|
+
const memberDeletes = group.map(ref => ({ key: ref, value: undefined }));
|
|
222
|
+
if (!transactionMutate<StoredEmbedding>(cellStore(database, cellId), memberDeletes)) return undefined;
|
|
223
|
+
}
|
|
224
|
+
const refDeletes = refs.map(ref => ({ key: ref, value: undefined }));
|
|
225
|
+
if (!transactionMutate<string>(refIndexStore(database), refDeletes)) return undefined;
|
|
226
|
+
return true;
|
|
227
|
+
}
|
|
@@ -3,6 +3,7 @@ export type TransactionSetStore = {
|
|
|
3
3
|
[fileNumber: string]: Uint8Array;
|
|
4
4
|
};
|
|
5
5
|
export declare function transactionRead<Value>(database: Database<TransactionSetStore>): Map<string, Value> | undefined;
|
|
6
|
+
export declare function replayTransactionStore<Value>(store: TransactionSetStore | undefined): Map<string, Value>;
|
|
6
7
|
export declare function transactionMutate<Value>(database: Database<TransactionSetStore>, transactions: {
|
|
7
8
|
key: string;
|
|
8
9
|
value: Value | undefined;
|
|
@@ -21,6 +21,17 @@ function maxNumber(values: number[], fallback: number): number {
|
|
|
21
21
|
return result;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
function decodeBuffers(buffers: Uint8Array[]): Transaction[] {
|
|
25
|
+
const transactions: Transaction[] = [];
|
|
26
|
+
for (const buffer of buffers) {
|
|
27
|
+
const decoded = transactionCbor.decode(buffer) as Transaction[];
|
|
28
|
+
for (const transaction of decoded) {
|
|
29
|
+
transactions.push(transaction);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return transactions;
|
|
33
|
+
}
|
|
34
|
+
|
|
24
35
|
function readTransactionFiles(
|
|
25
36
|
database: Database<TransactionSetStore>,
|
|
26
37
|
): { fileKeys: string[]; transactions: Transaction[] } | undefined {
|
|
@@ -29,14 +40,7 @@ function readTransactionFiles(
|
|
|
29
40
|
buffers: Object.values(store),
|
|
30
41
|
}));
|
|
31
42
|
if (!snapshot) return undefined;
|
|
32
|
-
|
|
33
|
-
for (const buffer of snapshot.buffers) {
|
|
34
|
-
const decoded = transactionCbor.decode(buffer) as Transaction[];
|
|
35
|
-
for (const transaction of decoded) {
|
|
36
|
-
transactions.push(transaction);
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
return { fileKeys: snapshot.fileKeys, transactions };
|
|
43
|
+
return { fileKeys: snapshot.fileKeys, transactions: decodeBuffers(snapshot.buffers) };
|
|
40
44
|
}
|
|
41
45
|
|
|
42
46
|
function replay(transactions: Transaction[]): Map<string, unknown> {
|
|
@@ -77,6 +81,12 @@ export function transactionRead<Value>(
|
|
|
77
81
|
return replay(files.transactions) as Map<string, Value>;
|
|
78
82
|
}
|
|
79
83
|
|
|
84
|
+
// Replay an already-read store object (no read of its own), so a caller can fetch many sets in one batched read and then materialize each.
|
|
85
|
+
export function replayTransactionStore<Value>(store: TransactionSetStore | undefined): Map<string, Value> {
|
|
86
|
+
if (!store) return new Map();
|
|
87
|
+
return replay(decodeBuffers(Object.values(store))) as Map<string, Value>;
|
|
88
|
+
}
|
|
89
|
+
|
|
80
90
|
export function transactionMutate<Value>(
|
|
81
91
|
database: Database<TransactionSetStore>,
|
|
82
92
|
transactions: { key: string; value: Value | undefined }[],
|
|
@@ -12,6 +12,13 @@ import { WebSocketServer, WebSocket } from "ws";
|
|
|
12
12
|
import { BulkDatabaseBase, bulkDatabase2Timing, noopReactiveDeps, BULK_ROOT_FOLDER } from "./BulkDatabase2/BulkDatabaseBase";
|
|
13
13
|
import { wrapHandle, NodeJSDirectoryHandleWrapper, DirectoryWrapper } from "./FileFolderAPI";
|
|
14
14
|
|
|
15
|
+
process.on("uncaughtException", err => {
|
|
16
|
+
console.error(`[writeServer] uncaughtException (continuing):`, (err as Error).stack ?? err);
|
|
17
|
+
});
|
|
18
|
+
process.on("unhandledRejection", reason => {
|
|
19
|
+
console.error(`[writeServer] unhandledRejection (continuing):`, (reason as Error)?.stack ?? reason);
|
|
20
|
+
});
|
|
21
|
+
|
|
15
22
|
// Remote file server for sliftutils getRemoteFileStorage / BulkDatabase2. Serves one folder on disk over
|
|
16
23
|
// self-signed HTTPS, authenticated with an auto-generated 6-word password (sent as
|
|
17
24
|
// `Authorization: Bearer <password>`). Run it with `yarn filehoster <folder>` (or the `filehoster` bin).
|