# 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 `WHERE` clauses. 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)
```sql
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 `approved` chunks** are loaded (see the corpus tutorial).
- **`date_end`** is 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_order` so 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
1. Embed the question (query prefix/instruction).
2. **Keyword leg:** `websearch_to_tsquery('english', question)` ranked with `ts_rank_cd`, top 20.
3. **Vector leg:** cosine distance `<=>`, top 20.
4. Both legs use the same filter: `corpus = ANY(%s) AND date_end <= cutoff`.
5. **Fuse with RRF** (k = 60). Keep the top 5.
6. **Order by corpus:** A first, then B, then C, as the loop requires.
7. 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.*
```python
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:** `MetadataFilters` with `FilterOperator.LTE` on a date field; `CitationQueryEngine` numbers sources [1], [2] and keeps `source_nodes`. Map those back to our IDs.
- **Haystack:** `PgvectorDocumentStore` (`search_strategy="hnsw"`), `PgvectorEmbeddingRetriever` + `PgvectorKeywordRetriever`, joined by `DocumentJoiner(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
1. Load ten approved Kendall chunks from 1847 and ten from 1857.
2. Set the cutoff to 1847-12-31. Ask "Tell me about Europe." Confirm no 1857 chunk returns.
3. Ask "Bond" and "the comet". Compare keyword-only, vector-only and fused lists.
## 10. Check before connecting to the persona
- [ ] One schema per figure; service role reads one schema only
- [ ] Cutoff and corpus filters are in SQL and tested
- [ ] Fused top 5 returned in under 100 ms on the kiosk PC
- [ ] Every hit carries ID, author, corpus and citation string
- [ ] Embedding model name and corpus version stored with each row
## 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]]