@adonis-agora/agent 0.34.1 → 0.36.0

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.
Files changed (122) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +34 -0
  3. package/dist/assets/spa/assets/index-7XqfoeQH.js +9 -0
  4. package/dist/assets/spa/index.html +1 -1
  5. package/dist/providers/agent_provider.d.ts.map +1 -1
  6. package/dist/providers/agent_provider.js +4 -0
  7. package/dist/providers/agent_provider.js.map +1 -1
  8. package/dist/src/agent-deps-factory.d.ts +5 -0
  9. package/dist/src/agent-deps-factory.d.ts.map +1 -1
  10. package/dist/src/agent-deps-factory.js +6 -0
  11. package/dist/src/agent-deps-factory.js.map +1 -1
  12. package/dist/src/agent-deps.d.ts +5 -0
  13. package/dist/src/agent-deps.d.ts.map +1 -1
  14. package/dist/src/agent-deps.js.map +1 -1
  15. package/dist/src/define_config.d.ts +12 -0
  16. package/dist/src/define_config.d.ts.map +1 -1
  17. package/dist/src/define_config.js.map +1 -1
  18. package/dist/src/guardrails/agent.d.ts +211 -0
  19. package/dist/src/guardrails/agent.d.ts.map +1 -0
  20. package/dist/src/guardrails/agent.js +433 -0
  21. package/dist/src/guardrails/agent.js.map +1 -0
  22. package/dist/src/guardrails/detectors/custom.d.ts +9 -0
  23. package/dist/src/guardrails/detectors/custom.d.ts.map +1 -0
  24. package/dist/src/guardrails/detectors/custom.js +81 -0
  25. package/dist/src/guardrails/detectors/custom.js.map +1 -0
  26. package/dist/src/guardrails/detectors/injection.d.ts +23 -0
  27. package/dist/src/guardrails/detectors/injection.d.ts.map +1 -0
  28. package/dist/src/guardrails/detectors/injection.js +208 -0
  29. package/dist/src/guardrails/detectors/injection.js.map +1 -0
  30. package/dist/src/guardrails/detectors/pii.d.ts +23 -0
  31. package/dist/src/guardrails/detectors/pii.d.ts.map +1 -0
  32. package/dist/src/guardrails/detectors/pii.js +295 -0
  33. package/dist/src/guardrails/detectors/pii.js.map +1 -0
  34. package/dist/src/guardrails/detectors/secrets.d.ts +5 -0
  35. package/dist/src/guardrails/detectors/secrets.d.ts.map +1 -0
  36. package/dist/src/guardrails/detectors/secrets.js +110 -0
  37. package/dist/src/guardrails/detectors/secrets.js.map +1 -0
  38. package/dist/src/guardrails/detectors/tool-poisoning.d.ts +16 -0
  39. package/dist/src/guardrails/detectors/tool-poisoning.d.ts.map +1 -0
  40. package/dist/src/guardrails/detectors/tool-poisoning.js +116 -0
  41. package/dist/src/guardrails/detectors/tool-poisoning.js.map +1 -0
  42. package/dist/src/guardrails/engine.d.ts +90 -0
  43. package/dist/src/guardrails/engine.d.ts.map +1 -0
  44. package/dist/src/guardrails/engine.js +324 -0
  45. package/dist/src/guardrails/engine.js.map +1 -0
  46. package/dist/src/guardrails/glob.d.ts +4 -0
  47. package/dist/src/guardrails/glob.d.ts.map +1 -0
  48. package/dist/src/guardrails/glob.js +20 -0
  49. package/dist/src/guardrails/glob.js.map +1 -0
  50. package/dist/src/guardrails/index.d.ts +20 -0
  51. package/dist/src/guardrails/index.d.ts.map +1 -0
  52. package/dist/src/guardrails/index.js +20 -0
  53. package/dist/src/guardrails/index.js.map +1 -0
  54. package/dist/src/guardrails/segments.d.ts +14 -0
  55. package/dist/src/guardrails/segments.d.ts.map +1 -0
  56. package/dist/src/guardrails/segments.js +32 -0
  57. package/dist/src/guardrails/segments.js.map +1 -0
  58. package/dist/src/guardrails/types.d.ts +161 -0
  59. package/dist/src/guardrails/types.d.ts.map +1 -0
  60. package/dist/src/guardrails/types.js +49 -0
  61. package/dist/src/guardrails/types.js.map +1 -0
  62. package/dist/src/guardrails/vault.d.ts +44 -0
  63. package/dist/src/guardrails/vault.d.ts.map +1 -0
  64. package/dist/src/guardrails/vault.js +103 -0
  65. package/dist/src/guardrails/vault.js.map +1 -0
  66. package/dist/src/index.d.ts +1 -1
  67. package/dist/src/index.js +1 -1
  68. package/dist/src/mcp-client/index.d.ts +1 -1
  69. package/dist/src/mcp-client/index.d.ts.map +1 -1
  70. package/dist/src/mcp-client/index.js.map +1 -1
  71. package/dist/src/mcp-client/options.d.ts +32 -0
  72. package/dist/src/mcp-client/options.d.ts.map +1 -1
  73. package/dist/src/mcp-client/tool-source.d.ts +2 -0
  74. package/dist/src/mcp-client/tool-source.d.ts.map +1 -1
  75. package/dist/src/mcp-client/tool-source.js +27 -1
  76. package/dist/src/mcp-client/tool-source.js.map +1 -1
  77. package/dist/src/rag/http-reranker.d.ts +46 -0
  78. package/dist/src/rag/http-reranker.d.ts.map +1 -0
  79. package/dist/src/rag/http-reranker.js +83 -0
  80. package/dist/src/rag/http-reranker.js.map +1 -0
  81. package/dist/src/rag/hybrid-retriever.d.ts +6 -1
  82. package/dist/src/rag/hybrid-retriever.d.ts.map +1 -1
  83. package/dist/src/rag/hybrid-retriever.js +27 -3
  84. package/dist/src/rag/hybrid-retriever.js.map +1 -1
  85. package/dist/src/rag/index.d.ts +6 -2
  86. package/dist/src/rag/index.d.ts.map +1 -1
  87. package/dist/src/rag/index.js +6 -2
  88. package/dist/src/rag/index.js.map +1 -1
  89. package/dist/src/rag/lexical-query.d.ts +28 -0
  90. package/dist/src/rag/lexical-query.d.ts.map +1 -0
  91. package/dist/src/rag/lexical-query.js +90 -0
  92. package/dist/src/rag/lexical-query.js.map +1 -0
  93. package/dist/src/rag/lexical-retriever.d.ts +23 -0
  94. package/dist/src/rag/lexical-retriever.d.ts.map +1 -0
  95. package/dist/src/rag/lexical-retriever.js +28 -0
  96. package/dist/src/rag/lexical-retriever.js.map +1 -0
  97. package/dist/src/rag/openai-embeddings.d.ts +62 -0
  98. package/dist/src/rag/openai-embeddings.d.ts.map +1 -0
  99. package/dist/src/rag/openai-embeddings.js +106 -0
  100. package/dist/src/rag/openai-embeddings.js.map +1 -0
  101. package/dist/src/rag/pg-vector-store.d.ts +133 -6
  102. package/dist/src/rag/pg-vector-store.d.ts.map +1 -1
  103. package/dist/src/rag/pg-vector-store.js +272 -31
  104. package/dist/src/rag/pg-vector-store.js.map +1 -1
  105. package/dist/src/rag/vector-store.d.ts +15 -0
  106. package/dist/src/rag/vector-store.d.ts.map +1 -1
  107. package/dist/src/rag/vector-store.js +4 -0
  108. package/dist/src/rag/vector-store.js.map +1 -1
  109. package/dist/src/stores/factory.d.ts +25 -3
  110. package/dist/src/stores/factory.d.ts.map +1 -1
  111. package/dist/src/stores/factory.js +21 -3
  112. package/dist/src/stores/factory.js.map +1 -1
  113. package/dist/src/testing/fake-embedding-provider.d.ts +12 -0
  114. package/dist/src/testing/fake-embedding-provider.d.ts.map +1 -1
  115. package/dist/src/testing/fake-embedding-provider.js +35 -0
  116. package/dist/src/testing/fake-embedding-provider.js.map +1 -1
  117. package/dist/src/testing/index.d.ts +1 -1
  118. package/dist/src/testing/index.d.ts.map +1 -1
  119. package/dist/src/testing/index.js +1 -1
  120. package/dist/src/testing/index.js.map +1 -1
  121. package/package.json +9 -3
  122. package/dist/assets/spa/assets/index-DjIMQOxi.js +0 -9
