@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
package/package.json
CHANGED
|
@@ -1,6 +1,64 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@statewalker/indexer-sqlite",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"private": false,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"description": "SQLite-backed indexer: FTS5, vector and n-gram/grep sub-indexes in one @statewalker/db-api Db (Node, browser worker, Durable Objects).",
|
|
7
|
+
"bugs": {
|
|
8
|
+
"url": "https://github.com/statewalker/statewalker-search/issues"
|
|
9
|
+
},
|
|
10
|
+
"homepage": "https://github.com/statewalker/statewalker-search/tree/main/packages/indexer-sqlite#readme",
|
|
11
|
+
"author": {
|
|
12
|
+
"name": "Mikhail Kotelnikov",
|
|
13
|
+
"email": "mikhail.kotelnikov@gmail.com"
|
|
14
|
+
},
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/statewalker/statewalker-search.git",
|
|
19
|
+
"directory": "packages/indexer-sqlite"
|
|
20
|
+
},
|
|
21
|
+
"exports": {
|
|
22
|
+
".": {
|
|
23
|
+
"source": "./src/index.ts",
|
|
24
|
+
"types": "./dist/index.d.ts",
|
|
25
|
+
"import": "./dist/index.js",
|
|
26
|
+
"default": "./dist/index.js"
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
"files": [
|
|
30
|
+
"dist",
|
|
31
|
+
"src"
|
|
32
|
+
],
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"@statewalker/db-api": "^0.1.0",
|
|
35
|
+
"@statewalker/db-sqlite": "^0.1.0",
|
|
36
|
+
"@statewalker/db-sqlite-index": "^0.1.0",
|
|
37
|
+
"@statewalker/indexer-api": "^0.1.3",
|
|
38
|
+
"@statewalker/indexer-fulltext": "^0.1.4",
|
|
39
|
+
"@statewalker/indexer-core": "^0.1.3",
|
|
40
|
+
"@statewalker/indexer-ngram": "^0.1.0",
|
|
41
|
+
"@statewalker/indexer-vector": "^0.1.4"
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"@statewalker/db-sqlite-do": "^0.1.0",
|
|
45
|
+
"@statewalker/db-sqlite-node": "^0.3.0",
|
|
46
|
+
"rimraf": "^6.1.3",
|
|
47
|
+
"tsdown": "^0.23.0",
|
|
48
|
+
"typescript": "^7.0.2",
|
|
49
|
+
"vitest": "^5.0.3",
|
|
50
|
+
"@statewalker/indexer-tests": "^0.0.0"
|
|
51
|
+
},
|
|
52
|
+
"sideEffects": false,
|
|
53
|
+
"publishConfig": {
|
|
54
|
+
"access": "public"
|
|
55
|
+
},
|
|
56
|
+
"scripts": {
|
|
57
|
+
"build": "tsdown",
|
|
58
|
+
"test": "vitest run --passWithNoTests",
|
|
59
|
+
"typecheck": "tsc --noEmit",
|
|
60
|
+
"clean": "rimraf dist",
|
|
61
|
+
"lint": "biome check --write .",
|
|
62
|
+
"format": "biome format --write ."
|
|
63
|
+
}
|
|
6
64
|
}
|
package/src/block-ids.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { Db } from "@statewalker/db-api";
|
|
2
|
+
import type { BlockReference, DocumentPath, PathSelector } from "@statewalker/indexer-api";
|
|
3
|
+
import { compositeKey } from "@statewalker/indexer-core";
|
|
4
|
+
import { pathPrefixSql } from "./path-sql.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The Index-wide `{path, blockId} ↔ integer id` table shared by every sub-index.
|
|
8
|
+
* Every method takes the `Db` to run on, so it works on `tx` inside `inTransaction`.
|
|
9
|
+
* Rows are never deleted before `deleteIndex`: an id stays stable for its block.
|
|
10
|
+
*/
|
|
11
|
+
export interface BlockIds {
|
|
12
|
+
readonly table: string;
|
|
13
|
+
/** Ids for `refs`, in order; missing rows are created. */
|
|
14
|
+
ensure(db: Db, refs: readonly BlockReference[]): Promise<number[]>;
|
|
15
|
+
/** Ids of every known block matched by any selector (path prefix, or exact block). */
|
|
16
|
+
select(db: Db, selectors: readonly PathSelector[]): Promise<number[]>;
|
|
17
|
+
/** References of `ids`; unknown ids are absent. */
|
|
18
|
+
refs(db: Db, ids: readonly number[]): Promise<Map<number, BlockReference>>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function openBlockIds(db: Db, table: string): Promise<BlockIds> {
|
|
22
|
+
await db.exec(
|
|
23
|
+
`CREATE TABLE IF NOT EXISTS ${table} (id INTEGER PRIMARY KEY, path TEXT NOT NULL, block_id TEXT NOT NULL, UNIQUE (path, block_id))`,
|
|
24
|
+
);
|
|
25
|
+
return {
|
|
26
|
+
table,
|
|
27
|
+
async ensure(tx, refs) {
|
|
28
|
+
if (refs.length === 0) return [];
|
|
29
|
+
const json = JSON.stringify(refs.map((r) => [r.path, r.blockId]));
|
|
30
|
+
// `WHERE true` disambiguates `SELECT … ON CONFLICT` for the SQLite parser.
|
|
31
|
+
await tx.query(
|
|
32
|
+
`INSERT INTO ${table} (path, block_id) SELECT json_extract(value, '$[0]'), json_extract(value, '$[1]') FROM json_each(?) WHERE true ON CONFLICT (path, block_id) DO NOTHING`,
|
|
33
|
+
[json],
|
|
34
|
+
);
|
|
35
|
+
const rows = await tx.query<{ id: number; path: string; block_id: string }>(
|
|
36
|
+
`SELECT b.id, b.path, b.block_id FROM ${table} b JOIN json_each(?) j ON b.path = json_extract(j.value, '$[0]') AND b.block_id = json_extract(j.value, '$[1]')`,
|
|
37
|
+
[json],
|
|
38
|
+
);
|
|
39
|
+
const byKey = new Map(
|
|
40
|
+
rows.map((r) => [compositeKey(r.path as DocumentPath, r.block_id), Number(r.id)]),
|
|
41
|
+
);
|
|
42
|
+
return refs.map((r) => {
|
|
43
|
+
const id = byKey.get(compositeKey(r.path, r.blockId));
|
|
44
|
+
if (id === undefined) throw new Error(`block id missing for ${r.path}#${r.blockId}`);
|
|
45
|
+
return id;
|
|
46
|
+
});
|
|
47
|
+
},
|
|
48
|
+
async select(tx, selectors) {
|
|
49
|
+
const ids = new Set<number>();
|
|
50
|
+
for (const sel of selectors) {
|
|
51
|
+
const where =
|
|
52
|
+
sel.blockId !== undefined
|
|
53
|
+
? { sql: "b.path = ? AND b.block_id = ?", params: [sel.path, sel.blockId] }
|
|
54
|
+
: pathPrefixSql("b.path", [sel.path]);
|
|
55
|
+
const rows = await tx.query<{ id: number }>(
|
|
56
|
+
`SELECT b.id FROM ${table} b WHERE ${where.sql}`,
|
|
57
|
+
where.params,
|
|
58
|
+
);
|
|
59
|
+
for (const r of rows) ids.add(Number(r.id));
|
|
60
|
+
}
|
|
61
|
+
return [...ids];
|
|
62
|
+
},
|
|
63
|
+
async refs(tx, ids) {
|
|
64
|
+
const out = new Map<number, BlockReference>();
|
|
65
|
+
if (ids.length === 0) return out;
|
|
66
|
+
const rows = await tx.query<{ id: number; path: string; block_id: string }>(
|
|
67
|
+
`SELECT id, path, block_id FROM ${table} WHERE id IN (SELECT value FROM json_each(?))`,
|
|
68
|
+
[JSON.stringify(ids)],
|
|
69
|
+
);
|
|
70
|
+
for (const r of rows)
|
|
71
|
+
out.set(Number(r.id), { path: r.path as DocumentPath, blockId: r.block_id });
|
|
72
|
+
return out;
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import type { Db } from "@statewalker/db-api";
|
|
2
|
+
import type {
|
|
3
|
+
AnySubIndexBinding,
|
|
4
|
+
CreateIndexParams,
|
|
5
|
+
GetIndexOptions,
|
|
6
|
+
Index,
|
|
7
|
+
Indexer,
|
|
8
|
+
IndexInfo,
|
|
9
|
+
} from "@statewalker/indexer-api";
|
|
10
|
+
import { createCompositeIndex, createSerialiser } from "@statewalker/indexer-core";
|
|
11
|
+
import { type BlockIds, openBlockIds } from "./block-ids.js";
|
|
12
|
+
import type { SqliteModalityContext, SqliteModalityProvider } from "./types.js";
|
|
13
|
+
|
|
14
|
+
export interface SqliteIndexerOptions {
|
|
15
|
+
/** The database. The indexer never closes it. */
|
|
16
|
+
db: Db;
|
|
17
|
+
/** One provider per modality type, e.g. FTS5, vector, n-gram. */
|
|
18
|
+
providers: SqliteModalityProvider[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
type SubSpec = { type: string } & Record<string, unknown>;
|
|
22
|
+
interface IndexManifest {
|
|
23
|
+
subIndexes: Record<string, SubSpec>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const MANIFEST_TABLE = "__indexer_manifest";
|
|
27
|
+
|
|
28
|
+
/** Escapes everything but lower-case letters and digits: SQLite table names fold case, so "Docs" and "docs" must not collide. */
|
|
29
|
+
const sanitizePrefix = (name: string) =>
|
|
30
|
+
name.replace(/[^a-z0-9]/g, (ch) => `_${ch.charCodeAt(0)}_`);
|
|
31
|
+
|
|
32
|
+
const indexPrefix = (name: string) => `idx_${sanitizePrefix(name)}__`;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* `Indexer` over one SQLite `Db`. Keeps a manifest row per Index, one shared
|
|
36
|
+
* `{indexPrefix}blocks` id table per Index, and opens each sub-index through
|
|
37
|
+
* its provider. Search fuses sub-indexes by RRF (`createCompositeIndex`).
|
|
38
|
+
*/
|
|
39
|
+
export async function createSqliteIndexer(options: SqliteIndexerOptions): Promise<Indexer> {
|
|
40
|
+
const { db } = options;
|
|
41
|
+
const providers = new Map(options.providers.map((p) => [p.type, p]));
|
|
42
|
+
const indexes = new Map<string, Index>();
|
|
43
|
+
const manifest = new Map<string, IndexManifest>();
|
|
44
|
+
const runExclusive = createSerialiser();
|
|
45
|
+
let closed = false;
|
|
46
|
+
|
|
47
|
+
await db.exec(
|
|
48
|
+
`CREATE TABLE IF NOT EXISTS ${MANIFEST_TABLE} (name TEXT PRIMARY KEY, config TEXT NOT NULL)`,
|
|
49
|
+
);
|
|
50
|
+
for (const row of await db.query<{ name: string; config: string }>(
|
|
51
|
+
`SELECT name, config FROM ${MANIFEST_TABLE}`,
|
|
52
|
+
)) {
|
|
53
|
+
manifest.set(row.name, JSON.parse(row.config) as IndexManifest);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const ensureOpen = () => {
|
|
57
|
+
if (closed) throw new Error("Indexer is closed");
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const contextFor = (name: string, sub: string, blockIds: BlockIds): SqliteModalityContext => ({
|
|
61
|
+
db,
|
|
62
|
+
prefix: `${indexPrefix(name)}${sanitizePrefix(sub)}_`,
|
|
63
|
+
blockIds,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
async function writeManifest(name: string, entry: IndexManifest): Promise<void> {
|
|
67
|
+
await db.query(
|
|
68
|
+
`INSERT INTO ${MANIFEST_TABLE} (name, config) VALUES (?, ?) ON CONFLICT (name) DO UPDATE SET config = excluded.config`,
|
|
69
|
+
[name, JSON.stringify(entry)],
|
|
70
|
+
);
|
|
71
|
+
manifest.set(name, entry);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function dropIndexTables(name: string, subIndexes: Record<string, SubSpec>): Promise<void> {
|
|
75
|
+
const blockIds = await openBlockIds(db, `${indexPrefix(name)}blocks`);
|
|
76
|
+
for (const [sub, spec] of Object.entries(subIndexes)) {
|
|
77
|
+
await providers.get(spec.type)?.drop(contextFor(name, sub, blockIds));
|
|
78
|
+
}
|
|
79
|
+
await db.exec(`DROP TABLE IF EXISTS ${blockIds.table}`);
|
|
80
|
+
await db.query(`DELETE FROM ${MANIFEST_TABLE} WHERE name = ?`, [name]);
|
|
81
|
+
manifest.delete(name);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function openIndex(
|
|
85
|
+
name: string,
|
|
86
|
+
subIndexes: Record<string, SubSpec>,
|
|
87
|
+
options?: GetIndexOptions,
|
|
88
|
+
): Promise<{ index: Index; effective: Record<string, SubSpec> }> {
|
|
89
|
+
const onMissing = options?.onMissingProvider ?? "throw";
|
|
90
|
+
const blockIds = await openBlockIds(db, `${indexPrefix(name)}blocks`);
|
|
91
|
+
const bindings: AnySubIndexBinding[] = [];
|
|
92
|
+
const effective: Record<string, SubSpec> = {};
|
|
93
|
+
|
|
94
|
+
for (const [sub, saved] of Object.entries(subIndexes)) {
|
|
95
|
+
const action = options?.subIndexes?.[sub];
|
|
96
|
+
if (action && "skip" in action && action.skip === true) {
|
|
97
|
+
effective[sub] = saved;
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
const ctx = contextFor(name, sub, blockIds);
|
|
101
|
+
const spec = action && !("skip" in action) ? (action as SubSpec) : saved;
|
|
102
|
+
const provider = providers.get(spec.type);
|
|
103
|
+
if (provider && spec !== saved) await providers.get(saved.type)?.drop(ctx);
|
|
104
|
+
if (!provider) {
|
|
105
|
+
if (onMissing === "throw") {
|
|
106
|
+
throw new Error(
|
|
107
|
+
`Cannot load index: sub-index "${sub}" requires modality "${spec.type}" but only [${[
|
|
108
|
+
...providers.keys(),
|
|
109
|
+
]
|
|
110
|
+
.map((t) => `"${t}"`)
|
|
111
|
+
.join(", ")}] are registered.`,
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
const g = globalThis as { console?: { warn(...args: unknown[]): void } };
|
|
115
|
+
g.console?.warn(`Sub-index "${sub}" (type "${spec.type}") has no provider; skipping.`);
|
|
116
|
+
effective[sub] = saved;
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
const { type, ...config } = spec;
|
|
120
|
+
bindings.push({ name: sub, type, config, index: await provider.open(ctx, config) });
|
|
121
|
+
effective[sub] = spec;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const index = createCompositeIndex({
|
|
125
|
+
name,
|
|
126
|
+
bindings,
|
|
127
|
+
onDeleteIndex: async () => {
|
|
128
|
+
// Bound sub-indexes dropped themselves; skipped ones still own tables.
|
|
129
|
+
for (const [sub, spec] of Object.entries(manifest.get(name)?.subIndexes ?? {})) {
|
|
130
|
+
await providers.get(spec.type)?.drop(contextFor(name, sub, blockIds));
|
|
131
|
+
}
|
|
132
|
+
await db.exec(`DROP TABLE IF EXISTS ${blockIds.table}`);
|
|
133
|
+
await db.query(`DELETE FROM ${MANIFEST_TABLE} WHERE name = ?`, [name]);
|
|
134
|
+
manifest.delete(name);
|
|
135
|
+
indexes.delete(name);
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
return { index, effective };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
async getIndexNames(): Promise<IndexInfo[]> {
|
|
143
|
+
ensureOpen();
|
|
144
|
+
return [...manifest.keys()].map((name) => ({ name }));
|
|
145
|
+
},
|
|
146
|
+
|
|
147
|
+
createIndex(params: CreateIndexParams): Promise<Index> {
|
|
148
|
+
return runExclusive(async () => {
|
|
149
|
+
ensureOpen();
|
|
150
|
+
const { name, subIndexes, overwrite, metadata } = params;
|
|
151
|
+
if (!subIndexes || Object.keys(subIndexes).length === 0) {
|
|
152
|
+
throw new Error("Cannot create index without any subIndexes");
|
|
153
|
+
}
|
|
154
|
+
const existing = manifest.get(name);
|
|
155
|
+
if (existing || indexes.has(name)) {
|
|
156
|
+
if (!overwrite) throw new Error(`Index "${name}" already exists`);
|
|
157
|
+
await indexes
|
|
158
|
+
.get(name)
|
|
159
|
+
?.close()
|
|
160
|
+
.catch(() => undefined);
|
|
161
|
+
indexes.delete(name);
|
|
162
|
+
await dropIndexTables(name, existing?.subIndexes ?? {});
|
|
163
|
+
}
|
|
164
|
+
const { index, effective } = await openIndex(name, subIndexes);
|
|
165
|
+
await writeManifest(name, { subIndexes: effective });
|
|
166
|
+
if (metadata !== undefined) (index as { metadata?: unknown }).metadata = metadata;
|
|
167
|
+
indexes.set(name, index);
|
|
168
|
+
return index;
|
|
169
|
+
});
|
|
170
|
+
},
|
|
171
|
+
|
|
172
|
+
getIndex(name: string, options?: GetIndexOptions): Promise<Index | null> {
|
|
173
|
+
// Serialised with createIndex so concurrent first opens open once
|
|
174
|
+
// (a second reinit would otherwise drop tables the first just wrote).
|
|
175
|
+
return runExclusive(async () => {
|
|
176
|
+
ensureOpen();
|
|
177
|
+
const cached = indexes.get(name);
|
|
178
|
+
if (cached) return cached;
|
|
179
|
+
const entry = manifest.get(name);
|
|
180
|
+
if (!entry) return null;
|
|
181
|
+
const { index, effective } = await openIndex(name, entry.subIndexes, options);
|
|
182
|
+
if (JSON.stringify(effective) !== JSON.stringify(entry.subIndexes)) {
|
|
183
|
+
await writeManifest(name, { subIndexes: effective });
|
|
184
|
+
}
|
|
185
|
+
indexes.set(name, index);
|
|
186
|
+
return index;
|
|
187
|
+
});
|
|
188
|
+
},
|
|
189
|
+
|
|
190
|
+
async hasIndex(name: string): Promise<boolean> {
|
|
191
|
+
ensureOpen();
|
|
192
|
+
return manifest.has(name);
|
|
193
|
+
},
|
|
194
|
+
|
|
195
|
+
async deleteIndex(name: string): Promise<void> {
|
|
196
|
+
ensureOpen();
|
|
197
|
+
const live = indexes.get(name);
|
|
198
|
+
if (live) {
|
|
199
|
+
await live.deleteIndex();
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
const entry = manifest.get(name);
|
|
203
|
+
if (entry) await dropIndexTables(name, entry.subIndexes);
|
|
204
|
+
},
|
|
205
|
+
|
|
206
|
+
async flush(): Promise<void> {
|
|
207
|
+
ensureOpen();
|
|
208
|
+
for (const index of indexes.values()) await index.flush();
|
|
209
|
+
},
|
|
210
|
+
|
|
211
|
+
async close(): Promise<void> {
|
|
212
|
+
if (closed) return;
|
|
213
|
+
closed = true;
|
|
214
|
+
for (const index of indexes.values()) await index.close().catch(() => undefined);
|
|
215
|
+
indexes.clear();
|
|
216
|
+
},
|
|
217
|
+
} satisfies Indexer;
|
|
218
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { Db } from "@statewalker/db-api";
|
|
2
|
+
import type { BlockReference, DocumentPath } from "@statewalker/indexer-api";
|
|
3
|
+
import type { BlockIds } from "./block-ids.js";
|
|
4
|
+
import { pathPrefixSql } from "./path-sql.js";
|
|
5
|
+
|
|
6
|
+
export interface BlockEntry extends BlockReference {
|
|
7
|
+
id: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** `SearchIndex` enumeration over a sub-index's member ids joined with the blocks table. */
|
|
11
|
+
export interface Enumeration {
|
|
12
|
+
size(prefix?: DocumentPath): Promise<number>;
|
|
13
|
+
entries(prefix?: DocumentPath): Promise<BlockEntry[]>;
|
|
14
|
+
paths(prefix?: DocumentPath): AsyncGenerator<DocumentPath>;
|
|
15
|
+
refs(prefix?: DocumentPath): AsyncGenerator<BlockReference>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** `memberIdsSql` is a `SELECT … AS id` listing the sub-index's ids, e.g. `SELECT rowid AS id FROM t`. */
|
|
19
|
+
export function newEnumeration(db: Db, blockIds: BlockIds, memberIdsSql: string): Enumeration {
|
|
20
|
+
const from = (prefix?: DocumentPath) => {
|
|
21
|
+
const scope = pathPrefixSql("b.path", prefix ? [prefix] : []);
|
|
22
|
+
return {
|
|
23
|
+
sql: `FROM (${memberIdsSql}) m JOIN ${blockIds.table} b ON b.id = m.id WHERE ${scope.sql}`,
|
|
24
|
+
params: scope.params,
|
|
25
|
+
};
|
|
26
|
+
};
|
|
27
|
+
return {
|
|
28
|
+
async size(prefix) {
|
|
29
|
+
const f = from(prefix);
|
|
30
|
+
const rows = await db.query<{ n: number }>(`SELECT COUNT(*) AS n ${f.sql}`, f.params);
|
|
31
|
+
return Number(rows[0]?.n ?? 0);
|
|
32
|
+
},
|
|
33
|
+
async entries(prefix) {
|
|
34
|
+
const f = from(prefix);
|
|
35
|
+
const rows = await db.query<{ id: number; path: string; block_id: string }>(
|
|
36
|
+
`SELECT b.id, b.path, b.block_id ${f.sql} ORDER BY b.id`,
|
|
37
|
+
f.params,
|
|
38
|
+
);
|
|
39
|
+
return rows.map((r) => ({
|
|
40
|
+
id: Number(r.id),
|
|
41
|
+
path: r.path as DocumentPath,
|
|
42
|
+
blockId: r.block_id,
|
|
43
|
+
}));
|
|
44
|
+
},
|
|
45
|
+
async *paths(prefix) {
|
|
46
|
+
const f = from(prefix);
|
|
47
|
+
const rows = await db.query<{ path: string }>(`SELECT DISTINCT b.path ${f.sql}`, f.params);
|
|
48
|
+
for (const r of rows) yield r.path as DocumentPath;
|
|
49
|
+
},
|
|
50
|
+
async *refs(prefix) {
|
|
51
|
+
for (const e of await this.entries(prefix)) yield { path: e.path, blockId: e.blockId };
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { inTransaction } from "@statewalker/db-sqlite";
|
|
2
|
+
import type { DocumentPath, Metadata, PathSelector } from "@statewalker/indexer-api";
|
|
3
|
+
import { toAsyncIterable } from "@statewalker/indexer-core";
|
|
4
|
+
import {
|
|
5
|
+
FULL_TEXT_TYPE,
|
|
6
|
+
type FullTextBlock,
|
|
7
|
+
type FullTextConfig,
|
|
8
|
+
type FullTextIndex,
|
|
9
|
+
type FulltextQuery,
|
|
10
|
+
type FulltextResult,
|
|
11
|
+
} from "@statewalker/indexer-fulltext";
|
|
12
|
+
import { newEnumeration } from "./enumeration.js";
|
|
13
|
+
import { pathPrefixSql } from "./path-sql.js";
|
|
14
|
+
import type { SqliteModalityProvider } from "./types.js";
|
|
15
|
+
|
|
16
|
+
/** `en` gets the Porter stemmer; any other language (known or not) gets plain `unicode61`. */
|
|
17
|
+
function tokenizerFor(language: string): string {
|
|
18
|
+
return language === "en"
|
|
19
|
+
? "porter unicode61 remove_diacritics 2"
|
|
20
|
+
: "unicode61 remove_diacritics 2";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* One FTS5 MATCH expression per query string: every word with a letter or a
|
|
25
|
+
* digit, double-quoted (so `AND`, `*`, `NEAR(` are plain words), OR-joined so
|
|
26
|
+
* partial matches are returned. `null` when nothing is searchable.
|
|
27
|
+
*/
|
|
28
|
+
export function toMatchExpression(query: string): string | null {
|
|
29
|
+
const words = query
|
|
30
|
+
.split(/\s+/)
|
|
31
|
+
.filter((w) => /[\p{L}\p{N}]/u.test(w))
|
|
32
|
+
.map((w) => `"${w.replaceAll('"', '""')}"`);
|
|
33
|
+
return words.length > 0 ? words.join(" OR ") : null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function newSqliteFullTextProvider(): SqliteModalityProvider<FullTextConfig, FullTextIndex> {
|
|
37
|
+
return {
|
|
38
|
+
type: FULL_TEXT_TYPE,
|
|
39
|
+
|
|
40
|
+
async drop({ db, prefix }) {
|
|
41
|
+
await db.exec(`DROP TABLE IF EXISTS ${prefix}fts`);
|
|
42
|
+
},
|
|
43
|
+
|
|
44
|
+
async open({ db, prefix, blockIds }, config) {
|
|
45
|
+
const table = `${prefix}fts`;
|
|
46
|
+
const blocks = blockIds.table;
|
|
47
|
+
await db.exec(
|
|
48
|
+
`CREATE VIRTUAL TABLE IF NOT EXISTS ${table} USING fts5(content, metadata UNINDEXED, tokenize='${tokenizerFor(config.language)}')`,
|
|
49
|
+
);
|
|
50
|
+
const list = newEnumeration(db, blockIds, `SELECT rowid AS id FROM ${table}`);
|
|
51
|
+
let closed = false;
|
|
52
|
+
const ensureOpen = () => {
|
|
53
|
+
if (closed) throw new Error("FullTextIndex is closed");
|
|
54
|
+
};
|
|
55
|
+
const collectSelectors = async (selectors: PathSelector[] | AsyncIterable<PathSelector>) => {
|
|
56
|
+
const all: PathSelector[] = [];
|
|
57
|
+
for await (const s of toAsyncIterable(selectors)) all.push(s);
|
|
58
|
+
return all;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const index: FullTextIndex = {
|
|
62
|
+
async getIndexInfo() {
|
|
63
|
+
return { language: config.language, metadata: config.metadata };
|
|
64
|
+
},
|
|
65
|
+
|
|
66
|
+
async *search(params: FulltextQuery): AsyncGenerator<FulltextResult> {
|
|
67
|
+
ensureOpen();
|
|
68
|
+
const topK = params.topK ?? 100;
|
|
69
|
+
const scope = pathPrefixSql("b.path", params.paths ?? []);
|
|
70
|
+
// Several queries accumulate (SUM): a block matching more of them ranks higher.
|
|
71
|
+
const merged = new Map<number, { score: number; best: number; snippet: string }>();
|
|
72
|
+
for (const query of params.queries ?? []) {
|
|
73
|
+
const match = toMatchExpression(query);
|
|
74
|
+
if (!match) continue;
|
|
75
|
+
const rows = await db.query<{ id: number; score: number; snippet: string }>(
|
|
76
|
+
`SELECT ${table}.rowid AS id, -bm25(${table}) AS score, snippet(${table}, 0, '', '', '…', 16) AS snippet FROM ${table} JOIN ${blocks} b ON b.id = ${table}.rowid WHERE ${table} MATCH ? AND ${scope.sql} ORDER BY score DESC LIMIT ?`,
|
|
77
|
+
[match, ...scope.params, topK],
|
|
78
|
+
);
|
|
79
|
+
for (const r of rows) {
|
|
80
|
+
const id = Number(r.id);
|
|
81
|
+
const prev = merged.get(id);
|
|
82
|
+
if (!prev) merged.set(id, { score: r.score, best: r.score, snippet: r.snippet });
|
|
83
|
+
else {
|
|
84
|
+
prev.score += r.score;
|
|
85
|
+
if (r.score > prev.best) Object.assign(prev, { best: r.score, snippet: r.snippet });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
const top = [...merged.entries()].sort((a, b) => b[1].score - a[1].score).slice(0, topK);
|
|
90
|
+
const refs = await blockIds.refs(
|
|
91
|
+
db,
|
|
92
|
+
top.map(([id]) => id),
|
|
93
|
+
);
|
|
94
|
+
for (const [id, hit] of top) {
|
|
95
|
+
const ref = refs.get(id);
|
|
96
|
+
if (ref) yield { ...ref, score: hit.score, snippet: hit.snippet };
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
|
|
100
|
+
async addDocument(blocksIn: FullTextBlock[]) {
|
|
101
|
+
ensureOpen();
|
|
102
|
+
if (blocksIn.length === 0) return;
|
|
103
|
+
await inTransaction(db, async (tx) => {
|
|
104
|
+
const ids = await blockIds.ensure(tx, blocksIn);
|
|
105
|
+
for (const [i, id] of ids.entries()) {
|
|
106
|
+
const block = blocksIn[i] as FullTextBlock;
|
|
107
|
+
// FTS5 has no UPSERT: delete then insert, atomically inside the transaction.
|
|
108
|
+
await tx.query(`DELETE FROM ${table} WHERE rowid = ?`, [id]);
|
|
109
|
+
await tx.query(`INSERT INTO ${table} (rowid, content, metadata) VALUES (?, ?, ?)`, [
|
|
110
|
+
id,
|
|
111
|
+
block.content,
|
|
112
|
+
block.metadata ? JSON.stringify(block.metadata) : null,
|
|
113
|
+
]);
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
},
|
|
117
|
+
|
|
118
|
+
async addDocuments(batches) {
|
|
119
|
+
for await (const batch of batches) await this.addDocument(batch);
|
|
120
|
+
},
|
|
121
|
+
|
|
122
|
+
async deleteDocuments(selectors) {
|
|
123
|
+
ensureOpen();
|
|
124
|
+
const sels = await collectSelectors(selectors);
|
|
125
|
+
await inTransaction(db, async (tx) => {
|
|
126
|
+
const ids = await blockIds.select(tx, sels);
|
|
127
|
+
await tx.query(`DELETE FROM ${table} WHERE rowid IN (SELECT value FROM json_each(?))`, [
|
|
128
|
+
JSON.stringify(ids),
|
|
129
|
+
]);
|
|
130
|
+
});
|
|
131
|
+
},
|
|
132
|
+
|
|
133
|
+
getSize: (p?: DocumentPath) => list.size(p),
|
|
134
|
+
getDocumentPaths: (p?: DocumentPath) => list.paths(p),
|
|
135
|
+
getDocumentBlocksRefs: (p?: DocumentPath) => list.refs(p),
|
|
136
|
+
|
|
137
|
+
async *getDocumentsBlocks(p?: DocumentPath): AsyncGenerator<FullTextBlock> {
|
|
138
|
+
const scope = pathPrefixSql("b.path", p ? [p] : []);
|
|
139
|
+
const rows = await db.query<{
|
|
140
|
+
path: string;
|
|
141
|
+
block_id: string;
|
|
142
|
+
content: string;
|
|
143
|
+
metadata: string | null;
|
|
144
|
+
}>(
|
|
145
|
+
`SELECT b.path, b.block_id, f.content, f.metadata FROM ${table} f JOIN ${blocks} b ON b.id = f.rowid WHERE ${scope.sql} ORDER BY b.id`,
|
|
146
|
+
scope.params,
|
|
147
|
+
);
|
|
148
|
+
for (const r of rows) {
|
|
149
|
+
yield {
|
|
150
|
+
path: r.path as DocumentPath,
|
|
151
|
+
blockId: r.block_id,
|
|
152
|
+
content: r.content,
|
|
153
|
+
...(r.metadata ? { metadata: JSON.parse(r.metadata) as Metadata } : {}),
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
},
|
|
157
|
+
|
|
158
|
+
async close() {
|
|
159
|
+
closed = true;
|
|
160
|
+
},
|
|
161
|
+
async flush() {},
|
|
162
|
+
async deleteIndex() {
|
|
163
|
+
closed = true;
|
|
164
|
+
await db.exec(`DROP TABLE IF EXISTS ${table}`);
|
|
165
|
+
},
|
|
166
|
+
};
|
|
167
|
+
return index;
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export type { BlockIds } from "./block-ids.js";
|
|
2
|
+
export { openBlockIds } from "./block-ids.js";
|
|
3
|
+
export { createSqliteIndexer, type SqliteIndexerOptions } from "./create-sqlite-indexer.js";
|
|
4
|
+
export { type BlockEntry, type Enumeration, newEnumeration } from "./enumeration.js";
|
|
5
|
+
export { newSqliteFullTextProvider } from "./fts-provider.js";
|
|
6
|
+
export { newSqliteNgramProvider } from "./ngram-provider.js";
|
|
7
|
+
export { pathPrefixSql } from "./path-sql.js";
|
|
8
|
+
export type { SqliteModalityContext, SqliteModalityProvider } from "./types.js";
|
|
9
|
+
export { newSqliteVectorProvider } from "./vector-provider.js";
|