Ask the Book, Not the Internet
A book-agnostic RAG system with hybrid retrieval and citation-level grounding. It answers only from the literal text of a book, quotes it verbatim, cites the chapter and printed page, and refuses when the book doesn't cover the subject.
- Project type
- Full-stack AI application (RAG over a closed text corpus)
- Role
- Solo developer and architect — product, data pipeline, retrieval, backend, frontend, evaluation
- Created
- April 2026
- Status
- Working system; two books ingested, end-to-end chat and reader in use
- Corpus used
- Alcoholics Anonymous and Twelve Steps and Twelve Traditions
- Codebase
- ~10,800 lines of Python across 30 modules, 380 automated tests
// 01 — The problem
A fabricated answer is indistinguishable from a good one.
A large language model already knows these books, or rather, it knows what the internet says about them. Ask it a question and it produces a fluent, confident, well-structured answer assembled from forum threads, blog posts, paraphrase and folklore. Sometimes that answer is right. Often it is subtly wrong: it attributes a phrase to a chapter that doesn't contain it, states a rule the text never states, or translates a decades-old vocabulary into modern language and quietly changes the meaning.
Nobody can audit a fabricated answer by reading it. The reader has no way to tell "the book says this" from "the internet thinks the book says this."
The problem was sharpened by four properties of the corpus:
| Property | Consequence |
|---|---|
| Huge readership, thin accurate secondary literature | Pretraining data is dominated by paraphrase and discussion, not the source text |
| A fixed, finite, authoritative text | There is a correct answer and it can be cited to a page, so an uncited answer is unacceptable |
| Period vocabulary (1939 / 1953) | The reader's word is frequently not the book's word; a naive search hunts for a term the corpus does not contain |
| Distributed as encrypted, typeset PDFs | No clean machine-readable text exists; it has to be reconstructed |
So the requirement wasn't "build a chatbot about these books." It was: build a system that structurally cannot answer from pretraining, and that proves what it says by quoting it.
// 02 — My responsibilities
- → Defined the product: a question-answering chat plus a page-faithful reading interface with select-to-understand study tools.
- → Built the PDF extraction pipeline from scratch, layout-mode reading, page furniture removal, drop-cap repair, paragraph reflow, de-hyphenation, Unicode normalisation, against two differently typeset book sets.
- → Designed the data model separating three kinds of text with three different authorities: source text (never edited), editorial layer (ours, labelled), and internal ingest notes (never reach the model).
- → Designed and implemented the chunking strategy, including block boundaries and whole-paragraph overlap.
- → Built the embedding pass with model-identity and vector-width enforcement, plus staleness detection via content checksums.
- → Designed and implemented hybrid retrieval: dense vector search, a hand-rolled IDF-weighted lexical arm with phrase support, reciprocal rank fusion, and a rare-term override above fusion.
- → Added the second-stage cross-encoder reranker with neighbour-window widening and de-duplication of chunker-introduced overlap.
- → Built the editorial vocabulary layer — glossary terms, modern-word aliases, chunk↔term joins — and query expansion that fires before retrieval.
- → Built the grounding verifier: a deterministic, non-LLM check that every quoted sentence exists in the passage it was attributed to, tolerant of typographic differences but not of invention.
- → Built the answering pipeline: intent triage, agentic multi-search loop, schema-constrained draft, verification, one corrective retry, refusal on second failure, and a computed confidence score.
- → Built the chat application, persistent conversations, auto-titling, source panels, and the paginated reader with four-colour highlights and notes.
- → Wrote 380 tests covering extraction, chunking, retrieval ranking, reranking, grounding normalisation, term resolution, agents and views.
- → Containerised the whole thing and documented every non-obvious decision inline, with the measurement or failing case that motivated it.
// 03 — The approach
Seven stages. Each one exists to prevent a specific, observed failure.
Most of the quality comes from what the model is given. So most of the engineering went into ingestion and retrieval, with the LLM as the final, narrowest step.
-
01
Ingest — parse and clean the source text.
Read each PDF in layout mode, strip typesetter marks, running heads and folios, repair drop caps that extract out of order, reflow lines into real paragraphs using relative indentation, and normalise Unicode, producing clean prose that still knows which printed page it came from.
-
02
Structure — split into blocks, then chunk.
Group paragraphs into blocks (a run of text under one heading), then pack paragraphs into chunks up to a 750-token ceiling that never cross a block boundary, overlapping each chunk with its predecessor by whole paragraphs so no passage is missing from both sides of a split.
-
03
Embed and store — vectors beside the text, not somewhere else.
Embed each chunk under a short provenance header (book, section, story, page) so structural questions can match too, and store the vector in the same Postgres row as the text, stamped with the model name and a checksum of exactly what was embedded.
-
04
Hybrid retrieval — search by meaning and by wording, then fuse.
Run a cosine-distance vector search and an IDF-weighted full-text search in parallel, fuse their rankings with reciprocal rank fusion, and let a decisively rare term from the question override the fused consensus.
-
05
Rerank — widen each hit, then read it against the question.
Expand each surviving candidate into a window of its neighbouring chunks within the same section, strip the duplicated overlap paragraphs, and score the windows with a cross-encoder that actually reads the question and the passage together.
-
06
Query — triage, translate, and let the model search again.
Classify the question (out-of-scope, crisis, meta, definition, ordinary), map the reader's modern words onto the book's own terms from a human-written glossary, then run an agent loop that can search the passages, look up terms, and consult editorial notes, rephrasing and searching again when the first pass comes back thin.
-
07
Answer and verify — ground it, or refuse.
Require a structured answer with explicit (chunk_id, quote) citations, check every quote mechanically against the passage it names, send one corrective retry naming the exact failure, refuse rather than publish on a second failure, and report a confidence score computed from retrieval distance and verified citations rather than asked of the model.
31 encrypted PDFs
│
▼
[1] extract ──► paragraphs + printed page labels
│
▼
[2] blocks ──► chunks (≤750 tok, whole-paragraph overlap, never cross a heading)
│
▼
[3] embed ──► halfvec(3072) + generated tsvector, both indexed, same row
│
▼
question ──► [6] intent triage ──► glossary expansion
│
▼
[4] dense search ┐
lexical search├─► RRF fusion ──► rare-term override ──► 15 candidates
│ ┘
▼
[5] widen to neighbour windows ──► cross-encoder ──► top 4 windows
│
▼
[6] agent loop: search / define / annotate, up to ~5 tool calls
│
▼
[7] structured draft ──► quote verification ──► retry once ──► answer or refusal
What makes it book-agnostic
| Per-book | Shared by every book |
|---|---|
| A parser profile (how that publisher's PDFs are laid out) | Chunking, embedding, indexing |
| A reading-layout profile for the page-faithful reader | Hybrid retrieval, fusion, reranking |
| An optional glossary of period or domain vocabulary | Grounding, confidence, refusal |
| Section ordering and chapter numbering rules | Chat, agent tools, reader, highlights |
Two books with completely different typesetting are already running through the same stack. Chat searches the whole shelf by default and retrieves the best passages from each book rather than taking a single global top-K, so one book's phrasing can't crowd the other out of the answer.
// 04 — Technical insights
Chunking: the paragraph is the wrong unit, and so is the chapter
The median paragraph in the main corpus is 91 tokens. Embed that and the vector describes its wording, not its idea. Embed a whole chapter and the vector sits close to everything and decisive about nothing. Several paragraphs, landing most chunks in a 550–750 token band, is where retrieval actually works.
Chunks never cross a heading. Several source files each contain 10–18 separate personal accounts. Chunking by file would have spliced the end of one person's story onto the start of another's. The file boundary is not the topic boundary; the heading is.
Overlap is whole paragraphs, never a token slice. A passage split across a boundary is otherwise retrievable from neither side. Carrying trailing paragraphs forward means no chunk ever opens mid-sentence, and because the duplicated region is always byte-identical, the reranker can later strip it with exact equality instead of a fuzzy match.
| Chunking parameter | Value | Reasoning |
|---|---|---|
| Token ceiling | 750 | A ceiling, not a target — short blocks stay short |
| Overlap budget | 120 tokens | Enough to carry an interrupted thought, not enough to win every search twice |
| Boundary | Heading / block | Topic boundary, not file boundary |
| Label absorption | ≤32 tokens | Prevents "Chapter 1" becoming its own three-token chunk, embedding and index entry |
Result over the ingested corpus: 557 chunks, average 553 tokens, from ~308,000 tokens of source text across 60 sections and 764 printed pages.
Why hybrid search: the two arms fail in opposite directions
This is the single most important finding of the project, and it came from a real miss. One book contains the simile "We are like the passengers of a great liner the moment after rescue from shipwreck." Asking about "the great liner" did not return that passage in the top 50 vector results. Nothing errored. The system simply said the book doesn't mention it, confidently, and falsely.
The cause is structural: one vector represents ~700 tokens, so the chunk's vector describes its subject and a vivid phrase mentioned once contributes almost nothing to it. Dense retrieval averages away exactly the rare wording that makes a passage findable.
| Arm | Finds | Misses |
|---|---|---|
| Dense (cosine over embeddings) | Meaning, paraphrase, synonymy | Rare wording, single-mention phrases, exact quotes |
| Lexical (IDF-weighted full text) | Wording, rare terms, exact phrases | Meaning, paraphrase, anything not literally present |
Three refinements turned "run both" into something that actually ranks well: a hand-rolled IDF score instead of Postgres' ts_rank (which has no notion of inverse document frequency), phrase-level scoring so stopword phrases like "one day at a time" become searchable addresses, and treating a decisively rare-term hit as an address rather than a vote, since plain reciprocal rank fusion can rank a chunk that's mediocre in both arms above the only chunk in the book containing the word that was actually asked about.
Keep it in Postgres
There's no separate vector database, no search service, and no external index. Source text, embeddings, full-text vectors, glossary, trigram indexes, chat history and the audit trail are all in one PostgreSQL instance.
| Benefit | Detail |
|---|---|
| One query planner | The lexical arm filters, joins and scopes by book in the same statement as everything else — no cross-service fan-out |
| Transactional ingest | Chunks and their provenance commit or roll back together; embeddings land in a later pass so no network call sits inside a transaction |
| Both arms indexed | HNSW (halfvec_cosine_ops, m=16, ef_construction=64) for dense; GIN over a persisted generated tsvector for lexical |
| Nothing to keep in sync | The tsvector is a generated column — it cannot drift from the text and needed no backfill |
| Cheap editorial layer | The glossary, aliases and annotations are ordinary rows with ordinary joins and a free admin UI |
The measurement that justified the design: computing to_tsvector per query was a sequential scan at 198ms against 557 chunks, so an ordinary eight-word question spent roughly 1.6 seconds just deciding which chunks held its words, the arm that exists to be the cheap one cost more than the embedding call it was helping. Persisting the vector in a generated column and indexing it with GIN turned that into an index lookup, ~30× faster immediately and asymptotically better as the corpus grows.
Grounding is a string problem, not a model problem
Verifying a quote doesn't need an LLM. The passage is on disk, the quote is a string, and whether one contains the other is a fact. The real difficulty is that "contains" isn't string equality when the text was typeset decades ago and the model types ASCII.
| Difference | Observed in corpus | Would reject |
|---|---|---|
| Curly apostrophes | 348 of 382 chunks use U+2019; none use ' | Nine honest quotes in ten |
| Em-dashes | 240 chunks contain one | Any quote spanning a dash |
| Line breaks | Every chunk contains newlines | Any quote spanning a line |
| Elision | Models write "the guide should listen... and help" honestly | Any abbreviated quote |
Both sides are folded through one normaliser, quote marks, the full dash range, whitespace, and both the model's "…" and the book's own ". . ." collapsed to a single sentinel, so a typographically different but honest quote passes, and an invented sentence under a correct citation still fails. That distinction is the one the whole system is built to make.
// 05 — Results
It answers from the text, or it declines, and the reader can check it.
| Measure | Result |
|---|---|
| Fabricated quotes reaching the reader | None — every quote is verified against its cited passage before display, and an unverifiable draft is retried once then refused |
| Fabricated citations | Structurally impossible — a cited passage id that no tool call returned is detected as invention |
| The great-liner class of miss (rare wording, vector-invisible) | Fixed — the decisive chunk now ranks first instead of being absent from the top 50 |
| The "one day at a time" class (no rare word at all) | Fixed by phrase-level IDF |
| Off-topic refusal | Reliable and cheap — out-of-scope questions are refused before any embedding or answering call is made |
| Lexical retrieval latency | ~1.6s → index lookup (≈30× faster at current corpus size, and it improves with scale) |
| Citation precision | Chapter, story title and printed folio, including roman-numeral front matter |
| Test suite | 380 tests across extraction, chunking, retrieval ranking, reranking, grounding, terms, agents and views, all passing (16 skipped, provider-dependent) |
| Books supported | 2, with different publishers and typesetting, through one shared pipeline |
| Added by the reranker | Each answer now reads 4 widened windows (up to ~12 chunks of continuous prose) instead of 5 isolated fragments |
Qualitatively, the behaviours that distinguish it from a general chatbot asked the same questions:
- It answers in the book's vocabulary and says so when a gloss is its own.
- It says "the sources do not cover this" without hedging or improvising.
- It finds exact phrases, including ones that appear exactly once in 308,000 tokens.
- It translates a modern reader's word onto the book's word before searching, from a human-written mapping of 229 terms and 130 aliases rather than from a model's guess.
- Its confidence score is derived from retrieval distance and verified citations, so it goes down when retrieval was weak, the opposite of what a model reports when you ask it how sure it is.
- Degradation is graceful: without a reranking key, retrieval returns its fused ranking unchanged. Degraded, not broken.
Calibration evidence for the refusal threshold
| Question type | Closest cosine distance |
|---|---|
| Paraphrase of a real topic | ~0.34 |
| Short but genuine question | ~0.69 |
| Deliberately unrelated ("how do I bake sourdough bread") | ~0.89 |
| Refusal floor | 0.85, with a rare-term exemption |
// 06 — What I learned
Retrieval is the product. Prompting is a rounding error.
Retrieval is the product. Prompting is a rounding error.
Every meaningful quality jump came from the retrieval layer: hybrid search, IDF weighting, phrase matching, the rare-term override, window widening, the cross-encoder. Prompt changes moved tone. They never once rescued a bad set of passages, and nothing downstream of retrieval can.
Vector search fails silently, and silence is the dangerous failure.
A wrong vector width errors loudly at the column. A right width from the wrong model inserts happily and returns confident nonsense forever. A missed passage produces "the book does not mention that," which sounds authoritative and is false. I designed against silence specifically: stamping every vector with its model and a content checksum, refusing batches of the wrong width, and treating "found nothing" as a claim that has to be earned.
Two retrievers are not redundancy; they are coverage.
I expected the lexical arm to be a cheap fallback. It is not — it is the only arm that can find a phrase mentioned once, and the dense arm is the only one that can find an idea the reader paraphrased. They miss in opposite directions, which is the entire argument for having both.
Fusion can be worse than either input.
RRF quietly assumes its inputs are comparable rankings. When one arm produces an absolute signal, "this is the only chunk in the book with this word," flattening it into a rank throws away the strongest evidence available. Knowing when not to normalise two scores onto a common scale mattered more than any weighting I tried.
Thresholds cannot distinguish a typo from a corruption.
Measured against the glossary: sponser → sponsor scores 0.455, while good → make good and will → self will both score 0.500. The correction I wanted scored lower than the corruptions I had to avoid. The fix was not a better threshold but a different question: "is this word absent from the book at all?"
Verification belongs outside the model.
Asking a model whether it grounded its answer is asking the defendant for a verdict. Checking a string against a passage is deterministic, instant, free, and it caught the failure that every prompt-level instruction had missed: a fluent, invented sentence under a correct citation.
Labelling authority in the prompt is load-bearing.
Four kinds of text reach the model: the book, our definitions, our claim that the reader's word maps to the book's word, and our commentary. Merging any of the last three into the first produces an answer that cannot tell "the book says" from "we think this means."
Document the failing case, not the fix.
Nearly every non-obvious constant in this codebase sits next to the measurement or the specific broken query that set it. Months later, that is the difference between a number I can revisit and a number I am afraid to touch.
Domain structure beats model scale.
A heading boundary, a whole-paragraph overlap, a page label, a 229-term human-written glossary. None of it is machine learning, all of it is cheap, and collectively it outperformed every attempt to solve the same problems with a bigger model or a longer prompt.
// Technologies used
| Layer | Technology | Why it was chosen |
|---|---|---|
| Language / framework | Python 3.12, Django 5.1 | ORM, admin, migrations and an editorial back office for free |
| Database | PostgreSQL 17 | One store for source text, vectors, full-text index, chat history and the editorial layer |
| Vector search | pgvector — HNSW index, halfvec(3072), cosine | HNSW refuses columns over 2000 dims; halfvec indexes 3072 dims at the byte cost of vector(1536) |
| Lexical search | Postgres FTS — stored generated tsvector column + GIN index | Keeps the second retrieval arm in the same query planner as the first |
| Fuzzy matching | pg_trgm (trigram similarity + GIN) | Indexed typo tolerance against the glossary, no model call |
| Embeddings | OpenAI text-embedding-3-large, 3072 dimensions | Highest-fidelity option; width pinned explicitly so it can never silently drift |
| Answering model | OpenAI gpt-4.1-mini, temperature=0 | Reading comprehension over supplied excerpts — creativity is a liability here |
| Reranking | Voyage AI rerank-2.5 cross-encoder | The one component that reads question and passage together |
| Orchestration | LangChain + LangGraph create_agent, Pydantic structured output | Multi-step tool loop with a schema-enforced final answer |
| Frontend | Vite 7, Tailwind CSS 4, vanilla JS modules | Two small pages; a SPA framework would have been overhead |
| Infra / tooling | Docker Compose, uv, ruff, Django test runner, Make | One-command local environment, locked dependencies |
// Appendix — corpus and pipeline at a glance
| Metric | Value |
|---|---|
| Source documents | 31 encrypted, typeset PDFs across 2 books |
| Sections | 60 |
| Printed pages reconstructed | 764 |
| Chunks | 557 (avg 553 tokens, max 1,071) |
| Source text indexed | ~308,000 tokens |
| Embedding dimensions | 3,072 (halfvec, HNSW, cosine) |
| Glossary terms / aliases | 229 / 130 |
| Candidates retrieved per query | 20 per arm → 15 fused candidates → 4 reranked windows |
| Agent tool calls per answer | Up to ~5, with a hard super-step backstop |
| Grounded attempts before refusal | 2 |
| Tests | 380, all passing (16 skipped) |