@statewalker/indexer-pglite 0.3.0 → 0.3.2
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/README.md +1 -1
- package/dist/dialect.js +10 -48
- package/dist/dialect.js.map +1 -1
- package/package.json +5 -5
- package/src/dialect.ts +31 -54
package/README.md
CHANGED
|
@@ -85,7 +85,7 @@ idx_<index>_<sub>_vec(doc_id, block_id, embedding vector(dim))
|
|
|
85
85
|
|
|
86
86
|
### Queries, scores and languages
|
|
87
87
|
|
|
88
|
-
- A full-text query is lowercased, split on
|
|
88
|
+
- A full-text query is lowercased, split into words on every character that is not a Unicode letter, combining mark, digit or `_` (so no tsquery operator such as `&`, `|`, `<->` or `:` gets through, and `foo:bar` becomes `foo | bar`), and joined with `|` (OR). Score is `ts_rank_cd`. With several `queries` in one sub-query, each block keeps its best score across them (scores are not summed).
|
|
89
89
|
- Vector `score` is `1 - (embedding <=> query)` (cosine). Embeddings are sent as text literals cast to `vector(dim)`.
|
|
90
90
|
- `language` accepts ISO-639-1 codes (`en`, `fr`, `de`, `es`, `it`, `pt`, `nl`, `ru`, `sv`, `no`, `da`, `fi`, `hu`, `ro`, `tr`), `simple`, or the matching Postgres config names (`english`, ...). Anything else throws `pglite FTS: unsupported language "<x>" (must be an ISO-639-1 code or a Postgres text-search config name)`. The value is put into SQL, which is why it is checked against this list.
|
|
91
91
|
- Default retrieval depth is `topK = 100` per sub-query.
|
package/dist/dialect.js
CHANGED
|
@@ -1,21 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { ISO_639_1_LANGUAGE_NAMES, buildSearchFilterSql, embeddingToLiteral, sqlDocsTableName } from "@statewalker/indexer-core";
|
|
2
2
|
//#region src/dialect.ts
|
|
3
3
|
const LANGUAGE_MAP = {
|
|
4
|
-
|
|
5
|
-
fr: "french",
|
|
6
|
-
de: "german",
|
|
7
|
-
es: "spanish",
|
|
8
|
-
it: "italian",
|
|
9
|
-
pt: "portuguese",
|
|
10
|
-
nl: "dutch",
|
|
11
|
-
ru: "russian",
|
|
12
|
-
sv: "swedish",
|
|
13
|
-
no: "norwegian",
|
|
14
|
-
da: "danish",
|
|
15
|
-
fi: "finnish",
|
|
16
|
-
hu: "hungarian",
|
|
17
|
-
ro: "romanian",
|
|
18
|
-
tr: "turkish",
|
|
4
|
+
...ISO_639_1_LANGUAGE_NAMES,
|
|
19
5
|
simple: "simple"
|
|
20
6
|
};
|
|
21
7
|
const KNOWN_PG_CONFIGS = new Set(Object.values(LANGUAGE_MAP));
|
|
@@ -26,15 +12,6 @@ function resolvePgLanguage(lang) {
|
|
|
26
12
|
throw new Error(`pglite FTS: unsupported language "${lang}" (must be an ISO-639-1 code or a Postgres text-search config name)`);
|
|
27
13
|
}
|
|
28
14
|
/**
|
|
29
|
-
* pgvector's `vector(dim)` column accepts either a JSON-array text literal (`"[1,2,3]"`) cast with
|
|
30
|
-
* `::vector(dim)`, or a JS array bound by the driver. The text-literal path is currently used
|
|
31
|
-
* because it works uniformly across the pglite vector extension versions available in the catalog;
|
|
32
|
-
* swapping to native array binding is a follow-up when driver support is confirmed.
|
|
33
|
-
*/
|
|
34
|
-
function embeddingToLiteral(embedding) {
|
|
35
|
-
return `[${Array.from(embedding).join(",")}]`;
|
|
36
|
-
}
|
|
37
|
-
/**
|
|
38
15
|
* PGlite FTS dialect — uses native PostgreSQL TSVECTOR with a GIN index, `ts_rank_cd` scoring,
|
|
39
16
|
* and `to_tsquery` with a language-specific stemmer.
|
|
40
17
|
*/
|
|
@@ -44,18 +21,11 @@ const pgliteFtsDialect = {
|
|
|
44
21
|
},
|
|
45
22
|
async search({ db, tableName, docsTable, info, query, paths, topK }) {
|
|
46
23
|
const pgLang = resolvePgLanguage(info.language);
|
|
47
|
-
const validWords = query.toLowerCase().split(
|
|
24
|
+
const validWords = query.toLowerCase().split(/[^\p{L}\p{M}\p{N}_]+/u).filter((w) => w.length > 0);
|
|
48
25
|
if (validWords.length === 0) return [];
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
const cond = buildPathPrefixesSql("d.path", paths, allParams.length + 1);
|
|
53
|
-
pathClause = ` AND ${cond.sql}`;
|
|
54
|
-
allParams.push(...cond.params);
|
|
55
|
-
}
|
|
56
|
-
const topKParam = `$${allParams.length + 1}`;
|
|
57
|
-
allParams.push(topK);
|
|
58
|
-
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}`;
|
|
26
|
+
const orTerms = validWords.join(" | ");
|
|
27
|
+
const { pathCondition, topKParam, params: allParams } = buildSearchFilterSql("d.path", paths, topK, [orTerms]);
|
|
28
|
+
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)${pathCondition ? ` AND ${pathCondition}` : ""} ORDER BY score DESC LIMIT ${topKParam}`;
|
|
59
29
|
return (await db.query(sql, allParams)).map((row) => ({
|
|
60
30
|
path: row.path,
|
|
61
31
|
blockId: row.block_id,
|
|
@@ -66,8 +36,7 @@ const pgliteFtsDialect = {
|
|
|
66
36
|
};
|
|
67
37
|
/**
|
|
68
38
|
* PGlite vector dialect — uses pgvector's HNSW index with the `<=>` cosine-distance operator.
|
|
69
|
-
* Embeddings are
|
|
70
|
-
* driver-native arrays.
|
|
39
|
+
* Embeddings are bound as stringified array literals.
|
|
71
40
|
*/
|
|
72
41
|
const pgliteVectorDialect = {
|
|
73
42
|
createTableDdl({ tableName, indexName, info }) {
|
|
@@ -78,15 +47,8 @@ const pgliteVectorDialect = {
|
|
|
78
47
|
async search({ db, tableName, docsTable, queryEmbedding, paths, topK, info, bindEmbedding, embeddingCastSuffix }) {
|
|
79
48
|
const vecLiteral = bindEmbedding(queryEmbedding);
|
|
80
49
|
const dim = info.dimensionality;
|
|
81
|
-
const allParams = [vecLiteral];
|
|
82
|
-
|
|
83
|
-
if (paths && paths.length > 0) {
|
|
84
|
-
const cond = buildPathPrefixesSql("d.path", paths, allParams.length + 1);
|
|
85
|
-
pathClause = `WHERE ${cond.sql} `;
|
|
86
|
-
allParams.push(...cond.params);
|
|
87
|
-
}
|
|
88
|
-
const topKParam = `$${allParams.length + 1}`;
|
|
89
|
-
allParams.push(topK);
|
|
50
|
+
const { pathCondition, topKParam, params: allParams } = buildSearchFilterSql("d.path", paths, topK, [vecLiteral]);
|
|
51
|
+
const pathClause = pathCondition ? `WHERE ${pathCondition} ` : "";
|
|
90
52
|
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) => ({
|
|
91
53
|
path: row.path,
|
|
92
54
|
blockId: row.block_id,
|
|
@@ -101,7 +63,7 @@ const pgliteVectorDialect = {
|
|
|
101
63
|
const pgliteDialect = {
|
|
102
64
|
extensionInit: ["CREATE EXTENSION IF NOT EXISTS vector"],
|
|
103
65
|
docsTableDdl(prefix) {
|
|
104
|
-
return [`CREATE TABLE IF NOT EXISTS
|
|
66
|
+
return [`CREATE TABLE IF NOT EXISTS ${sqlDocsTableName(prefix)} (doc_id SERIAL PRIMARY KEY, path TEXT NOT NULL UNIQUE)`];
|
|
105
67
|
},
|
|
106
68
|
unionAliasSuffix: " AS combined",
|
|
107
69
|
supportsDDLInTransaction: true,
|
package/dist/dialect.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"dialect.js","names":[],"sources":["../src/dialect.ts"],"sourcesContent":["import type { SqlBackedDialect, SqlFtsDialect, SqlVectorDialect } from \"@statewalker/indexer-core\";\nimport {
|
|
1
|
+
{"version":3,"file":"dialect.js","names":[],"sources":["../src/dialect.ts"],"sourcesContent":["import type { SqlBackedDialect, SqlFtsDialect, SqlVectorDialect } from \"@statewalker/indexer-core\";\nimport {\n buildSearchFilterSql,\n embeddingToLiteral,\n ISO_639_1_LANGUAGE_NAMES,\n sqlDocsTableName,\n} from \"@statewalker/indexer-core\";\n\n// ISO-639-1 codes map to the Postgres text-search configuration of the same language name.\nconst LANGUAGE_MAP: Record<string, string> = {\n ...ISO_639_1_LANGUAGE_NAMES,\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 * 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 // Keep only words: split on everything that is not a Unicode letter, combining mark, digit\n // or `_`, so no tsquery operator (`& | ! ( ) : < > <-> <N>`, quotes, backslash) reaches\n // `to_tsquery`, and non-ASCII words (Russian, Hindi, decomposed accents, …) stay whole.\n const validWords = query\n .toLowerCase()\n .split(/[^\\p{L}\\p{M}\\p{N}_]+/u)\n .filter((w) => w.length > 0);\n if (validWords.length === 0) return [];\n\n const orTerms = validWords.join(\" | \");\n\n const {\n pathCondition,\n topKParam,\n params: allParams,\n } = buildSearchFilterSql(\"d.path\", paths, topK, [orTerms]);\n const pathClause = pathCondition ? ` AND ${pathCondition}` : \"\";\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 bound as stringified array literals.\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 // 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 used because it\n // works uniformly across the pglite vector extension versions available in the catalog; swapping\n // to native array binding is a follow-up when driver support is confirmed.\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 {\n pathCondition,\n topKParam,\n params: allParams,\n } = buildSearchFilterSql(\"d.path\", paths, topK, [vecLiteral]);\n const pathClause = pathCondition ? `WHERE ${pathCondition} ` : \"\";\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 ${sqlDocsTableName(prefix)} (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":";;AASA,MAAM,eAAuC;CAC3C,GAAG;CACH,QAAQ;AACV;AAKA,MAAM,mBAAwC,IAAI,IAAI,OAAO,OAAO,YAAY,CAAC;AAEjF,SAAS,kBAAkB,MAAsB;CAC/C,MAAM,SAAS,aAAa;CAC5B,IAAI,QAAQ,OAAO;CACnB,IAAI,iBAAiB,IAAI,IAAI,GAAG,OAAO;CACvC,MAAM,IAAI,MACR,qCAAqC,KAAK,oEAC5C;AACF;;;;;AAMA,MAAa,mBAAkC;CAC7C,eAAe,EAAE,WAAW,QAAQ;EAElC,OAAO,CACL,8BAA8B,UAAU,mIAF3B,kBAAkB,KAAK,QAE4I,EAAE,sEAClL,8BAA8B,UAAU,cAAc,UAAU,yBAClE;CACF;CAEA,MAAM,OAAO,EAAE,IAAI,WAAW,WAAW,MAAM,OAAO,OAAO,QAAQ;EACnE,MAAM,SAAS,kBAAkB,KAAK,QAAQ;EAI9C,MAAM,aAAa,MAChB,YAAY,CAAC,CACb,MAAM,uBAAuB,CAAC,CAC9B,QAAQ,MAAM,EAAE,SAAS,CAAC;EAC7B,IAAI,WAAW,WAAW,GAAG,OAAO,CAAC;EAErC,MAAM,UAAU,WAAW,KAAK,KAAK;EAErC,MAAM,EACJ,eACA,WACA,QAAQ,cACN,qBAAqB,UAAU,OAAO,MAAM,CAAC,OAAO,CAAC;EAGzD,MAAM,MAAM,+EAA+E,OAAO,wBAAwB,UAAU,UAAU,UAAU,+DAA+D,OAAO,QAF3M,gBAAgB,QAAQ,kBAAkB,GAEoL,6BAA6B;EAS9Q,QAAO,MAPY,GAAG,MAKnB,KAAK,SAAS,EAAA,CAEL,KAAK,SAAS;GACxB,MAAM,IAAI;GACV,SAAS,IAAI;GACb,SAAS,IAAI;GACb,OAAO,IAAI;EACb,EAAE;CACJ;AACF;;;;;AAMA,MAAa,sBAAwC;CACnD,eAAe,EAAE,WAAW,WAAW,QAAQ;EAE7C,OAAO,CACL,8BAA8B,UAAU,sEAF9B,KAAK,eAEmG,8CAClH,8BAA8B,UAAU,MAAM,UAAU,0CAC1D;CACF;CAMA,eAAe;CACf,sBAAsB,QAAQ,YAAY,IAAI;CAE9C,MAAM,OAAO,EACX,IACA,WACA,WACA,gBACA,OACA,MACA,MACA,eACA,uBACC;EACD,MAAM,aAAa,cAAc,cAAc;EAC/C,MAAM,MAAM,KAAK;EAEjB,MAAM,EACJ,eACA,WACA,QAAQ,cACN,qBAAqB,UAAU,OAAO,MAAM,CAAC,UAAU,CAAC;EAC5D,MAAM,aAAa,gBAAgB,SAAS,cAAc,KAAK;EAO/D,QAAO,MALY,GAAG,MACpB,iDAAiD,oBAAoB,GAAG,EAAE,iBAAiB,UAAU,UAAU,UAAU,4BAA4B,WAAW,0BAA0B,aAC1L,SACF,EAAA,CAEY,KAAK,SAAS;GACxB,MAAM,IAAI;GACV,SAAS,IAAI;GACb,OAAO,IAAI,IAAI;EACjB,EAAE;CACJ;CAEA,gBAAgB,KAAK;EAEnB,OAAO,IAAI,aAAa,KAAK,MAAM,GAAa,CAAa;CAC/D;AACF;;AAGA,MAAa,gBAAkC;CAC7C,eAAe,CAAC,uCAAuC;CACvD,aAAa,QAAQ;EACnB,OAAO,CACL,8BAA8B,iBAAiB,MAAM,EAAE,wDACzD;CACF;CACA,kBAAkB;CAClB,0BAA0B;CAC1B,KAAK;CACL,KAAK;AACP"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@statewalker/indexer-pglite",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.2",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "PGlite-backed indexer implementation: full-text + vector search over a @statewalker/db-api Db.",
|
|
@@ -32,10 +32,10 @@
|
|
|
32
32
|
],
|
|
33
33
|
"dependencies": {
|
|
34
34
|
"@statewalker/db-api": "^0.1.0",
|
|
35
|
-
"@statewalker/indexer-api": "^0.1.
|
|
36
|
-
"@statewalker/indexer-core": "^0.1.
|
|
37
|
-
"@statewalker/indexer-fulltext": "^0.1.
|
|
38
|
-
"@statewalker/indexer-vector": "^0.1.
|
|
35
|
+
"@statewalker/indexer-api": "^0.1.4",
|
|
36
|
+
"@statewalker/indexer-core": "^0.1.4",
|
|
37
|
+
"@statewalker/indexer-fulltext": "^0.1.5",
|
|
38
|
+
"@statewalker/indexer-vector": "^0.1.5"
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
|
41
41
|
"@electric-sql/pglite-pgvector": "^0.0.9",
|
package/src/dialect.ts
CHANGED
|
@@ -1,22 +1,14 @@
|
|
|
1
1
|
import type { SqlBackedDialect, SqlFtsDialect, SqlVectorDialect } from "@statewalker/indexer-core";
|
|
2
|
-
import {
|
|
3
|
-
|
|
2
|
+
import {
|
|
3
|
+
buildSearchFilterSql,
|
|
4
|
+
embeddingToLiteral,
|
|
5
|
+
ISO_639_1_LANGUAGE_NAMES,
|
|
6
|
+
sqlDocsTableName,
|
|
7
|
+
} from "@statewalker/indexer-core";
|
|
8
|
+
|
|
9
|
+
// ISO-639-1 codes map to the Postgres text-search configuration of the same language name.
|
|
4
10
|
const LANGUAGE_MAP: Record<string, string> = {
|
|
5
|
-
|
|
6
|
-
fr: "french",
|
|
7
|
-
de: "german",
|
|
8
|
-
es: "spanish",
|
|
9
|
-
it: "italian",
|
|
10
|
-
pt: "portuguese",
|
|
11
|
-
nl: "dutch",
|
|
12
|
-
ru: "russian",
|
|
13
|
-
sv: "swedish",
|
|
14
|
-
no: "norwegian",
|
|
15
|
-
da: "danish",
|
|
16
|
-
fi: "finnish",
|
|
17
|
-
hu: "hungarian",
|
|
18
|
-
ro: "romanian",
|
|
19
|
-
tr: "turkish",
|
|
11
|
+
...ISO_639_1_LANGUAGE_NAMES,
|
|
20
12
|
simple: "simple",
|
|
21
13
|
};
|
|
22
14
|
|
|
@@ -34,16 +26,6 @@ function resolvePgLanguage(lang: string): string {
|
|
|
34
26
|
);
|
|
35
27
|
}
|
|
36
28
|
|
|
37
|
-
/**
|
|
38
|
-
* pgvector's `vector(dim)` column accepts either a JSON-array text literal (`"[1,2,3]"`) cast with
|
|
39
|
-
* `::vector(dim)`, or a JS array bound by the driver. The text-literal path is currently used
|
|
40
|
-
* because it works uniformly across the pglite vector extension versions available in the catalog;
|
|
41
|
-
* swapping to native array binding is a follow-up when driver support is confirmed.
|
|
42
|
-
*/
|
|
43
|
-
function embeddingToLiteral(embedding: Float32Array): string {
|
|
44
|
-
return `[${Array.from(embedding).join(",")}]`;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
29
|
/**
|
|
48
30
|
* PGlite FTS dialect — uses native PostgreSQL TSVECTOR with a GIN index, `ts_rank_cd` scoring,
|
|
49
31
|
* and `to_tsquery` with a language-specific stemmer.
|
|
@@ -59,27 +41,23 @@ export const pgliteFtsDialect: SqlFtsDialect = {
|
|
|
59
41
|
|
|
60
42
|
async search({ db, tableName, docsTable, info, query, paths, topK }) {
|
|
61
43
|
const pgLang = resolvePgLanguage(info.language);
|
|
62
|
-
//
|
|
63
|
-
//
|
|
44
|
+
// Keep only words: split on everything that is not a Unicode letter, combining mark, digit
|
|
45
|
+
// or `_`, so no tsquery operator (`& | ! ( ) : < > <-> <N>`, quotes, backslash) reaches
|
|
46
|
+
// `to_tsquery`, and non-ASCII words (Russian, Hindi, decomposed accents, …) stay whole.
|
|
64
47
|
const validWords = query
|
|
65
48
|
.toLowerCase()
|
|
66
|
-
.split(
|
|
67
|
-
.map((w) => w.replace(/[&|!():'"\\]/g, ""))
|
|
49
|
+
.split(/[^\p{L}\p{M}\p{N}_]+/u)
|
|
68
50
|
.filter((w) => w.length > 0);
|
|
69
51
|
if (validWords.length === 0) return [];
|
|
70
52
|
|
|
71
53
|
const orTerms = validWords.join(" | ");
|
|
72
54
|
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
const topKParam = `$${allParams.length + 1}`;
|
|
82
|
-
allParams.push(topK);
|
|
55
|
+
const {
|
|
56
|
+
pathCondition,
|
|
57
|
+
topKParam,
|
|
58
|
+
params: allParams,
|
|
59
|
+
} = buildSearchFilterSql("d.path", paths, topK, [orTerms]);
|
|
60
|
+
const pathClause = pathCondition ? ` AND ${pathCondition}` : "";
|
|
83
61
|
|
|
84
62
|
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}`;
|
|
85
63
|
|
|
@@ -101,8 +79,7 @@ export const pgliteFtsDialect: SqlFtsDialect = {
|
|
|
101
79
|
|
|
102
80
|
/**
|
|
103
81
|
* PGlite vector dialect — uses pgvector's HNSW index with the `<=>` cosine-distance operator.
|
|
104
|
-
* Embeddings are
|
|
105
|
-
* driver-native arrays.
|
|
82
|
+
* Embeddings are bound as stringified array literals.
|
|
106
83
|
*/
|
|
107
84
|
export const pgliteVectorDialect: SqlVectorDialect = {
|
|
108
85
|
createTableDdl({ tableName, indexName, info }) {
|
|
@@ -113,6 +90,10 @@ export const pgliteVectorDialect: SqlVectorDialect = {
|
|
|
113
90
|
];
|
|
114
91
|
},
|
|
115
92
|
|
|
93
|
+
// pgvector's `vector(dim)` column accepts either a JSON-array text literal (`"[1,2,3]"`) cast with
|
|
94
|
+
// `::vector(dim)`, or a JS array bound by the driver. The text-literal path is used because it
|
|
95
|
+
// works uniformly across the pglite vector extension versions available in the catalog; swapping
|
|
96
|
+
// to native array binding is a follow-up when driver support is confirmed.
|
|
116
97
|
bindEmbedding: embeddingToLiteral,
|
|
117
98
|
embeddingCastSuffix: (dim) => `::vector(${dim})`,
|
|
118
99
|
|
|
@@ -130,16 +111,12 @@ export const pgliteVectorDialect: SqlVectorDialect = {
|
|
|
130
111
|
const vecLiteral = bindEmbedding(queryEmbedding);
|
|
131
112
|
const dim = info.dimensionality;
|
|
132
113
|
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
const topKParam = `$${allParams.length + 1}`;
|
|
142
|
-
allParams.push(topK);
|
|
114
|
+
const {
|
|
115
|
+
pathCondition,
|
|
116
|
+
topKParam,
|
|
117
|
+
params: allParams,
|
|
118
|
+
} = buildSearchFilterSql("d.path", paths, topK, [vecLiteral]);
|
|
119
|
+
const pathClause = pathCondition ? `WHERE ${pathCondition} ` : "";
|
|
143
120
|
|
|
144
121
|
const rows = await db.query<{ path: string; block_id: string; dist: number }>(
|
|
145
122
|
`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}`,
|
|
@@ -164,7 +141,7 @@ export const pgliteDialect: SqlBackedDialect = {
|
|
|
164
141
|
extensionInit: ["CREATE EXTENSION IF NOT EXISTS vector"],
|
|
165
142
|
docsTableDdl(prefix) {
|
|
166
143
|
return [
|
|
167
|
-
`CREATE TABLE IF NOT EXISTS
|
|
144
|
+
`CREATE TABLE IF NOT EXISTS ${sqlDocsTableName(prefix)} (doc_id SERIAL PRIMARY KEY, path TEXT NOT NULL UNIQUE)`,
|
|
168
145
|
];
|
|
169
146
|
},
|
|
170
147
|
unionAliasSuffix: " AS combined",
|