@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.
- package/CHANGELOG.md +24 -0
- package/README.md +34 -0
- package/dist/assets/spa/assets/index-7XqfoeQH.js +9 -0
- package/dist/assets/spa/index.html +1 -1
- package/dist/providers/agent_provider.d.ts.map +1 -1
- package/dist/providers/agent_provider.js +4 -0
- package/dist/providers/agent_provider.js.map +1 -1
- package/dist/src/agent-deps-factory.d.ts +5 -0
- package/dist/src/agent-deps-factory.d.ts.map +1 -1
- package/dist/src/agent-deps-factory.js +6 -0
- package/dist/src/agent-deps-factory.js.map +1 -1
- package/dist/src/agent-deps.d.ts +5 -0
- package/dist/src/agent-deps.d.ts.map +1 -1
- package/dist/src/agent-deps.js.map +1 -1
- package/dist/src/define_config.d.ts +12 -0
- package/dist/src/define_config.d.ts.map +1 -1
- package/dist/src/define_config.js.map +1 -1
- package/dist/src/guardrails/agent.d.ts +211 -0
- package/dist/src/guardrails/agent.d.ts.map +1 -0
- package/dist/src/guardrails/agent.js +433 -0
- package/dist/src/guardrails/agent.js.map +1 -0
- package/dist/src/guardrails/detectors/custom.d.ts +9 -0
- package/dist/src/guardrails/detectors/custom.d.ts.map +1 -0
- package/dist/src/guardrails/detectors/custom.js +81 -0
- package/dist/src/guardrails/detectors/custom.js.map +1 -0
- package/dist/src/guardrails/detectors/injection.d.ts +23 -0
- package/dist/src/guardrails/detectors/injection.d.ts.map +1 -0
- package/dist/src/guardrails/detectors/injection.js +208 -0
- package/dist/src/guardrails/detectors/injection.js.map +1 -0
- package/dist/src/guardrails/detectors/pii.d.ts +23 -0
- package/dist/src/guardrails/detectors/pii.d.ts.map +1 -0
- package/dist/src/guardrails/detectors/pii.js +295 -0
- package/dist/src/guardrails/detectors/pii.js.map +1 -0
- package/dist/src/guardrails/detectors/secrets.d.ts +5 -0
- package/dist/src/guardrails/detectors/secrets.d.ts.map +1 -0
- package/dist/src/guardrails/detectors/secrets.js +110 -0
- package/dist/src/guardrails/detectors/secrets.js.map +1 -0
- package/dist/src/guardrails/detectors/tool-poisoning.d.ts +16 -0
- package/dist/src/guardrails/detectors/tool-poisoning.d.ts.map +1 -0
- package/dist/src/guardrails/detectors/tool-poisoning.js +116 -0
- package/dist/src/guardrails/detectors/tool-poisoning.js.map +1 -0
- package/dist/src/guardrails/engine.d.ts +90 -0
- package/dist/src/guardrails/engine.d.ts.map +1 -0
- package/dist/src/guardrails/engine.js +324 -0
- package/dist/src/guardrails/engine.js.map +1 -0
- package/dist/src/guardrails/glob.d.ts +4 -0
- package/dist/src/guardrails/glob.d.ts.map +1 -0
- package/dist/src/guardrails/glob.js +20 -0
- package/dist/src/guardrails/glob.js.map +1 -0
- package/dist/src/guardrails/index.d.ts +20 -0
- package/dist/src/guardrails/index.d.ts.map +1 -0
- package/dist/src/guardrails/index.js +20 -0
- package/dist/src/guardrails/index.js.map +1 -0
- package/dist/src/guardrails/segments.d.ts +14 -0
- package/dist/src/guardrails/segments.d.ts.map +1 -0
- package/dist/src/guardrails/segments.js +32 -0
- package/dist/src/guardrails/segments.js.map +1 -0
- package/dist/src/guardrails/types.d.ts +161 -0
- package/dist/src/guardrails/types.d.ts.map +1 -0
- package/dist/src/guardrails/types.js +49 -0
- package/dist/src/guardrails/types.js.map +1 -0
- package/dist/src/guardrails/vault.d.ts +44 -0
- package/dist/src/guardrails/vault.d.ts.map +1 -0
- package/dist/src/guardrails/vault.js +103 -0
- package/dist/src/guardrails/vault.js.map +1 -0
- package/dist/src/index.d.ts +1 -1
- package/dist/src/index.js +1 -1
- package/dist/src/mcp-client/index.d.ts +1 -1
- package/dist/src/mcp-client/index.d.ts.map +1 -1
- package/dist/src/mcp-client/index.js.map +1 -1
- package/dist/src/mcp-client/options.d.ts +32 -0
- package/dist/src/mcp-client/options.d.ts.map +1 -1
- package/dist/src/mcp-client/tool-source.d.ts +2 -0
- package/dist/src/mcp-client/tool-source.d.ts.map +1 -1
- package/dist/src/mcp-client/tool-source.js +27 -1
- package/dist/src/mcp-client/tool-source.js.map +1 -1
- package/dist/src/rag/http-reranker.d.ts +46 -0
- package/dist/src/rag/http-reranker.d.ts.map +1 -0
- package/dist/src/rag/http-reranker.js +83 -0
- package/dist/src/rag/http-reranker.js.map +1 -0
- package/dist/src/rag/hybrid-retriever.d.ts +6 -1
- package/dist/src/rag/hybrid-retriever.d.ts.map +1 -1
- package/dist/src/rag/hybrid-retriever.js +27 -3
- package/dist/src/rag/hybrid-retriever.js.map +1 -1
- package/dist/src/rag/index.d.ts +6 -2
- package/dist/src/rag/index.d.ts.map +1 -1
- package/dist/src/rag/index.js +6 -2
- package/dist/src/rag/index.js.map +1 -1
- package/dist/src/rag/lexical-query.d.ts +28 -0
- package/dist/src/rag/lexical-query.d.ts.map +1 -0
- package/dist/src/rag/lexical-query.js +90 -0
- package/dist/src/rag/lexical-query.js.map +1 -0
- package/dist/src/rag/lexical-retriever.d.ts +23 -0
- package/dist/src/rag/lexical-retriever.d.ts.map +1 -0
- package/dist/src/rag/lexical-retriever.js +28 -0
- package/dist/src/rag/lexical-retriever.js.map +1 -0
- package/dist/src/rag/openai-embeddings.d.ts +62 -0
- package/dist/src/rag/openai-embeddings.d.ts.map +1 -0
- package/dist/src/rag/openai-embeddings.js +106 -0
- package/dist/src/rag/openai-embeddings.js.map +1 -0
- package/dist/src/rag/pg-vector-store.d.ts +133 -6
- package/dist/src/rag/pg-vector-store.d.ts.map +1 -1
- package/dist/src/rag/pg-vector-store.js +272 -31
- package/dist/src/rag/pg-vector-store.js.map +1 -1
- package/dist/src/rag/vector-store.d.ts +15 -0
- package/dist/src/rag/vector-store.d.ts.map +1 -1
- package/dist/src/rag/vector-store.js +4 -0
- package/dist/src/rag/vector-store.js.map +1 -1
- package/dist/src/stores/factory.d.ts +25 -3
- package/dist/src/stores/factory.d.ts.map +1 -1
- package/dist/src/stores/factory.js +21 -3
- package/dist/src/stores/factory.js.map +1 -1
- package/dist/src/testing/fake-embedding-provider.d.ts +12 -0
- package/dist/src/testing/fake-embedding-provider.d.ts.map +1 -1
- package/dist/src/testing/fake-embedding-provider.js +35 -0
- package/dist/src/testing/fake-embedding-provider.js.map +1 -1
- package/dist/src/testing/index.d.ts +1 -1
- package/dist/src/testing/index.d.ts.map +1 -1
- package/dist/src/testing/index.js +1 -1
- package/dist/src/testing/index.js.map +1 -1
- package/package.json +9 -3
- 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
|
-
/**
|
|
34
|
-
|
|
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
|
-
|
|
82
|
-
|
|
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
|
-
|
|
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":"AAAA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,8BAA8B,CAAC;AACtE,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAC;AACnD,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAC;AACzD,OAAO,EAAE,kBAAkB,EAAE,MAAM,0BAA0B,CAAC;
|
|
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} <=>
|
|
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} <->
|
|
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} <#>
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 ${
|
|
400
|
-
LIMIT
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
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
|