-
Notifications
You must be signed in to change notification settings - Fork 4
Expand file tree
/
Copy pathIndexedDbVectorStorage.ts
More file actions
185 lines (167 loc) · 6.6 KB
/
Copy pathIndexedDbVectorStorage.ts
File metadata and controls
185 lines (167 loc) · 6.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
/**
* @license
* Copyright 2025 Steven Roussey <sroussey@gmail.com>
* SPDX-License-Identifier: Apache-2.0
*/
import type {
AnyVectorStorage,
AutoGeneratedKeys,
ClientProvidedKeysOption,
InsertEntity,
IVectorStorage,
VectorSearchOptions,
} from "@workglow/storage";
import {
assertVectorShape,
getMetadataProperty,
getVectorProperty,
matchesFilter,
safeEmit,
validateVectorEntities,
} from "@workglow/storage";
import type { EventEmitter } from "@workglow/util";
import { createServiceToken } from "@workglow/util";
import type {
DataPortSchemaObject,
FromSchema,
TypedArray,
TypedArrayConstructor,
TypedArraySchemaOptions,
} from "@workglow/util/schema";
import { cosineSimilarity } from "@workglow/util/schema";
import type { MigrationOptions } from "./IndexedDbTable";
import { IndexedDbTabularStorage } from "./IndexedDbTabularStorage";
export const IDB_VECTOR_REPOSITORY = createServiceToken<AnyVectorStorage>(
"storage.vectorRepository.indexedDb"
);
/**
* IndexedDB vector storage implementation.
* Extends IndexedDbTabularStorage for storage.
* Suitable for browser applications that need persistent vector storage.
* No vector serialization needed since IndexedDB supports TypedArrays
* natively via structured clone.
*
* @template Schema - The schema definition for the entity
* @template PrimaryKeyNames - The primary key names
* @template Metadata - The metadata type for the vector
* @template VectorCtor - Constructor for stored vectors (default {@link typeof Float32Array})
* @template Entity - The entity type
*/
export class IndexedDbVectorStorage<
Schema extends DataPortSchemaObject,
PrimaryKeyNames extends ReadonlyArray<keyof Schema["properties"]>,
Metadata extends Record<string, unknown> = Record<string, unknown>,
Entity = FromSchema<Schema, TypedArraySchemaOptions>,
InsertType extends InsertEntity<Entity, AutoGeneratedKeys<Schema>> = InsertEntity<
Entity,
AutoGeneratedKeys<Schema>
>,
>
extends IndexedDbTabularStorage<Schema, PrimaryKeyNames, Entity>
implements IVectorStorage<Metadata, Schema, Entity, PrimaryKeyNames>
{
private vectorDimensions: number;
private vectorPropertyName: keyof Entity;
private metadataPropertyName: keyof Entity | undefined;
constructor(
table: string = "vectors",
schema: Schema,
primaryKeyNames: PrimaryKeyNames,
indexes: readonly (keyof NoInfer<Entity> | readonly (keyof NoInfer<Entity>)[])[] = [],
dimensions: number,
_vectorCtor: TypedArrayConstructor = Float32Array,
migrationOptions: MigrationOptions = {},
clientProvidedKeys: ClientProvidedKeysOption = "if-missing"
) {
super(table, schema, primaryKeyNames, indexes, migrationOptions, clientProvidedKeys);
this.vectorDimensions = dimensions;
const vectorProp = getVectorProperty(schema);
if (!vectorProp) {
throw new Error("Schema must have a property with type array and format TypedArray");
}
this.vectorPropertyName = vectorProp as keyof Entity;
this.metadataPropertyName = getMetadataProperty(schema) as keyof Entity | undefined;
}
getVectorDimensions(): number {
return this.vectorDimensions;
}
/**
* Validate vector shape SYNCHRONOUSLY before any IDB transaction opens.
* The throw propagates through the awaited Promise; no IDB request is ever
* created, so a malformed put cannot leak a corrupt row into the object store.
*/
override async put(record: InsertType): Promise<Entity> {
assertVectorShape(
(record as Record<string, unknown>)[this.vectorPropertyName as string],
this.vectorDimensions,
"write"
);
return super.put(record);
}
/**
* Validate every entry up front so a single malformed vector aborts the
* whole batch before any row is written, then route through a single IDB
* `readwrite` transaction for atomicity. The inherited tabular `putBulk`
* opens one transaction per row via `Promise.all`, so a duplicate-key or
* quota error on the second or later record can leave earlier rows
* committed — breaking the all-or-nothing guarantee vector ingestion (RAG
* chunk upserts) depends on. The single-transaction path defers `put` event
* emission to `tx.oncomplete`, which means subscribers see a successful
* batch as a burst rather than interleaved with the per-record IDB requests.
*/
override async putBulk(records: InsertType[]): Promise<Entity[]> {
validateVectorEntities(
records as ReadonlyArray<Record<string, unknown>>,
this.vectorPropertyName as string,
this.vectorDimensions
);
return this.putBulkInTransaction(records);
}
async similaritySearch(
query: TypedArray,
options: VectorSearchOptions<Record<string, unknown>> = {}
) {
assertVectorShape(query, this.vectorDimensions, "query");
// Default to no floor: cosine similarity ranges over [-1, 1], so a default
// of 0 would silently drop negatively-correlated hits and return fewer than
// `topK`. Callers opt into a relevance floor explicitly via `scoreThreshold`.
const { topK = 10, filter, scoreThreshold = -Infinity } = options;
const results: Array<Entity & { score: number }> = [];
const allEntities = (await this.getAll()) || [];
for (const entity of allEntities) {
// IndexedDB stores TypedArrays natively via structured clone
const vector = entity[this.vectorPropertyName] as TypedArray;
// A present-but-null metadata column value coalesces to `{}` so a filtered
// search treats the row as a non-match rather than dereferencing null.
const rawMetadata = this.metadataPropertyName ? entity[this.metadataPropertyName] : undefined;
const metadata = (rawMetadata ?? {}) as Metadata;
if (filter && !matchesFilter(metadata, filter)) {
continue;
}
const score = cosineSimilarity(query, vector);
if (score < scoreThreshold) {
continue;
}
results.push({
...entity,
score,
} as Entity & { score: number });
}
results.sort((a, b) => b.score - a.score);
const topResults = results.slice(0, topK);
// The inherited `events` emitter is typed for the tabular event surface;
// `similaritySearch` lives on the vector extension of that surface. The
// emitter instance is the same object, so widen the view to a record that
// carries the event so it can be emitted type-safely.
type SimilaritySearchEvents = {
similaritySearch: (query: TypedArray, results: (Entity & { score: number })[]) => void;
};
safeEmit(
this.events as unknown as EventEmitter<SimilaritySearchEvents>,
"similaritySearch",
query,
topResults
);
return topResults;
}
}