@@ -2,7 +2,7 @@ import type { EmbeddingProvider } from '../spi/embedding-provider.js';
2
2
  import type { Passage } from '../spi/retriever.js';
3
3
  import type { LucidRawRunner } from '../stores/lucid.js';
4
4
  import { EmbeddingRetriever } from './embedding-retriever.js';
5
- import type { IndexedDocument, MetadataPatch, VectorRecord, VectorSearchOptions, VectorStore } from './vector-store.js';
5
+ import type { IndexedDocument, LexicalVectorStore, MetadataPatch, VectorRecord, VectorSearchOptions, VectorStore } from './vector-store.js';
6
6
  /**
7
7
  * The similarity metric — which pgvector distance operator ranks results (and which index opclass the
8
8
  * migration should build). `cosine` (the default) is scale-invariant and the right choice for most
@@ -30,8 +30,40 @@ export interface PgVectorColumns {
30
30
  export interface PgVectorStoreOptions {
31
31
  /** Table name. Default `agent_rag_chunks`. Validated against the identifier regex. */
32
32
  table?: string;
33
- /** Embedding width — must match your model (e.g. 1536 for text-embedding-3-small). Default 1536. */
34
- dimension?: number;
33
+ /**
34
+ * Embedding width — must match your model (e.g. 1536 for text-embedding-3-small). Default 1536.
35
+ *
36
+ * Pass a **list** to let several models' vectors share one table (mixed dimensions — say, while
37
+ * migrating from a 768-wide local model to a 1536-wide hosted one): the column becomes an untyped
38
+ * `vector`, the schema gets one *partial* HNSW index per listed width
39
+ * (`((embedding::vector(n))) … WHERE vector_dims(embedding) = n`), and `search` only compares the
40
+ * query against vectors of its own width, through that width's index. A width that is not listed is
41
+ * still answered — exactly, by a sequential scan. Put the model in the chunk metadata and filter on
42
+ * it when two models share a width.
43
+ */
44
+ dimension?: number | readonly number[];
45
+ /**
46
+ * Allow chunks with no embedding (`embedding: []` on upsert is stored as `NULL`): index text first
47
+ * and embed later, or keep full-text-only chunks. `search` skips them; the lexical leg of
48
+ * {@link PgLexicalVectorStore} still finds them. Default `false` — the column stays `NOT NULL`. Only
49
+ * changes the DDL for a table that does not exist yet.
50
+ */
51
+ nullableEmbeddings?: boolean;
52
+ /**
53
+ * pgvector ≥ 0.8 iterative index scans for `search`: keep walking the HNSW graph until enough rows
54
+ * pass the metadata filter, instead of returning fewer than `topK` when a selective filter (one
55
+ * tenant of many) rejects most of what the index produced. Applied with `SET LOCAL` inside a
56
+ * transaction, so it needs a handle with `transaction` (Lucid's `db` has one); silently skipped on an
57
+ * older pgvector or a runner without transactions. Default off.
58
+ */
59
+ iterativeScan?: 'strict_order' | 'relaxed_order';
60
+ /** `hnsw.ef_search` for `search` (candidate list size; pgvector's default is 40). Needs `transaction`. */
61
+ efSearch?: number;
62
+ /**
63
+ * Rows per multi-row `INSERT … ON CONFLICT` statement in `upsert`. Default 100. Duplicate ids in one
64
+ * `upsert` call collapse to the last occurrence (as the old row-by-row loop left them).
65
+ */
66
+ upsertBatchSize?: number;
35
67
  /** Similarity metric / distance operator. Default `cosine`. */
