@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 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 whitespace, stripped of tsquery operators (`& | ! ( ) : ' " \`) 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).
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 { buildPathPrefixesSql } from "@statewalker/indexer-core";
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
- en: "english",
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(/\s+/).map((w) => w.replace(/[&|!():'"\\]/g, "")).filter((w) => w.length > 0);
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 allParams = [validWords.join(" | ")];
50
- let pathClause = "";
51
- if (paths && paths.length > 0) {
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 currently bound as stringified array literals — Phase 9 switches to parameter-bound
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
- let pathClause = "";
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 idx_${prefix}_docs (doc_id SERIAL PRIMARY KEY, path TEXT NOT NULL UNIQUE)`];
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,
@@ -1 +1 @@
1
- {"version":3,"file":"dialect.js","names":[],"sources":["../src/dialect.ts"],"sourcesContent":["import type { SqlBackedDialect, SqlFtsDialect, SqlVectorDialect } from \"@statewalker/indexer-core\";\nimport { buildPathPrefixesSql } from \"@statewalker/indexer-core\";\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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"}
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.0",
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.3",
36
- "@statewalker/indexer-core": "^0.1.2",
37
- "@statewalker/indexer-fulltext": "^0.1.3",
38
- "@statewalker/indexer-vector": "^0.1.3"
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 { buildPathPrefixesSql } from "@statewalker/indexer-core";
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
- en: "english",
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
- // Strip only tsquery-significant punctuation; keep Unicode letters/digits so
63
- // non-ASCII languages (Russian, French, CJK, …) reach the FTS engine intact.
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(/\s+/)
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 allParams: unknown[] = [orTerms];
74
- let pathClause = "";
75
- if (paths && paths.length > 0) {
76
- const cond = buildPathPrefixesSql("d.path", paths as string[], allParams.length + 1);
77
- pathClause = ` AND ${cond.sql}`;
78
- allParams.push(...cond.params);
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 currently bound as stringified array literals — Phase 9 switches to parameter-bound
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 allParams: unknown[] = [vecLiteral];
134
- let pathClause = "";
135
- if (paths && paths.length > 0) {
136
- const cond = buildPathPrefixesSql("d.path", paths as string[], allParams.length + 1);
137
- pathClause = `WHERE ${cond.sql} `;
138
- allParams.push(...cond.params);
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 idx_${prefix}_docs (doc_id SERIAL PRIMARY KEY, path TEXT NOT NULL UNIQUE)`,
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",