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

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);

4. Chunking

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.

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

7. The B attribution rule

8. Using a framework instead

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

Sources