← All projects

Project 2 of 5

BookMind

RAG over a book where every answer sentence is checked against its evidence.

What I didBuilt end to end: retrieval, answers, the answer checker and the evaluations.

PythonFastAPIStreamlitBM25nomic-embed-textOllamaClaude APIDocker

Every number on this page links to the file it came from, pinned to bookmind @ bb8f37f.

Headline results

95%Recall@5, hybrid retrievalthe right chapter is in the top 5 passages for 36 of 38 answerable questionsSee where it comes from ↗
0.796MRR, hybrid retrievalBM25 alone scores 0.703, embeddings alone 0.763See where it comes from ↗
0 of 29bad sentences marked supportedwrong-evidence and invented sentences, none got a green checkSee where it comes from ↗
326chunks, each citeable by chapterbuilt from one EPUB, with a hand-written BM25 indexSee where it comes from ↗

The short version

The problem

Asking a chatbot about a book gives you fluent answers you cannot trust. I wanted answers grounded in cited passages, and a way to measure how often the system makes things up.

What I built

Retrieval first, from scratch. An EPUB becomes 326 citeable chunks, BM25 is written by hand, embeddings come from Ollama, and the two rankings are merged by a fusion step that fits in ten lines. On top of that sit answers that cite chapters, a checker that matches each answer sentence to its evidence and labels it supported, weak or unsupported, and a quiz mode that writes study questions from a chapter’s own sentences. It runs on a free local model or on Claude.

How I tested it

A test set of 42 questions: 38 that the book answers, each labelled with the chapter that answers it, and 4 off-topic ones the system must refuse. I wrote 26 of the questions after the first version and committed them before measuring the new retrievers, so they could not be tuned to the new code.

What I would do next

Grow the set past 100 questions with passage-level labels, so the hybrid gain can be confirmed or ruled out. Then run the answer-quality metrics (citation accuracy, refusal correctness, hallucination against a closed-book baseline) that the README still marks as needing a model run.

How it works

Pick a request and watch it go through the system. The red ones are the requests I use to attack it. They end where the system is supposed to stop them.

ingesttop 5no answerQuestionEPUBone bookHybrid searchBM25 plus embeddings, RRFChunks326, cited by chapterGrounded answerClaude or local OllamaSentence checksupported, weak, unsupportedCited answerevery sentence labelledRefusalreply: not in the bookFlaggedunsupported sentence marked

Passages are retrieved, the model answers with chapter citations, and every answer sentence is labelled against its evidence.

  1. Question
  2. Hybrid search
  3. Grounded answer
  4. Sentence check
  5. Cited answer

Two flows share one index. A question is retrieved, answered and checked. Two of the three traces end in a refusal or a flagged sentence on purpose.

Test results

9 claims, each with the bar I held it to and what I measured. 1 failed and 3 are unclear. They stay in, because a list where everything passes tells you nothing.

Show
ResultClaimExpectedWhat I measuredSource
PASSHybrid retrieval finds the right chapterRecall@5 above either retriever aloneRecall@5 95%, against 92% for BM25 and 89% for embeddings (38 questions)README.md ↗
@ bb8f37f
PASSHybrid never loses a question BM25 foundn/ait found one more, ranked the right chapter higher on 13 questions and lower on 7README.md ↗
@ bb8f37f
UNCLEARHybrid ranks better than BM25 by more than noiseHybrid is the default because it was best or joint best on every slice and never worse on recall, not because the gain is proven. 38 questions cannot rule out luck.an MRR gain whose interval excludes zeroMRR +0.093, 95% interval -0.03 to +0.22, which includes zeroREADME.md ↗
@ bb8f37f
PASSThe sentence checker never gives a green check to a bad sentenceSimilarity is not meaning, so a sentence that reverses a passage can still look close, and the calibration set is small and from one model.n/a0 of 29 wrong-evidence or invented sentences were marked supportedREADME.md ↗
@ bb8f37f
PASSEvery citation points at a real chapterIt checks that a cited chapter exists. Whether the passage supports the sentence is the sentence checker's job.n/achecked deterministically, even without a model or an API keyREADME.md ↗
@ bb8f37f
PASSIt runs without a paid APIIt works, slowly. The 0.5b model answered in 17 s but copied the passage instead of answering.n/aqwen2.5:7b on a laptop CPU takes 27 s to 2 min per cited answerREADME.md ↗
@ bb8f37f
FAILRetrieval is judged at passage leveln/achapter level only; a hit means a passage from the right chapterREADME.md ↗
@ bb8f37f
UNCLEAROff-topic questions are refusedall 4 off-topic questions refusedone off-topic question was refused in a manual check; the full measurement still needs a model runREADME.md ↗
@ bb8f37f
UNCLEARRAG hallucinates less than closed-bookn/anot yet measured at scaleREADME.md ↗
@ bb8f37f

