The LLM is not allowed to write scripture
Kuran Chat answers questions about the Quran, but the model never produces a verse. The text comes from the database, always, with a reference attached. Here is why that constraint shaped the whole retrieval stack.
Most RAG demos fail in a way nobody notices: the model paraphrases the source and the paraphrase reads better than the source. For a product answering questions about the Quran, that failure mode is not a quality issue. It is the end of the product.
So the rule came first, before any of the architecture:
The model may explain, compare, and point. It may never emit an Arabic verse or a translation as generated text.
Everything below is a consequence of that one line.
The data layer owns the text
The corpus is 6,236 verses, 15 translations, and 6,237 chunks of tafsir, all
ingested into Postgres with pgvector and an HNSW index for the embeddings.
Retrieval returns row IDs, not prose. The answer the user sees is assembled
in two pieces:
- the verse and translation, rendered from the row, with surah and ayah numbers
- the model's commentary around it, clearly the model's
If the retrieval step finds nothing, the answer says so. The model does not get the chance to fill the gap from memory, because the verse slot is not a slot the model can write into.
One retriever is not enough for Turkish
Semantic search alone misses exact phrases. Lexical search alone misses paraphrases, and in Turkish it misses almost everything if you are careless about diacritics — a user typing sukur should find şükür, and an accent-sensitive index will quietly return zero rows.
So both run, and the results are fused:
# Reciprocal Rank Fusion — rank matters, raw scores don't
def rrf(rankings: list[list[str]], k: int = 60) -> dict[str, float]:
scores: dict[str, float] = {}
for ranking in rankings:
for rank, doc_id in enumerate(ranking, start=1):
scores[doc_id] = scores.get(doc_id, 0.0) + 1 / (k + rank)
return dict(sorted(scores.items(), key=lambda kv: -kv[1]))RRF is the boring choice and that is the point. It needs no score calibration
between a cosine distance and a ts_rank, no tuned alpha that drifts the moment
you swap the embedding model. It only needs both lists to be ordered.
The lexical side is a tsvector column built from diacritic-folded text; the
semantic side is multilingual-e5 embeddings, which handle Turkish and Arabic
in the same space well enough that a Turkish question retrieves an Arabic verse
without a translation hop. Generation runs on DeepSeek.
What this costs
Latency, mostly. Two retrievers and a fusion step before the first token, plus the embedding call. It is worth it, and the trade is not subtle: a fast answer that invents a verse is worse than no product.
The app is on the App Store. The interesting part was never the model.