The goal
This is the second chapter of the Ahmad-AI build. The first chapter was about safety — making an agent that refuses to fabricate citations. This chapter is about capability — making an agent that can actually find the right verse, in the right language, with the right intent.
The product: a browser-based Islamic knowledge assistant. Ask it "what does the Quran say about patience" and it returns the canonical sabr verses. Ask it "حور العين" in Arabic and it returns the classical hoor passages. Ask it "namaz" in Urdu and it returns prayer hadiths. Ask it "2:255" and it returns Ayat-ul-Kursi exactly.
Under the hood: Streamlit for the UI, ChromaDB for vector storage, a multilingual sentence-transformer for embeddings, and 56,998 indexed texts spanning six canonical Quran translations and nine major Hadith collections.
Getting from zero to "working" was four weeks. Getting from "working" to "actually good" was six more bugs, most of which I built into the system myself.
What's under the hood
The architecture is deliberately simple:
- Two isolated vector stores — one for Quran verses, one for Hadith — kept in separate folders so their metadata schemas can diverge without cross-contamination.
- A multilingual embedding model running locally on CPU — no cloud calls, no rate limits, no data leaving the machine.
- A hybrid search path — exact citation parsing fires first (instant, no model involved); semantic search only runs for open-ended queries.
- A synonym expansion layer — the model doesn't know what "tahajjud" or "حور العين" mean, so I taught the query layer to expand them before search.
The first real design decision was to not combine everything into one search — because Quran verses and Hadith passages have completely different structures, formats, and metadata needs. Sharing a vector space between them would have averaged the two.
Week 1-2: data pipeline, then a hard lesson in version drift
The Quran side went smoothly. I pulled a canonical dataset, extracted verses, translations, and metadata, and indexed 6,236 entries without incident. The Hadith side, 50,762 entries from nine collections, went equally smoothly — until I tried to read the databases back.
Both vector databases had been created across a range of ChromaDB library versions as I iterated. Individually, each version worked. But a database written by one version and read by another triggered a hard Rust-level panic — range start index 10 out of range for slice of length 9 — deep inside the ChromaDB SQLite backend, before any Python exception handler could catch it. The panic was thrown from the compiled bindings, not from Python, so it killed the whole process.
The fix was a full rebuild of both databases under one locked version. But the deeper lesson was about how silently libraries change formats between minor releases, and how "it works on my machine" quietly becomes "it works on my machine running exactly this version of this library and no other."
ChromaDB 1.0 was a major structural change from 0.5 — different SQLite schema, different default distance metric, different collection metadata format. Every bug in the rest of this log traces back to that one version boundary in one way or another.
Week 3: nine hundred megabytes of Hadith, and a power cut
Indexing 50,762 Hadiths with a 384-dimensional embedding model produced a database just under a gigabyte and took about 20 minutes of solid CPU time. Halfway through the first attempt, the power in my neighborhood cut.
The ChromaDB write path is transaction-safe, so nothing was corrupted — but the collection was only 59.4% populated when the process died. I wrote a short resume script that read the set of already-indexed IDs directly from the collection, diffed it against the full dataset, and only embedded the missing 20,612 rows. Twelve minutes later the collection was whole.
This is the kind of thing that looks obvious in retrospect and never occurs to you until the first time it happens. Every long-running indexing job I write now starts with a checkpoint pattern baked in from minute one.
Week 4: the multilingual upgrade — and a rebuild, and another rebuild
English-only semantic search worked. But it didn't understand Islamic terminology. Ask about "hoor" and it returned generic Paradise verses. Ask about "tahajjud" and it returned general prayer hadiths. Ask about "namaz" and it fell back to whatever English keyword matched.
The fix, in theory, was simple: swap the English-only embedding model for a multilingual one. Same dimension count, same API, same pipeline. In practice, this triggered four separate classes of bug that took an entire day to untangle.
The Python version that was silently used
ChromaDB's underlying HNSW library has no prebuilt wheels for Python 3.12 on Windows. When the virtual environment was recreated, Windows silently picked Python 3.12 instead of 3.11, and the vector store refused to initialize. Every subsequent error looked like a database problem but was actually an environment problem.
Lesson: pin the Python version in the venv creation command explicitly. Never trust the default.
A protobuf version conflict that broke two libraries at once
Streamlit needed protobuf <6. A transitive dependency of the observability stack needed protobuf ≥6.33. Installing either one broke the other. The fix was to pin Streamlit to an older minor version that accepted protobuf 5.x, satisfying both constraints at the boundary.
Lesson: pin ranges, not exact versions, in requirements.txt. Let pip find the compatible intersection; forcing versions creates false conflicts.
L2 distance vs. cosine distance — same numbers, different meaning
The scoring code assumed ChromaDB returned cosine distance (range 0-2) and computed similarity as 1 - distance/2. But the collections had been created with the default L2 metric, whose range depends on the vectors' norms. When I switched to a model that produced unnormalized embeddings, every L2 distance grew by 3-6x, and the formula floored every score to 0%.
The fix was to rebuild both collections with hnsw:space: "cosine" explicitly, and re-index. Cosine distance has a fixed 0-2 range regardless of vector norm, so the original formula works again.
Lesson: always check the distance metric on the collection, not just the score formula in the code. They can disagree silently.
The wrong model was encoding the query
Even with cosine space, scores stayed at 0%. The databases had been indexed correctly with the multilingual model — I verified by re-encoding a stored verse and confirming a 1.0000 similarity — but the query was being re-encoded by ChromaDB's internal default model, which is English-only. The two vector spaces were completely separate.
The fix was to stop passing query_texts to ChromaDB and start passing query_embeddings instead, computed with the same model used at index time.
Lesson: never let a library silently choose the embedding model for you. If you control the index model, control the query model too.
The Hadith corpus didn't contain what we were searching for
After all four environment fixes, Arabic queries like حور العين finally returned the correct Quran verses. But Hadith results for "hoor" still returned hadiths about pious wives and greetings — never the classical Hoor al-Ayn narrations. I queried the dataset directly for the Arabic word حور and found exactly five hits — all of them as part of narrator names, none of them as the subject of the hadith.
The dataset was simply missing that content. Not a bug in the code, not a failure of the model — a limitation of the source. The fix, for a future iteration, is to supplement with a Hadith collection that has stronger eschatology coverage.
Lesson: investigate the data before blaming the algorithm. A semantic search system can only find what's actually there.
The URLs that pointed to the wrong folders
Windows treats D:\Ahmad-AI and D:\ahmad-ai as the same folder — but the ChromaDB Rust bindings compute path hashes byte-for-byte, so the casing mattered. Virtual environments created at the lowercase path silently mis-resolved when the working directory used uppercase. The fix was to standardize every path reference, every config file, and every venv on one canonical casing.
Lesson: on Windows, casing is not decorative. Any tool that stores paths as bytes needs consistent casing everywhere.
Where it stands now
The system runs locally, exposes itself through a Streamlit UI, is password-gated, and can be shared publicly through a Cloudflare Tunnel without opening any inbound ports on the machine. It is deployed, multilingual, and access-controlled.
Sample verified outputs from the deployed system:
what is hoor→ Quran 56:22 · "And [for them are] fair women with large, [beautiful] eyes" (78% match)حور العين(Arabic) → Quran 37:48 · "And with them will be women limiting [their] glances" (79% match)tahajjud→ Quran 25:64 · "And those who spend [part of] the night to their Lord prostrating" (82% match)namaz(Urdu) → Quran 107:4 · "So woe to those who pray" (89% match)2:255→ Ayat-ul-Kursi, exact, instant
What I'd tell another engineer building something like this
Pin the Python version and every library that touches your data persistence layer. ChromaDB, SQLite bindings, and vector databases in general are not interchangeable across minor versions. The upgrade path is a rebuild, not a migration.
Test the model in isolation before committing to a rebuild. I could have caught the multilingual model bug in ten minutes with a standalone similarity script — instead I caught it after two full rebuilds of both databases.
When scores collapse to zero, check three things in order: the distance metric, the query embedding model, and the stored embedding model. Ninety percent of "semantic search is broken" bugs live in one of those three.
Investigate data before blaming code. The Hadith coverage gap looked like a semantic search failure. It was actually a source dataset limitation. Only querying the database directly revealed the truth.
Keep the old version working until the new one is proven. Having both vector stores live in parallel — English-only and multilingual — meant every failed multilingual attempt was a two-line config revert, not a full disaster recovery.
What's next
Two immediate improvements are queued. First, cleaning the isnad (narrator chain) prefix out of Hadith text before indexing — the chain sometimes takes up more space in the embedding than the actual content, which dilutes semantic matching across all queries. Second, supplementing the Hadith corpus with a collection that has stronger coverage of Paradise, eschatology, and Hoor narrations, so queries about those topics return Hadith results instead of only Quran results.
Longer term, the plan is a second chapter of this same system — a business-data assistant built on the same agent framework, connected to a live ERP database, sharing the same UX and deployment pipeline. Same pattern, different domain.
This is where it actually stands. Not a demo. Not a proof of concept. A working, deployed, multilingual system, tested against real queries in three languages.
Curious about the earlier chapter — how the guardrails against hallucination were built? Read Chapter 01 →