@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
TypeScript
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