Skip to content

feat: add retrieval-augmented generation over Quran and Hadith sources - #38

Open
GEEKYFOCUS wants to merge 3 commits into
Deen-Bridge:devfrom
GEEKYFOCUS:feat/rag-upstream
Open

feat: add retrieval-augmented generation over Quran and Hadith sources#38
GEEKYFOCUS wants to merge 3 commits into
Deen-Bridge:devfrom
GEEKYFOCUS:feat/rag-upstream

Conversation

@GEEKYFOCUS

@GEEKYFOCUS GEEKYFOCUS commented Jul 21, 2026

Copy link
Copy Markdown

Overview

This PR adds retrieval-augmented generation (RAG) to the /chat endpoint. Quran and hadith passages are embedded into a ChromaDB persistent vector store, retrieved by cosine similarity on the user query, and injected into the model prompt with metadata — so answers can cite real sources instead of relying on the model's parametric memory.

Related Issue

Closes #24

Verification Results

  • flake8 main.py rag.py tests/test_rag.py scripts/ingest_corpus.py — lint ✅ passed
  • python -m compileall main.py rag.py scripts/ingest_corpus.py — syntax ✅ passed
  • pytest tests/test_rag.py -v — 11/11 ✅ passed

Changes

⚙️ RAG Module

  • [ADD] rag.py — Core RAG module:
    • ChromaStore: ChromaDB in embedded/persistent mode (no separate server needed)
    • VectorStore interface for future swap to qdrant/pgvector
    • Embedding via Gemini text-embedding-004 behind a testable seam
    • Configurable RAG_TOP_K (default 5) and RAG_MIN_SCORE (default 0.0)
    • format_reference_passages() formats retrieved passages for prompt injection
    • SourceDocument Pydantic model (text, reference, score)

📥 Ingestion Script

  • [ADD] scripts/ingest_corpus.py — Offline script (not at request time):
    • Downloads Saheeh International Quran translation from Tanzil.net (CC BY-ND 3.0)
    • Downloads Sahih al-Bukhari from permissively licensed GitHub export (CC0)
    • Verse-level chunks for Quran (surah/ayah metadata); per-hadith chunks (collection/book/number)
    • Batch embedding via Gemini, persists to ChromaDB
    • Caches raw JSONL under data/; --reuse-cache to skip re-download

🌐 API Integration

  • [MODIFY] main.py/chat endpoint:
    • Retrieves passages from ChromaDB before generation when RAG_ENABLED=1
    • Injects formatted "Reference passages" block into the prompt
    • Skips retrieval gracefully when index missing or RAG disabled
    • ChatResponse.sources optional field with retrieved passages
    • Existing clients unaffected (sources is None when absent)

🧪 Tests

  • [ADD] tests/test_rag.py — 11 offline tests with fake embeddings:
    • Quran chunk has surah/ayah metadata
    • Hadith chunk has collection/number metadata
    • Exact match retrieval above threshold
    • Score threshold filtering (below-threshold results excluded)
    • Empty corpus returns empty list
    • Reference passage formatting (Quran, Hadith, empty)
    • SourceDocument model creation
    • RAG config defaults
  • [ADD] tests/fixtures/quran_sample.jsonl — 10 ayat
  • [ADD] tests/fixtures/hadith_sample.jsonl — 5 hadith

🔧 Configuration

Variable Default Description
RAG_ENABLED 0 Enable RAG
RAG_TOP_K 5 Passages retrieved per query
RAG_MIN_SCORE 0.0 Minimum similarity score
CHROMA_PERSIST_DIR chroma_data ChromaDB persistence directory
  • [MODIFY] .github/workflows/ci.yml — Lint/compile rag.py, run RAG tests
  • [MODIFY] .gitignore — Ignore data/ and chroma_data/
  • [MODIFY] requirements.txt — Added chromadb>=0.5.0
  • [MODIFY] README.md — Documented env vars, ingestion instructions, RAG feature

Acceptance Criteria

Criterion Status
Ingestion script builds Chroma index from Quran + hadith; sources documented
Quran chunks carry surah/ayah metadata; hadith chunks carry collection/number
/chat answers include sources list with passages; out-of-corpus questions work with empty sources
Retrieval skipped gracefully when index missing or RAG_ENABLED=0
RAG_TOP_K and RAG_MIN_SCORE env-configurable and documented
Tests cover chunking, metadata, threshold; no live API calls; CI green
No secrets or large binary index files committed

