Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ jobs:
- name: Run study generator tests
run: pytest -q tests/test_study.py

- name: Run RAG tests
run: pytest -q tests/test_rag.py

- name: Run semantic cache tests
run: pytest -q tests/test_semantic_cache.py

Expand Down
6 changes: 5 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,8 @@ ENV/
*.swo

# Logs
*.log
*.log

# RAG / ChromaDB
data/
chroma_data/
Comment on lines +40 to +42

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.

27 changes: 26 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ The platform is composed of three services:
- 🤖 **Islamic context-aware responses** grounded in a curated system prompt
- 🧵 **Conversation history** per chat session
- 🛡️ **Content safety filters** on model output
- 📖 **RAG over Quran and Hadith** — retrieves relevant passages from a ChromaDB index and injects them into the prompt
- ⚡ **FastAPI** with automatic OpenAPI docs at `/docs`

## 🔗 API
Expand Down Expand Up @@ -73,16 +74,40 @@ uvicorn main:app --reload

The API runs at `http://localhost:8000` — interactive docs at `http://localhost:8000/docs`.

### RAG / Corpus Ingestion

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
Comment on lines +79 to +94

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.

```

### Environment Variables

| Variable | Description | Default |
|----------|-------------|---------|
| `GEMINI_API_KEY` | Google Gemini API key | — |
| `SAFETY_PIPELINE_ENABLED` | Layered policy enforcement | `true` |
| `RAG_ENABLED` | Enable retrieval-augmented generation over Quran/Hadith | `0` (disabled) |
| `RAG_TOP_K` | Number of passages to retrieve per query | `5` |
| `RAG_MIN_SCORE` | Minimum similarity score for retrieved passages | `0.0` |
| `CHROMA_PERSIST_DIR` | Directory for the persistent ChromaDB index | `chroma_data` |
| `SEMANTIC_CACHE_ENABLED` | Enable semantic response cache (`1`/`true`/`yes`) | `0` (disabled) |
| `SEMANTIC_CACHE_THRESHOLD` | Minimum cosine similarity for a cache hit | `0.95` |
| `SEMANTIC_CACHE_TTL_SECONDS` | Entry time-to-live in seconds | `86400` (24h) |
| `SEMANTIC_CACHE_MAX_ENTRIES` | Maximum cache entries (LRU eviction) | `1000` |
| `SAFETY_PIPELINE_ENABLED` | Layered policy enforcement; defaults to `true` |

### Content-safety testing

Expand Down
3 changes: 3 additions & 0 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
)
from hadith import HADITH_ADAB_CONTEXT, HadithReference, annotate as annotate_hadith, build_caution_note
from study import router as study_router
from rag import RAG_ENABLED, SourceDocument, format_reference_passages, retrieve

# Configure logging
logging.basicConfig(level=logging.INFO)
Expand Down Expand Up @@ -177,6 +178,8 @@ async def ping():

@app.post("/chat", response_model=ChatResponse)
async def chat(request: ChatRequest, http_request: Request, fastapi_response: Response):
rag_sources: list[SourceDocument] = []

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

Expand Down
248 changes: 248 additions & 0 deletions rag.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,248 @@
"""Retrieval-augmented generation over Quran and Hadith sources.

Store choice: ChromaDB in embedded (persistent local) mode.
-----------------------------------------------------------
The service is a single Render process with no database add-on.
Qdrant (separate server) and pgvector (needs Postgres) add operational
surface this repo does not have. Chroma runs in-process and persists to
disk. Behind a thin VectorStore interface so a later swap is a one-file
change.
"""

import logging
import os
from typing import Any, Optional

from pydantic import BaseModel

logger = logging.getLogger(__name__)

# ---------------------------------------------------------------------------
# Environment configuration
# ---------------------------------------------------------------------------

RAG_ENABLED = os.getenv("RAG_ENABLED", "0").lower() in ("1", "true", "yes")
RAG_TOP_K = int(os.getenv("RAG_TOP_K", "5"))
RAG_MIN_SCORE = float(os.getenv("RAG_MIN_SCORE", "0.0"))
CHROMA_PERSIST_DIR = os.getenv("CHROMA_PERSIST_DIR", "chroma_data")
CHROMA_COLLECTION = "islamic_sources"

# ---------------------------------------------------------------------------
# Data types
# ---------------------------------------------------------------------------


class SourceDocument(BaseModel):
text: str
reference: str
score: float


# ---------------------------------------------------------------------------
# Embedding seam
# ---------------------------------------------------------------------------

_FAKE_EMBEDDING: Optional[list[float]] = None


def set_fake_embedding(vec: Optional[list[float]]) -> None:
global _FAKE_EMBEDDING
_FAKE_EMBEDDING = vec


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"])
Comment on lines +53 to +66

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



def embed_batch(texts: list[str]) -> list[list[float]]:
return [embed_text(t) for t in texts]


# ---------------------------------------------------------------------------
# Vector store
# ---------------------------------------------------------------------------


class VectorStore:
"""Abstract interface for the RAG vector store."""

def add_documents(
self,
texts: list[str],
metadatas: list[dict[str, Any]],
ids: list[str],
) -> None:
raise NotImplementedError

def search(
self,
query: str,
top_k: int = 5,
min_score: float = 0.0,
) -> list[SourceDocument]:
raise NotImplementedError

@property
def count(self) -> int:
raise NotImplementedError


class ChromaStore(VectorStore):
"""ChromaDB-backed vector store, embedded/persistent mode."""

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
Comment on lines +105 to +126

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


@property
def _ready(self) -> bool:
return self._collection is not None

def add_documents(
self,
texts: list[str],
metadatas: list[dict[str, Any]],
ids: list[str],
) -> None:
if not self._ready:
logger.warning("Chroma not available — skipping add")
return
embeddings = embed_batch(texts)
self._collection.add(
embeddings=embeddings,
documents=texts,
metadatas=metadatas,
ids=ids,
)
logger.info("Added %d documents to Chroma collection", len(texts))

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"],
)

if not results["ids"] or not results["ids"][0]:
return []

documents: list[SourceDocument] = []
for i in range(len(results["ids"][0])):
distance = results["distances"][0][i] if results.get("distances") else 0.0
# Chroma returns L2-squared by default; convert to a similarity score.
# Cosine distance = 1 - cosine_similarity, so score = 1 - distance.
score = max(0.0, 1.0 - distance)
if score < min_score:
continue

meta = results["metadatas"][0][i] if results.get("metadatas") else {}
reference = _format_reference(meta)
documents.append(SourceDocument(
text=results["documents"][0][i],
reference=reference,
score=round(score, 4),
))

return documents

@property
def count(self) -> int:
if not self._ready:
return 0
return self._collection.count()


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


def _format_reference(meta: dict[str, Any]) -> str:
source = meta.get("source", "")
if source == "quran":
return f"Quran {meta.get('surah', '?')}:{meta.get('ayah', '?')}"
if source == "hadith":
parts = [meta.get("collection", "")]
if meta.get("book"):
parts.append(f"Book {meta['book']}")
if meta.get("hadith_number"):
parts.append(f"Hadith {meta['hadith_number']}")
return ", ".join(parts)
return source


def format_reference_passages(docs: list[SourceDocument]) -> str:
"""Format retrieved passages for injection into the model prompt."""
if not docs:
return ""
lines = ["\n\nReference passages:"]
for i, doc in enumerate(docs, 1):
lines.append(f"[{i}] {doc.reference} — {doc.text}")
lines.append(
"\nWhere relevant, prefer these passages over your training data. "
"Cite the reference in square brackets.\n"
)
return "\n".join(lines)


# ---------------------------------------------------------------------------
# Singleton store
# ---------------------------------------------------------------------------

_store: Optional[ChromaStore] = None


def get_store() -> ChromaStore:
global _store
if _store is None:
_store = ChromaStore()
return _store


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)
Comment on lines +243 to +248

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

1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@ google-generativeai==0.8.3
pydantic==2.5.3
stellar-sdk>=12.0.0
PyYAML==6.0.2
chromadb>=0.5.0
numpy==2.2.4
Loading
Loading