@adonis-agora/agent
Version:
Governed, durable-backed AI agent (chat + tool-calling + governance) for AdonisJS — part of the Agora ecosystem.
666 lines • 35 kB
JavaScript
import { EmbeddingRetriever } from './embedding-retriever.js';
import { anyTermTsquery, DEFAULT_STOP_WORDS, hasSearchSyntax, keywordTerms, } from './lexical-query.js';
import { assertRemovalFilter, effectivePatchKeys, filterDeniesAll } from './vector-store.js';
const METRICS = {
cosine: {
operator: '<=>',
opclass: 'vector_cosine_ops',
score: (col, type = 'vector') => `1 - (${col} <=> ?::${type})`,
},
l2: {
operator: '<->',
opclass: 'vector_l2_ops',
score: (col, type = 'vector') => `-(${col} <-> ?::${type})`,
},
inner: {
// pgvector's `<#>` returns the NEGATIVE inner product, so `-(a <#> b)` recovers the inner product
// and `ORDER BY a <#> b ASC` ranks the largest inner product first — consistent with the others.
operator: '<#>',
opclass: 'vector_ip_ops',
score: (col, type = 'vector') => `-(${col} <#> ?::${type})`,
},
};
/** Only unqualified SQL identifiers (letter/underscore start, then letters/digits/underscores) are allowed. */
const IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/;
/**
* Reject any table/column name that is not a bare SQL identifier. Table and column names are the only
* parts of a pgvector statement that CANNOT be a positional binding (an identifier is not a value), so
* every one is validated here before it is spliced in — closing the injection vector that raw operator
* SQL would otherwise open. Embeddings, filters, top-K are always `?` bindings and never reach this path.
*/
function assertIdentifier(name, role) {
if (!IDENTIFIER.test(name)) {
throw new Error(`Invalid ${role} "${name}": a pgvector identifier must match ${IDENTIFIER.source}`);
}
return name;
}
/** pgvector accepts a `'[1,2,3]'` text literal cast to `vector`; the literal itself is a `?` binding. */
export function toVectorLiteral(embedding) {
return `[${embedding.join(',')}]`;
}
/**
* Strip every NUL byte (`0x00`) from a value before it can reach a Postgres `text`/`jsonb` binding.
* Postgres rejects `0x00` in those types outright (`invalid byte sequence for encoding "UTF8": 0x00`),
* while source systems like Qdrant accept it — so text extracted from PDFs that happens to carry a stray
* NUL byte works fine right up until it is written to pgvector. Strings have the byte removed (the rest
* of the text is kept intact); arrays and PLAIN objects are walked recursively, including object KEYS
* (a NUL in a metadata key would otherwise reach `JSON.stringify` and then Postgres just the same); every
* other value (numbers, booleans, `null`, `undefined`, embeddings, a `Date`, a class instance, a
* `Buffer`, …) passes through unchanged, by reference.
*
* Two things a naive `for...in` + assignment walk gets wrong, both handled here:
*
* - **Only plain objects are walked.** The check is `Object.getPrototypeOf(v) === Object.prototype ||
* === null`, never `typeof v === 'object'` alone — a `Date`, a class instance, a `Buffer`, … would
* otherwise be torn down into a plain `{}` of its enumerable own properties, silently losing its
* prototype (and, for a `Date`, every method on it).
* - **The result is built with `Object.fromEntries`, never `result[key] = value` on a fresh `{}`.** A
* metadata key literally named `__proto__`, assigned that way, does not create an own property — it
* hits the inherited `Object.prototype` accessor instead, which (for the ordinary case, a string
* value) silently no-ops, so the key is dropped rather than round-tripped. `Object.fromEntries` uses
* `CreateDataProperty` under the hood, which always creates a real own property, `__proto__` included.
*/
export function stripNulBytes(value) {
if (typeof value === 'string') {
// split/join rather than a regex literal -- a bare NUL inside a RegExp trips biome's
// noControlCharactersInRegex lint, and split/join needs no escaping at all.
return value.split('\u0000').join('');
}
if (Array.isArray(value)) {
return value.map((item) => stripNulBytes(item));
}
if (value !== null && typeof value === 'object') {
const proto = Object.getPrototypeOf(value);
if (proto !== Object.prototype && proto !== null) {
// Not a plain object -- a Date, a class instance, a Buffer, a Map, ... -- leave it exactly as is.
return value;
}
return Object.fromEntries(Object.entries(value).map(([key, val]) => [
stripNulBytes(key),
stripNulBytes(val),
]));
}
return value;
}
/**
* Build the metadata `WHERE` fragment for a filter, using Lucid/Knex positional `?` bindings (never
* string interpolation). Two predicate kinds:
*
* - **scalar** filter values collapse into a single jsonb-containment check (`metadata @> ?::jsonb`) —
* exact-match, byte-for-byte the previous behavior so scalar filters stay backward compatible.
* - an **array** filter value is a **match-any** (OR / set membership) check: the record matches when
* its value for that key — scalar or array — shares an element with the filter array. An empty array
* can never match (the `false` deny primitive).
*
* Set membership uses `jsonb_exists_any(jsonb, text[])` — the function form of the `?|` operator, chosen
* because bare `?|` collides with Knex's `?` binding placeholder. The metadata KEY is itself a `?`
* binding (`metadata->?`), so a caller-supplied key can never inject SQL; only the (already validated)
* table/column identifiers are ever spliced in. Returns `{ sql: '', bindings: [] }` when there is no
* filter, preserving the previous unfiltered query shape.
*
* Every binding built here — the key, the scalar containment JSON, each array token — is run through
* {@link stripNulBytes} first, the same as every write path: a filter key or value can carry a NUL byte
* just as easily as stored text can, Postgres rejects it here exactly as it would on a write, and
* stripping it here keeps a filter lookup consistent with the already-stripped data it is matched against.
*/
export function buildMetadataWhere(filter, metadataColumn) {
if (filter === undefined || Object.keys(filter).length === 0) {
return { sql: '', bindings: [] };
}
const clauses = [];
const bindings = [];
// Accumulated as [key, value] pairs, not `scalar[cleanKey] = value` on a fresh `{}` -- a filter key
// literally named `__proto__` would otherwise hit the inherited `Object.prototype` accessor instead
// of creating an own property, and (for the ordinary case, a non-object value) that setter silently
// no-ops, dropping the key before it ever reaches `JSON.stringify`. `Object.fromEntries` below builds
// the object with `CreateDataProperty`, the same fix {@link stripNulBytes} makes for its own walk.
const scalarEntries = [];
for (const [key, value] of Object.entries(filter)) {
const cleanKey = stripNulBytes(key);
if (Array.isArray(value)) {
if (value.length === 0) {
clauses.push('false');
continue;
}
// Coerce the record's value to an array (a scalar becomes a one-element array) so `?|` set
// membership works uniformly, then test overlap with the caller's tokens.
clauses.push(`jsonb_exists_any(CASE WHEN jsonb_typeof(${metadataColumn}->?) = 'array' ` +
`THEN ${metadataColumn}->? ELSE jsonb_build_array(${metadataColumn}->?) END, ?::text[])`);
bindings.push(cleanKey, cleanKey, cleanKey, value.map((token) => stripNulBytes(String(token))));
}
else {
scalarEntries.push([cleanKey, value]);
}
}
if (scalarEntries.length > 0) {
clauses.push(`${metadataColumn} @> ?::jsonb`);
bindings.push(JSON.stringify(stripNulBytes(Object.fromEntries(scalarEntries))));
}
return { sql: `WHERE ${clauses.join(' AND ')}`, bindings };
}
/** Normalize whatever a Lucid `rawQuery` returns (PG `{rows}`, SQLite array, MySQL `[rows,fields]`). */
export function normalizeRows(raw) {
if (Array.isArray(raw)) {
if (raw.length > 0 && Array.isArray(raw[0])) {
return raw[0];
}
return raw;
}
if (raw !== null && typeof raw === 'object' && Array.isArray(raw.rows)) {
return raw.rows;
}
return [];
}
/** A `jsonb` column comes back parsed (object) on `pg` or as a string on some drivers — accept both. */
function parseMetadata(value) {
if (value === null || value === undefined)
return undefined;
if (typeof value === 'object')
return value;
if (typeof value === 'string' && value.length > 0) {
try {
return JSON.parse(value);
}
catch {
return undefined;
}
}
return undefined;
}
/**
* SQL that collapses a chunk id (`${documentId}#<n>`) back to its source document id — the pgvector
* mirror of {@link import('./vector-store.js').documentIdOf}. Shared by `remove` and `listDocuments` so
* both key on the exact same definition of "chunk belongs to document". `col` is a validated identifier.
*/
function documentIdExpr(col) {
return `regexp_replace(${col}, '#[0-9]+$', '')`;
}
/**
* A pgvector-backed {@link VectorStore} — the production RAG adapter, the durable twin of
* {@link import('./memory-vector-store.js').MemoryVectorStore}. It runs over the structural
* {@link LucidRawRunner} (so `@adonisjs/lucid` stays an optional peer — this file imports no Lucid
* types) with raw SQL for the pgvector operators. Similarity ranks via the `<=>`/`<->`/`<#>` operator for
* the configured {@link PgVectorMetric}; the query embedding is always a `?::vector` positional binding
* (NEVER string-interpolated). Scalar metadata filters are an `@> ?::jsonb` containment binding; an
* array filter value is set-membership (`jsonb_exists_any`, the `?|` function form) — the capability-token
* ACL primitive. Only the (validated) table/column identifiers are ever spliced into a statement.
*
* Usually you don't construct this directly: `config/agent.ts` selects it via `retrievers.pgvector({...})`
* and the provider builds it, lazily importing `@adonisjs/lucid` only when the pgvector retriever is
* chosen. Call {@link PgVectorStore.ensureSchema} once (or run the bundled migration) to provision the
* `vector` extension, the chunk table, and the metric's index.
*/
export class PgVectorStore {
db;
table;
/** The single width (fixed-dimension table), or `undefined` for a mixed-dimension table. */
dimension;
/** The widths with a partial HNSW index, for a mixed-dimension table. */
indexedDimensions;
nullableEmbeddings;
iterativeScan;
efSearch;
upsertBatchSize;
iterativeScanSupport;
metric;
metricName;
col;
constructor(db, options = {}) {
this.db = db;
this.table = assertIdentifier(options.table ?? 'agent_rag_chunks', 'table');
const dimension = options.dimension ?? 1536;
if (typeof dimension === 'number') {
this.dimension = dimension;
this.indexedDimensions = [];
}
else {
for (const width of dimension) {
if (!Number.isInteger(width) || width <= 0) {
throw new Error(`Invalid pgvector dimension ${String(width)}: must be a positive integer`);
}
}
this.dimension = undefined;
this.indexedDimensions = [...new Set(dimension)];
}
this.nullableEmbeddings = options.nullableEmbeddings ?? false;
this.iterativeScan = options.iterativeScan;
this.efSearch = options.efSearch;
this.upsertBatchSize = Math.max(1, Math.floor(options.upsertBatchSize ?? 100));
this.metricName = options.metric ?? 'cosine';
this.metric = METRICS[this.metricName];
const columns = options.columns ?? {};
this.col = {
id: assertIdentifier(columns.id ?? 'id', 'column'),
text: assertIdentifier(columns.text ?? 'text', 'column'),
source: assertIdentifier(columns.source ?? 'source', 'column'),
metadata: assertIdentifier(columns.metadata ?? 'metadata', 'column'),
embedding: assertIdentifier(columns.embedding ?? 'embedding', 'column'),
};
}
/**
* Idempotent DDL — the `vector` extension, the chunk table, and the metric's index. Handy for tests
* and scripts; an AdonisJS app should prefer the bundled migration so the schema is versioned.
*/
async ensureSchema() {
for (const statement of this.schemaStatements()) {
await this.db.rawQuery(statement);
}
}
/** The `CREATE EXTENSION` / `CREATE TABLE` / `CREATE INDEX` statements {@link ensureSchema} issues. */
schemaStatements() {
const c = this.col;
const mixed = this.dimension === undefined;
const statements = [
'CREATE EXTENSION IF NOT EXISTS vector',
`CREATE TABLE IF NOT EXISTS ${this.table} (
${c.id} TEXT PRIMARY KEY,
${c.text} TEXT NOT NULL,
${c.source} TEXT,
${c.metadata} JSONB,
${c.embedding} ${mixed ? 'vector' : `vector(${this.dimension})`}${this.nullableEmbeddings ? '' : ' NOT NULL'}
)`,
];
if (mixed) {
for (const width of this.indexedDimensions) {
statements.push(`CREATE INDEX IF NOT EXISTS ${this.table}_${c.embedding}_${width}_idx
ON ${this.table} USING hnsw ((${c.embedding}::vector(${width})) ${this.metric.opclass})
WHERE vector_dims(${c.embedding}) = ${width}`);
}
}
else {
statements.push(`CREATE INDEX IF NOT EXISTS ${this.table}_${c.embedding}_idx
ON ${this.table} USING hnsw (${c.embedding} ${this.metric.opclass})`);
}
return statements;
}
/**
* Every `text`/`jsonb` binding (id, text, source, metadata — including nested metadata values, array
* items and object keys) is run through {@link stripNulBytes} first: Postgres rejects the NUL byte
* (`0x00`) in `text`/`jsonb` outright, while upstream sources (PDF extraction, Qdrant) happily carry
* it, so an unstripped chunk would otherwise fail this INSERT with `invalid byte sequence for encoding
* "UTF8": 0x00`. The embedding is untouched — it is never text/jsonb.
*
* Written as multi-row `INSERT … ON CONFLICT` statements of {@link PgVectorStoreOptions.upsertBatchSize}
* rows each (one round trip per batch instead of per chunk). An empty `embedding` is written as `NULL`.
*/
async upsert(records) {
// The id is stripped before de-duplication, so two ids differing only by a NUL byte are the one
// row they would be stored as. One statement can't touch a row twice (`ON CONFLICT DO UPDATE
// command cannot affect row a second time`), so duplicates collapse to the LAST occurrence — the
// same last-write-wins the old row-by-row loop had.
const byId = new Map();
for (const record of records) {
const id = stripNulBytes(record.id);
byId.delete(id);
byId.set(id, { ...record, id });
}
const unique = [...byId.values()];
const c = this.col;
for (let start = 0; start < unique.length; start += this.upsertBatchSize) {
const batch = unique.slice(start, start + this.upsertBatchSize);
const bindings = [];
for (const record of batch) {
bindings.push(record.id, stripNulBytes(record.text), stripNulBytes(record.source ?? null), record.metadata !== undefined ? JSON.stringify(stripNulBytes(record.metadata)) : null,
// An empty embedding is a chunk with no vector yet — NULL (see `nullableEmbeddings`).
record.embedding.length > 0 ? toVectorLiteral(record.embedding) : null);
}
await this.db.rawQuery(`INSERT INTO ${this.table} (${c.id}, ${c.text}, ${c.source}, ${c.metadata}, ${c.embedding})
VALUES ${batch.map(() => '(?, ?, ?, ?::jsonb, ?::vector)').join(', ')}
ON CONFLICT (${c.id}) DO UPDATE SET
${c.text} = EXCLUDED.${c.text},
${c.source} = EXCLUDED.${c.source},
${c.metadata} = EXCLUDED.${c.metadata},
${c.embedding} = EXCLUDED.${c.embedding}`, bindings);
}
}
/**
* `documentId` is run through {@link stripNulBytes} before binding — the stored `id` column never
* carries a NUL byte (it went through the same stripping on `upsert`), so a raw NUL in the lookup
* value could only ever fail to match; stripping it here keeps the lookup consistent with the data
* instead of trusting a caller-supplied string to already be clean.
*/
async remove(documentId) {
await this.db.rawQuery(`DELETE FROM ${this.table} WHERE ${documentIdExpr(this.col.id)} = ?`, [
stripNulBytes(documentId),
]);
}
/**
* {@link VectorStore.updateMetadata} — one `UPDATE` for the whole document, with the
* {@link import('./vector-store.js').MetadataPatch} merge done **in Postgres** rather than read back
* into JS: `(COALESCE(metadata, '{}') || <set>) - <removed keys>`. `||` is jsonb concatenation, which
* at the top level is exactly a shallow merge (right-hand keys win, wholesale); `jsonb - text[]`
* removes keys, which is how a `null` in the patch deletes. So there is no read-modify-write and no
* lost-update window between a `SELECT` and an `UPDATE`.
*
* The embedding is untouched **structurally**: `SET` names only the metadata column, so no statement
* this method can emit is capable of writing `embedding` or `text`. The row is not re-inserted, so
* pgvector never re-parses a vector literal either.
*
* `RETURNING` the id column yields the chunk count without a second query.
*
* The patch is run through {@link stripNulBytes} first (values AND keys) for the same reason as
* {@link PgVectorStore.upsert}: Postgres rejects the NUL byte (`0x00`) in `jsonb`, so an unstripped
* value or key would otherwise fail this `UPDATE`. `documentId` is stripped too, for the same
* lookup-consistency reason as {@link PgVectorStore.remove}.
*/
async updateMetadata(documentId, patch) {
const cleanPatch = stripNulBytes(patch);
const keys = effectivePatchKeys(cleanPatch);
if (keys.length === 0) {
return 0;
}
// Accumulated as [key, value] pairs, not `set[key] = cleanPatch[key]` on a fresh `{}` -- see the
// matching comment in `buildMetadataWhere`: a patch key literally named `__proto__` would otherwise
// be silently dropped by the inherited prototype setter instead of surviving as an own property.
const setEntries = [];
const removed = [];
for (const key of keys) {
if (cleanPatch[key] === null) {
removed.push(key);
}
else {
setEntries.push([key, cleanPatch[key]]);
}
}
const set = Object.fromEntries(setEntries);
const c = this.col;
const raw = await this.db.rawQuery(`UPDATE ${this.table}
SET ${c.metadata} = (COALESCE(${c.metadata}, '{}'::jsonb) || ?::jsonb) - ?::text[]
WHERE ${documentIdExpr(c.id)} = ?
RETURNING ${c.id} AS id`, [JSON.stringify(set), removed, stripNulBytes(documentId)]);
return normalizeRows(raw).length;
}
async listDocuments(filter) {
const c = this.col;
const docExpr = documentIdExpr(c.id);
const where = buildMetadataWhere(filter, c.metadata);
const raw = await this.db.rawQuery(`SELECT DISTINCT ON (${docExpr}) ${docExpr} AS doc_id, ${c.metadata} AS metadata
FROM ${this.table}
${where.sql}
ORDER BY ${docExpr}`, where.bindings);
return normalizeRows(raw).map((row) => {
const metadata = parseMetadata(row.metadata);
return {
id: String(row.doc_id),
...(metadata !== undefined ? { metadata } : {}),
};
});
}
/**
* {@link VectorStore.listDocumentIds} — `SELECT DISTINCT` of the document-id expression. Unlike
* {@link PgVectorStore.listDocuments} it never selects the `jsonb` metadata column, so Postgres does not
* transfer it and {@link parseMetadata} is never called: on a large corpus that is most of the cost.
* The `WHERE` comes from the same {@link buildMetadataWhere} `search` uses.
*/
async listDocumentIds(filter) {
const docExpr = documentIdExpr(this.col.id);
const where = buildMetadataWhere(filter, this.col.metadata);
const raw = await this.db.rawQuery(`SELECT DISTINCT ${docExpr} AS doc_id
FROM ${this.table}
${where.sql}
ORDER BY doc_id`, where.bindings);
return normalizeRows(raw).map((row) => String(row.doc_id));
}
/**
* {@link VectorStore.removeWhere} — one `DELETE … RETURNING`, so the chunk count comes back from the
* same statement that does the deleting: no count-then-delete, no race between the two, one round trip.
*
* Filter parity with {@link PgVectorStore.search} is by construction — the `WHERE` fragment is produced
* by the identical {@link buildMetadataWhere} call, so an empty-array value emits the same `false`
* clause here as it does there, and a scalar the same `@> ?::jsonb` containment.
*/
async removeWhere(filter) {
assertRemovalFilter(filter);
if (filterDeniesAll(filter)) {
return 0;
}
const where = buildMetadataWhere(filter, this.col.metadata);
const raw = await this.db.rawQuery(`DELETE FROM ${this.table} ${where.sql} RETURNING ${this.col.id} AS id`, where.bindings);
return normalizeRows(raw).length;
}
/**
* Nearest first by the configured metric. On a mixed-dimension table only vectors as wide as the
* query are compared (through that width's partial index when it has one); a chunk without an
* embedding is never returned.
*/
async search(embedding, options) {
if (embedding.length === 0) {
return [];
}
const c = this.col;
const vector = toVectorLiteral(embedding);
// A fixed-width table keeps the exact expression its index was built on (`embedding`); a mixed one
// casts both sides to the query's width so the planner can match that width's partial index.
let vectorType = 'vector';
const shapeClauses = [];
if (this.dimension === undefined) {
const width = embedding.length;
if (this.indexedDimensions.includes(width)) {
vectorType = `vector(${width})`;
}
shapeClauses.push(`${c.embedding} IS NOT NULL`, `vector_dims(${c.embedding}) = ${width}`);
}
else if (this.nullableEmbeddings) {
shapeClauses.push(`${c.embedding} IS NOT NULL`);
}
const column = vectorType === 'vector' ? c.embedding : `(${c.embedding}::${vectorType})`;
const scoreExpr = this.metric.score(column, vectorType);
const meta = buildMetadataWhere(options.filter, c.metadata);
// Combine the metadata predicate with an optional minScore relevance floor. Filtering the score
// expression in SQL (not in JS after the fact) keeps the floor applied BEFORE `LIMIT`, so the K
// returned are all above the floor. `buildMetadataWhere` prefixes `WHERE ` (6 chars) — strip it so
// the clauses can be re-joined with the floor via AND. When neither is present the query shape and
// binding order are byte-for-byte identical to before.
const clauses = [];
const whereBindings = [];
if (meta.sql !== '') {
clauses.push(meta.sql.slice('WHERE '.length));
whereBindings.push(...meta.bindings);
}
clauses.push(...shapeClauses);
if (options.minScore !== undefined) {
// The score expression carries its own `?::vector`, so the embedding is bound again here.
clauses.push(`${scoreExpr} >= ?`);
whereBindings.push(vector, options.minScore);
}
const whereSql = clauses.length > 0 ? `WHERE ${clauses.join(' AND ')}` : '';
// Bindings are consumed positionally, in the exact order the `?`s appear: score expr (embedding),
// then the optional filter/minScore clause(s), then the ORDER BY embedding, then the LIMIT. The
// embedding is bound once per `?::vector` because a positional `?` cannot be reused like a `$1`.
const bindings = [vector, ...whereBindings, vector, options.topK];
const sql = `SELECT ${c.id} AS id, ${c.text} AS text, ${c.source} AS source, ${c.metadata} AS metadata,
${scoreExpr} AS score
FROM ${this.table}
${whereSql}
ORDER BY ${column} ${this.metric.operator} ?::${vectorType}
LIMIT ?`;
const raw = await this.withSearchSettings((runner) => runner.rawQuery(sql, bindings));
return normalizeRows(raw).map(toPassage);
}
/** Run a read with the configured HNSW settings `SET LOCAL` in one transaction, when possible. */
async withSearchSettings(read) {
const transaction = this.db.transaction;
if (typeof transaction !== 'function') {
return read(this.db);
}
const iterative = this.iterativeScan !== undefined && (await this.supportsIterativeScan());
if (!iterative && this.efSearch === undefined) {
return read(this.db);
}
return transaction.call(this.db, async (trx) => {
// Lucid's transaction client is a full query client (it has `rawQuery`); the structural type
// this file sees only promises `from`/`table`, hence the check.
const runner = trx;
if (typeof runner.rawQuery !== 'function') {
return read(this.db);
}
const tx = runner;
if (iterative) {
await tx.rawQuery(`SELECT set_config('hnsw.iterative_scan', ?, true)`, [
this.iterativeScan,
]);
}
if (this.efSearch !== undefined) {
await tx.rawQuery(`SELECT set_config('hnsw.ef_search', ?, true)`, [String(this.efSearch)]);
}
return read(tx);
});
}
/** `hnsw.iterative_scan` exists from pgvector 0.8.0; setting it on an older one is an error. */
supportsIterativeScan() {
this.iterativeScanSupport ??= this.db
.rawQuery(`SELECT extversion FROM pg_extension WHERE extname = 'vector'`)
.then((raw) => {
const version = String(normalizeRows(raw)[0]?.extversion ?? '0.0');
const [major = 0, minor = 0] = version.split('.').map(Number);
return major > 0 || minor >= 8;
})
.catch(() => false);
return this.iterativeScanSupport;
}
}
/**
* {@link PgVectorStore} plus Postgres full-text search: a {@link LexicalVectorStore}, so a
* {@link import('./lexical-retriever.js').LexicalRetriever} and a
* {@link import('./hybrid-retriever.js').HybridRetriever} can fuse the dense and lexical legs over the
* same rows and ids. `retrievers.pgvector({ fullText: {} })` wires exactly that.
*
* A question is searched by its meaningful terms: its stop words dropped, rows holding any remaining
* term ranked by IDF-weighted coverage, `ts_rank_cd` (cover density) breaking ties (see
* {@link PgFullTextOptions.anyTermFallback}); explicit syntax (quoted phrases, `-exclusions`) goes
* through `websearch_to_tsquery` first.
*
* A separate class rather than a method on {@link PgVectorStore} on purpose: full-text search needs a
* GIN index, which on a populated table belongs in a migration you run and watch (`CREATE INDEX` in
* `ensureSchema` takes a write lock at boot), and a store that silently grew `searchText` on upgrade
* would be picked up by every `isLexicalVectorStore` check and scan an unindexed table.
*/
export class PgLexicalVectorStore extends PgVectorStore {
tsConfig;
tsColumn;
anyTermFallback;
stopWords;
constructor(db, options = {}) {
super(db, options);
this.tsConfig = assertIdentifier(options.fullText?.config ?? 'simple', 'text search config');
this.tsColumn =
options.fullText?.column !== undefined
? assertIdentifier(options.fullText.column, 'column')
: undefined;
this.anyTermFallback = options.fullText?.anyTermFallback ?? true;
const stopWords = options.fullText?.stopWords;
this.stopWords = stopWords === false ? {} : (stopWords ?? DEFAULT_STOP_WORDS);
}
schemaStatements() {
const statements = super.schemaStatements();
if (this.tsColumn === undefined) {
statements.push(`CREATE INDEX IF NOT EXISTS ${this.table}_${this.col.text}_tsv_idx
ON ${this.table} USING gin (${this.tsvector()})`);
}
return statements;
}
/**
* Full-text search over the chunk text. `score` is an IDF / `ts_rank_cd` rank — not a similarity,
* and not on the vector leg's scale — so `minScore` is deliberately not applied here.
*/
async searchText(query, options) {
if ((options.filter !== undefined && filterDeniesAll(options.filter)) || query.trim() === '') {
return [];
}
if (!this.anyTermFallback || hasSearchSyntax(query)) {
const strict = await this.runTextSearch(query, options);
if (strict.length > 0 || !this.anyTermFallback) {
return strict;
}
}
const terms = keywordTerms(query, 24, this.stopWords);
return terms.length === 0 ? [] : this.runRankedTermSearch(terms, options);
}
/** `websearch_to_tsquery` on the query as written: every word, quoted phrases, `-exclusions`. */
async runTextSearch(query, options) {
const c = this.col;
const where = buildMetadataWhere(options.filter, c.metadata);
// The parsed query is a lateral relation, so the one `?` binding feeds both the match and the rank.
const clauses = [`${this.tsvector()} @@ __tsq.q`];
if (where.sql !== '') {
clauses.push(where.sql.slice('WHERE '.length));
}
const raw = await this.db.rawQuery(`SELECT ${c.id} AS id, ${c.text} AS text, ${c.source} AS source, ${c.metadata} AS metadata,
ts_rank_cd(${this.tsvector()}, __tsq.q) AS score
FROM ${this.table}, websearch_to_tsquery('${this.tsConfig}', ?) AS __tsq(q)
WHERE ${clauses.join(' AND ')}
ORDER BY score DESC, ${c.id}
LIMIT ?`, [query, ...where.bindings, options.topK]);
return normalizeRows(raw).map(toPassage);
}
/**
* Rows holding any of `terms`, ranked by the sum of the matched terms' IDF among those rows,
* `ln(1 + (N - df + 0.5) / (df + 0.5))`, then `ts_rank_cd`. Each (row, term) match is evaluated
* once, and `ts_rank_cd` only for the rows ranked within `topK` (ties included): a question full of
* common words can match most of the table. Terms are letter/digit runs: space-joined in one
* binding (no array binding), each quoted as a literal lexeme.
*/
async runRankedTermSearch(terms, options) {
const c = this.col;
const cfg = this.tsConfig;
const where = buildMetadataWhere(options.filter, c.metadata);
const clauses = [`${this.tsvector()} @@ __any.q`];
if (where.sql !== '') {
clauses.push(where.sql.slice('WHERE '.length));
}
const raw = await this.db.rawQuery(`SELECT id, text, source, metadata, score FROM (
WITH __terms AS (
SELECT DISTINCT to_tsquery('${cfg}', quote_literal(t)) AS q
FROM regexp_split_to_table(?, ' ') AS t
), __cand AS MATERIALIZED (
SELECT ${c.id} AS id, ${c.text} AS text, ${c.source} AS source, ${c.metadata} AS metadata,
${this.tsvector()} AS __tsv, __any.q AS __q
FROM ${this.table}, to_tsquery('${cfg}', ?) AS __any(q)
WHERE ${clauses.join(' AND ')}
), __hits AS MATERIALIZED (
SELECT __cand.id AS __id, __terms.q FROM __cand JOIN __terms ON __cand.__tsv @@ __terms.q
), __idf AS (
SELECT q, ln(1 + ((SELECT count(*) FROM __cand) - count(*) + 0.5) / (count(*) + 0.5)) AS w
FROM __hits GROUP BY q
), __scored AS (
SELECT __hits.__id, sum(__idf.w) AS s, rank() OVER (ORDER BY sum(__idf.w) DESC) AS r
FROM __hits JOIN __idf USING (q) GROUP BY __hits.__id
)
SELECT __cand.id, __cand.text, __cand.source, __cand.metadata,
__scored.s AS __s, ts_rank_cd(__cand.__tsv, __cand.__q, 32) AS __tr,
__scored.s + ts_rank_cd(__cand.__tsv, __cand.__q, 32) AS score
FROM __scored JOIN __cand ON __cand.id = __scored.__id
WHERE __scored.r <= ?
) AS __ranked
ORDER BY __s DESC, __tr DESC, id
LIMIT ?`, [terms.join(' '), anyTermTsquery(terms), ...where.bindings, options.topK, options.topK]);
return normalizeRows(raw).map(toPassage);
}
/** Must be character-for-character the index expression, or the planner won't use the index. */
tsvector() {
return this.tsColumn ?? `to_tsvector('${this.tsConfig}'::regconfig, ${this.col.text})`;
}
}
function toPassage(row) {
const metadata = parseMetadata(row.metadata);
const source = row.source;
return {
id: String(row.id),
text: String(row.text),
score: Number(row.score),
...(source !== null && source !== undefined ? { source: String(source) } : {}),
...(metadata !== undefined ? { metadata } : {}),
};
}
/**
* A {@link import('../spi/retriever.js').Retriever} over a {@link PgVectorStore}: embed the query, then
* pgvector-search. The production sibling of the in-memory {@link EmbeddingRetriever} wiring — this is
* what `retrievers.pgvector({...})` builds. Construct it directly for programmatic use, or let the factory.
*/
export class PgVectorRetriever extends EmbeddingRetriever {
// biome-ignore lint/complexity/noUselessConstructor: not useless — it NARROWS `store` from the base's `VectorStore` to `PgVectorStore`, which is this subclass's whole point. Delete it and `new PgVectorRetriever(embedder, anyInMemoryStore)` starts compiling.
constructor(embedder, store) {
super(embedder, store);
}
}
//# sourceMappingURL=pg-vector-store.js.map