UNPKG

@adonis-agora/agent

Version:

Governed, durable-backed AI agent (chat + tool-calling + governance) for AdonisJS — part of the Agora ecosystem.

217 lines • 13.8 kB
import type { Passage } from '../spi/retriever.js'; /** A stored, embedded chunk. `embedding` length must match the store's configured dimensions. */ export interface VectorRecord { id: string; text: string; embedding: number[]; /** Citation-facing origin (document title, URL, row id). */ source?: string; metadata?: Record<string, unknown>; } export interface VectorSearchOptions { topK: number; /** * Metadata filter, passed through from `RetrieveOptions.filter`. A scalar value is exact-match; an * array value is match-any (OR / set membership), with an empty array denying everything — the * capability-token ACL primitive. See {@link import('./filter.js').matchesFilter}. */ filter?: Record<string, unknown>; /** * Relevance floor, passed through from `RetrieveOptions.minScore`: results whose `score` is below it * are dropped BEFORE the top-K cut (so the K returned are all above the floor). `score` is higher-is- * more-relevant (cosine similarity `1 - distance`, or a negated L2/inner distance). Undefined → no * floor (unchanged behavior). */ minScore?: number; } /** * The write + search side of RAG storage. This package ships {@link import('./memory-vector-store.js').MemoryVectorStore} * (in-JS cosine, tests + small/embedded corpora); a pgvector/Lucid-backed store is deferred. Pair one * with an {@link import('../spi/embedding-provider.js').EmbeddingProvider} via * {@link import('./embedding-retriever.js').EmbeddingRetriever} to get a `Retriever`. */ export interface VectorStore { upsert(records: VectorRecord[]): Promise<void>; search(embedding: number[], options: VectorSearchOptions): Promise<Passage[]>; /** * Delete every chunk belonging to a source document — all records whose id is `${documentId}` or * `${documentId}#<n>` (the id scheme {@link import('./ingest.js').chunkDocuments} produces). Use it to * drop a document from the index, and before re-ingesting one: `upsert` overwrites matching ids but * can't remove chunks a shorter new version no longer produces, so a re-ingest without a preceding * `remove` leaves the old tail orphaned. */ remove(documentId: string): Promise<void>; /** * List the distinct source documents currently indexed (chunk ids collapsed back to their document by * stripping the trailing `#<n>`), each with a representative chunk's `metadata`, optionally narrowed * by a metadata `filter`. */ listDocuments(filter?: Record<string, unknown>): Promise<IndexedDocument[]>; /** * OPTIONAL capability. Rewrite a document's metadata **without touching its text or its embeddings**. * * The update path for a dimension that is genuinely mutable — which collection a document belongs to, * who may see it, what it was reclassified as — rather than derived from its content. Without this the * only way to change one string is {@link VectorStore.upsert}, which needs the text and a fresh * embedding, so a consumer whose documents get re-classified has two bad options: re-embed a whole * document to change a label (paying the model bill for a reclassification), or refuse to stamp the * mutable dimension onto chunks at all and resolve it at query time — turning a filter the index could * have applied into a join the caller has to do. The second is what actually happens, and it is what * makes an actor-derived retrieval filter unaffordable: such a filter only pays off if the dimensions * it filters on are on the chunks AND can be corrected when they change. * * `patch` is applied to EVERY chunk of the document (metadata is a document-level property that * happens to be stored per chunk), with the **shallow JSON Merge Patch** semantics spelled out on * {@link MetadataPatch} — in those words because "patch" alone does not tell a caller whether `null` * deletes a key or stores a null. It deletes. * * Resolves to **the number of chunks written**. An unknown `documentId` writes nothing and returns * `0` rather than throwing, matching {@link VectorStore.remove}, which is likewise silent on a * document that is not there: this is a reconciliation-shaped call, driven by a loop diffing a source * of truth against the index, and such a loop races with ingestion and deletion by construction. A * patch with no effective keys also writes nothing and returns `0` — the count is chunks *written*, * not chunks *matched*. * * Optional so a host-written store still compiles; feature-detect with `store.updateMetadata?.(…)`. * All three stores this package ships implement it. */ updateMetadata?(documentId: string, patch: MetadataPatch): Promise<number>; /** * OPTIONAL capability. The distinct source document ids currently indexed, optionally narrowed by a * metadata `filter` — {@link VectorStore.listDocuments} without the metadata. * * Use it when the id set is all you need: a reconciliation diff against your source of truth, a count, * a delete list. `listDocuments` has to fetch and JSON-parse a metadata blob **per chunk** just to * collapse it to one entry per document, which on a large corpus is most of its cost; implementations * here are expected to skip that entirely (a `SELECT DISTINCT` of the id expression, a scroll that * transfers one payload key). * * Filter semantics are **identical to {@link VectorStore.search}** — same builder, empty-array deny * included, so an empty-array value yields `[]`. * * Optional so a host-written store still compiles; feature-detect with `store.listDocumentIds?.(…)`. */ listDocumentIds?(filter?: Record<string, unknown>): Promise<string[]>; /** * OPTIONAL capability. Delete every chunk matching a metadata `filter` in ONE store round trip, and * resolve the number of **chunks** removed. * * The collection-maintenance primitive: dropping a collection, evicting a tenant, purging a retired * audience. Without it, doing any of those means `listDocuments()` — which materialises per-chunk * metadata only to collapse it — followed by one {@link VectorStore.remove} per document, so N+1 round * trips where one filtered delete would do. (That the Qdrant adapter already needed a defensive page * cap on its `listDocuments` scroll is the tell: the enumeration API was being used for something it is * not shaped for.) * * **What it removes is exactly what a {@link VectorStore.search} carrying the same filter could * reach** — every implementation here builds the delete predicate with the very same filter builder its * `search` uses, so the two cannot drift. It is chunk-scoped, like `search`: for any corpus built by * {@link import('./ingest.js').chunkDocuments} — which stamps the document's metadata on every chunk — * that is the same thing as whole documents. In the pathological case where chunks of one document * carry *different* metadata, this removes the matching chunks and leaves the rest, which is the safe * direction: it can never remove more than the filter reaches. * * Two guard rails, because this is the only method here that destroys data: * * - **The empty-array deny is honoured, and it means "delete nothing".** `removeWhere({ audience: [] })` * removes zero chunks, exactly as a search with that filter returns none. Treating a deny as "no * filter" would delete the whole corpus — precisely inverted from what the caller asked for. * - **The filter must be non-empty.** `removeWhere({})` throws {@link UnsafeRemovalError} instead of * wiping the store, because an empty object is overwhelmingly more likely to be a filter that got * built wrong (a dropped key, an `undefined` scope that vanished on serialisation) than a deliberate * request to delete everything. Deliberate mass deletion stays available and stays explicit: loop * {@link VectorStore.remove} over {@link VectorStore.listDocumentIds}, or use the backend's own drop. * * Optional so a host-written store still compiles; feature-detect with `store.removeWhere?.(…)`. */ removeWhere?(filter: Record<string, unknown>): Promise<number>; } /** * OPTIONAL capability: a store that can also search its own full-text index. Lets a * {@link import('./lexical-retriever.js').LexicalRetriever} run the lexical half of a hybrid search * over the SAME rows (and ids) the vector half searches — no second index to feed. Feature-detect with * {@link isLexicalVectorStore}. {@link import('./pg-vector-store.js').PgLexicalVectorStore} implements it. */ export interface LexicalVectorStore extends VectorStore { /** * Rank chunks by full-text relevance to `query`, best first, honouring `filter` exactly as `search` * does. `score` is the backend's text rank — not comparable to a vector similarity. */ searchText(query: string, options: VectorSearchOptions): Promise<Passage[]>; } /** Whether `store` implements {@link LexicalVectorStore}. */ export declare function isLexicalVectorStore(store: VectorStore): store is LexicalVectorStore; /** * A destructive call was refused because its scope could not be trusted. Thrown by * {@link VectorStore.removeWhere} — never for a filter that merely matched nothing (that is a legitimate * result of `0`), only for one that would have deleted *more* than the caller plausibly meant. */ export declare class UnsafeRemovalError extends Error { /** Why the removal was refused. */ readonly reason: 'empty-filter'; constructor( /** Why the removal was refused. */ reason: 'empty-filter', message: string); } /** * Guard for {@link VectorStore.removeWhere}: refuse a filter that scopes nothing. Shared so all three * stores refuse the same input with the same error, rather than each deciding for itself how much of the * corpus an empty object means. */ export declare function assertRemovalFilter(filter: Record<string, unknown>): void; /** * Does this filter deny everything? True when any key carries an **empty array** — no value is a member * of the empty set, so nothing can match (see {@link import('./filter.js').matchesFilter}). * * Every filter builder in this package already encodes that (`matchesFilter` returns false, * `buildMetadataWhere` emits a `false` clause, `buildQdrantFilter` emits `any: []`), and the live specs * confirm the backends agree. {@link VectorStore.removeWhere} checks it anyway and short-circuits before * issuing anything: for the one operation where misreading a deny as "no filter" destroys the corpus, the * deny should not depend on a backend's interpretation of an empty set. */ export declare function filterDeniesAll(filter: Record<string, unknown>): boolean; /** * A metadata patch, applied by {@link VectorStore.updateMetadata}. **Shallow JSON Merge Patch** * (RFC 7396, restricted to one level): * * - a key with a **non-null** value **replaces** that key's value outright. Values are replaced * *wholesale*, arrays and nested objects included — that is the "shallow" part: `{ tags: ['b'] }` * makes `tags` exactly `['b']`, it does not append to it, and `{ acl: { read: [] } }` replaces the * whole `acl` object rather than merging into it. To edit one field of a nested object, send the * nested object you want. * - a key with the value **`null` DELETES that key**. This is the one semantic a caller cannot guess and * will get wrong in the destructive direction, so: `null` removes, it does not store a JSON null. * - a key set to `undefined` is **ignored** — treated as absent, since JSON has no `undefined`. A patch * whose every value is `undefined` therefore writes nothing. * - a key **not present** in the patch is left alone. That is the point: this is a partial update, not a * replacement of the metadata object. * * A chunk with no metadata at all gains an object on its first patch, and metadata stays an object * afterwards — possibly the empty one, if every key was deleted. */ export type MetadataPatch = Record<string, unknown>; /** * The keys of a `patch` that will actually be written — every key whose value is not `undefined`. Shared * by all three stores so "an empty patch writes nothing and returns 0" means the same thing on each, and * so none of them issues a round trip for a no-op. */ export declare function effectivePatchKeys(patch: MetadataPatch): string[]; /** * Apply a {@link MetadataPatch} to one chunk's metadata, returning the new metadata object. The single * definition of the merge, shared by every store that can do the merge in JS — so the semantics cannot * drift between adapters. Pure: the input object is never mutated. * * Always returns an object (never `undefined`), so a patched chunk has metadata even if it had none * before, and an all-deleting patch leaves `{}` rather than a hole. `{}` and absent metadata are * indistinguishable to {@link import('./filter.js').matchesFilter} and to pgvector's `@>`, so this costs * a caller nothing. */ export declare function applyMetadataPatch(metadata: Record<string, unknown> | undefined, patch: MetadataPatch): Record<string, unknown>; /** A distinct source document as seen by the index — its id plus a representative chunk's metadata. */ export interface IndexedDocument { id: string; metadata?: Record<string, unknown>; } /** Collapse a chunk id (`${documentId}#<n>`) back to its source document id. */ export declare function documentIdOf(chunkId: string): string; //# sourceMappingURL=vector-store.d.ts.map