Skip to content
Adwitiya Rahman

← All work

Semantic Search

A local, offline hybrid search and RAG evaluation workbench over PDF textbooks: semantic plus BM25, fused with Reciprocal Rank Fusion, every run benchmarked and comparable.

Role
Product and engineering, solo
Year
2026
Stack
Python, FastAPI, SQLite, sentence-transformers, Ollama

No demo for this one. It runs on my own hardware with my own books on it, and it is the bench I test the other two projects against rather than something built to be handed out.

What it is

Search over my pdf textbooks, running on a CPU only server. Meaning search and keyword search at the same time, fused, optionally re-ranked, with a local model writing a cited answer from what came back. No cloud, no GPU, no account. Double-click a file and a browser opens.

It's also turned into the thing I didn't set out to build: a bench for measuring retrieval. 97 recorded runs, filterable by which embedding model, reranker and LLM produced them. I now use it to test the other two projects.

I didn't build this to learn RAG

I built it because I couldn't find things in the textbooks.

I had a pile of PDFs and a real problem: I knew something was in there and keyword search wouldn't find it, because I remembered the idea and not the words. That's the whole origin. RAG is just what this arrangement is called. it wasn't the goal, it was the shape the answer took.

That distinction turned out to matter, because it meant I cared whether it actually worked rather than whether I'd built one.

So I built the measuring part first

Before I tuned a single retrieval setting, the thing could already save a set of questions and replay them through any combination of models, recording every timing.

That was deliberate. Four parts:

  • Decide what to measure. Speed, to begin with, because it is honest and cheap. Answer quality left out on purpose, because scoring quality needs ground truth I didn't have.
  • Make it repeatable. Saved question sets, persisted results, run ids.
  • Make it comparable. Filter by model combination, average the set.
  • Watch it while it runs. Live progress, per-question rows, a stop button.

Choosing speed first and writing down why quality was deferred is the difference between shipping a measurement and shipping a number that flatters you.

The same query, split by retriever: what meaning search ranked, what keyword search ranked, and the score each gave. The header records the exact settings, so any result can be reproduced.
The same query, split by retriever: what meaning search ranked, what keyword search ranked, and the score each gave. The header records the exact settings, so any result can be reproduced.

Being able to see why something surfaced

Retrieval you can't inspect is retrieval you're tuning by feel.

Every knob is exposed: how many candidates each retriever contributes, the weight on each, the RRF constant, minimum similarity, which reranker and how many candidates it re-scores. Then it shows what each retriever actually found: rank and score, per source, per result.

The screenshot above is the argument for doing it this way. The same passage sits at rank 2 for meaning search and rank 1 for keyword search, on scores that aren't even on the same scale. Either one alone puts it in the wrong place. The fusion is what sorts that out, and you can watch it happen instead of taking my word for it.

Every retrieval knob, with a plain-language note on what each one does to the ranking.
Every retrieval knob, with a plain-language note on what each one does to the ranking.

Questions I asked myself before building the runner

I wrote these down first and let the answers decide the shape.

QuestionWhat I settled on
Where do the test questions come from?Editable, saved question sets, not a file baked into the code
One model combination, or sweep them all?One per run. Comparable, and simple to hold in my head
Speed or quality?Speed. Quality deferred, on purpose
What if a question fails halfway through?Record the failure and carry on, because a failed question is data
How much control in the runner?Models, k, rerank pool. Fusion weights come from the Tuning tab

The one I'd defend hardest is treating a failure as a row rather than an abort. A run that dies on question 7 of 20 teaches you nothing.

A saved question set running end to end. Every question recorded with its retrieve time, total time and token count.
A saved question set running end to end. Every question recorded with its retrieve time, total time and token count.

Five bugs, fixed in the order they hurt

A review turned up five concurrency problems. I fixed them by blast radius, not by the order I found them.

The deadlock went first. Clicking Start twice took the whole server down, and it never came back on its own. Nothing else compares to that.

Then correctness and typing, before letting requests actually run at the same time. Making something faster before making it right just gets you the same bugs at higher speed.

The change anyone would actually notice, getting the blocking work off the event loop, went last. On its own it would have shown no speed-up at all, because everything was still queued behind the same lock. It would have looked like a failed optimisation and I'd probably have reverted it.

That last one is the bit I think about. I ordered the work partly so the measurement wouldn't lie to me. I also wrote the project's first tests, 16 of them, alongside the fix.

97 runs kept and filterable by model combination, averaged at the bottom.
97 runs kept and filterable by model combination, averaged at the bottom.

I reproduced the failure instead of guessing at it

Runs on the server started dying mid-answer with "peer closed connection".

Easy to guess from that. I pulled the same model down locally, ran the identical code path, and it worked, which told me the code was fine and the environment wasn't. The model runtime on the server was running out of memory. Then I added retries with backoff and gave myself the exact things to check next time: memory, container logs, evidence of the process being killed.

Separating "my code is wrong" from "the box is wrong", with evidence rather than a hunch, is most of what debugging something in production actually is.

What I chose not to build

No sweeps across every model combination. One per run, so results stay comparable and the manual loop is proven before I automate it. No answer-quality scoring yet, with the path written down for when I add it. And a locking issue I found during the concurrency work is filed rather than rushed, because it needed more care than that change had room for. Each with the reason recorded at the time, so I can defend it later or change my mind honestly.

What's next

Answer quality, the thing I deliberately left out. Ground-truth question sets with the book and page per question, hit rate and ranking measures, then model-as-judge as a cheaper approximation once the expensive version has told me what good looks like.

What I'd do differently

I'd pin the question set and the model versions to every run. Right now a run records which models it used but not which revision of the questions, which means comparing runs across a long enough stretch of time is quietly untrustworthy. It's the one place where the thing I built to stop me fooling myself can still fool me.

What this one proves

Building the measurement loop before optimising anything, measure twice, cut once.

Next

EaseMySemester

Shipping a prediction feature with a measured strike rate, and deliberately building it with statistics rather than an LLM, because determinism was the product.

Get in touch

contact@adwitiyarahman.in

Happy to walk through any of this, including the parts that did not work. Or see the other projects.