36
68
  metric?: PgVectorMetric;
37
69
  /** Override the physical column names (each validated). */
@@ -78,12 +110,20 @@ export declare function stripNulBytes<T>(value: T): T;
78
110
  * `vector` extension, the chunk table, and the metric's index.
79
111
  */
80
112
  export declare class PgVectorStore implements VectorStore {
81
- private readonly db;
82
- private readonly table;
113
+ protected readonly db: LucidRawRunner;
114
+ protected readonly table: string;
115
+ /** The single width (fixed-dimension table), or `undefined` for a mixed-dimension table. */
83
116
  private readonly dimension;
117
+ /** The widths with a partial HNSW index, for a mixed-dimension table. */
118
+ private readonly indexedDimensions;
119
+ private readonly nullableEmbeddings;
120
+ private readonly iterativeScan;
121
+ private readonly efSearch;
122
+ private readonly upsertBatchSize;
123
+ private iterativeScanSupport;
84
124
  private readonly metric;
85
125
  private readonly metricName;
86
- private readonly col;
126
+ protected readonly col: Required<PgVectorColumns>;
87
127
  constructor(db: LucidRawRunner, options?: PgVectorStoreOptions);
88
128
  /**
89
129
  * Idempotent DDL — the `vector` extension, the chunk table, and the metric's index. Handy for tests
@@ -98,6 +138,9 @@ export declare class PgVectorStore implements VectorStore {
98
138
  * (`0x00`) in `text`/`jsonb` outright, while upstream sources (PDF extraction, Qdrant) happily carry
99
139
  * it, so an unstripped chunk would otherwise fail this INSERT with `invalid byte sequence for encoding
100
140
  * "UTF8": 0x00`. The embedding is untouched — it is never text/jsonb.
141
+ *
142
+ * Written as multi-row `INSERT … ON CONFLICT` statements of {@link PgVectorStoreOptions.upsertBatchSize}
143
+ * rows each (one round trip per batch instead of per chunk). An empty `embedding` is written as `NULL`.
101
144
  */
102
145
  upsert(records: VectorRecord[]): Promise<void>;
103
146
  /**
@@ -144,7 +187,91 @@ export declare class PgVectorStore implements VectorStore {
144
187
  * clause here as it does there, and a scalar the same `@> ?::jsonb` containment.
145
188
  */
146
189
  removeWhere(filter: Record<string, unknown>): Promise<number>;
190
+ /**
191
+ * Nearest first by the configured metric. On a mixed-dimension table only vectors as wide as the
192
+ * query are compared (through that width's partial index when it has one); a chunk without an
193
+ * embedding is never returned.
194
+ */
147
195
  search(embedding: number[], options: VectorSearchOptions): Promise<Passage[]>;
196
+ /** Run a read with the configured HNSW settings `SET LOCAL` in one transaction, when possible. */
197
+ private withSearchSettings;
198
+ /** `hnsw.iterative_scan` exists from pgvector 0.8.0; setting it on an older one is an error. */
199
+ private supportsIterativeScan;
200
+ }
201
+ /** Where the lexical leg of a {@link PgLexicalVectorStore} reads its `tsvector` from. */
202
+ export interface PgFullTextOptions {
203
+ /**
204
+ * Text-search configuration (`simple`, `english`, `portuguese`, …). Default `simple` — no stemming
205
+ * or stop words, so it works for any language and matches exact terms. Must be the configuration
206
+ * the index was built with, or Postgres cannot use the index and scans the table.
207
+ */
208
+ config?: string;
209
+ /**
210
+ * A `tsvector` column you maintain yourself (typically `GENERATED ALWAYS AS (…) STORED` in a
211
+ * migration, e.g. weighting a title above the body) with its own GIN index. When omitted the store
212
+ * searches `to_tsvector(config, text)`, and {@link PgVectorStore.schemaStatements} adds a GIN
213
+ * expression index over exactly that.
214
+ */
215
+ column?: string;
216
+ /**
217
+ * Search natural-language questions by their meaningful terms (default `true`): the question's
218
+ * stop words are dropped (see {@link PgFullTextOptions.stopWords}) and rows holding any remaining
219
+ * term (at most 24) are ranked by the sum of the matched terms' IDF among those rows (BM25 without
220
+ * term frequencies), `ts_rank_cd` breaking ties; a question with explicit search syntax (a quoted
221
+ * phrase, `-word`) is first tried as written. `false`: only `websearch_to_tsquery` on the query as
222
+ * written (every word must appear).
223
+ */
224
+ anyTermFallback?: boolean;
225
+ /**
226
+ * Stop word lists dropped from questions, by language; only the list(s) with the most words in the
227
+ * question apply, so a word that is a stop word in another language stays a term. Default
228
+ * {@link DEFAULT_STOP_WORDS} (English, Portuguese, Spanish: Postgres' Snowball lists). `false` keeps
229
+ * every word. With a language `config` (`english`) Postgres drops that language's stop words too.
230
+ */
231
+ stopWords?: Readonly<Record<string, ReadonlySet<string>>> | false;
232
+ }
233
+ /**
234
+ * {@link PgVectorStore} plus Postgres full-text search: a {@link LexicalVectorStore}, so a
235
+ * {@link import('./lexical-retriever.js').LexicalRetriever} and a
236
+ * {@link import('./hybrid-retriever.js').HybridRetriever} can fuse the dense and lexical legs over the
237
+ * same rows and ids. `retrievers.pgvector({ fullText: {} })` wires exactly that.
238
+ *
239
+ * A question is searched by its meaningful terms: its stop words dropped, rows holding any remaining
240
+ * term ranked by IDF-weighted coverage, `ts_rank_cd` (cover density) breaking ties (see
241
+ * {@link PgFullTextOptions.anyTermFallback}); explicit syntax (quoted phrases, `-exclusions`) goes
242
+ * through `websearch_to_tsquery` first.
243
+ *
244
+ * A separate class rather than a method on {@link PgVectorStore} on purpose: full-text search needs a
245
+ * GIN index, which on a populated table belongs in a migration you run and watch (`CREATE INDEX` in
246
+ * `ensureSchema` takes a write lock at boot), and a store that silently grew `searchText` on upgrade
247
+ * would be picked up by every `isLexicalVectorStore` check and scan an unindexed table.
248
+ */
249
+ export declare class PgLexicalVectorStore extends PgVectorStore implements LexicalVectorStore {
250
+ private readonly tsConfig;
251
+ private readonly tsColumn;
252
+ private readonly anyTermFallback;
253
+ private readonly stopWords;
254
+ constructor(db: LucidRawRunner, options?: PgVectorStoreOptions & {
255
+ fullText?: PgFullTextOptions;
256
+ });
257
+ schemaStatements(): string[];
258
+ /**
259
+ * Full-text search over the chunk text. `score` is an IDF / `ts_rank_cd` rank — not a similarity,
260
+ * and not on the vector leg's scale — so `minScore` is deliberately not applied here.
261
+ */
262
+ searchText(query: string, options: VectorSearchOptions): Promise<Passage[]>;
263
+ /** `websearch_to_tsquery` on the query as written: every word, quoted phrases, `-exclusions`. */
264
+ private runTextSearch;
265
+ /**
266
+ * Rows holding any of `terms`, ranked by the sum of the matched terms' IDF among those rows,
267
+ * `ln(1 + (N - df + 0.5) / (df + 0.5))`, then `ts_rank_cd`. Each (row, term) match is evaluated
268
+ * once, and `ts_rank_cd` only for the rows ranked within `topK` (ties included): a question full of
269
+ * common words can match most of the table. Terms are letter/digit runs: space-joined in one
270
+ * binding (no array binding), each quoted as a literal lexeme.
271
+ */
272
+ private runRankedTermSearch;
273
+ /** Must be character-for-character the index expression, or the planner won't use the index. */
274
+ private tsvector;
148
275
  }
