@statewalker/indexer-sqlite 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +239 -2
- package/dist/block-ids.d.ts +20 -0
- package/dist/block-ids.d.ts.map +1 -0
- package/dist/block-ids.js +47 -0
- package/dist/block-ids.js.map +1 -0
- package/dist/create-sqlite-indexer.d.ts +18 -0
- package/dist/create-sqlite-indexer.d.ts.map +1 -0
- package/dist/create-sqlite-indexer.js +152 -0
- package/dist/create-sqlite-indexer.js.map +1 -0
- package/dist/enumeration.d.ts +18 -0
- package/dist/enumeration.d.ts.map +1 -0
- package/dist/enumeration.js +42 -0
- package/dist/enumeration.js.map +1 -0
- package/dist/fts-provider.d.ts +6 -0
- package/dist/fts-provider.d.ts.map +1 -0
- package/dist/fts-provider.js +143 -0
- package/dist/fts-provider.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +8 -0
- package/dist/ngram-provider.d.ts +12 -0
- package/dist/ngram-provider.d.ts.map +1 -0
- package/dist/ngram-provider.js +142 -0
- package/dist/ngram-provider.js.map +1 -0
- package/dist/path-sql.d.ts +15 -0
- package/dist/path-sql.d.ts.map +1 -0
- package/dist/path-sql.js +23 -0
- package/dist/path-sql.js.map +1 -0
- package/dist/types.d.ts +24 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/vector-provider.d.ts +12 -0
- package/dist/vector-provider.d.ts.map +1 -0
- package/dist/vector-provider.js +114 -0
- package/dist/vector-provider.js.map +1 -0
- package/package.json +61 -3
- package/src/block-ids.ts +75 -0
- package/src/create-sqlite-indexer.ts +218 -0
- package/src/enumeration.ts +54 -0
- package/src/fts-provider.ts +170 -0
- package/src/index.ts +9 -0
- package/src/ngram-provider.ts +153 -0
- package/src/path-sql.ts +17 -0
- package/src/types.ts +26 -0
- package/src/vector-provider.ts +132 -0
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { NgramStore } from "@statewalker/db-sqlite-index/ngram";
|
|
2
|
+
import type { DocumentPath, PathSelector } from "@statewalker/indexer-api";
|
|
3
|
+
import { toAsyncIterable } from "@statewalker/indexer-core";
|
|
4
|
+
import {
|
|
5
|
+
NGRAM_TYPE,
|
|
6
|
+
type NgramBlock,
|
|
7
|
+
type NgramConfig,
|
|
8
|
+
type NgramIndex,
|
|
9
|
+
type NgramMatch,
|
|
10
|
+
type NgramQuery,
|
|
11
|
+
type NgramResult,
|
|
12
|
+
type NgramSearchSummary,
|
|
13
|
+
} from "@statewalker/indexer-ngram";
|
|
14
|
+
import { newEnumeration } from "./enumeration.js";
|
|
15
|
+
import type { SqliteModalityProvider } from "./types.js";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Blocks per `NgramStore.upsert`: each upsert is one transaction, and a
|
|
19
|
+
* Durable Object retries a write that runs past its ~15 s deadline.
|
|
20
|
+
*/
|
|
21
|
+
const UPSERT_BATCH = 500;
|
|
22
|
+
|
|
23
|
+
/** 1-based line of each offset, from the positions of `\n` in `text`. */
|
|
24
|
+
function withLines(text: string, matches: { start: number; end: number }[]): NgramMatch[] {
|
|
25
|
+
const breaks: number[] = [];
|
|
26
|
+
for (let i = text.indexOf("\n"); i !== -1; i = text.indexOf("\n", i + 1)) breaks.push(i);
|
|
27
|
+
let line = 1;
|
|
28
|
+
let b = 0;
|
|
29
|
+
return [...matches]
|
|
30
|
+
.sort((x, y) => x.start - y.start)
|
|
31
|
+
.map(({ start, end }) => {
|
|
32
|
+
while (b < breaks.length && (breaks[b] as number) < start) {
|
|
33
|
+
b++;
|
|
34
|
+
line++;
|
|
35
|
+
}
|
|
36
|
+
return { start, end, line };
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Wraps the id-based `NgramStore` (own transactions; never call it inside
|
|
42
|
+
* `inTransaction`). Tables live under `{prefix}ng_`. Size and enumeration join
|
|
43
|
+
* the store's `idTable`. Score = match count; ties keep the store's
|
|
44
|
+
* ascending-id order.
|
|
45
|
+
*/
|
|
46
|
+
export function newSqliteNgramProvider(): SqliteModalityProvider<NgramConfig, NgramIndex> {
|
|
47
|
+
return {
|
|
48
|
+
type: NGRAM_TYPE,
|
|
49
|
+
|
|
50
|
+
async drop({ db, prefix }) {
|
|
51
|
+
await new NgramStore({ db, prefix: `${prefix}ng_` }).drop(); // drops either store's tables
|
|
52
|
+
},
|
|
53
|
+
|
|
54
|
+
async open({ db, prefix, blockIds }, config) {
|
|
55
|
+
const storeKind = config.store ?? "fts5";
|
|
56
|
+
const store = new NgramStore({ db, prefix: `${prefix}ng_`, store: storeKind });
|
|
57
|
+
await store.init();
|
|
58
|
+
const list = newEnumeration(db, blockIds, `SELECT id FROM ${store.idTable}`);
|
|
59
|
+
let closed = false;
|
|
60
|
+
const ensureOpen = () => {
|
|
61
|
+
if (closed) throw new Error("NgramIndex is closed");
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const index: NgramIndex = {
|
|
65
|
+
async getIndexInfo() {
|
|
66
|
+
return { store: storeKind, ...(config.metadata ? { metadata: config.metadata } : {}) };
|
|
67
|
+
},
|
|
68
|
+
|
|
69
|
+
async *search(params: NgramQuery): AsyncGenerator<NgramResult, NgramSearchSummary> {
|
|
70
|
+
ensureOpen();
|
|
71
|
+
const allowed = params.paths?.length
|
|
72
|
+
? new Set(
|
|
73
|
+
await blockIds.select(
|
|
74
|
+
db,
|
|
75
|
+
params.paths.map((path) => ({ path })),
|
|
76
|
+
),
|
|
77
|
+
)
|
|
78
|
+
: undefined;
|
|
79
|
+
const result = await store.search(params.pattern, {
|
|
80
|
+
regex: params.regex ?? false,
|
|
81
|
+
caseSensitive: params.caseSensitive ?? true,
|
|
82
|
+
limit: params.topK ?? 100,
|
|
83
|
+
...(allowed ? { filter: (id: number) => allowed.has(id) } : {}),
|
|
84
|
+
});
|
|
85
|
+
const hits = result.hits.filter((h) => h.matches.length > 0);
|
|
86
|
+
const ids = hits.map((h) => h.id);
|
|
87
|
+
const [texts, refs] = await Promise.all([store.getTexts(ids), blockIds.refs(db, ids)]);
|
|
88
|
+
// Array sort is stable: equal counts keep the store's ascending-id order.
|
|
89
|
+
const ranked = hits.sort((x, y) => y.matches.length - x.matches.length);
|
|
90
|
+
for (const h of ranked) {
|
|
91
|
+
const ref = refs.get(h.id);
|
|
92
|
+
if (!ref) continue;
|
|
93
|
+
const count = h.matches.length;
|
|
94
|
+
yield {
|
|
95
|
+
...ref,
|
|
96
|
+
score: count,
|
|
97
|
+
count,
|
|
98
|
+
matches: withLines(texts.get(h.id) ?? "", h.matches),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
return { truncated: result.truncated };
|
|
102
|
+
},
|
|
103
|
+
|
|
104
|
+
async addDocument(blocks: NgramBlock[]) {
|
|
105
|
+
ensureOpen();
|
|
106
|
+
for (let i = 0; i < blocks.length; i += UPSERT_BATCH) {
|
|
107
|
+
const batch = blocks.slice(i, i + UPSERT_BATCH);
|
|
108
|
+
const ids = await blockIds.ensure(db, batch);
|
|
109
|
+
await store.upsert(
|
|
110
|
+
ids.map((id, j) => ({ id, text: (batch[j] as NgramBlock).content })),
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
|
|
115
|
+
async addDocuments(batches) {
|
|
116
|
+
for await (const batch of batches) await this.addDocument(batch);
|
|
117
|
+
},
|
|
118
|
+
|
|
119
|
+
async deleteDocuments(selectors) {
|
|
120
|
+
ensureOpen();
|
|
121
|
+
const sels: PathSelector[] = [];
|
|
122
|
+
for await (const s of toAsyncIterable(selectors)) sels.push(s);
|
|
123
|
+
const ids = await blockIds.select(db, sels);
|
|
124
|
+
if (ids.length === 0) return;
|
|
125
|
+
await store.delete(ids);
|
|
126
|
+
},
|
|
127
|
+
|
|
128
|
+
getSize: (p?: DocumentPath) => list.size(p),
|
|
129
|
+
getDocumentPaths: (p?: DocumentPath) => list.paths(p),
|
|
130
|
+
getDocumentBlocksRefs: (p?: DocumentPath) => list.refs(p),
|
|
131
|
+
|
|
132
|
+
async *getDocumentsBlocks(p?: DocumentPath): AsyncGenerator<NgramBlock> {
|
|
133
|
+
const entries = await list.entries(p);
|
|
134
|
+
const texts = await store.getTexts(entries.map((e) => e.id));
|
|
135
|
+
for (const e of entries) {
|
|
136
|
+
const content = texts.get(e.id);
|
|
137
|
+
if (content !== undefined) yield { path: e.path, blockId: e.blockId, content };
|
|
138
|
+
}
|
|
139
|
+
},
|
|
140
|
+
|
|
141
|
+
async close() {
|
|
142
|
+
closed = true;
|
|
143
|
+
},
|
|
144
|
+
async flush() {},
|
|
145
|
+
async deleteIndex() {
|
|
146
|
+
closed = true;
|
|
147
|
+
await store.drop();
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
return index;
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
}
|
package/src/path-sql.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SQL condition matching `column` under any of `prefixes`, on path-component
|
|
3
|
+
* boundaries (`/foo` matches `/foo` and `/foo/x`, never `/foobar`) and
|
|
4
|
+
* case-sensitively. Uses `substr` rather than `LIKE`: SQLite's `LIKE` ignores
|
|
5
|
+
* ASCII case, and `PRAGMA case_sensitive_like` is not authorised in Durable
|
|
6
|
+
* Objects. `column` must be table-qualified (`b.path`): `json_each` has a `path`
|
|
7
|
+
* column of its own. No prefixes → `1` (no constraint).
|
|
8
|
+
*/
|
|
9
|
+
export function pathPrefixSql(
|
|
10
|
+
column: string,
|
|
11
|
+
prefixes: readonly string[],
|
|
12
|
+
): { sql: string; params: string[] } {
|
|
13
|
+
if (prefixes.length === 0) return { sql: "1", params: [] };
|
|
14
|
+
// One JSON parameter, however many prefixes: Durable Objects cap bound variables at 100.
|
|
15
|
+
const sql = `EXISTS (SELECT 1 FROM json_each(?) p WHERE CASE WHEN substr(p.value, -1) = '/' THEN substr(${column}, 1, length(p.value)) = p.value ELSE ${column} = p.value OR substr(${column}, 1, length(p.value) + 1) = p.value || '/' END)`;
|
|
16
|
+
return { sql, params: [JSON.stringify(prefixes)] };
|
|
17
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Db } from "@statewalker/db-api";
|
|
2
|
+
import type { ScoredHit, SearchIndex } from "@statewalker/indexer-api";
|
|
3
|
+
import type { BlockIds } from "./block-ids.js";
|
|
4
|
+
|
|
5
|
+
export interface SqliteModalityContext {
|
|
6
|
+
/** The database. Inside `inTransaction(db, (tx) => …)` use `tx` and `ctx.blockIds` methods with `tx`. */
|
|
7
|
+
db: Db;
|
|
8
|
+
/** Unique table-name prefix for this sub-index, e.g. `idx_docs__body_`. */
|
|
9
|
+
prefix: string;
|
|
10
|
+
/** The Index's shared block-id table. */
|
|
11
|
+
blockIds: BlockIds;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A modality backed by tables in one SQLite `Db`. Unlike `ModalityProvider`,
|
|
16
|
+
* opening is async (tables are created, existing data is adopted) and the
|
|
17
|
+
* provider can drop its tables without an open instance.
|
|
18
|
+
*/
|
|
19
|
+
export interface SqliteModalityProvider<
|
|
20
|
+
C = unknown,
|
|
21
|
+
I extends SearchIndex<unknown, unknown, ScoredHit> = SearchIndex<unknown, unknown, ScoredHit>,
|
|
22
|
+
> {
|
|
23
|
+
readonly type: string;
|
|
24
|
+
open(ctx: SqliteModalityContext, config: C): Promise<I>;
|
|
25
|
+
drop(ctx: SqliteModalityContext): Promise<void>;
|
|
26
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { dropVectorIndex, VectorIndex as VectorEngine } from "@statewalker/db-sqlite-index/vector";
|
|
2
|
+
import type { DocumentPath, PathSelector } from "@statewalker/indexer-api";
|
|
3
|
+
import { toAsyncIterable, validateDimensionality } from "@statewalker/indexer-core";
|
|
4
|
+
import {
|
|
5
|
+
VECTOR_TYPE,
|
|
6
|
+
type VectorBlock,
|
|
7
|
+
type VectorConfig,
|
|
8
|
+
type VectorIndex,
|
|
9
|
+
type VectorQuery,
|
|
10
|
+
type VectorResult,
|
|
11
|
+
} from "@statewalker/indexer-vector";
|
|
12
|
+
import { newEnumeration } from "./enumeration.js";
|
|
13
|
+
import type { SqliteModalityProvider } from "./types.js";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Wraps the id-based `VectorIndex` engine. The engine owns its tables under
|
|
17
|
+
* `{prefix}vec_` and its own transactions (never call it inside `inTransaction`);
|
|
18
|
+
* this provider adds only the `{path, blockId}` mapping. Size and enumeration
|
|
19
|
+
* join the engine's `idTable`, so they list exactly what the engine holds.
|
|
20
|
+
*/
|
|
21
|
+
export function newSqliteVectorProvider(): SqliteModalityProvider<VectorConfig, VectorIndex> {
|
|
22
|
+
return {
|
|
23
|
+
type: VECTOR_TYPE,
|
|
24
|
+
|
|
25
|
+
async drop({ db, prefix }) {
|
|
26
|
+
await dropVectorIndex(db, `${prefix}vec_`);
|
|
27
|
+
},
|
|
28
|
+
|
|
29
|
+
async open({ db, prefix, blockIds }, config) {
|
|
30
|
+
const engine = new VectorEngine({
|
|
31
|
+
db,
|
|
32
|
+
prefix: `${prefix}vec_`,
|
|
33
|
+
dims: config.dimensionality,
|
|
34
|
+
metric: "cosine",
|
|
35
|
+
});
|
|
36
|
+
await engine.init();
|
|
37
|
+
const list = newEnumeration(db, blockIds, `SELECT id FROM ${engine.idTable}`);
|
|
38
|
+
let closed = false;
|
|
39
|
+
const ensureOpen = () => {
|
|
40
|
+
if (closed) throw new Error("VectorIndex is closed");
|
|
41
|
+
};
|
|
42
|
+
const info = {
|
|
43
|
+
dimensionality: config.dimensionality,
|
|
44
|
+
model: config.model,
|
|
45
|
+
metadata: config.metadata,
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const index: VectorIndex = {
|
|
49
|
+
async getIndexInfo() {
|
|
50
|
+
return { ...info };
|
|
51
|
+
},
|
|
52
|
+
|
|
53
|
+
async *search(params: VectorQuery): AsyncGenerator<VectorResult> {
|
|
54
|
+
ensureOpen();
|
|
55
|
+
const k = params.topK ?? 100;
|
|
56
|
+
const allowed = params.paths?.length
|
|
57
|
+
? new Set(
|
|
58
|
+
await blockIds.select(
|
|
59
|
+
db,
|
|
60
|
+
params.paths.map((path) => ({ path })),
|
|
61
|
+
),
|
|
62
|
+
)
|
|
63
|
+
: undefined;
|
|
64
|
+
const filter = allowed ? (id: number) => allowed.has(id) : undefined;
|
|
65
|
+
// Several embeddings merge by max score, like every other backend.
|
|
66
|
+
const best = new Map<number, number>();
|
|
67
|
+
for (const q of params.embeddings ?? []) {
|
|
68
|
+
for (const hit of await engine.search(q, { k, ...(filter ? { filter } : {}) })) {
|
|
69
|
+
const score = 1 - hit.distance;
|
|
70
|
+
if (score > (best.get(hit.id) ?? Number.NEGATIVE_INFINITY)) best.set(hit.id, score);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const top = [...best.entries()].sort((a, b) => b[1] - a[1]).slice(0, k);
|
|
74
|
+
const refs = await blockIds.refs(
|
|
75
|
+
db,
|
|
76
|
+
top.map(([id]) => id),
|
|
77
|
+
);
|
|
78
|
+
for (const [id, score] of top) {
|
|
79
|
+
const ref = refs.get(id);
|
|
80
|
+
if (ref) yield { ...ref, score };
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
|
|
84
|
+
async addDocument(blocks: VectorBlock[]) {
|
|
85
|
+
ensureOpen();
|
|
86
|
+
if (blocks.length === 0) return;
|
|
87
|
+
for (const b of blocks) validateDimensionality(info, b.embedding);
|
|
88
|
+
const ids = await blockIds.ensure(db, blocks);
|
|
89
|
+
await engine.upsert(
|
|
90
|
+
ids.map((id, i) => ({ id, vector: (blocks[i] as VectorBlock).embedding })),
|
|
91
|
+
);
|
|
92
|
+
},
|
|
93
|
+
|
|
94
|
+
async addDocuments(batches) {
|
|
95
|
+
for await (const batch of batches) await this.addDocument(batch);
|
|
96
|
+
},
|
|
97
|
+
|
|
98
|
+
async deleteDocuments(selectors) {
|
|
99
|
+
ensureOpen();
|
|
100
|
+
const sels: PathSelector[] = [];
|
|
101
|
+
for await (const s of toAsyncIterable(selectors)) sels.push(s);
|
|
102
|
+
const ids = await blockIds.select(db, sels);
|
|
103
|
+
if (ids.length === 0) return;
|
|
104
|
+
await engine.delete(ids);
|
|
105
|
+
},
|
|
106
|
+
|
|
107
|
+
getSize: (p?: DocumentPath) => list.size(p),
|
|
108
|
+
getDocumentPaths: (p?: DocumentPath) => list.paths(p),
|
|
109
|
+
getDocumentBlocksRefs: (p?: DocumentPath) => list.refs(p),
|
|
110
|
+
|
|
111
|
+
async *getDocumentsBlocks(p?: DocumentPath): AsyncGenerator<VectorBlock> {
|
|
112
|
+
const entries = await list.entries(p);
|
|
113
|
+
const vectors = await engine.getVectors(entries.map((e) => e.id));
|
|
114
|
+
for (const e of entries) {
|
|
115
|
+
const embedding = vectors.get(e.id);
|
|
116
|
+
if (embedding) yield { path: e.path, blockId: e.blockId, embedding };
|
|
117
|
+
}
|
|
118
|
+
},
|
|
119
|
+
|
|
120
|
+
async close() {
|
|
121
|
+
closed = true;
|
|
122
|
+
},
|
|
123
|
+
async flush() {},
|
|
124
|
+
async deleteIndex() {
|
|
125
|
+
closed = true;
|
|
126
|
+
await dropVectorIndex(db, `${prefix}vec_`);
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
return index;
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
}
|