Tutorial — Retrieval with Citations
Augmented Traveler local tutorial. Back to AI Mind — Tutorials · overview: Tutorial — How the Mind Works · where chunks come from: Tutorial — Building a Figure's Corpus
How the mind finds passages and returns citation IDs. We use Postgres + pgvector, hybrid search (full-text + vector), reciprocal rank fusion (RRF), and hard filters for corpus and date ≤ cutoff. One index per figure.
Draft. Written from the pgvector, PostgreSQL, Supabase, LlamaIndex and Haystack docs. The code is a skeleton. The AI conversation engineer tests it on the first approved Mitchell chunks and corrects this page.
1. Why this design
- Hybrid search. Vector search finds meaning ("frightened" ≈ "afraid"). Full-text search finds exact names and terms ("Bond", "Danish medal"). Old spelling and proper names need both.
- RRF merges two ranked lists using ranks only, so we do not have to compare unlike scores. The original paper uses score = Σ 1/(k + rank) with k = 60.
- Filters in SQL, not in the prompt. The date cutoff and corpus list are
WHEREclauses. The model never sees a later passage. - One database schema per figure.
mitchell.chunks,boston.chunks. The service connects with a role that can read one schema only.
2. Tools and versions
| Part | Choice | Notes |
|---|---|---|
| Database | PostgreSQL 18 | "Current" in the docs on 20 Sept 2026 |
| Vector extension | pgvector 0.8.x | HNSW indexes; vector indexes up to 2,000 dimensions, halfvec up to 4,000 |
| Embeddings (local) | Qwen3-Embedding-0.6B (Apache 2.0, up to 1,024 dims) or nomic-embed-text-v1.5 (Apache 2.0, 64–768 dims) | Both run in Hugging Face Text Embeddings Inference; RTX 50-series images are marked experimental |
| Alternative | BAAI bge-m3 (MIT, 1,024 dims, dense + sparse) | Not in the TEI supported list; run with its own library |
| Frameworks (optional) | LlamaIndex, Haystack | Both have pgvector stores, metadata filters and citation helpers |
Model prefixes matter. nomic-embed-text needs search_query: and search_document: prefixes. Qwen3-Embedding takes an instruction on the query side. Store the model name and version with each vector.
3. Schema (sketch)
CREATE EXTENSION IF NOT EXISTS vector;
CREATE SCHEMA mitchell;
CREATE TABLE mitchell.chunks (
id text PRIMARY KEY, -- MM-A-1847-10-01-a
corpus char(1) NOT NULL CHECK (corpus IN ('A','B','C')),
date_start date NOT NULL,
date_end date NOT NULL, -- = date_start unless a range
author text NOT NULL,
source_edition text NOT NULL,
page_or_folio text,
shelf_mark text,
citation text NOT NULL, -- caption-ready string
body text NOT NULL, -- clean text for the model
tsv tsvector GENERATED ALWAYS AS (to_tsvector('english', body)) STORED,
embedding vector(1024) NOT NULL,
embed_model text NOT NULL,
corpus_version text NOT NULL
);
CREATE INDEX ON mitchell.chunks USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);
CREATE INDEX ON mitchell.chunks USING gin (tsv);
CREATE INDEX ON mitchell.chunks (corpus, date_end);
- Only
approvedchunks are loaded (see the corpus tutorial). date_endis used for the cutoff, so a range that ends after the cutoff is excluded.- Filtered HNSW queries can return too few rows. Set
hnsw.iterative_scan = strict_orderso pgvector scans further.
4. Chunking
- Chunk by dated entry, not by fixed size. A diary entry or a letter is the unit historians cite.
- Split long entries at paragraph breaks. General guidance suggests about 200–500 tokens per chunk for general text; test on our data.
- No overlap across different dates. Overlap within one long entry is fine.
- Tables and almanac columns stay out. Describe them in prose in C if needed (Mitchell Corpus Inventory).
5. The query, step by step
- Embed the question (query prefix/instruction).
- Keyword leg:
websearch_to_tsquery('english', question)ranked withts_rank_cd, top 20. - Vector leg: cosine distance
<=>, top 20. - Both legs use the same filter:
corpus = ANY(%s) AND date_end <= cutoff. - Fuse with RRF (k = 60). Keep the top 5.
- Order by corpus: A first, then B, then C, as the loop requires.
- Return passages with IDs and citation strings. The model must cite IDs; the output check drops any ID not in this list (Tutorial — Policy Layer with Guardrails).
6. Python skeleton
Skeleton — test before use.
import psycopg
K_RRF = 60
LEG_SQL = {
"kw": """SELECT id FROM mitchell.chunks
WHERE corpus = ANY(%(c)s) AND date_end <= %(cut)s
AND tsv @@ websearch_to_tsquery('english', %(q)s)
ORDER BY ts_rank_cd(tsv, websearch_to_tsquery('english', %(q)s)) DESC
LIMIT 20""",
"vec": """SELECT id FROM mitchell.chunks
WHERE corpus = ANY(%(c)s) AND date_end <= %(cut)s
ORDER BY embedding <=> %(v)s::vector
LIMIT 20""",
}
def rrf(rankings, k=K_RRF):
scores = {}
for ranked_ids in rankings:
for rank, cid in enumerate(ranked_ids, start=1):
scores[cid] = scores.get(cid, 0.0) + 1.0 / (k + rank)
return sorted(scores, key=scores.get, reverse=True)
def retrieve(conn, question, qvec, cutoff, corpora=("A", "B", "C"), top=5):
params = {"q": question, "v": str(qvec), "cut": cutoff, "c": list(corpora)}
with conn.cursor() as cur:
cur.execute("SET hnsw.iterative_scan = strict_order")
legs = []
for sql in LEG_SQL.values():
cur.execute(sql, params)
legs.append([row[0] for row in cur.fetchall()])
ids = rrf(legs)[:top]
if not ids:
return []
cur.execute(
"SELECT id, corpus, author, citation, body FROM mitchell.chunks WHERE id = ANY(%s)",
(ids,),
)
rows = {r[0]: r for r in cur.fetchall()}
hits = [rows[i] for i in ids]
return sorted(hits, key=lambda r: r[1]) # A before B before C
- The schema name is fixed per service instance. Do not build it from visitor input.
- An empty result is normal. The persona then says "I did not write about that."
7. The B attribution rule
- Every B passage is passed to the model inside a tag that names its author, e.g.
<account author="Phebe Mitchell Kendall" id="MM-B-…">. - The persona prompt says B is spoken as "My sister wrote…", never as "I".
- The output check fails an answer that uses a B ID in a first-person sentence without an attribution phrase. Canned fallback plays instead.
8. Using a framework instead
- LlamaIndex:
MetadataFilterswithFilterOperator.LTEon a date field;CitationQueryEnginenumbers sources [1], [2] and keepssource_nodes. Map those back to our IDs. - Haystack:
PgvectorDocumentStore(search_strategy="hnsw"),PgvectorEmbeddingRetriever+PgvectorKeywordRetriever, joined byDocumentJoiner(join_mode="reciprocal_rank_fusion"). Filters use{"field": "meta.date", "operator": "<=", "value": …}. - Either way, keep the filter and the ID check in our code.
9. Practice exercise
- Load ten approved Kendall chunks from 1847 and ten from 1857.
- Set the cutoff to 1847-12-31. Ask "Tell me about Europe." Confirm no 1857 chunk returns.
- Ask "Bond" and "the comet". Compare keyword-only, vector-only and fused lists.
10. Check before connecting to the persona
Sources
- pgvector, README (HNSW, filtering, hybrid search): https://github.com/pgvector/pgvector
- PostgreSQL 18, Controlling Text Search: https://www.postgresql.org/docs/current/textsearch-controls.html
- Cormack, Clarke, Büttcher, Reciprocal Rank Fusion (SIGIR 2009): https://cormack.uwaterloo.ca/cormacksigir09-rrf.pdf
- Supabase, Hybrid search: https://supabase.com/docs/guides/ai/hybrid-search
- Databricks, Chunking strategies for RAG: https://community.databricks.com/t5/technical-blog/the-ultimate-guide-to-chunking-strategies-for-rag-applications/ba-p/113089
- LlamaIndex, CitationQueryEngine: https://developers.llamaindex.ai/python/examples/query_engine/citation_query_engine/
- LlamaIndex, vector store filters API: https://developers.llamaindex.ai/python/framework-api-reference/storage/vector_store/
- Haystack, pgvector integration: https://haystack.deepset.ai/integrations/pgvector-documentstore
- Haystack, DocumentJoiner: https://docs.haystack.deepset.ai/docs/documentjoiner
- Haystack, Metadata filtering: https://docs.haystack.deepset.ai/docs/metadata-filtering
- Qwen, Qwen3-Embedding-0.6B: https://huggingface.co/Qwen/Qwen3-Embedding-0.6B
- Nomic, nomic-embed-text-v1.5: https://huggingface.co/nomic-ai/nomic-embed-text-v1.5
- BAAI, bge-m3: https://huggingface.co/BAAI/bge-m3
- Hugging Face, Text Embeddings Inference supported models: https://huggingface.co/docs/text-embeddings-inference/en/supported_models
- Full list: Sources — 2026-09-20