149
276
  /**
150
277
  * A {@link import('../spi/retriever.js').Retriever} over a {@link PgVectorStore}: embed the query, then
@@ -1 +1 @@
1
- {"version":3,"file":"pg-vector-store.d.ts","sourceRoot":"","sources":["../../../src/rag/pg-vector-store.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"pg-vector-store.d.ts","sourceRoot":"","sources":["../../../src/rag/pg-vector-store.ts"],"names":[],"mappings":"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"}
@@ -1,22 +1,23 @@
1
1
  import { EmbeddingRetriever } from './embedding-retriever.js';
2
+ import { anyTermTsquery, DEFAULT_STOP_WORDS, hasSearchSyntax, keywordTerms, } from './lexical-query.js';
2
3
  import { assertRemovalFilter, effectivePatchKeys, filterDeniesAll } from './vector-store.js';
3
4
  const METRICS = {
4
5
  cosine: {
5
6
  operator: '<=>',
6
7
  opclass: 'vector_cosine_ops',
7
- score: (col) => `1 - (${col} <=> ?::vector)`,
8
+ score: (col, type = 'vector') => `1 - (${col} <=> ?::${type})`,
8
9
  },
9
10
  l2: {
10
11
  operator: '<->',
11
12
  opclass: 'vector_l2_ops',
12
- score: (col) => `-(${col} <-> ?::vector)`,
13
+ score: (col, type = 'vector') => `-(${col} <-> ?::${type})`,
13
14
  },
14
15
  inner: {
15
16
  // pgvector's `<#>` returns the NEGATIVE inner product, so `-(a <#> b)` recovers the inner product
16
17
  // and `ORDER BY a <#> b ASC` ranks the largest inner product first — consistent with the others.
17
18
  operator: '<#>',
18
19
  opclass: 'vector_ip_ops',
19
- score: (col) => `-(${col} <#> ?::vector)`,
20
+ score: (col, type = 'vector') => `-(${col} <#> ?::${type})`,
20
21
  },
21
22
  };
22
23
  /** Only unqualified SQL identifiers (letter/underscore start, then letters/digits/underscores) are allowed. */