Summary by CodeRabbit

  • New Features
    • Added optional retrieval-augmented chat using Quran and Hadith passages from a local ChromaDB index.
    • Added a corpus ingestion workflow to download, chunk, embed, and persist the index.
  • Documentation
    • Expanded setup instructions for corpus ingestion and documented RAG-related environment variables.
  • Tests
    • Added offline RAG test coverage for retrieval, scoring/filtering, and reference formatting.
  • Chores
    • Updated CI to run the RAG test suite, expanded ignore rules for RAG data/logs, and added the ChromaDB dependency.

Implements RAG (Retrieval-Augmented Generation) for the /chat endpoint.
A ChromaDB persistent vector store indexes verse-level Quran chunks and
per-hadith chunks. Retrieved passages are injected into the prompt with
metadata so the model can cite real sources.

Key changes:
- New rag.py module: ChromaStore (ChromaDB embedded/persistent),
  embedding via Gemini text-embedding-004, configurable top-k/min-score,
  reference-passage formatting
- ChatResponse.sources: optional list of {text, reference, score}
  containing the passages actually retrieved
- /chat endpoint retrieves passages before generation; skips gracefully
  when index is missing or RAG_ENABLED=0
- scripts/ingest_corpus.py: downloads Saheeh International Quran
  (Tanzil.net) and Sahih al-Bukhari (permissively licensed GitHub
  export), chunks, embeds, and stores in ChromaDB; caches raw JSONL
  under data/
- 11 offline tests: chunk metadata, retrieval with fake embeddings,
  score threshold, empty corpus, reference formatting
- CI updated: lint/compile rag.py, run RAG tests
- RAG_ENABLED, RAG_TOP_K, RAG_MIN_SCORE, CHROMA_PERSIST_DIR env vars

Closes Deen-Bridge#24
@coderabbitai

coderabbitai Bot commented Jul 21, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

Adds configurable Quran and Hadith retrieval with Gemini embeddings and persistent ChromaDB storage, an offline ingestion script, deterministic tests, CI coverage, and documentation. The /chat handler only imports RAG helpers and initializes an empty source list; retrieval and response integration are not implemented in this diff.

Changes

Retrieval-augmented generation

