CASE STUDY
BookRAG
A reading assistant that only answers from the part of the book you've read
The Problem
The assistant has to stay behind the reader, with no exceptions.
Say you're three chapters into a novel and ask who a character is. The model has the whole book. If the only thing stopping it from giving away the ending is an instruction in the prompt, a leak is unlikely but possible, and it's most likely to happen when the true answer is the interesting one.
You can't take a spoiler back, and the reader can't tell whether it came from the book or from the app. So I kept the rule out of the prompt and put it in the database query. A chunk past your position never gets selected.
Architecture
Every position is a character offset into the whole book.
Indexing runs in a separate worker process, so the web app keeps responding while a long novel is ingested. Readers see three panes (contents, text and chat). Storage is Postgres with pgvector.
- Decode and split: a TXT upload is decoded as UTF-8 first, then GB18030, and only then as lossy UTF-8, because Chinese novels downloaded from the web are often GBK. Chapters are split first, and each one records its [charStart, charEnd] in the whole book.
- Chunk: each chapter is cut into chunks of at most 800 tokens with a 50-token overlap. A chunk never crosses a chapter boundary.
- Index: Qwen3-Embedding produces 1024-dimension vectors under an HNSW cosine index. For keyword search there is a trigram index and a tsvector column.
- Long-term memory: the worker writes a summary and an entity card for each chapter, in reading order.
- Jobs: a jobs table holds the state machine. The worker claims work with FOR UPDATE SKIP LOCKED and, on startup, re-queues anything a crash left in the running state.
- Answering: reading progress → boundary → the whole read portion if it fits a 60k-token budget, otherwise hybrid retrieval with reranking → a streamed answer with citations.
Engineering Decisions
Six decisions keep the assistant behind the reader.
- Chapters, chunks and reading progress all store character offsets into the whole book. I wrote the chunker so that a chunk's [charStart, charEnd) covers only its own new text and leaves out the overlap. That makes the check charEnd <= reading offset exact.
- Both retrieval paths carry the same WHERE clause on that offset. The query never returns anything past the reader's position, so nothing past it can end up in a prompt.
- After retrieval and before the context is built, a guard checks every piece of evidence again. If a chunk reaches past the boundary, it throws SPOILER_GUARD and the request fails with a 500. I picked a visible failure over an answer with a quiet hole in it.
- Chinese has no spaces between words, and tsvector splits it badly. Keyword search takes the union of three matches (trigram, tsvector and a prefix LIKE) and sorts by similarity; names and places are mostly found by the trigram match. The keyword list and the vector list are merged with reciprocal rank fusion (k = 60), reranked with Qwen3-Reranker and cut to 8. If the reranker fails, the merged order is used.
- If the part you've read fits a 60k-token budget, the model gets all of it and retrieval is skipped, so nothing gets missed. Hybrid retrieval runs only when it doesn't fit. That spends more tokens, and for shorter books I accept the cost.
- Evidence goes to the model numbered [n], and the app saves only the entries the answer cites, with the chapter index and the whole-book offset. When you click a citation, the app subtracts the chapter's start offset and scrolls to that paragraph.
Evaluation
Tests check behaviour. Nothing measures answer quality yet.
npm test runs a node:test suite. It checks that no chunk goes over 800 tokens; that chunk offsets increase, don't overlap, and that charEnd lands exactly at the end of the chunk's own text; that reciprocal rank fusion moves an item found by both searches up; and that the spoiler guard throws when it gets a chunk from later in the book. The tests also include a made-up four-chapter book in which a name first appears in chapter four, so the boundary assumption has a test of its own.
I haven't measured spoiler-violation rate, citation accuracy or retrieval latency, and there's no evaluation set. CI doesn't run the test suite either. It builds the app and checks for an HTTP 200.
Building an evaluation set and a script that measures those three numbers is my next piece of work. The figures I can give today are about size: the production database is 86 MB, a 37-chapter Chinese novel indexes into 300 chunks, and a 17-chapter English novel into 181. Retrieval has only run at that size so far, and I'm not assuming it stays that small.
Lessons Learned
What I took away from building it.
- When a rule has to hold, I now put it where the model can't argue with it: a query, a schema, a type. An instruction in a prompt only makes the bad outcome less likely.
- Agreeing on character offsets early cost almost nothing and saved the most work later. The chunker handles overlap the way it does only because the boundary check depends on it, and once that was settled, the check itself came down to one comparison.
- If the second check had dropped bad chunks and carried on, a bug upstream would have shown up as answers that look fine but are missing something, with clean logs. A 500 gets noticed.
- When the boundary works, nothing happens on screen. Nobody sees the assistant hold back a spoiler, so a later change could break it without anyone noticing. That's why the rule is written down in the docs as well as in the code.
Failure & Trade-offs
Some of these are choices. Others aren't finished.
- Sending the whole read portion makes up for weak retrieval. On a short book, retrieval can miss things while the context window still has room, so spending tokens is the cheaper option. As books get longer that stops being true, which is why the switch depends on size.
- I switched embedding providers once, and the migrations show it. The first migration defaults to BAAI/bge-m3 with bge-reranker-v2-m3 on a .cn endpoint, the next moves to the Qwen3 pair, and the one after that changes the API domain to .com. The code can swap providers because of that switch, and the rule that only models accepting the dimensions parameter receive it came from the same migration.
- I didn't use tiktoken, because it's large and needs the right encoding table for each model. A small estimator counts about one token per Chinese character and one per four ASCII characters. Because it estimates, the budgets leave some room.
- books.content_hash exists, but nothing uses it yet; its comment says it's reserved for dedup. If you upload the same book twice, it gets indexed twice. I haven't decided how dedup should work.
- The format enum includes epub, but there's only a TXT parser, and re-parsing rejects other formats. epub isn't supported yet.
- Guest questions run on my own API key. Two limits keep the cost down: each guest's book is copied row by row from a book I indexed in advance, so a new guest triggers no embedding calls, and each guest can ask 30 questions.