@@ -192,14 +193,39 @@ function documentIdExpr(col) {
192
193
  export class PgVectorStore {
193
194
  db;
194
195
  table;
196
+ /** The single width (fixed-dimension table), or `undefined` for a mixed-dimension table. */
195
197
  dimension;
198
+ /** The widths with a partial HNSW index, for a mixed-dimension table. */
199
+ indexedDimensions;
200
+ nullableEmbeddings;
201
+ iterativeScan;
202
+ efSearch;
203
+ upsertBatchSize;
204
+ iterativeScanSupport;
196
205
  metric;
197
206
  metricName;
198
207
  col;
199
208
  constructor(db, options = {}) {
200
209
  this.db = db;
201
210
  this.table = assertIdentifier(options.table ?? 'agent_rag_chunks', 'table');
202
- this.dimension = options.dimension ?? 1536;
211
+ const dimension = options.dimension ?? 1536;
212
+ if (typeof dimension === 'number') {
213
+ this.dimension = dimension;
214
+ this.indexedDimensions = [];
215
+ }
216
+ else {
217
+ for (const width of dimension) {
218
+ if (!Number.isInteger(width) || width <= 0) {
219
+ throw new Error(`Invalid pgvector dimension ${String(width)}: must be a positive integer`);
220
+ }
221
+ }
222
+ this.dimension = undefined;
223
+ this.indexedDimensions = [...new Set(dimension)];
224
+ }
225
+ this.nullableEmbeddings = options.nullableEmbeddings ?? false;
226
+ this.iterativeScan = options.iterativeScan;
227
+ this.efSearch = options.efSearch;
228
+ this.upsertBatchSize = Math.max(1, Math.floor(options.upsertBatchSize ?? 100));
203
229
  this.metricName = options.metric ?? 'cosine';
204
230
  this.metric = METRICS[this.metricName];
205
231
  const columns = options.columns ?? {};
@@ -223,18 +249,29 @@ export class PgVectorStore {
223
249
  /** The `CREATE EXTENSION` / `CREATE TABLE` / `CREATE INDEX` statements {@link ensureSchema} issues. */
224
250
  schemaStatements() {
225
251
  const c = this.col;
226
- return [
252
+ const mixed = this.dimension === undefined;
253
+ const statements = [
227
254
  'CREATE EXTENSION IF NOT EXISTS vector',
228
255
  `CREATE TABLE IF NOT EXISTS ${this.table} (
229
256
  ${c.id} TEXT PRIMARY KEY,
230
257
  ${c.text} TEXT NOT NULL,
231
258
  ${c.source} TEXT,
232
259
  ${c.metadata} JSONB,
233
- ${c.embedding} vector(${this.dimension}) NOT NULL
260
+ ${c.embedding} ${mixed ? 'vector' : `vector(${this.dimension})`}${this.nullableEmbeddings ? '' : ' NOT NULL'}
234
261
  )`,
235
- `CREATE INDEX IF NOT EXISTS ${this.table}_${c.embedding}_idx
236
- ON ${this.table} USING hnsw (${c.embedding} ${this.metric.opclass})`,
237
262
  ];
263
+ if (mixed) {
264
+ for (const width of this.indexedDimensions) {
265
+ statements.push(`CREATE INDEX IF NOT EXISTS ${this.table}_${c.embedding}_${width}_idx
266
+ ON ${this.table} USING hnsw ((${c.embedding}::vector(${width})) ${this.metric.opclass})
267
+ WHERE vector_dims(${c.embedding}) = ${width}`);
268
+ }
269
+ }
270
+ else {
271
+ statements.push(`CREATE INDEX IF NOT EXISTS ${this.table}_${c.embedding}_idx
272
+ ON ${this.table} USING hnsw (${c.embedding} ${this.metric.opclass})`);
273
+ }
274
+ return statements;
238
275
  }
239
276
  /**
240
277
  * Every `text`/`jsonb` binding (id, text, source, metadata — including nested metadata values, array
@@ -242,23 +279,38 @@ export class PgVectorStore {
242
279
  * (`0x00`) in `text`/`jsonb` outright, while upstream sources (PDF extraction, Qdrant) happily carry
243
280
  * it, so an unstripped chunk would otherwise fail this INSERT with `invalid byte sequence for encoding
244
281
  * "UTF8": 0x00`. The embedding is untouched — it is never text/jsonb.
282
+ *
283
+ * Written as multi-row `INSERT … ON CONFLICT` statements of {@link PgVectorStoreOptions.upsertBatchSize}
284
+ * rows each (one round trip per batch instead of per chunk). An empty `embedding` is written as `NULL`.
245
285
  */
246
286
  async upsert(records) {
247
- const c = this.col;
287
+ // The id is stripped before de-duplication, so two ids differing only by a NUL byte are the one
288
+ // row they would be stored as. One statement can't touch a row twice (`ON CONFLICT DO UPDATE
289
+ // command cannot affect row a second time`), so duplicates collapse to the LAST occurrence — the
290
+ // same last-write-wins the old row-by-row loop had.
291
+ const byId = new Map();
248
292
  for (const record of records) {
293
+ const id = stripNulBytes(record.id);
294
+ byId.delete(id);
295
+ byId.set(id, { ...record, id });
296
+ }
297
+ const unique = [...byId.values()];
298
+ const c = this.col;
299
+ for (let start = 0; start < unique.length; start += this.upsertBatchSize) {
300
+ const batch = unique.slice(start, start + this.upsertBatchSize);
301
+ const bindings = [];
302
+ for (const record of batch) {
303
+ bindings.push(record.id, stripNulBytes(record.text), stripNulBytes(record.source ?? null), record.metadata !== undefined ? JSON.stringify(stripNulBytes(record.metadata)) : null,
304
+ // An empty embedding is a chunk with no vector yet — NULL (see `nullableEmbeddings`).
305
+ record.embedding.length > 0 ? toVectorLiteral(record.embedding) : null);
306
+ }
249
307
  await this.db.rawQuery(`INSERT INTO ${this.table} (${c.id}, ${c.text}, ${c.source}, ${c.metadata}, ${c.embedding})
250
- VALUES (?, ?, ?, ?::jsonb, ?::vector)
308
+ VALUES ${batch.map(() => '(?, ?, ?, ?::jsonb, ?::vector)').join(', ')}
251
309
  ON CONFLICT (${c.id}) DO UPDATE SET
252
310
  ${c.text} = EXCLUDED.${c.text},
253
311
  ${c.source} = EXCLUDED.${c.source},
254
312
  ${c.metadata} = EXCLUDED.${c.metadata},
255
- ${c.embedding} = EXCLUDED.${c.embedding}`, [
256
- stripNulBytes(record.id),
257
- stripNulBytes(record.text),
258
- stripNulBytes(record.source ?? null),
259
- record.metadata !== undefined ? JSON.stringify(stripNulBytes(record.metadata)) : null,
260
- toVectorLiteral(record.embedding),
261
- ]);
313
+ ${c.embedding} = EXCLUDED.${c.embedding}`, bindings);
262
314
  }
263
315
  }
264
316
  /**
@@ -366,10 +418,33 @@ export class PgVectorStore {
366
418
  const raw = await this.db.rawQuery(`DELETE FROM ${this.table} ${where.sql} RETURNING ${this.col.id} AS id`, where.bindings);
367
419
  return normalizeRows(raw).length;
368
420
  }
421
+ /**
422
+ * Nearest first by the configured metric. On a mixed-dimension table only vectors as wide as the
423
+ * query are compared (through that width's partial index when it has one); a chunk without an
424
+ * embedding is never returned.
425
+ */
369
426
  async search(embedding, options) {
427
+ if (embedding.length === 0) {
428
+ return [];
429
+ }
370
430
  const c = this.col;
371
431
  const vector = toVectorLiteral(embedding);
372
- const scoreExpr = this.metric.score(c.embedding);
432
+ // A fixed-width table keeps the exact expression its index was built on (`embedding`); a mixed one
433
+ // casts both sides to the query's width so the planner can match that width's partial index.
434
+ let vectorType = 'vector';
435
+ const shapeClauses = [];
436
+ if (this.dimension === undefined) {
437
+ const width = embedding.length;
438
+ if (this.indexedDimensions.includes(width)) {
439
+ vectorType = `vector(${width})`;
440
+ }
441
+ shapeClauses.push(`${c.embedding} IS NOT NULL`, `vector_dims(${c.embedding}) = ${width}`);
442
+ }
443
+ else if (this.nullableEmbeddings) {
444
+ shapeClauses.push(`${c.embedding} IS NOT NULL`);
445
+ }
446
+ const column = vectorType === 'vector' ? c.embedding : `(${c.embedding}::${vectorType})`;
447
+ const scoreExpr = this.metric.score(column, vectorType);
373
448
  const meta = buildMetadataWhere(options.filter, c.metadata);
374
449
  // Combine the metadata predicate with an optional minScore relevance floor. Filtering the score
375
450
  // expression in SQL (not in JS after the fact) keeps the floor applied BEFORE `LIMIT`, so the K
@@ -382,6 +457,7 @@ export class PgVectorStore {
382
457
  clauses.push(meta.sql.slice('WHERE '.length));
383
458
  whereBindings.push(...meta.bindings);
384
459
  }
460
+ clauses.push(...shapeClauses);
385
461
  if (options.minScore !== undefined) {
386
462
  // The score expression carries its own `?::vector`, so the embedding is bound again here.
387
463
  clauses.push(`${scoreExpr} >= ?`);
@@ -392,24 +468,189 @@ export class PgVectorStore {
392
468
  // then the optional filter/minScore clause(s), then the ORDER BY embedding, then the LIMIT. The
393
469
  // embedding is bound once per `?::vector` because a positional `?` cannot be reused like a `$1`.
394
470
  const bindings = [vector, ...whereBindings, vector, options.topK];
395
- const raw = await this.db.rawQuery(`SELECT ${c.id} AS id, ${c.text} AS text, ${c.source} AS source, ${c.metadata} AS metadata,
471
+ const sql = `SELECT ${c.id} AS id, ${c.text} AS text, ${c.source} AS source, ${c.metadata} AS metadata,
396
472
  ${scoreExpr} AS score
397
473
  FROM ${this.table}
398
474
  ${whereSql}
399
- ORDER BY ${c.embedding} ${this.metric.operator} ?::vector
400
- LIMIT ?`, bindings);
401
- return normalizeRows(raw).map((row) => {
402
- const metadata = parseMetadata(row.metadata);
403
- const source = row.source;
404
- return {
405
- id: String(row.id),
406
- text: String(row.text),
407
- score: Number(row.score),
408
- ...(source !== null && source !== undefined ? { source: String(source) } : {}),
409
- ...(metadata !== undefined ? { metadata } : {}),
410
- };
475
+ ORDER BY ${column} ${this.metric.operator} ?::${vectorType}
476
+ LIMIT ?`;
477
+ const raw = await this.withSearchSettings((runner) => runner.rawQuery(sql, bindings));
478
+ return normalizeRows(raw).map(toPassage);
479
+ }
480
+ /** Run a read with the configured HNSW settings `SET LOCAL` in one transaction, when possible. */
481
+ async withSearchSettings(read) {
482
+ const transaction = this.db.transaction;
483
+ if (typeof transaction !== 'function') {
484
+ return read(this.db);
485
+ }
486
+ const iterative = this.iterativeScan !== undefined && (await this.supportsIterativeScan());
487
+ if (!iterative && this.efSearch === undefined) {
488
+ return read(this.db);
489
+ }
490
+ return transaction.call(this.db, async (trx) => {
491
+ // Lucid's transaction client is a full query client (it has `rawQuery`); the structural type
492
+ // this file sees only promises `from`/`table`, hence the check.
493
+ const runner = trx;
494
+ if (typeof runner.rawQuery !== 'function') {
495
+ return read(this.db);
496
+ }
497
+ const tx = runner;
498
+ if (iterative) {
499
+ await tx.rawQuery(`SELECT set_config('hnsw.iterative_scan', ?, true)`, [
500
+ this.iterativeScan,
501
+ ]);
502
+ }
503
+ if (this.efSearch !== undefined) {
504
+ await tx.rawQuery(`SELECT set_config('hnsw.ef_search', ?, true)`, [String(this.efSearch)]);
505
+ }
506
+ return read(tx);
411
507
  });
412
508
  }
509
+ /** `hnsw.iterative_scan` exists from pgvector 0.8.0; setting it on an older one is an error. */
510
+ supportsIterativeScan() {
511
+ this.iterativeScanSupport ??= this.db
512
+ .rawQuery(`SELECT extversion FROM pg_extension WHERE extname = 'vector'`)
513
+ .then((raw) => {
514
+ const version = String(normalizeRows(raw)[0]?.extversion ?? '0.0');
515
+ const [major = 0, minor = 0] = version.split('.').map(Number);
516
+ return major > 0 || minor >= 8;
517
+ })
518
+ .catch(() => false);
519
+ return this.iterativeScanSupport;
520
+ }
521
+ }
522
+ /**
523
+ * {@link PgVectorStore} plus Postgres full-text search: a {@link LexicalVectorStore}, so a
524
+ * {@link import('./lexical-retriever.js').LexicalRetriever} and a
525
+ * {@link import('./hybrid-retriever.js').HybridRetriever} can fuse the dense and lexical legs over the
526
+ * same rows and ids. `retrievers.pgvector({ fullText: {} })` wires exactly that.
527
+ *
528
+ * A question is searched by its meaningful terms: its stop words dropped, rows holding any remaining
529
+ * term ranked by IDF-weighted coverage, `ts_rank_cd` (cover density) breaking ties (see
530
+ * {@link PgFullTextOptions.anyTermFallback}); explicit syntax (quoted phrases, `-exclusions`) goes
531
+ * through `websearch_to_tsquery` first.
532
+ *
533
+ * A separate class rather than a method on {@link PgVectorStore} on purpose: full-text search needs a
534
+ * GIN index, which on a populated table belongs in a migration you run and watch (`CREATE INDEX` in
535
+ * `ensureSchema` takes a write lock at boot), and a store that silently grew `searchText` on upgrade
536
+ * would be picked up by every `isLexicalVectorStore` check and scan an unindexed table.
537
+ */
538
+ export class PgLexicalVectorStore extends PgVectorStore {
539
+ tsConfig;
540
+ tsColumn;
541
+ anyTermFallback;
542
+ stopWords;
543
+ constructor(db, options = {}) {
544
+ super(db, options);
545
+ this.tsConfig = assertIdentifier(options.fullText?.config ?? 'simple', 'text search config');
546
+ this.tsColumn =
547
+ options.fullText?.column !== undefined
548
+ ? assertIdentifier(options.fullText.column, 'column')
549
+ : undefined;
550
+ this.anyTermFallback = options.fullText?.anyTermFallback ?? true;
551
+ const stopWords = options.fullText?.stopWords;
552
+ this.stopWords = stopWords === false ? {} : (stopWords ?? DEFAULT_STOP_WORDS);
553
+ }
554
+ schemaStatements() {
555
+ const statements = super.schemaStatements();
556
+ if (this.tsColumn === undefined) {
557
+ statements.push(`CREATE INDEX IF NOT EXISTS ${this.table}_${this.col.text}_tsv_idx
558
+ ON ${this.table} USING gin (${this.tsvector()})`);
559
+ }
560
+ return statements;
561
+ }
562
+ /**
563
+ * Full-text search over the chunk text. `score` is an IDF / `ts_rank_cd` rank — not a similarity,
564
+ * and not on the vector leg's scale — so `minScore` is deliberately not applied here.
565
+ */
566
+ async searchText(query, options) {
567
+ if ((options.filter !== undefined && filterDeniesAll(options.filter)) || query.trim() === '') {
568
+ return [];
569
+ }
570
+ if (!this.anyTermFallback || hasSearchSyntax(query)) {
571
+ const strict = await this.runTextSearch(query, options);
572
+ if (strict.length > 0 || !this.anyTermFallback) {
573
+ return strict;
574
+ }
575
+ }
576
+ const terms = keywordTerms(query, 24, this.stopWords);
577
+ return terms.length === 0 ? [] : this.runRankedTermSearch(terms, options);
578
+ }
579
+ /** `websearch_to_tsquery` on the query as written: every word, quoted phrases, `-exclusions`. */
580
+ async runTextSearch(query, options) {
581
+ const c = this.col;
582
+ const where = buildMetadataWhere(options.filter, c.metadata);
583
+ // The parsed query is a lateral relation, so the one `?` binding feeds both the match and the rank.
584
+ const clauses = [`${this.tsvector()} @@ __tsq.q`];
585
+ if (where.sql !== '') {
586
+ clauses.push(where.sql.slice('WHERE '.length));
587
+ }
588
+ const raw = await this.db.rawQuery(`SELECT ${c.id} AS id, ${c.text} AS text, ${c.source} AS source, ${c.metadata} AS metadata,
589
+ ts_rank_cd(${this.tsvector()}, __tsq.q) AS score
590
+ FROM ${this.table}, websearch_to_tsquery('${this.tsConfig}', ?) AS __tsq(q)
591
+ WHERE ${clauses.join(' AND ')}
592
+ ORDER BY score DESC, ${c.id}
593
+ LIMIT ?`, [query, ...where.bindings, options.topK]);
594
+ return normalizeRows(raw).map(toPassage);
595
+ }
596
+ /**
597
+ * Rows holding any of `terms`, ranked by the sum of the matched terms' IDF among those rows,
598
+ * `ln(1 + (N - df + 0.5) / (df + 0.5))`, then `ts_rank_cd`. Each (row, term) match is evaluated
599
+ * once, and `ts_rank_cd` only for the rows ranked within `topK` (ties included): a question full of
600
+ * common words can match most of the table. Terms are letter/digit runs: space-joined in one
601
+ * binding (no array binding), each quoted as a literal lexeme.
602
+ */
603
+ async runRankedTermSearch(terms, options) {
604
+ const c = this.col;
605
+ const cfg = this.tsConfig;
606
+ const where = buildMetadataWhere(options.filter, c.metadata);
607
+ const clauses = [`${this.tsvector()} @@ __any.q`];
608
+ if (where.sql !== '') {
609
+ clauses.push(where.sql.slice('WHERE '.length));
610
+ }
611
+ const raw = await this.db.rawQuery(`SELECT id, text, source, metadata, score FROM (
612
+ WITH __terms AS (
613
+ SELECT DISTINCT to_tsquery('${cfg}', quote_literal(t)) AS q
614
+ FROM regexp_split_to_table(?, ' ') AS t
615
+ ), __cand AS MATERIALIZED (
616
+ SELECT ${c.id} AS id, ${c.text} AS text, ${c.source} AS source, ${c.metadata} AS metadata,
617
+ ${this.tsvector()} AS __tsv, __any.q AS __q
618
+ FROM ${this.table}, to_tsquery('${cfg}', ?) AS __any(q)
619
+ WHERE ${clauses.join(' AND ')}
620
+ ), __hits AS MATERIALIZED (
621
+ SELECT __cand.id AS __id, __terms.q FROM __cand JOIN __terms ON __cand.__tsv @@ __terms.q
622
+ ), __idf AS (
623
+ SELECT q, ln(1 + ((SELECT count(*) FROM __cand) - count(*) + 0.5) / (count(*) + 0.5)) AS w
624
+ FROM __hits GROUP BY q
625
+ ), __scored AS (
626
+ SELECT __hits.__id, sum(__idf.w) AS s, rank() OVER (ORDER BY sum(__idf.w) DESC) AS r
627
+ FROM __hits JOIN __idf USING (q) GROUP BY __hits.__id
628
+ )
629
+ SELECT __cand.id, __cand.text, __cand.source, __cand.metadata,
630
+ __scored.s AS __s, ts_rank_cd(__cand.__tsv, __cand.__q, 32) AS __tr,
631
+ __scored.s + ts_rank_cd(__cand.__tsv, __cand.__q, 32) AS score
632
+ FROM __scored JOIN __cand ON __cand.id = __scored.__id
633
+ WHERE __scored.r <= ?
634
+ ) AS __ranked
635
+ ORDER BY __s DESC, __tr DESC, id
636
+ LIMIT ?`, [terms.join(' '), anyTermTsquery(terms), ...where.bindings, options.topK, options.topK]);
637
+ return normalizeRows(raw).map(toPassage);
638
+ }
639
+ /** Must be character-for-character the index expression, or the planner won't use the index. */
640
+ tsvector() {
641
+ return this.tsColumn ?? `to_tsvector('${this.tsConfig}'::regconfig, ${this.col.text})`;
642
+ }
643
+ }
644
+ function toPassage(row) {
645
+ const metadata = parseMetadata(row.metadata);
646
+ const source = row.source;
647
+ return {
648
+ id: String(row.id),
649
+ text: String(row.text),
650
+ score: Number(row.score),
651
+ ...(source !== null && source !== undefined ? { source: String(source) } : {}),
652
+ ...(metadata !== undefined ? { metadata } : {}),
653
+ };
413
654
  }
414
655
  /**
415
656
  * A {@link import('../spi/retriever.js').Retriever} over a {@link PgVectorStore}: embed the query, then