Layer / File(s) Summary
RAG configuration and retrieval core
rag.py, requirements.txt
Adds environment-driven settings, embedding helpers, vector-store abstractions, persistent ChromaDB search, similarity filtering, reference formatting, and retrieval access.
Corpus download and persistent ingestion
scripts/ingest_corpus.py
Downloads and normalizes Quran and Hadith data, caches JSONL records, and ingests verse- and hadith-level documents with metadata and stable IDs.
Offline validation and project integration
tests/fixtures/*, tests/test_rag.py, .github/workflows/ci.yml, .gitignore, README.md, main.py
Adds deterministic fixture-based tests, a CI test step, generated-data ignores, RAG setup documentation, and preliminary RAG imports plus an empty source list in /chat.

Estimated code review effort: 4 (Complex) | ~45 minutes

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning Issue [#24] is only partially met because the RAG backend is added, but /chat is not wired to retrieve passages or return the optional sources field. Connect /chat to retrieve(), inject reference passages into the prompt when enabled, and expose retrieved docs through an optional sources response field.
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: adding RAG over Quran and Hadith sources.
Out of Scope Changes check ✅ Passed The changes stay focused on RAG, corpus ingestion, docs, tests, CI, and ignore rules, with no clearly unrelated feature work.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 Checkov (3.3.8)
.github/workflows/ci.yml

Traceback (most recent call last):
File "/usr/local/bin/checkov", line 2, in
from checkov.main import Checkov
ModuleNotFoundError: No module named 'checkov'


Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 9

🧹 Nitpick comments (3)
tests/test_rag.py (2)

137-151: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Assert that exact-match results have positive similarity.

With min_score=0.0, this test only checks that some Quran references are returned. A zero-score or otherwise incorrect result could still satisfy it. Assert positive scores and the expected top result/count.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_rag.py` around lines 137 - 151, Strengthen
test_retrieve_exact_match by asserting the exact expected result count and
verifying that the top result is the expected Quran document, then assert every
returned result has a strictly positive similarity score. Keep the existing
embedding setup and search parameters unchanged.

159-180: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Use a mixed-score case for threshold coverage.

The query is orthogonal to every stored embedding, so an implementation that always returns [] passes. Query with one matching vector and one below-threshold vector, then assert that only the matching document remains.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_rag.py` around lines 159 - 180, Update
test_retrieve_min_score_filters to use a query embedding that matches one stored
document while leaving another below the 0.5 threshold, then assert the results
contain only the matching document. Keep the test setup focused on threshold
filtering rather than an all-zero-result orthogonal query.
rag.py (1)

53-66: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Recommended: use asymmetric task_type for Gemini embeddings.

embed_text is reused for both document ingestion (embed_batchadd_documents) and query embedding (search), but genai.embed_content is called without task_type. Gemini's text-embedding-004 produces meaningfully better retrieval quality when documents are embedded with RETRIEVAL_DOCUMENT and queries with RETRIEVAL_QUERY — omitting it silently degrades relevance ranking without any visible error.

♻️ Suggested refactor — pass task_type through
-def embed_text(text: str) -> list[float]:
+def embed_text(text: str, task_type: str = "RETRIEVAL_QUERY") -> list[float]:
     if _FAKE_EMBEDDING is not None:
         return _FAKE_EMBEDDING
     import google.generativeai as genai
 
     result = genai.embed_content(
         model="models/text-embedding-004",
         content=text,
+        task_type=task_type,
     )
     return list(result["embedding"])


-def embed_batch(texts: list[str]) -> list[list[float]]:
-    return [embed_text(t) for t in texts]
+def embed_batch(texts: list[str]) -> list[list[float]]:
+    return [embed_text(t, task_type="RETRIEVAL_DOCUMENT") for t in texts]

Since my knowledge of the exact google-generativeai 0.8.3 SDK surface may be stale, please confirm the task_type kwarg name/values against the version in requirements.txt.

Also applies to: 141-141

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rag.py` around lines 53 - 66, Update embed_text to accept a task_type
parameter and pass it to genai.embed_content using the SDK-supported keyword and
retrieval task values. Update document-ingestion callers such as
embed_batch/add_documents to use RETRIEVAL_DOCUMENT and query callers in search
to use RETRIEVAL_QUERY, while preserving the fake-embedding path and confirming
compatibility with the pinned SDK version.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.github/workflows/ci.yml:
- Line 31: Update the flake8 command in the CI workflow to include
tests/test_rag.py alongside the existing Python targets, preserving all current
options and exclusions.

In @.gitignore:
- Around line 40-42: Replace the broad data/ entry in the RAG / ChromaDB ignore
section with a narrowly scoped pattern for generated root-level data/*.jsonl
files, and retain the chroma_data/ ignore rule.

In `@main.py`:
- Around line 162-178: Update the RAG retrieval call in the async chat handler
to offload the synchronous retrieve function to a worker thread, awaiting its
result so the event loop remains responsive. Preserve the existing RAG_ENABLED
branching, source formatting, and logging behavior.

In `@rag.py`:
- Around line 243-248: Make retrieve asynchronous and offload the blocking
get_store().search(query) operation to asyncio.to_thread, while preserving the
disabled-RAG early return and existing logging. Update the async /chat caller to
await retrieve so synchronous embedding, network, and disk work does not block
the event loop.
- Around line 53-66: Update embed_text() and the ChromaStore.search() flow to
catch embedding and collection.query failures, log them appropriately, and
return empty results instead of propagating exceptions. Ensure retrieve()
continues gracefully when either operation fails so RAG errors cannot cause
/chat to return an HTTP 500 or expose raw exception details.
- Around line 105-126: Update the collection initialization in __init__ to
create CHROMA_COLLECTION with cosine distance metadata, matching search()’s 1 -
distance score conversion. Detect any existing persisted collection using the
incompatible distance space, migrate or recreate it with cosine space while
preserving its documents and embeddings, then retain the existing collection
setup and failure behavior.

In `@README.md`:
- Around line 76-91: The README ingestion instructions do not enable retrieval
after building the ChromaDB index. Update the setup example around
`scripts/ingest_corpus.py` to explicitly set `RAG_ENABLED=1` before starting the
API, or add a clear enablement step, while preserving the existing ingestion and
`--reuse-cache` commands.

In `@scripts/ingest_corpus.py`:
- Around line 169-179: Update the id construction in ingest so Quran entries
include both their surah and ayah identifiers, producing a unique ID for every
verse; retain hadith_number for hadith entries and the index fallback for other
records. Ensure the resulting ids passed to store.add_documents are unique
within the batch.

In `@tests/test_rag.py`:
- Around line 232-236: The test_rag_config_defaults function should isolate
environment variables and module import so it validates the documented exact
default values for RAG_TOP_K, RAG_MIN_SCORE, and RAG_ENABLED rather than broad
ranges. Extend the test to exercise retrieve() with RAG_ENABLED disabled and
assert it returns an empty list, matching the disabled gate in rag.py.

---

Nitpick comments:
In `@rag.py`:
- Around line 53-66: Update embed_text to accept a task_type parameter and pass
it to genai.embed_content using the SDK-supported keyword and retrieval task
values. Update document-ingestion callers such as embed_batch/add_documents to
use RETRIEVAL_DOCUMENT and query callers in search to use RETRIEVAL_QUERY, while
preserving the fake-embedding path and confirming compatibility with the pinned
SDK version.

In `@tests/test_rag.py`:
- Around line 137-151: Strengthen test_retrieve_exact_match by asserting the
exact expected result count and verifying that the top result is the expected
Quran document, then assert every returned result has a strictly positive
similarity score. Keep the existing embedding setup and search parameters
unchanged.
- Around line 159-180: Update test_retrieve_min_score_filters to use a query
embedding that matches one stored document while leaving another below the 0.5
threshold, then assert the results contain only the matching document. Keep the
test setup focused on threshold filtering rather than an all-zero-result
orthogonal query.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: f8a02f21-b30d-4554-a5e9-4cea83941824

📥 Commits

Reviewing files that changed from the base of the PR and between 28fc479 and e3e3c05.

📒 Files selected for processing (10)
  • .github/workflows/ci.yml
  • .gitignore
  • README.md
  • main.py
  • rag.py
  • requirements.txt
  • scripts/ingest_corpus.py
  • tests/fixtures/hadith_sample.jsonl
  • tests/fixtures/quran_sample.jsonl
  • tests/test_rag.py

Comment thread .github/workflows/ci.yml Outdated

- name: Run linting
run: flake8 main.py stellar.py safety tests/redteam study.py --max-line-length=120 --ignore=E501,W503
run: flake8 main.py stellar.py safety tests/redteam study.py rag.py --max-line-length=120 --ignore=E501,W503

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Include tests/test_rag.py in the flake8 target.

CI executes the new test file but does not lint it, allowing style violations in changed Python code to merge undetected.

Proposed fix
-        run: flake8 main.py stellar.py safety tests/redteam study.py rag.py --max-line-length=120 --ignore=E501,W503
+        run: flake8 main.py stellar.py safety tests/redteam tests/test_rag.py study.py rag.py --max-line-length=120 --ignore=E501,W503

As per path instructions, this FastAPI service's CI enforces flake8, so style violations should fail the build.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
run: flake8 main.py stellar.py safety tests/redteam study.py rag.py --max-line-length=120 --ignore=E501,W503
run: flake8 main.py stellar.py safety tests/redteam tests/test_rag.py study.py rag.py --max-line-length=120 --ignore=E501,W503
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/ci.yml at line 31, Update the flake8 command in the CI
workflow to include tests/test_rag.py alongside the existing Python targets,
preserving all current options and exclusions.

Source: Path instructions

Comment thread .gitignore
Comment on lines +40 to +42
# RAG / ChromaDB
data/
chroma_data/ No newline at end of file

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Narrow the data/ ignore pattern.

Ignoring every data/ directory can silently hide unrelated source-controlled inputs. Prefer the exact generated corpus paths, such as root-level data/*.jsonl, while retaining chroma_data/.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.gitignore around lines 40 - 42, Replace the broad data/ entry in the RAG /
ChromaDB ignore section with a narrowly scoped pattern for generated root-level
data/*.jsonl files, and retain the chroma_data/ ignore rule.

Comment thread main.py
Comment on lines +162 to +178
rag_sources: list[SourceDocument] = []

try:
logger.info(f"Received chat request: {request.prompt[:100]}...")

chat_id = request.chat_id or str(uuid.uuid4())

# --- RAG retrieval (before generate) ---
rag_passages = ""
if RAG_ENABLED:
rag_sources = retrieve(request.prompt)
if rag_sources:
rag_passages = format_reference_passages(rag_sources)
logger.info("RAG: %d source(s) retrieved", len(rag_sources))
else:
logger.info("RAG: no sources retrieved")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Blocking retrieval call inside the async /chat handler.

retrieve(request.prompt) is a plain synchronous call that internally does a network round-trip (Gemini embeddings) and a Chroma query. Since chat() is async def, this blocks the event loop for every RAG-enabled request, stalling other concurrent requests on the same worker. See linked comment in rag.py for the root cause and suggested async-friendly fix; this call site needs an await/offload once that's in place (e.g. rag_sources = await asyncio.to_thread(retrieve, request.prompt)).

As per path instructions, "network calls should be awaited or offloaded" for a FastAPI async endpoint.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@main.py` around lines 162 - 178, Update the RAG retrieval call in the async
chat handler to offload the synchronous retrieve function to a worker thread,
awaiting its result so the event loop remains responsive. Preserve the existing
RAG_ENABLED branching, source formatting, and logging behavior.

Source: Path instructions

Comment thread rag.py
Comment on lines +53 to +66
def embed_text(text: str) -> list[float]:
"""Embed a single text string using Gemini text-embedding-004.

When _FAKE_EMBEDDING is set (tests), return that instead.
"""
if _FAKE_EMBEDDING is not None:
return _FAKE_EMBEDDING
import google.generativeai as genai

result = genai.embed_content(
model="models/text-embedding-004",
content=text,
)
return list(result["embedding"])

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

search()/embed_text() have no error handling — a single failed embedding call takes down /chat.

ChromaStore.__init__ deliberately catches init failures and degrades gracefully (matching the PR's "preserve chat behavior when retrieval is unavailable" goal), but search() and embed_text() don't mirror that. If genai.embed_content throws (timeout, quota, transient network error) or collection.query throws, the exception propagates all the way up through retrieve() into main.py's /chat try block, which is caught by the outer generic handler and turned into an HTTP 500 for the entire chat request — even though the PR objective is that RAG failures should never break general chat.

🛡️ Proposed fix — degrade to empty results on failure
     def search(
         self,
         query: str,
         top_k: int = RAG_TOP_K,
         min_score: float = RAG_MIN_SCORE,
     ) -> list[SourceDocument]:
         if not self._ready:
             logger.warning("Chroma not available — returning empty results")
             return []
 
-        query_emb = embed_text(query)
-        results = self._collection.query(
-            query_embeddings=[query_emb],
-            n_results=top_k,
-            include=["documents", "metadatas", "distances"],
-        )
+        try:
+            query_emb = embed_text(query)
+            results = self._collection.query(
+                query_embeddings=[query_emb],
+                n_results=top_k,
+                include=["documents", "metadatas", "distances"],
+            )
+        except Exception as exc:
+            logger.warning("RAG search failed: %s — returning empty results", exc)
+            return []

As per path instructions ("bare excepts that leak stack traces to clients"): without this guard, an embedding/query failure surfaces as a raw str(e) in the /chat HTTPException detail in main.py.

Also applies to: 150-187

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rag.py` around lines 53 - 66, Update embed_text() and the
ChromaStore.search() flow to catch embedding and collection.query failures, log
them appropriately, and return empty results instead of propagating exceptions.
Ensure retrieve() continues gracefully when either operation fails so RAG errors
cannot cause /chat to return an HTTP 500 or expose raw exception details.

Source: Path instructions

Comment thread rag.py
Comment on lines +105 to +126
def __init__(self, persist_dir: str = CHROMA_PERSIST_DIR) -> None:
self._persist_dir = persist_dir
self._collection: Any = None
try:
import chromadb
from chromadb.config import Settings

self._client = chromadb.PersistentClient(
path=persist_dir,
settings=Settings(anonymized_telemetry=False),
)
self._collection = self._client.get_or_create_collection(
name=CHROMA_COLLECTION,
)
logger.info(
"ChromaDB ready: persist_dir=%s count=%d",
persist_dir,
self._collection.count(),
)
except Exception as exc:
logger.warning("ChromaDB init failed: %s — RAG disabled", exc)
self._collection = None

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Map the relevant files first
git ls-files | rg '^(rag\.py|main\.py|requirements\.txt)$'

printf '\n--- rag.py outline ---\n'
ast-grep outline rag.py --view expanded || true

printf '\n--- main.py outline ---\n'
ast-grep outline main.py --view expanded || true

printf '\n--- rag.py relevant lines ---\n'
sed -n '1,280p' rag.py | cat -n | sed -n '1,280p'

printf '\n--- main.py relevant lines ---\n'
sed -n '1,260p' main.py | cat -n | sed -n '1,260p'

printf '\n--- requirements.txt ---\n'
sed -n '1,220p' requirements.txt | cat -n

Repository: Deen-Bridge/dnb-ai

Length of output: 22818


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Search for any explicit Chroma space configuration elsewhere in the repo.
rg -n "hnsw:space|configuration=\{|get_or_create_collection|PersistentClient|collection\.query|score = 1 - distance|distance" .

Repository: Deen-Bridge/dnb-ai

Length of output: 686


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# If available, inspect ChromaDB version pinning and any docs/comments around score semantics.
python3 - <<'PY'
from pathlib import Path
for path in [Path("requirements.txt"), Path("rag.py"), Path("main.py")]:
    if path.exists():
        print(f"\n=== {path} ===")
        for i, line in enumerate(path.read_text().splitlines(), 1):
            if "chroma" in line.lower() or "score" in line.lower() or "distance" in line.lower() or "query(" in line.lower():
                print(f"{i}: {line}")
PY

Repository: Deen-Bridge/dnb-ai

Length of output: 2029


🌐 Web query:

ChromaDB default collection distance metric hnsw:space default l2 query distance semantics 1 - distance cosine

💡 Result:

In ChromaDB, the default distance metric for HNSW collections is L2 (squared Euclidean distance) [1][2][3]. When configuring a collection, you can specify the distance metric using the hnsw:space parameter in the collection configuration [1][2]. The supported values for this parameter are: l2: Squared L2 (Euclidean) distance [2][3]. This is the default setting [1][2]. cosine: Cosine distance [1][2]. ChromaDB implements this as 1 - cosine_similarity [1][4][5]. Consequently, a distance of 0.0 indicates identical vectors (perfect similarity), while values closer to 2.0 indicate greater dissimilarity [5]. ip: Inner product [1][2]. Important technical considerations include: Collection Configuration: The distance metric (hnsw:space) must be defined at the time of collection creation [1]. It cannot be changed for an existing collection; you must clone the collection or create a new one to use a different metric [1][6]. Normalization: Because L2 and inner product distance metrics are sensitive to the magnitude of vectors, it is often recommended to normalize your embeddings if you want to use them for cosine-like comparisons or to ensure consistency across different models [3][7][8]. Distance vs. Similarity: ChromaDB returns distances in its query results [5][9]. When using the cosine distance metric, you can derive the similarity score using the formula: similarity = 1 - distance [5]. Lower distances always indicate higher similarity [5][9].

Citations:


Set Chroma’s distance space to match the score conversion. get_or_create_collection() leaves this collection on Chroma’s default L2 distance, but search() interprets results as cosine with 1 - distance. That can clamp valid hits to 0.0, break RAG_MIN_SCORE, and surface misleading scores to /chat clients. Create the collection with cosine space, and recreate/migrate any existing persisted collection if it was already initialized.

🧰 Tools
🪛 Ruff (0.15.21)

[warning] 124-124: Do not catch blind exception: Exception

(BLE001)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rag.py` around lines 105 - 126, Update the collection initialization in
__init__ to create CHROMA_COLLECTION with cosine distance metadata, matching
search()’s 1 - distance score conversion. Detect any existing persisted
collection using the incompatible distance space, migrate or recreate it with
cosine space while preserving its documents and embeddings, then retain the
existing collection setup and failure behavior.

Source: Path instructions

Comment thread rag.py
Comment on lines +243 to +248
def retrieve(query: str) -> list[SourceDocument]:
"""Convenience: search the singleton store."""
if not RAG_ENABLED:
logger.info("RAG disabled — skipping retrieval")
return []
return get_store().search(query)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

retrieve() performs blocking network + disk I/O and is called synchronously from the async /chat endpoint.

retrieve()search()embed_text() makes a synchronous HTTP call to Gemini and a synchronous Chroma disk query. main.py's chat() is async def and awaits other work (safety pipeline, model generation), but calls retrieve() directly with no await/offload — this blocks the event loop for the full round-trip of every RAG-enabled chat request, stalling all other concurrent requests on the same worker.

Consider making this async internally so callers can await it without changing call sites elsewhere:

import asyncio

async def retrieve(query: str) -> list[SourceDocument]:
    if not RAG_ENABLED:
        logger.info("RAG disabled — skipping retrieval")
        return []
    return await asyncio.to_thread(get_store().search, query)

As per path instructions, "Flag blocking calls inside async endpoints (network calls should be awaited or offloaded)" — see linked consolidated comment for the main.py call site.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rag.py` around lines 243 - 248, Make retrieve asynchronous and offload the
blocking get_store().search(query) operation to asyncio.to_thread, while
preserving the disabled-RAG early return and existing logging. Update the async
/chat caller to await retrieve so synchronous embedding, network, and disk work
does not block the event loop.

Source: Path instructions

Comment thread README.md
Comment on lines +76 to +91
To build the ChromaDB index, run the ingestion script after setup:

```bash
python scripts/ingest_corpus.py
```

This downloads the Saheeh International Quran translation (Tanzil.net) and
Sahih al-Bukhari hadith (permissively licensed GitHub export), chunks them
(one ayah per chunk, one hadith per chunk), embeds them via Gemini
`text-embedding-004`, and stores the vectors in the directory configured by
`CHROMA_PERSIST_DIR` (default `chroma_data/`). The raw corpus JSONL is
cached under `data/`.

Use `--reuse-cache` to skip re-downloading:
```bash
python scripts/ingest_corpus.py --reuse-cache

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Document how to enable RAG after ingestion.

The documented flow builds the index and starts the API, but RAG_ENABLED defaults to 0. Users following these steps will not receive retrieval or sources unless they independently discover that RAG_ENABLED=1 is required. Add that setting to the setup example or explicitly show the enablement step.

This follows the documented default in this README and the retrieval gate supplied from rag.py.

Also applies to: 100-103

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@README.md` around lines 76 - 91, The README ingestion instructions do not
enable retrieval after building the ChromaDB index. Update the setup example
around `scripts/ingest_corpus.py` to explicitly set `RAG_ENABLED=1` before
starting the API, or add a clear enablement step, while preserving the existing
ingestion and `--reuse-cache` commands.

Comment thread scripts/ingest_corpus.py
Comment on lines +169 to +179
def ingest(entries: list[dict], store: ChromaStore) -> None:
texts = [e["text"] for e in entries]
metadatas = [
{k: v for k, v in e.items() if k != "text"} for e in entries
]
ids = [
f"{e['source']}-{e.get('surah', e.get('hadith_number', idx))}"
for idx, e in enumerate(entries)
]
store.add_documents(texts, metadatas, ids)
logger.info("Ingested %d documents into Chroma", len(texts))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🔴 Critical | ⚡ Quick win

Critical: Quran ingestion IDs collide by surah, discarding almost the entire corpus.

ids is built as f"{e['source']}-{e.get('surah', e.get('hadith_number', idx))}". Every Quran entry has surah set (see download_quran, line 107), so all ayat in the same surah produce the identical id (e.g. every ayah of Al-Baqarah becomes "quran-2"). collection.add() requires unique ids within a batch — this will either raise on the duplicate ids or silently overwrite, leaving at most one ayah per surah (≤114 documents) instead of the full ~6,236-verse corpus.

🐛 Proposed fix — include the ayah/hadith_number in the id
     ids = [
-        f"{e['source']}-{e.get('surah', e.get('hadith_number', idx))}"
+        f"{e['source']}-{e.get('surah', '')}-{e.get('ayah', e.get('hadith_number', idx))}"
         for idx, e in enumerate(entries)
     ]

Do you want me to open a follow-up issue and generate a regression test asserting unique ids per surah/ayah pair?

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def ingest(entries: list[dict], store: ChromaStore) -> None:
texts = [e["text"] for e in entries]
metadatas = [
{k: v for k, v in e.items() if k != "text"} for e in entries
]
ids = [
f"{e['source']}-{e.get('surah', e.get('hadith_number', idx))}"
for idx, e in enumerate(entries)
]
store.add_documents(texts, metadatas, ids)
logger.info("Ingested %d documents into Chroma", len(texts))
def ingest(entries: list[dict], store: ChromaStore) -> None:
texts = [e["text"] for e in entries]
metadatas = [
{k: v for k, v in e.items() if k != "text"} for e in entries
]
ids = [
f"{e['source']}-{e.get('surah', '')}-{e.get('ayah', e.get('hadith_number', idx))}"
for idx, e in enumerate(entries)
]
store.add_documents(texts, metadatas, ids)
logger.info("Ingested %d documents into Chroma", len(texts))
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/ingest_corpus.py` around lines 169 - 179, Update the id construction
in ingest so Quran entries include both their surah and ayah identifiers,
producing a unique ID for every verse; retain hadith_number for hadith entries
and the index fallback for other records. Ensure the resulting ids passed to
store.add_documents are unique within the batch.

Comment thread tests/test_rag.py
Comment on lines +232 to +236
def test_rag_config_defaults():
# These are read from env at module import time.
assert RAG_TOP_K >= 1
assert RAG_MIN_SCORE >= 0.0
assert isinstance(RAG_ENABLED, bool)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Assert the documented defaults and disabled-retrieval behavior.

These checks allow incorrect values such as RAG_TOP_K=10 or RAG_MIN_SCORE=0.5, and they never verify that retrieve() returns [] when RAG_ENABLED is false. Isolate the environment/import and assert the exact defaults, plus the disabled gate described in rag.py.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_rag.py` around lines 232 - 236, The test_rag_config_defaults
function should isolate environment variables and module import so it validates
the documented exact default values for RAG_TOP_K, RAG_MIN_SCORE, and
RAG_ENABLED rather than broad ranges. Extend the test to exercise retrieve()
with RAG_ENABLED disabled and assert it returns an empty list, matching the
disabled gate in rag.py.

@zeemscript

Copy link
Copy Markdown
Contributor

@GEEKYFOCUS pls fix conflict

# Conflicts:
#	.github/workflows/ci.yml
#	README.md
#	main.py
#	requirements.txt

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
main.py (3)

191-194: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Include madhhab in the semantic-cache key.

madhhab now changes the generated system prompt, but cache eligibility and lookup remain based only on the prompt embedding. The same question can therefore return a response generated for a different madhhab. Include the normalized madhhab in the cache identity, or disable semantic caching when it is set.

Also applies to: 235-241

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@main.py` around lines 191 - 194, Update the semantic-cache eligibility and
lookup flow around normalize_madhhab, classify_fiqh, and FiqhInfo so the
normalized madhhab contributes to the cache identity whenever it is set. Ensure
cache reads and writes use the same madhhab-aware key, or disable semantic
caching for requests with a specified madhhab.

102-102: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Validate ChatRequest.madhhab at the model boundary.
normalize_madhhab() accepts aliases and silently maps unknown strings to None, so typos or unsupported values are just dropped. A Pydantic validator here would make the request contract explicit and avoid surprising fiqh behavior.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@main.py` at line 102, Update the ChatRequest model’s madhhab field validation
to reject unsupported or misspelled values at request parsing time, while
accepting the canonical madhhab names and aliases handled by
normalize_madhhab(). Preserve None as the unset value, and ensure invalid
non-null strings raise the model’s standard validation error instead of being
silently converted to None.

Source: Path instructions


115-116: 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Wire RAG into /chat. rag_sources is initialized but never populated, format_reference_passages(rag_sources) never reaches full_prompt, and ChatResponse still has no field for returning sources. That leaves RAG effectively disabled even when RAG_ENABLED is on. Populate the retrieval results, inject the formatted passages, and return them on both the cache-hit and normal paths; if retrieval stays synchronous, move it off the async request path.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@main.py` around lines 115 - 116, Update the /chat handler and its
ChatResponse construction to execute retrieval when RAG_ENABLED, populate
rag_sources, inject format_reference_passages(rag_sources) into full_prompt, and
expose the sources through a ChatResponse field. Return the same populated
sources on both cache-hit and normal response paths, and run synchronous
retrieval outside the async request path if no async implementation exists.

Source: Path instructions

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@main.py`:
- Around line 191-194: Update the semantic-cache eligibility and lookup flow
around normalize_madhhab, classify_fiqh, and FiqhInfo so the normalized madhhab
contributes to the cache identity whenever it is set. Ensure cache reads and
writes use the same madhhab-aware key, or disable semantic caching for requests
with a specified madhhab.
- Line 102: Update the ChatRequest model’s madhhab field validation to reject
unsupported or misspelled values at request parsing time, while accepting the
canonical madhhab names and aliases handled by normalize_madhhab(). Preserve
None as the unset value, and ensure invalid non-null strings raise the model’s
standard validation error instead of being silently converted to None.
- Around line 115-116: Update the /chat handler and its ChatResponse
construction to execute retrieval when RAG_ENABLED, populate rag_sources, inject
format_reference_passages(rag_sources) into full_prompt, and expose the sources
through a ChatResponse field. Return the same populated sources on both
cache-hit and normal response paths, and run synchronous retrieval outside the
async request path if no async implementation exists.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: f6a41256-4b62-4c7b-8f0b-e59dd392dde3

📥 Commits

Reviewing files that changed from the base of the PR and between 1468b8c and e27fc3f.

📒 Files selected for processing (3)
  • .github/workflows/ci.yml
  • README.md
  • main.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • README.md

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants