# 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]]