UNPKG

@adonis-agora/agent

Version:

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

355 lines • 18.2 kB
import type { CursorPage } from '../pagination.js'; import type { EmbeddingProvider } from '../spi/embedding-provider.js'; import type { Passage } from '../spi/retriever.js'; import { EmbeddingRetriever } from './embedding-retriever.js'; import type { IndexedDocument, MetadataPatch, VectorRecord, VectorSearchOptions, VectorStore } from './vector-store.js'; /** Similarity → Qdrant distance name. Cosine (default), Dot (inner), Euclid (l2). */ export type QdrantMetric = 'cosine' | 'inner' | 'l2'; export interface QdrantStoreOptions { /** Collection name. Default `agent_rag_chunks`. */ collection?: string; /** Embedding width — must match the model (1536 for text-embedding-3-small). Default 1536. */ dimension?: number; /** Similarity metric. Default `cosine`. */ metric?: QdrantMetric; /** * Maximum points per upsert request. Large sources become MANY chunks (a ~200-page PDF is * ~700 points); sending them in a single request blows the client's default timeout * (`@qdrant/js-client-rest`, 300s) because a multi-megabyte body stalls on the way. Slicing into * batches keeps every request small and predictable. Default 100 (measured at ~4-6s per batch of * 100). Zero or negative → no slicing. */ upsertBatchSize?: number; } /** A Qdrant point (UUID id, vector, payload). */ export interface QdrantPoint { id: string; vector: number[]; payload: Record<string, unknown>; } /** * A Qdrant filter condition (the subset this store uses). A field condition matches on `match` * (exact value, set membership, exclusion); an {@link QdrantIsEmptyCondition} matches the * ABSENCE — field missing, null or empty list — which is what lets a filter express the * fallback buckets an admin aggregation needs (a chunk with no `metadata.tipo`, a document * keyed by NO field of a priority chain). */ export interface QdrantFieldCondition { key: string; match: { value: unknown; } | { any: unknown[]; } | { except: unknown[]; }; } export interface QdrantIsEmptyCondition { is_empty: { key: string; }; } export type QdrantCondition = QdrantFieldCondition | QdrantIsEmptyCondition; /** * A Qdrant filter. `must` is ANDed, `must_not` negates (so `must_not: [{ is_empty: … }]` reads * "field PRESENT"), and `should` requires at least one match when present — together the boolean * shape a server-side GROUP BY over a priority chain (COALESCE) needs without fetching the * points to evaluate it client-side. */ export interface QdrantFilter { must?: QdrantCondition[]; must_not?: QdrantCondition[]; should?: QdrantCondition[]; } /** * The subset of the `@qdrant/js-client-rest` client {@link QdrantStore} uses — structural, so the * package stays an OPTIONAL peer (the factory imports the real client and hands it in) and tests can * pass a fake. Mirrors pgvector's `LucidDatabaseLike`. */ export interface QdrantClientLike { getCollections(): Promise<{ collections: { name: string; }[]; }>; createCollection(name: string, config: { vectors: { size: number; distance: string; }; }): Promise<unknown>; upsert(collection: string, args: { points: QdrantPoint[]; }): Promise<unknown>; query(collection: string, args: { query: number[]; limit: number; with_payload: boolean; filter?: QdrantFilter; score_threshold?: number; }): Promise<{ points: { score: number; payload?: Record<string, unknown>; }[]; }>; delete(collection: string, args: { filter?: QdrantFilter; points?: string[]; wait?: boolean; }): Promise<unknown>; scroll(collection: string, args: { filter?: QdrantFilter; /** `true` for the whole payload, or a key list to transfer only those keys. */ with_payload: boolean | string[]; with_vector: boolean; limit: number; offset?: unknown; }): Promise<{ points: { payload?: Record<string, unknown>; }[]; next_page_offset?: unknown; }>; /** * Set payload keys on specific points, leaving every other key — and **the vector** — alone. Backs * {@link QdrantStore.updateMetadata}; the real `@qdrant/js-client-rest` provides it. * * OPTIONAL here, mirroring the `expire?` precedent on this package's Redis client shim, so adding it * cannot break a host that hand-rolled this structural interface. `updateMetadata` throws a named * error rather than degrading if it is absent: the alternative degradation would be scroll-with-vector * plus `upsert`, which rewrites the embedding — the exact thing the method promises not to do. */ setPayload?(collection: string, args: { payload: Record<string, unknown>; points: string[]; wait?: boolean; }): Promise<unknown>; /** * Count the points matching a filter WITHOUT transferring them. Backs the chunk count * {@link QdrantStore.removeWhere} resolves to — Qdrant's delete response carries no count, and this is * an aggregate, not an enumeration: no payloads, no vectors, one cheap round trip. Optional for the * same reason as {@link QdrantClientLike.setPayload}; the real `@qdrant/js-client-rest` provides it. */ count?(collection: string, args: { filter?: QdrantFilter; exact?: boolean; }): Promise<{ count: number; }>; /** * Server-side GROUP BY over one indexed payload key (`POST /collections/{c}/facet`): one * `{ value, count }` per distinct value, narrowed by the same {@link QdrantFilter} the other * calls use — WITHOUT transferring the points. This is the aggregate an inspection panel needs * ("how many chunks per document, per field value"); a scroll-based emulation is O(collection) * round trips per request and stops being a request at corpus scale. * * OPTIONAL, same structural-shim reason as {@link QdrantClientLike.count}; the real * `@qdrant/js-client-rest` (>= 1.18 against a server >= 1.10) provides it. Requires a payload * index on the field — {@link QdrantStore.facetValues} provisions one. */ facet?(collection: string, args: { key: string; limit?: number; filter?: QdrantFilter; exact?: boolean; }): Promise<{ hits: FacetHit[]; }>; /** * Index one payload field for filtering/faceting (`is_tenant` is NOT set — no performance * promise beyond plain indexing). OPTIONAL for the same reason as * {@link QdrantClientLike.facet}; the real client provides it. */ createPayloadIndex?(collection: string, args: { field_name: string; field_schema: { type: 'keyword'; }; wait?: boolean; }): Promise<unknown>; } /** One row of a {@link QdrantClientLike.facet} response: a distinct value and its exact count. */ export interface FacetHit { value: string | number | boolean; count: number; } /** * Translates a metadata filter (`Record<string, unknown>`) into a {@link QdrantFilter}, preserving * token-ACL semantics: a scalar is an exact match; an array is match-any (set membership — it mirrors * pgvector's `jsonb_exists_any`, since Qdrant's `any` tests intersection against a scalar OR an array * field); an empty array denies everything (`any: []` never matches). Keys target `metadata.<k>` (the * payload nests metadata under `metadata`). Multiple keys are ANDed into `must`. Empty or absent → * undefined. */ export declare function buildQdrantFilter(filter: Record<string, unknown> | undefined): QdrantFilter | undefined; /** UUIDv5(NAMESPACE, name) via SHA-1 — deterministic, with no external dependency. */ export declare function chunkIdToPointId(chunkId: string): string; /** * A {@link VectorStore} over Qdrant — the twin of {@link import('./pg-vector-store.js').PgVectorStore} * for managed vector databases. It talks to the client through the structural {@link QdrantClientLike} * (so `@qdrant/js-client-rest` stays an optional peer). One collection, scoped by payload filter. The * chunk id (`${documentId}#<n>`) becomes the point's UUIDv5 and travels back in the payload. */ export declare class QdrantStore implements VectorStore { private readonly client; private readonly collection; private readonly dimension; private readonly metric; private readonly upsertBatchSize; constructor(client: QdrantClientLike, options?: QdrantStoreOptions); /** Idempotent: creates the collection (dimension + metric) if it does not exist yet. */ ensureCollection(): Promise<void>; upsert(records: VectorRecord[]): Promise<void>; search(embedding: number[], options: VectorSearchOptions): Promise<Passage[]>; remove(documentId: string): Promise<void>; /** * {@link VectorStore.updateMetadata} — rewrite every chunk's metadata for one document, touching * neither `text` nor the **vector**. * * Qdrant is the one adapter here that cannot do the merge server-side. `set_payload` merges at the TOP * level of a point's payload, and this store nests the caller's metadata one level down (under * `metadata`, so a filter key is `metadata.<k>` — see {@link buildQdrantFilter}), so a top-level set of * `metadata` replaces the whole object rather than merging into it. The merge therefore happens in JS: * scroll the document's points for their current metadata, apply the shared * {@link import('./vector-store.js').applyMetadataPatch}, write back. Cost: one scroll (payload only, * **`with_vector: false`**, and only the two keys needed) plus one `set_payload` per distinct resulting * metadata — which is ONE for any corpus built by `chunkDocuments`, since that stamps the same document * metadata on every chunk. * * The vector is untouched by construction, not by care: `set_payload` has no vector field, and the * points are never re-`upsert`ed (upsert is the call that would carry a vector). The scroll does not * even fetch the vectors, so this method never has one in hand to write back. * * Point ids are re-derived with {@link chunkIdToPointId} from the chunk id in the payload, so the * scroll needs no point id from the response and this store keeps working against a client whose * `scroll` returns only payloads. * * `wait: true` because the returned count is a claim that the writes landed; Qdrant's default is to * acknowledge before applying. */ updateMetadata(documentId: string, patch: MetadataPatch): Promise<number>; /** * {@link VectorStore.listDocumentIds} — the same scroll {@link QdrantStore.listDocuments} does, but * transferring ONE payload key (`documentId`) instead of the whole payload, so the per-chunk metadata * blob never crosses the wire or gets parsed. Qdrant has no `DISTINCT`, so the collapse still happens * here; what this saves is the payload, which is the bulk of it. Filter comes from the same * {@link buildQdrantFilter} `search` uses. */ listDocumentIds(filter?: Record<string, unknown>): Promise<string[]>; /** * {@link VectorStore.removeWhere} — ONE filtered `delete`, no enumeration: Qdrant deletes by filter * server-side, so this never scrolls the points it is about to remove. * * Filter parity with {@link QdrantStore.search} is by construction — the same * {@link buildQdrantFilter} call produces the predicate for both, so `metadata.<k>` keying, scalar * `match.value`, array `match.any` set-membership and the empty-array deny are the same object here as * there. * * It DOES cost one extra round trip that memory and pgvector do not: Qdrant's delete response carries * no count, so the chunk count comes from a preceding `count` (an aggregate — no payloads, no vectors, * not an enumeration). Consequence worth knowing: the count is taken just before the delete, so under * concurrent writes it is a very slightly stale count of what matched, where memory and pgvector return * an exact count from the deleting statement itself. The delete is filter-scoped either way; only the * reported number can drift. */ removeWhere(filter: Record<string, unknown>): Promise<number>; listDocuments(filter?: Record<string, unknown>): Promise<IndexedDocument[]>; /** * OPTIONAL capability. Server-side GROUP BY + exact count over one payload field — the * aggregate an inspection panel reads per document ("chunks per value of `metadata.numeroProcesso`", * values of the top-level `source`), optionally narrowed by a RAW {@link QdrantFilter} (the * metadata-`Record` filters of {@link VectorStore.search} cannot express the absence a priority * chain needs; raw filters can, via `is_empty`/`must_not`/`should`). * * Qdrant requires a payload index on the facetted field. When the server complains, this method * PROVISIONS a `keyword` index (idempotent, `wait: true`) and retries the facet exactly once — * the first call on a fresh collection pays one index build, every later call is a plain facet. * If the client lacks `facet` (too old) the error says so; if it lacks `createPayloadIndex` the * original server error propagates instead of a silent degrade. * * Counts are the server's FAST default (per-segment indexed statistics). `exact: true` asks for * the expensive recount and can cost tens of seconds per call on a corpus of hundreds of * thousands of points — which for a panel that fans out into a dozen facets is the difference * between a page load and a hang. It stays opt-in for reconciliation-shaped uses. * * `first` is the number of distinct values to return (default 200) — spelled as in * {@link import('../pagination.js').CursorParams} so page sizes are named the same thing across the * ecosystem. It is deliberately NOT a {@link import('../pagination.js').CursorPage}: Qdrant's facet * endpoint returns the top-`first` values in one shot and hands back **no continuation token**, so a * page envelope here would advertise a `nextCursor` that could only ever be `null`. Faceting is a * capped aggregate; {@link QdrantStore.scrollChunks} is the paginated surface. */ facetValues(field: string, options?: { filter?: QdrantFilter; first?: number; exact?: boolean; }): Promise<FacetHit[]>; /** * OPTIONAL capability. Count chunks matching a RAW {@link QdrantFilter} without transferring * any — the aggregate behind a panel row like "chunks with NO key field at all". Requires a * client with `count` (the real one has it; {@link QdrantStore.removeWhere} already depends on * it). The count is the server's fast default; `exact: true` forces the expensive recount * (same trade-off as {@link QdrantStore.facetValues}). */ countChunks(options?: { filter?: QdrantFilter; exact?: boolean; }): Promise<number>; /** * OPTIONAL capability. Enumerate chunk PAYLOADS matching a RAW {@link QdrantFilter} — the * read side of the same server-side narrowing {@link QdrantStore.facetValues} counts. Vectors * never cross the wire; `payloadKeys` narrows the transfer further. * * Unlike {@link VectorStore.listDocuments} this is per CHUNK (not collapsed per document) and * speaks the raw filter language — it exists for inspection/deletion panels, where the caller * knows the exact payload shape it stored. * * **Cursor-paginated**, in the ecosystem's forward-only interface (see * {@link import('../pagination.js').CursorParams}): `{ after, first }` in, a * {@link import('../pagination.js').CursorPage} out. ONE round trip per call — `first` (default * 256) is the Qdrant scroll `limit`, and `nextCursor` wraps Qdrant's own opaque * `next_page_offset`. Qdrant's scroll only ever resumes *forward* from that offset, so * `prevCursor` is always `null` and `hasPrev` always `false`. * * The cursor is OPAQUE — {@link encodeScrollCursor} base64url's the raw offset precisely so * callers cannot come to depend on it being a Qdrant point id. Hand it back verbatim. * * To drain the whole match, loop while `hasNext`: * * ```ts * const all: Record<string, unknown>[] = []; * let after: string | undefined; * do { * const page = await store.scrollChunks({ filter, ...(after ? { after } : {}) }); * all.push(...page.items); * after = page.nextCursor ?? undefined; * } while (after !== undefined); * ``` */ scrollChunks(options?: { filter?: QdrantFilter; payloadKeys?: string[]; after?: string; first?: number; }): Promise<CursorPage<Record<string, unknown>>>; } /** * Wrap Qdrant's `next_page_offset` (a point id — string, number or UUID, the server's business) in an * opaque, URL-safe cursor: base64url of its JSON. Mirrors how `@adonis-agora/filter` encodes its * keyset cursors, and for the same reason — a cursor a caller can read is a cursor a caller will * eventually construct. */ export declare function encodeScrollCursor(offset: unknown): string; /** * Unwrap a cursor produced by {@link encodeScrollCursor}. Throws on a malformed one rather than * silently restarting from the first page: a scroll that quietly rewinds re-emits chunks a * deletion panel already processed, which is worse than a loud failure. */ export declare function decodeScrollCursor(cursor: string): unknown; /** A {@link import('../spi/retriever.js').Retriever} over a {@link QdrantStore}: embeds the query, * then searches Qdrant. Twin of `PgVectorRetriever`. */ export declare class QdrantRetriever extends EmbeddingRetriever { constructor(embedder: EmbeddingProvider, store: QdrantStore); } //# sourceMappingURL=qdrant-store.d.ts.map