@statewalker/indexer-pglite 0.2.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 +53 -0
- package/dist/dialect.js +124 -0
- package/dist/dialect.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/pglite-indexer.d.ts +17 -0
- package/dist/pglite-indexer.d.ts.map +1 -0
- package/dist/pglite-indexer.js +28 -0
- package/dist/pglite-indexer.js.map +1 -0
- package/package.json +58 -0
- package/src/dialect.ts +191 -0
- package/src/index.ts +2 -0
- package/src/pglite-indexer.ts +32 -0
- package/src/text-codec.d.ts +3 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mikhail Kotelnikov
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# @statewalker/indexer-pglite
|
|
2
|
+
|
|
3
|
+
PGlite-backed implementation of `@statewalker/indexer-api`: full-text search via PostgreSQL's native `tsvector` + GIN index, vector search via [pgvector](https://github.com/pgvector/pgvector) with an HNSW cosine index. Runs entirely in-process (in the browser, Node, or anywhere [PGlite](https://github.com/electric-sql/pglite) does).
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
pnpm add @statewalker/indexer-pglite @electric-sql/pglite
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import { createPGLiteIndexer } from "@statewalker/indexer-pglite";
|
|
15
|
+
|
|
16
|
+
// Uses an in-memory PGlite instance by default.
|
|
17
|
+
const indexer = await createPGLiteIndexer();
|
|
18
|
+
|
|
19
|
+
// Or pass an existing PGlite (with the `vector` extension already loaded):
|
|
20
|
+
// import { PGlite } from "@electric-sql/pglite";
|
|
21
|
+
// import { vector } from "@electric-sql/pglite/vector";
|
|
22
|
+
// const db = await PGlite.create({ dataDir: "idb://myapp", extensions: { vector } });
|
|
23
|
+
// const indexer = await createPGLiteIndexer({ db });
|
|
24
|
+
|
|
25
|
+
const index = await indexer.createIndex({
|
|
26
|
+
name: "docs",
|
|
27
|
+
fulltext: { language: "en" },
|
|
28
|
+
vector: { dimensionality: 384, model: "all-MiniLM-L6-v2" },
|
|
29
|
+
});
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Runtime extension
|
|
33
|
+
|
|
34
|
+
`createPGLiteIndexer` runs `CREATE EXTENSION IF NOT EXISTS vector` at init. When the factory creates its own PGlite (no `db` passed), it auto-loads the `vector` extension via PGlite's `extensions: { vector }` option; when a caller-supplied `db` is passed, the caller is responsible for having the extension available (pass `extensions: { vector }` to `PGlite.create`).
|
|
35
|
+
|
|
36
|
+
## SQL shape
|
|
37
|
+
|
|
38
|
+
Per named index:
|
|
39
|
+
|
|
40
|
+
- `idx_<prefix>_docs(doc_id SERIAL PRIMARY KEY, path TEXT UNIQUE)` — path ↔ doc_id mapping.
|
|
41
|
+
- `idx_<prefix>_fts(doc_id, block_id, content, content_tsv TSVECTOR GENERATED ..., metadata)` + `USING GIN (content_tsv)`.
|
|
42
|
+
- `idx_<prefix>_vec(doc_id, block_id, embedding vector(dim))` + `USING hnsw (embedding vector_cosine_ops)`.
|
|
43
|
+
|
|
44
|
+
Search uses `ts_rank_cd` + `to_tsquery(<lang>, ...)` for FTS, and the `<=>` cosine-distance operator for vector search.
|
|
45
|
+
|
|
46
|
+
## API
|
|
47
|
+
|
|
48
|
+
- `createPGLiteIndexer(options?)` — returns `Promise<Indexer>`. Options: `db?: PGlite` (bring your own; otherwise a fresh in-memory instance is created, and the returned indexer owns and will close it on `indexer.close()`).
|
|
49
|
+
- `PGLiteIndexerOptions` — option type.
|
|
50
|
+
|
|
51
|
+
## Related
|
|
52
|
+
|
|
53
|
+
- `@statewalker/indexer-api`, `@electric-sql/pglite`, `pgvector`.
|
package/dist/dialect.js
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { buildPathPrefixesSql } from "@statewalker/indexer-core";
|
|
2
|
+
//#region src/dialect.ts
|
|
3
|
+
/** Adapt `@electric-sql/pglite`'s `PGlite` to `@statewalker/indexer-core`'s minimal `SqlDb`. */
|
|
4
|
+
function wrapDbAsSqlDb(db) {
|
|
5
|
+
return {
|
|
6
|
+
exec: (sql) => db.exec(sql).then(() => void 0),
|
|
7
|
+
async query(sql, params) {
|
|
8
|
+
const { rows } = await db.query(sql, params ?? []);
|
|
9
|
+
return rows;
|
|
10
|
+
}
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
const LANGUAGE_MAP = {
|
|
14
|
+
en: "english",
|
|
15
|
+
fr: "french",
|
|
16
|
+
de: "german",
|
|
17
|
+
es: "spanish",
|
|
18
|
+
it: "italian",
|
|
19
|
+
pt: "portuguese",
|
|
20
|
+
nl: "dutch",
|
|
21
|
+
ru: "russian",
|
|
22
|
+
sv: "swedish",
|
|
23
|
+
no: "norwegian",
|
|
24
|
+
da: "danish",
|
|
25
|
+
fi: "finnish",
|
|
26
|
+
hu: "hungarian",
|
|
27
|
+
ro: "romanian",
|
|
28
|
+
tr: "turkish",
|
|
29
|
+
simple: "simple"
|
|
30
|
+
};
|
|
31
|
+
const KNOWN_PG_CONFIGS = new Set(Object.values(LANGUAGE_MAP));
|
|
32
|
+
function resolvePgLanguage(lang) {
|
|
33
|
+
const mapped = LANGUAGE_MAP[lang];
|
|
34
|
+
if (mapped) return mapped;
|
|
35
|
+
if (KNOWN_PG_CONFIGS.has(lang)) return lang;
|
|
36
|
+
throw new Error(`pglite FTS: unsupported language "${lang}" (must be an ISO-639-1 code or a Postgres text-search config name)`);
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* pgvector's `vector(dim)` column accepts either a JSON-array text literal (`"[1,2,3]"`) cast with
|
|
40
|
+
* `::vector(dim)`, or a JS array bound by the driver. The text-literal path is currently used
|
|
41
|
+
* because it works uniformly across the pglite vector extension versions available in the catalog;
|
|
42
|
+
* swapping to native array binding is a follow-up when driver support is confirmed.
|
|
43
|
+
*/
|
|
44
|
+
function embeddingToLiteral(embedding) {
|
|
45
|
+
return `[${Array.from(embedding).join(",")}]`;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* PGlite FTS dialect — uses native PostgreSQL TSVECTOR with a GIN index, `ts_rank_cd` scoring,
|
|
49
|
+
* and `to_tsquery` with a language-specific stemmer.
|
|
50
|
+
*/
|
|
51
|
+
const pgliteFtsDialect = {
|
|
52
|
+
createTableDdl({ tableName, info }) {
|
|
53
|
+
return [`CREATE TABLE IF NOT EXISTS ${tableName} (doc_id INTEGER NOT NULL, block_id TEXT NOT NULL, content TEXT NOT NULL, content_tsv TSVECTOR GENERATED ALWAYS AS (to_tsvector('${resolvePgLanguage(info.language)}', content)) STORED, metadata TEXT, PRIMARY KEY (doc_id, block_id))`, `CREATE INDEX IF NOT EXISTS ${tableName}_tsv_idx ON ${tableName} USING GIN (content_tsv)`];
|
|
54
|
+
},
|
|
55
|
+
async search({ db, tableName, docsTable, info, query, paths, topK }) {
|
|
56
|
+
const pgLang = resolvePgLanguage(info.language);
|
|
57
|
+
const validWords = query.toLowerCase().split(/\s+/).map((w) => w.replace(/[&|!():'"\\]/g, "")).filter((w) => w.length > 0);
|
|
58
|
+
if (validWords.length === 0) return [];
|
|
59
|
+
const allParams = [validWords.join(" | ")];
|
|
60
|
+
let pathClause = "";
|
|
61
|
+
if (paths && paths.length > 0) {
|
|
62
|
+
const cond = buildPathPrefixesSql("d.path", paths, allParams.length + 1);
|
|
63
|
+
pathClause = ` AND ${cond.sql}`;
|
|
64
|
+
allParams.push(...cond.params);
|
|
65
|
+
}
|
|
66
|
+
const topKParam = `$${allParams.length + 1}`;
|
|
67
|
+
allParams.push(topK);
|
|
68
|
+
const sql = `SELECT d.path, b.block_id, b.content, ts_rank_cd(b.content_tsv, to_tsquery('${pgLang}', $1)) AS score FROM ${tableName} b JOIN ${docsTable} d ON d.doc_id = b.doc_id WHERE b.content_tsv @@ to_tsquery('${pgLang}', $1)${pathClause} ORDER BY score DESC LIMIT ${topKParam}`;
|
|
69
|
+
return (await db.query(sql, allParams)).map((row) => ({
|
|
70
|
+
path: row.path,
|
|
71
|
+
blockId: row.block_id,
|
|
72
|
+
content: row.content,
|
|
73
|
+
score: row.score
|
|
74
|
+
}));
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
/**
|
|
78
|
+
* PGlite vector dialect — uses pgvector's HNSW index with the `<=>` cosine-distance operator.
|
|
79
|
+
* Embeddings are currently bound as stringified array literals — Phase 9 switches to parameter-bound
|
|
80
|
+
* driver-native arrays.
|
|
81
|
+
*/
|
|
82
|
+
const pgliteVectorDialect = {
|
|
83
|
+
createTableDdl({ tableName, indexName, info }) {
|
|
84
|
+
return [`CREATE TABLE IF NOT EXISTS ${tableName} (doc_id INTEGER NOT NULL, block_id TEXT NOT NULL, embedding vector(${info.dimensionality}) NOT NULL, PRIMARY KEY (doc_id, block_id))`, `CREATE INDEX IF NOT EXISTS ${indexName} ON ${tableName} USING hnsw (embedding vector_cosine_ops)`];
|
|
85
|
+
},
|
|
86
|
+
bindEmbedding: embeddingToLiteral,
|
|
87
|
+
embeddingCastSuffix: (dim) => `::vector(${dim})`,
|
|
88
|
+
async search({ db, tableName, docsTable, queryEmbedding, paths, topK, info, bindEmbedding, embeddingCastSuffix }) {
|
|
89
|
+
const vecLiteral = bindEmbedding(queryEmbedding);
|
|
90
|
+
const dim = info.dimensionality;
|
|
91
|
+
const allParams = [vecLiteral];
|
|
92
|
+
let pathClause = "";
|
|
93
|
+
if (paths && paths.length > 0) {
|
|
94
|
+
const cond = buildPathPrefixesSql("d.path", paths, allParams.length + 1);
|
|
95
|
+
pathClause = `WHERE ${cond.sql} `;
|
|
96
|
+
allParams.push(...cond.params);
|
|
97
|
+
}
|
|
98
|
+
const topKParam = `$${allParams.length + 1}`;
|
|
99
|
+
allParams.push(topK);
|
|
100
|
+
return (await db.query(`SELECT d.path, b.block_id, (b.embedding <=> $1${embeddingCastSuffix(dim)}) AS dist FROM ${tableName} b JOIN ${docsTable} d ON d.doc_id = b.doc_id ${pathClause}ORDER BY dist ASC LIMIT ${topKParam}`, allParams)).map((row) => ({
|
|
101
|
+
path: row.path,
|
|
102
|
+
blockId: row.block_id,
|
|
103
|
+
score: 1 - row.dist
|
|
104
|
+
}));
|
|
105
|
+
},
|
|
106
|
+
decodeEmbedding(raw) {
|
|
107
|
+
return new Float32Array(JSON.parse(raw));
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
/** Aggregate PGlite dialect for `createSqlBackedIndexer`. */
|
|
111
|
+
const pgliteDialect = {
|
|
112
|
+
extensionInit: ["CREATE EXTENSION IF NOT EXISTS vector"],
|
|
113
|
+
docsTableDdl(prefix) {
|
|
114
|
+
return [`CREATE TABLE IF NOT EXISTS idx_${prefix}_docs (doc_id SERIAL PRIMARY KEY, path TEXT NOT NULL UNIQUE)`];
|
|
115
|
+
},
|
|
116
|
+
unionAliasSuffix: " AS combined",
|
|
117
|
+
supportsDDLInTransaction: true,
|
|
118
|
+
fts: pgliteFtsDialect,
|
|
119
|
+
vec: pgliteVectorDialect
|
|
120
|
+
};
|
|
121
|
+
//#endregion
|
|
122
|
+
export { pgliteDialect, pgliteFtsDialect, pgliteVectorDialect, wrapDbAsSqlDb };
|
|
123
|
+
|
|
124
|
+
//# sourceMappingURL=dialect.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"dialect.js","names":[],"sources":["../src/dialect.ts"],"sourcesContent":["import type { PGlite } from \"@electric-sql/pglite\";\nimport type {\n SqlBackedDialect,\n SqlDb,\n SqlFtsDialect,\n SqlVectorDialect,\n} from \"@statewalker/indexer-core\";\nimport { buildPathPrefixesSql } from \"@statewalker/indexer-core\";\n\n/** Adapt `@electric-sql/pglite`'s `PGlite` to `@statewalker/indexer-core`'s minimal `SqlDb`. */\nexport function wrapDbAsSqlDb(db: PGlite): SqlDb {\n return {\n exec: (sql) => db.exec(sql).then(() => undefined),\n async query<T>(sql: string, params?: unknown[]): Promise<T[]> {\n const { rows } = await db.query<T>(sql, params ?? []);\n return rows;\n },\n };\n}\n\nconst LANGUAGE_MAP: Record<string, string> = {\n en: \"english\",\n fr: \"french\",\n de: \"german\",\n es: \"spanish\",\n it: \"italian\",\n pt: \"portuguese\",\n nl: \"dutch\",\n ru: \"russian\",\n sv: \"swedish\",\n no: \"norwegian\",\n da: \"danish\",\n fi: \"finnish\",\n hu: \"hungarian\",\n ro: \"romanian\",\n tr: \"turkish\",\n simple: \"simple\",\n};\n\n// Postgres text-search configuration names that have always-on availability;\n// any value outside this set is interpolated into DDL/queries and must be a\n// whitelisted identifier — never user-controlled free-form text.\nconst KNOWN_PG_CONFIGS: ReadonlySet<string> = new Set(Object.values(LANGUAGE_MAP));\n\nfunction resolvePgLanguage(lang: string): string {\n const mapped = LANGUAGE_MAP[lang];\n if (mapped) return mapped;\n if (KNOWN_PG_CONFIGS.has(lang)) return lang;\n throw new Error(\n `pglite FTS: unsupported language \"${lang}\" (must be an ISO-639-1 code or a Postgres text-search config name)`,\n );\n}\n\n/**\n * pgvector's `vector(dim)` column accepts either a JSON-array text literal (`\"[1,2,3]\"`) cast with\n * `::vector(dim)`, or a JS array bound by the driver. The text-literal path is currently used\n * because it works uniformly across the pglite vector extension versions available in the catalog;\n * swapping to native array binding is a follow-up when driver support is confirmed.\n */\nfunction embeddingToLiteral(embedding: Float32Array): string {\n return `[${Array.from(embedding).join(\",\")}]`;\n}\n\n/**\n * PGlite FTS dialect — uses native PostgreSQL TSVECTOR with a GIN index, `ts_rank_cd` scoring,\n * and `to_tsquery` with a language-specific stemmer.\n */\nexport const pgliteFtsDialect: SqlFtsDialect = {\n createTableDdl({ tableName, info }) {\n const pgLang = resolvePgLanguage(info.language);\n return [\n `CREATE TABLE IF NOT EXISTS ${tableName} (doc_id INTEGER NOT NULL, block_id TEXT NOT NULL, content TEXT NOT NULL, content_tsv TSVECTOR GENERATED ALWAYS AS (to_tsvector('${pgLang}', content)) STORED, metadata TEXT, PRIMARY KEY (doc_id, block_id))`,\n `CREATE INDEX IF NOT EXISTS ${tableName}_tsv_idx ON ${tableName} USING GIN (content_tsv)`,\n ];\n },\n\n async search({ db, tableName, docsTable, info, query, paths, topK }) {\n const pgLang = resolvePgLanguage(info.language);\n // Strip only tsquery-significant punctuation; keep Unicode letters/digits so\n // non-ASCII languages (Russian, French, CJK, …) reach the FTS engine intact.\n const validWords = query\n .toLowerCase()\n .split(/\\s+/)\n .map((w) => w.replace(/[&|!():'\"\\\\]/g, \"\"))\n .filter((w) => w.length > 0);\n if (validWords.length === 0) return [];\n\n const orTerms = validWords.join(\" | \");\n\n const allParams: unknown[] = [orTerms];\n let pathClause = \"\";\n if (paths && paths.length > 0) {\n const cond = buildPathPrefixesSql(\"d.path\", paths as string[], allParams.length + 1);\n pathClause = ` AND ${cond.sql}`;\n allParams.push(...cond.params);\n }\n\n const topKParam = `$${allParams.length + 1}`;\n allParams.push(topK);\n\n const sql = `SELECT d.path, b.block_id, b.content, ts_rank_cd(b.content_tsv, to_tsquery('${pgLang}', $1)) AS score FROM ${tableName} b JOIN ${docsTable} d ON d.doc_id = b.doc_id WHERE b.content_tsv @@ to_tsquery('${pgLang}', $1)${pathClause} ORDER BY score DESC LIMIT ${topKParam}`;\n\n const rows = await db.query<{\n path: string;\n block_id: string;\n content: string;\n score: number;\n }>(sql, allParams);\n\n return rows.map((row) => ({\n path: row.path as import(\"@statewalker/indexer-api\").DocumentPath,\n blockId: row.block_id,\n content: row.content,\n score: row.score,\n }));\n },\n};\n\n/**\n * PGlite vector dialect — uses pgvector's HNSW index with the `<=>` cosine-distance operator.\n * Embeddings are currently bound as stringified array literals — Phase 9 switches to parameter-bound\n * driver-native arrays.\n */\nexport const pgliteVectorDialect: SqlVectorDialect = {\n createTableDdl({ tableName, indexName, info }) {\n const dim = info.dimensionality;\n return [\n `CREATE TABLE IF NOT EXISTS ${tableName} (doc_id INTEGER NOT NULL, block_id TEXT NOT NULL, embedding vector(${dim}) NOT NULL, PRIMARY KEY (doc_id, block_id))`,\n `CREATE INDEX IF NOT EXISTS ${indexName} ON ${tableName} USING hnsw (embedding vector_cosine_ops)`,\n ];\n },\n\n bindEmbedding: embeddingToLiteral,\n embeddingCastSuffix: (dim) => `::vector(${dim})`,\n\n async search({\n db,\n tableName,\n docsTable,\n queryEmbedding,\n paths,\n topK,\n info,\n bindEmbedding,\n embeddingCastSuffix,\n }) {\n const vecLiteral = bindEmbedding(queryEmbedding);\n const dim = info.dimensionality;\n\n const allParams: unknown[] = [vecLiteral];\n let pathClause = \"\";\n if (paths && paths.length > 0) {\n const cond = buildPathPrefixesSql(\"d.path\", paths as string[], allParams.length + 1);\n pathClause = `WHERE ${cond.sql} `;\n allParams.push(...cond.params);\n }\n\n const topKParam = `$${allParams.length + 1}`;\n allParams.push(topK);\n\n const rows = await db.query<{ path: string; block_id: string; dist: number }>(\n `SELECT d.path, b.block_id, (b.embedding <=> $1${embeddingCastSuffix(dim)}) AS dist FROM ${tableName} b JOIN ${docsTable} d ON d.doc_id = b.doc_id ${pathClause}ORDER BY dist ASC LIMIT ${topKParam}`,\n allParams,\n );\n\n return rows.map((row) => ({\n path: row.path as import(\"@statewalker/indexer-api\").DocumentPath,\n blockId: row.block_id,\n score: 1 - row.dist,\n }));\n },\n\n decodeEmbedding(raw) {\n // PGlite returns vector as a string \"[1,2,3]\".\n return new Float32Array(JSON.parse(raw as string) as number[]);\n },\n};\n\n/** Aggregate PGlite dialect for `createSqlBackedIndexer`. */\nexport const pgliteDialect: SqlBackedDialect = {\n extensionInit: [\"CREATE EXTENSION IF NOT EXISTS vector\"],\n docsTableDdl(prefix) {\n return [\n `CREATE TABLE IF NOT EXISTS idx_${prefix}_docs (doc_id SERIAL PRIMARY KEY, path TEXT NOT NULL UNIQUE)`,\n ];\n },\n unionAliasSuffix: \" AS combined\",\n supportsDDLInTransaction: true,\n fts: pgliteFtsDialect,\n vec: pgliteVectorDialect,\n};\n"],"mappings":"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"}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { PGlite } from "@electric-sql/pglite";
|
|
2
|
+
import { Indexer } from "@statewalker/indexer-api";
|
|
3
|
+
//#region src/pglite-indexer.d.ts
|
|
4
|
+
export interface PGLiteIndexerOptions {
|
|
5
|
+
db?: PGlite;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* PGlite-backed `Indexer` over the open-registry contract.
|
|
9
|
+
*
|
|
10
|
+
* Wires the per-Index docs table, per-sub-index FTS / vector tables, and the
|
|
11
|
+
* `__indexer_manifest` table via `createSqlIndexer`. Each Index is an open
|
|
12
|
+
* registry of named sub-indexes; the FTS modality uses PostgreSQL `tsvector`
|
|
13
|
+
* with GIN; the vector modality uses pgvector's `vector(dim)` with HNSW.
|
|
14
|
+
*/
|
|
15
|
+
export declare function createPGLiteIndexer(options?: PGLiteIndexerOptions): Promise<Indexer>;
|
|
16
|
+
//#endregion
|
|
17
|
+
//# sourceMappingURL=pglite-indexer.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"pglite-indexer.d.ts","names":[],"sources":["../src/pglite-indexer.ts"],"mappings":";;;iBAMiB;EACf,KAAK;;;;;;;;;;wBAWe,oBAAoB,UAAU,uBAAuB,QAAQ"}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { pgliteDialect, wrapDbAsSqlDb } from "./dialect.js";
|
|
2
|
+
import { PGlite } from "@electric-sql/pglite";
|
|
3
|
+
import { vector } from "@electric-sql/pglite-pgvector";
|
|
4
|
+
import { createSqlIndexer } from "@statewalker/indexer-core";
|
|
5
|
+
//#region src/pglite-indexer.ts
|
|
6
|
+
/**
|
|
7
|
+
* PGlite-backed `Indexer` over the open-registry contract.
|
|
8
|
+
*
|
|
9
|
+
* Wires the per-Index docs table, per-sub-index FTS / vector tables, and the
|
|
10
|
+
* `__indexer_manifest` table via `createSqlIndexer`. Each Index is an open
|
|
11
|
+
* registry of named sub-indexes; the FTS modality uses PostgreSQL `tsvector`
|
|
12
|
+
* with GIN; the vector modality uses pgvector's `vector(dim)` with HNSW.
|
|
13
|
+
*/
|
|
14
|
+
async function createPGLiteIndexer(options) {
|
|
15
|
+
const ownsDb = !options?.db;
|
|
16
|
+
const db = options?.db ?? await PGlite.create({ extensions: { vector } });
|
|
17
|
+
return createSqlIndexer({
|
|
18
|
+
db: wrapDbAsSqlDb(db),
|
|
19
|
+
dialect: pgliteDialect,
|
|
20
|
+
onClose: ownsDb ? async () => {
|
|
21
|
+
await db.close();
|
|
22
|
+
} : void 0
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
//#endregion
|
|
26
|
+
export { createPGLiteIndexer };
|
|
27
|
+
|
|
28
|
+
//# sourceMappingURL=pglite-indexer.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"pglite-indexer.js","names":[],"sources":["../src/pglite-indexer.ts"],"sourcesContent":["import { PGlite } from \"@electric-sql/pglite\";\nimport { vector } from \"@electric-sql/pglite-pgvector\";\nimport type { Indexer } from \"@statewalker/indexer-api\";\nimport { createSqlIndexer } from \"@statewalker/indexer-core\";\nimport { pgliteDialect, wrapDbAsSqlDb } from \"./dialect.js\";\n\nexport interface PGLiteIndexerOptions {\n db?: PGlite;\n}\n\n/**\n * PGlite-backed `Indexer` over the open-registry contract.\n *\n * Wires the per-Index docs table, per-sub-index FTS / vector tables, and the\n * `__indexer_manifest` table via `createSqlIndexer`. Each Index is an open\n * registry of named sub-indexes; the FTS modality uses PostgreSQL `tsvector`\n * with GIN; the vector modality uses pgvector's `vector(dim)` with HNSW.\n */\nexport async function createPGLiteIndexer(options?: PGLiteIndexerOptions): Promise<Indexer> {\n const ownsDb = !options?.db;\n const db = options?.db ?? (await PGlite.create({ extensions: { vector } }));\n\n return createSqlIndexer({\n db: wrapDbAsSqlDb(db),\n dialect: pgliteDialect,\n onClose: ownsDb\n ? async () => {\n await db.close();\n }\n : undefined,\n });\n}\n"],"mappings":";;;;;;;;;;;;;AAkBA,eAAsB,oBAAoB,SAAkD;CAC1F,MAAM,SAAS,CAAC,SAAS;CACzB,MAAM,KAAK,SAAS,MAAO,MAAM,OAAO,OAAO,EAAE,YAAY,EAAE,OAAO,EAAE,CAAC;CAEzE,OAAO,iBAAiB;EACtB,IAAI,cAAc,EAAE;EACpB,SAAS;EACT,SAAS,SACL,YAAY;GACV,MAAM,GAAG,MAAM;EACjB,IACA,KAAA;CACN,CAAC;AACH"}
|
package/package.json
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@statewalker/indexer-pglite",
|
|
3
|
+
"version": "0.2.1",
|
|
4
|
+
"private": false,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"description": "PGlite-backed indexer implementation: full-text + vector search using in-browser Postgres.",
|
|
7
|
+
"homepage": "https://github.com/statewalker/statewalker-indexer",
|
|
8
|
+
"author": {
|
|
9
|
+
"name": "Mikhail Kotelnikov",
|
|
10
|
+
"email": "mikhail.kotelnikov@gmail.com"
|
|
11
|
+
},
|
|
12
|
+
"license": "MIT",
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "git+ssh://git@github.com/statewalker/statewalker-indexer.git"
|
|
16
|
+
},
|
|
17
|
+
"exports": {
|
|
18
|
+
".": {
|
|
19
|
+
"source": "./src/index.ts",
|
|
20
|
+
"types": "./dist/index.d.ts",
|
|
21
|
+
"import": "./dist/index.js",
|
|
22
|
+
"default": "./dist/index.js"
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"dist",
|
|
27
|
+
"src"
|
|
28
|
+
],
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"@electric-sql/pglite": "^0.5.8",
|
|
31
|
+
"@electric-sql/pglite-pgvector": "^0.0.9",
|
|
32
|
+
"@statewalker/indexer-api": "^0.1.1",
|
|
33
|
+
"@statewalker/indexer-core": "^0.1.1",
|
|
34
|
+
"@statewalker/indexer-fulltext": "^0.1.1",
|
|
35
|
+
"@statewalker/indexer-vector": "^0.1.1"
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"rimraf": "^6.1.3",
|
|
39
|
+
"tsdown": "^0.23.0",
|
|
40
|
+
"typescript": "^7.0.2",
|
|
41
|
+
"vitest": "^5.0.3",
|
|
42
|
+
"@statewalker/indexer-tests": "^0.0.0"
|
|
43
|
+
},
|
|
44
|
+
"sideEffects": false,
|
|
45
|
+
"publishConfig": {
|
|
46
|
+
"access": "public"
|
|
47
|
+
},
|
|
48
|
+
"scripts": {
|
|
49
|
+
"build": "tsdown",
|
|
50
|
+
"dev": "tsdown --watch",
|
|
51
|
+
"test": "vitest run --passWithNoTests",
|
|
52
|
+
"test:watch": "vitest",
|
|
53
|
+
"typecheck": "tsc --noEmit",
|
|
54
|
+
"clean": "rimraf dist",
|
|
55
|
+
"lint": "biome check --write .",
|
|
56
|
+
"format": "biome format --write ."
|
|
57
|
+
}
|
|
58
|
+
}
|
package/src/dialect.ts
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import type { PGlite } from "@electric-sql/pglite";
|
|
2
|
+
import type {
|
|
3
|
+
SqlBackedDialect,
|
|
4
|
+
SqlDb,
|
|
5
|
+
SqlFtsDialect,
|
|
6
|
+
SqlVectorDialect,
|
|
7
|
+
} from "@statewalker/indexer-core";
|
|
8
|
+
import { buildPathPrefixesSql } from "@statewalker/indexer-core";
|
|
9
|
+
|
|
10
|
+
/** Adapt `@electric-sql/pglite`'s `PGlite` to `@statewalker/indexer-core`'s minimal `SqlDb`. */
|
|
11
|
+
export function wrapDbAsSqlDb(db: PGlite): SqlDb {
|
|
12
|
+
return {
|
|
13
|
+
exec: (sql) => db.exec(sql).then(() => undefined),
|
|
14
|
+
async query<T>(sql: string, params?: unknown[]): Promise<T[]> {
|
|
15
|
+
const { rows } = await db.query<T>(sql, params ?? []);
|
|
16
|
+
return rows;
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const LANGUAGE_MAP: Record<string, string> = {
|
|
22
|
+
en: "english",
|
|
23
|
+
fr: "french",
|
|
24
|
+
de: "german",
|
|
25
|
+
es: "spanish",
|
|
26
|
+
it: "italian",
|
|
27
|
+
pt: "portuguese",
|
|
28
|
+
nl: "dutch",
|
|
29
|
+
ru: "russian",
|
|
30
|
+
sv: "swedish",
|
|
31
|
+
no: "norwegian",
|
|
32
|
+
da: "danish",
|
|
33
|
+
fi: "finnish",
|
|
34
|
+
hu: "hungarian",
|
|
35
|
+
ro: "romanian",
|
|
36
|
+
tr: "turkish",
|
|
37
|
+
simple: "simple",
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// Postgres text-search configuration names that have always-on availability;
|
|
41
|
+
// any value outside this set is interpolated into DDL/queries and must be a
|
|
42
|
+
// whitelisted identifier — never user-controlled free-form text.
|
|
43
|
+
const KNOWN_PG_CONFIGS: ReadonlySet<string> = new Set(Object.values(LANGUAGE_MAP));
|
|
44
|
+
|
|
45
|
+
function resolvePgLanguage(lang: string): string {
|
|
46
|
+
const mapped = LANGUAGE_MAP[lang];
|
|
47
|
+
if (mapped) return mapped;
|
|
48
|
+
if (KNOWN_PG_CONFIGS.has(lang)) return lang;
|
|
49
|
+
throw new Error(
|
|
50
|
+
`pglite FTS: unsupported language "${lang}" (must be an ISO-639-1 code or a Postgres text-search config name)`,
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* pgvector's `vector(dim)` column accepts either a JSON-array text literal (`"[1,2,3]"`) cast with
|
|
56
|
+
* `::vector(dim)`, or a JS array bound by the driver. The text-literal path is currently used
|
|
57
|
+
* because it works uniformly across the pglite vector extension versions available in the catalog;
|
|
58
|
+
* swapping to native array binding is a follow-up when driver support is confirmed.
|
|
59
|
+
*/
|
|
60
|
+
function embeddingToLiteral(embedding: Float32Array): string {
|
|
61
|
+
return `[${Array.from(embedding).join(",")}]`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* PGlite FTS dialect — uses native PostgreSQL TSVECTOR with a GIN index, `ts_rank_cd` scoring,
|
|
66
|
+
* and `to_tsquery` with a language-specific stemmer.
|
|
67
|
+
*/
|
|
68
|
+
export const pgliteFtsDialect: SqlFtsDialect = {
|
|
69
|
+
createTableDdl({ tableName, info }) {
|
|
70
|
+
const pgLang = resolvePgLanguage(info.language);
|
|
71
|
+
return [
|
|
72
|
+
`CREATE TABLE IF NOT EXISTS ${tableName} (doc_id INTEGER NOT NULL, block_id TEXT NOT NULL, content TEXT NOT NULL, content_tsv TSVECTOR GENERATED ALWAYS AS (to_tsvector('${pgLang}', content)) STORED, metadata TEXT, PRIMARY KEY (doc_id, block_id))`,
|
|
73
|
+
`CREATE INDEX IF NOT EXISTS ${tableName}_tsv_idx ON ${tableName} USING GIN (content_tsv)`,
|
|
74
|
+
];
|
|
75
|
+
},
|
|
76
|
+
|
|
77
|
+
async search({ db, tableName, docsTable, info, query, paths, topK }) {
|
|
78
|
+
const pgLang = resolvePgLanguage(info.language);
|
|
79
|
+
// Strip only tsquery-significant punctuation; keep Unicode letters/digits so
|
|
80
|
+
// non-ASCII languages (Russian, French, CJK, …) reach the FTS engine intact.
|
|
81
|
+
const validWords = query
|
|
82
|
+
.toLowerCase()
|
|
83
|
+
.split(/\s+/)
|
|
84
|
+
.map((w) => w.replace(/[&|!():'"\\]/g, ""))
|
|
85
|
+
.filter((w) => w.length > 0);
|
|
86
|
+
if (validWords.length === 0) return [];
|
|
87
|
+
|
|
88
|
+
const orTerms = validWords.join(" | ");
|
|
89
|
+
|
|
90
|
+
const allParams: unknown[] = [orTerms];
|
|
91
|
+
let pathClause = "";
|
|
92
|
+
if (paths && paths.length > 0) {
|
|
93
|
+
const cond = buildPathPrefixesSql("d.path", paths as string[], allParams.length + 1);
|
|
94
|
+
pathClause = ` AND ${cond.sql}`;
|
|
95
|
+
allParams.push(...cond.params);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const topKParam = `$${allParams.length + 1}`;
|
|
99
|
+
allParams.push(topK);
|
|
100
|
+
|
|
101
|
+
const sql = `SELECT d.path, b.block_id, b.content, ts_rank_cd(b.content_tsv, to_tsquery('${pgLang}', $1)) AS score FROM ${tableName} b JOIN ${docsTable} d ON d.doc_id = b.doc_id WHERE b.content_tsv @@ to_tsquery('${pgLang}', $1)${pathClause} ORDER BY score DESC LIMIT ${topKParam}`;
|
|
102
|
+
|
|
103
|
+
const rows = await db.query<{
|
|
104
|
+
path: string;
|
|
105
|
+
block_id: string;
|
|
106
|
+
content: string;
|
|
107
|
+
score: number;
|
|
108
|
+
}>(sql, allParams);
|
|
109
|
+
|
|
110
|
+
return rows.map((row) => ({
|
|
111
|
+
path: row.path as import("@statewalker/indexer-api").DocumentPath,
|
|
112
|
+
blockId: row.block_id,
|
|
113
|
+
content: row.content,
|
|
114
|
+
score: row.score,
|
|
115
|
+
}));
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* PGlite vector dialect — uses pgvector's HNSW index with the `<=>` cosine-distance operator.
|
|
121
|
+
* Embeddings are currently bound as stringified array literals — Phase 9 switches to parameter-bound
|
|
122
|
+
* driver-native arrays.
|
|
123
|
+
*/
|
|
124
|
+
export const pgliteVectorDialect: SqlVectorDialect = {
|
|
125
|
+
createTableDdl({ tableName, indexName, info }) {
|
|
126
|
+
const dim = info.dimensionality;
|
|
127
|
+
return [
|
|
128
|
+
`CREATE TABLE IF NOT EXISTS ${tableName} (doc_id INTEGER NOT NULL, block_id TEXT NOT NULL, embedding vector(${dim}) NOT NULL, PRIMARY KEY (doc_id, block_id))`,
|
|
129
|
+
`CREATE INDEX IF NOT EXISTS ${indexName} ON ${tableName} USING hnsw (embedding vector_cosine_ops)`,
|
|
130
|
+
];
|
|
131
|
+
},
|
|
132
|
+
|
|
133
|
+
bindEmbedding: embeddingToLiteral,
|
|
134
|
+
embeddingCastSuffix: (dim) => `::vector(${dim})`,
|
|
135
|
+
|
|
136
|
+
async search({
|
|
137
|
+
db,
|
|
138
|
+
tableName,
|
|
139
|
+
docsTable,
|
|
140
|
+
queryEmbedding,
|
|
141
|
+
paths,
|
|
142
|
+
topK,
|
|
143
|
+
info,
|
|
144
|
+
bindEmbedding,
|
|
145
|
+
embeddingCastSuffix,
|
|
146
|
+
}) {
|
|
147
|
+
const vecLiteral = bindEmbedding(queryEmbedding);
|
|
148
|
+
const dim = info.dimensionality;
|
|
149
|
+
|
|
150
|
+
const allParams: unknown[] = [vecLiteral];
|
|
151
|
+
let pathClause = "";
|
|
152
|
+
if (paths && paths.length > 0) {
|
|
153
|
+
const cond = buildPathPrefixesSql("d.path", paths as string[], allParams.length + 1);
|
|
154
|
+
pathClause = `WHERE ${cond.sql} `;
|
|
155
|
+
allParams.push(...cond.params);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const topKParam = `$${allParams.length + 1}`;
|
|
159
|
+
allParams.push(topK);
|
|
160
|
+
|
|
161
|
+
const rows = await db.query<{ path: string; block_id: string; dist: number }>(
|
|
162
|
+
`SELECT d.path, b.block_id, (b.embedding <=> $1${embeddingCastSuffix(dim)}) AS dist FROM ${tableName} b JOIN ${docsTable} d ON d.doc_id = b.doc_id ${pathClause}ORDER BY dist ASC LIMIT ${topKParam}`,
|
|
163
|
+
allParams,
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
return rows.map((row) => ({
|
|
167
|
+
path: row.path as import("@statewalker/indexer-api").DocumentPath,
|
|
168
|
+
blockId: row.block_id,
|
|
169
|
+
score: 1 - row.dist,
|
|
170
|
+
}));
|
|
171
|
+
},
|
|
172
|
+
|
|
173
|
+
decodeEmbedding(raw) {
|
|
174
|
+
// PGlite returns vector as a string "[1,2,3]".
|
|
175
|
+
return new Float32Array(JSON.parse(raw as string) as number[]);
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
/** Aggregate PGlite dialect for `createSqlBackedIndexer`. */
|
|
180
|
+
export const pgliteDialect: SqlBackedDialect = {
|
|
181
|
+
extensionInit: ["CREATE EXTENSION IF NOT EXISTS vector"],
|
|
182
|
+
docsTableDdl(prefix) {
|
|
183
|
+
return [
|
|
184
|
+
`CREATE TABLE IF NOT EXISTS idx_${prefix}_docs (doc_id SERIAL PRIMARY KEY, path TEXT NOT NULL UNIQUE)`,
|
|
185
|
+
];
|
|
186
|
+
},
|
|
187
|
+
unionAliasSuffix: " AS combined",
|
|
188
|
+
supportsDDLInTransaction: true,
|
|
189
|
+
fts: pgliteFtsDialect,
|
|
190
|
+
vec: pgliteVectorDialect,
|
|
191
|
+
};
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { PGlite } from "@electric-sql/pglite";
|
|
2
|
+
import { vector } from "@electric-sql/pglite-pgvector";
|
|
3
|
+
import type { Indexer } from "@statewalker/indexer-api";
|
|
4
|
+
import { createSqlIndexer } from "@statewalker/indexer-core";
|
|
5
|
+
import { pgliteDialect, wrapDbAsSqlDb } from "./dialect.js";
|
|
6
|
+
|
|
7
|
+
export interface PGLiteIndexerOptions {
|
|
8
|
+
db?: PGlite;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* PGlite-backed `Indexer` over the open-registry contract.
|
|
13
|
+
*
|
|
14
|
+
* Wires the per-Index docs table, per-sub-index FTS / vector tables, and the
|
|
15
|
+
* `__indexer_manifest` table via `createSqlIndexer`. Each Index is an open
|
|
16
|
+
* registry of named sub-indexes; the FTS modality uses PostgreSQL `tsvector`
|
|
17
|
+
* with GIN; the vector modality uses pgvector's `vector(dim)` with HNSW.
|
|
18
|
+
*/
|
|
19
|
+
export async function createPGLiteIndexer(options?: PGLiteIndexerOptions): Promise<Indexer> {
|
|
20
|
+
const ownsDb = !options?.db;
|
|
21
|
+
const db = options?.db ?? (await PGlite.create({ extensions: { vector } }));
|
|
22
|
+
|
|
23
|
+
return createSqlIndexer({
|
|
24
|
+
db: wrapDbAsSqlDb(db),
|
|
25
|
+
dialect: pgliteDialect,
|
|
26
|
+
onClose: ownsDb
|
|
27
|
+
? async () => {
|
|
28
|
+
await db.close();
|
|
29
|
+
}
|
|
30
|
+
: undefined,
|
|
31
|
+
});
|
|
32
|
+
}
|