The numbers

Each headline is the conclusion. Open “View the data” under a chart for the exact values.

Hybrid finds the right chapter more often than either retriever alone.

Recall@5 by retriever

0%25%50%75%100%BM25: 92%92%BM25Embeddings: 89%89%EmbeddingsHybrid: 95%95%Hybrid

38 answerable questions. A hit is a passage from the right chapter, not the exact passage.

Source: README.md @ bb8f37f ↗
View the data
ItemValueNote
BM2592%
Embeddings89%
Hybrid95%

Hybrid is best or joint best on every slice, but the gain over BM25 is not proven.

MRR by question slice

BM25EmbeddingsHybrid0.000.250.500.751.00All 38 · BM25: 0.7030.703All 38 · Embeddings: 0.7630.763All 38 · Hybrid: 0.7960.796All 38Everyday wording (19) · BM25: 0.6290.629Everyday wording (19) · Embeddings: 0.7190.719Everyday wording (19) · Hybrid: 0.7630.763Everyday wording (19)The book's own terms (7) · BM25: 0.9290.929The book's own terms (7) · Embeddings: 0.8570.857The book's own terms (7) · Hybrid: 1.0001.000The book's own terms (7)

The MRR difference over BM25 is +0.093 with a 95% interval of -0.03 to +0.22, which includes zero.

Source: README.md @ bb8f37f ↗
View the data
GroupBM25EmbeddingsHybrid
All 380.7030.7630.796
Everyday wording (19)0.6290.7190.763
The book's own terms (7)0.9290.8571.000

No unsupported sentence got a green check.

What the sentence checker said

SupportedWeakUnsupportedReal sentences, supported17 sentencesReal sentences, supported · Supported: 1313Real sentences, supported · Weak: 4417Real sentences, weak4 sentencesReal sentences, weak · Weak: 444Checked against the wrong passages21 sentencesChecked against the wrong passages · Weak: 99Checked against the wrong passages · Unsupported: 121221Invented claims8 sentencesInvented claims · Weak: 22Invented claims · Unsupported: 668

Thresholds were set on 8 real qwen2.5:7b answers labelled by hand. The calibration set is small and from one model.

Source: README.md @ bb8f37f ↗
View the data
RowSupportedWeakUnsupported
Real sentences, supported (17 sentences)1340
Real sentences, weak (4 sentences)040
Checked against the wrong passages (21 sentences)0912
Invented claims (8 sentences)026

What went wrong

FIXED

Concept maps were built, then deleted

How it surfaced
Word co-occurrence graphs, and graphs from LLM-extracted triples over a truncated prompt, gave readers nothing they could act on.
What I did
I removed the feature. A shipped graph nobody can use costs more than the feature is worth.
FIXED

The first test set could not tell two retrievers apart

How it surfaced
With only 12 questions, a single question moved Recall@5 by 8 points, so no comparison between retrievers meant anything.
What I did
I grew the set to 38 answerable and 4 off-topic questions, written and committed before dense or hybrid retrieval was measured so they could not be tuned to the new code.
FIXED

Each retriever failed in a different way

How it surfaced
BM25 matches words, so it misses a passage that says the same thing differently. Embeddings match meaning but drift toward passages that are merely on the same topic, and they are worse at exact terms.
What I did
Run both and merge the rankings with Reciprocal Rank Fusion, which uses only ranks, so the two score scales never need to be reconciled.
ACCEPTED RISK

The smallest local model copied instead of answering

How it surfaced
qwen2.5:0.5b answered in 17 seconds, but it pasted the retrieved passage back instead of answering the question.
What I did
The default stays on the 7b model. If no model is reachable, an extractive fallback returns the top passage with its citation and says so.

Choices I would defend

  • Write the retriever by hand

    I wanted every part understood, not magic. BM25 is mine, the fusion fits in ten lines, and one file is the only code that talks to a model.

  • Fuse ranks, not scores

    Reciprocal Rank Fusion gives each passage 1/(60 + rank) per list. BM25's unbounded scores and cosine similarity never share a scale. The constant 60 is the standard value, not tuned here.

  • Check answers with embeddings, not another LLM

    Matching each answer sentence to its evidence is cheap, deterministic and cannot hallucinate. The thresholds were set on 8 real answers (21 sentences) labelled by hand.

  • Print the limits next to the headline

    The README shows the interval that includes zero and the chapter-level labels right beside the 95%, so the number cannot be read without its caveat.