pgvector store
The production RAG store — cosine/L2/inner similarity over a vector(N) column on Postgres + pgvector, through @adonisjs/lucid, with a published migration and safe identifier handling.
retrievers.pgvector is the production retriever: it stores chunk embeddings in a vector(N) column and ranks them with pgvector's distance operators over @adonisjs/lucid. Postgres + the pgvector extension only. The Lucid peer is imported lazily inside the factory thunk, so it stays optional.
Run the migration
node ace configure @adonis-agora/agent publishes a create_agent_rag_chunks migration alongside the core tables. Apply it (Postgres):
node ace migration:runIt provisions the vector extension, the chunk table (default agent_rag_chunks), and the metric's index opclass.
Wire the retriever
import { defineConfig, retrievers } from '@adonis-agora/agent'
export default defineConfig({
model: () => aiSdkModel(openai('gpt-4o-mini')),
retriever: retrievers.pgvector({
embedder: myEmbedder,
dimension: 1536,
}),
retrievalTopK: 5,
})Configuration
| Field | Default | Meaning |
|---|---|---|
embedder | — | The EmbeddingProvider (query + ingestion), or a lazy factory. Required. |
db | resolved lazily | A structural Lucid DB handle (bring-your-own). Omit to resolve @adonisjs/lucid's default db service inside the thunk. |
connection | default connection | Lucid connection name (used only when db is omitted). |
table | agent_rag_chunks | Chunk table name. Validated against a strict identifier regex. |
dimension | 1536 | Embedding width — must match the model (e.g. 1536 for text-embedding-3-small). A list ([768, 1536]) makes a mixed-dimension table; see below. |
metric | cosine | Similarity metric / distance operator (cosine, l2, inner). |
columns | mirror defaults | Override the physical column names (each validated). |
nullableEmbeddings | false | Allow chunks without a vector: embedding: [] is stored as NULL and skipped by vector search (full-text still finds it). |
iterativeScan | off | 'strict_order' / 'relaxed_order': pgvector ≥ 0.8 keeps scanning the HNSW index until topK rows pass the filter. SET LOCAL in a transaction; skipped on older pgvector. |
efSearch | pgvector's (40) | hnsw.ef_search for searches. |
upsertBatchSize | 100 | Rows per multi-row INSERT … ON CONFLICT. Duplicate ids in one call keep the last. |
fullText | off | Add a Postgres full-text leg and return a HybridRetriever (see below). |
ensureSchema | false | Provision the extension/table/index at boot (idempotent DDL). Handy for tests; production should run the migration. |
documents | — | Documents to chunk → embed → upsert at boot. |
chunkSize / overlap | 800 / 100 | Chunking parameters. |
Identifiers are validated, embeddings are bound
The store composes raw SQL for pgvector's operators (Lucid has no native vector type), but every table/column name is validated against a strict identifier regex before it is ever spliced in, and the query embedding is passed as a ?::vector binding — never string-concatenated. Scores are normalized so a higher score always means more relevant, across all three metrics.
Hybrid search with Postgres full-text
fullText turns the store into a PgLexicalVectorStore and the retriever into a HybridRetriever
that fuses vector search with full-text search (reciprocal rank fusion) over the same rows:
retriever: retrievers.pgvector({
embedder: () => aiSdkEmbedding(openai.embedding('text-embedding-3-small')),
fullText: { config: 'simple' }, // or { column: 'tsv' } for a tsvector column you maintain
}),A question is searched by its meaningful terms: its stop words are dropped (English, Portuguese and
Spanish lists by default, only the language(s) the question is written in; stopWords to change, or
false to keep every word) and rows holding any remaining term are ranked by the sum of the matched
terms' IDF (BM25 without term frequencies), ts_rank_cd breaking ties, so three rare terms beat a row
full of "the/of/which". Quoted phrases and -word go through websearch_to_tsquery as written
first; anyTermFallback: false keeps only that (every word must appear). Without column, the schema adds a GIN index over
to_tsvector(config, text) — on a populated table, create it CONCURRENTLY in a migration
(store.schemaStatements() prints it). Programmatically: new PgLexicalVectorStore(db, {...}) with
new HybridRetriever([new EmbeddingRetriever(embedder, store), new LexicalRetriever(store)]).
minScore does not apply to a fused result — a rank is not a similarity.
Several embedding models in one table
dimension: [768, 1536] makes the column an untyped vector with one partial HNSW index per width
(USING hnsw ((embedding::vector(768)) …) WHERE vector_dims(embedding) = 768), and a search only
compares vectors as wide as the query. Store the model in chunk metadata and filter on it when two
models share a width.
Scores and ranking
Each metric maps to a pgvector operator (<=>, <->, <#>) and an index opclass. The store rewrites the raw distance into a monotonically-increasing relevance score on each returned Passage, so downstream code (inject-mode context, a reranker, your UI) can treat score uniformly regardless of the configured metric.
For the in-memory alternative and the retrieval SPIs, see RAG & retrieval.
RAG & retrieval
Ground the agent in your own corpus — the Retriever, EmbeddingProvider, and Reranker SPIs, the memory and pgvector retrievers, and always-on "inject" retrieval that folds cited passages into the system prompt.
Qdrant store
A managed vector database as the RAG backend — collection provisioning, payload-filter ACLs, batched upserts, and the metric handling that keeps scores comparable with pgvector.