RAGNext.jsFastAPIQdrantVoyage AIClaude

How 3arif.ai Is Built: A Technical Deep Dive

The full engineering stack behind a multilingual Islamic knowledge RAG system — from embeddings to citation tooltips and the quality review gate.

3arif Engineering·8 June 2026·14 min read

Overview

3arif.ai is a retrieval-augmented generation (RAG) system for Islamic knowledge. Users ask questions in natural language and receive cited answers drawn from a corpus of primary sources: the Quran, seven major tafsirs (Quranic commentaries), and — in progress — twelve hadith collections spanning over 50,000 narrations.

This post covers the complete engineering stack: why we chose each component, how the pipeline works end-to-end, and the non-obvious decisions we made along the way.


Frontend

Framework: Next.js 16 (App Router) with TypeScript Styling: Tailwind CSS v4 with CSS custom properties for theming Auth: Clerk (optional — app works without an account) Hosting: Vercel

The frontend is a single-page chat interface with three sidebars: conversation history (desktop only), main chat, and a sources panel. We use the App Router with a `"use client"` chat component, which meant careful handling of `useSearchParams` requiring a `Suspense` boundary.

One deliberate choice: auth is optional. Clerk wraps the layout but the middleware makes all routes public. Signed-in users get conversation persistence and shareable links; guests get the full query experience. This eliminates friction for first-time visitors.

Streaming responses: The backend streams each answer over SSE, so the chat UI shows the response token-by-token as it is generated (appended to a live accumulator), then renders the finished message as Markdown with clickable citations. This feels far better than a flash-appearance, at zero extra infrastructure cost.

Response mode selector: The input includes a `+` dropdown with four modes — Short, Longer, Deep Research, Mystic. Each maps to a different system prompt and max token limit on the backend. The selected mode is passed as `?mode=mystic` in the URL when linking from the landing page, and read via `useSearchParams` in the chat component.


Backend

Framework: FastAPI (Python 3.11, async throughout) ORM: SQLAlchemy 2.0 with asyncpg Database: Neon (serverless PostgreSQL with pgvector extension) Hosting: Railway

The backend is a single FastAPI service with these primary endpoints:

``` POST /api/chat — RAG pipeline entry point GET /api/sources — list ingested sources GET /api/hadith/{book}/{number} — hadith lookup for isnad tooltip POST /api/conversations/save — persist conversation (auth required) GET /api/admin/stats — admin KPIs (admin key required) ```

The DB connection uses `pool_pre_ping=True` and `pool_recycle=300` — essential for Neon's serverless model, which closes idle connections aggressively. Without pre-ping, the first request after any idle period would fail silently.

Admin protection uses a simple `X-Admin-Key` header check rather than Clerk JWT verification on the backend. The admin key is server-side only in Vercel (`ADMIN_KEY`, not `NEXT_PUBLIC_`) and proxied through a Next.js API route — so it's never exposed to the browser.


The RAG Pipeline

``` User query │ ▼ embed_query() ──► voyage-multilingual-2 (1024 dims) │ ▼ query_chunks() ──► Qdrant cosine search, top-k=8 │ ▼ Fetch chunk metadata ──► Postgres JOIN (Chunk → Source) │ ▼ build_context() ──► "[SOURCE: Ibn Kathir Vol.3 — ...] text..." │ ▼ Claude API ──► mode-aware system prompt │ ▼ Cited response with [Q 2:255] and [bukhari:52] markers ```

The context window is built by joining the top-8 retrieved chunks with their reference labels. Each chunk is prefixed with `[SOURCE: label]` so Claude knows exactly what it's citing. The model is instructed never to fabricate citations — if the context doesn't contain the answer, it must say so.


Embeddings: Voyage AI

We originally used OpenAI's `text-embedding-3-small` (1536 dims). This worked for English content but would fail for the Arabic sources we're adding next.

We migrated to Voyage AI's `voyage-multilingual-2` (1024 dims):

  • English and Arabic text share the same vector space
  • An English query for "patience" surfaces Arabic content about "صبر"
  • 200M free tokens on the paid tier — our full corpus fits with room to spare
  • Anthropic's recommended embedding partner

Re-embedding 51,404 existing chunks took approximately 90 minutes at Voyage's standard rate limits.


Vector Store: Qdrant Cloud

Qdrant stores the embeddings with the following payload per vector:

```json { "source_slug": "ibn-kathir", "surah": 2, "ayah_start": 255, "reference_label": "Tafsir Ibn Kathir Vol.1 — Surah Al-Baqarah", "content": "first 500 chars of chunk..." } ```

We use cosine distance. The `get_collection()` function returns a thin wrapper around Qdrant's client with a `get(ids=[...])` method used by ingestion scripts to check which chunks are already embedded — enabling resumable ingestion.

The migration script for the OpenAI → Voyage switch deleted the old 1536-dim collection, created a new 1024-dim one, then re-embedded in batches of 64.


Knowledge Base

| Category | Works | Chunks | |----------|-------|--------| | Quran | 1 | 6,236 | | Tafsīr (English + Arabic) | 15 | 82,000+ | | Ḥadīth (Sunnī + Jaʿfarī) | 10 | 56,000+ | | Primary Texts (theology, spirituality) | 4 | 16,784 | | Law / Sharīʿa | 3+ | 5,000+ | | History & Sīra | 2+ | growing | | Total | 32+ | 227,000+ |

The knowledge base spans 14 centuries — from Al-Ṭabarī (923 CE) to the contemporary legal rulings of Sayyid al-Sīstānī. Every source is either an accepted scholarly work or an authenticated primary text. Nothing is sourced from Wikipedia, AI-generated summaries, or unverified websites.

Arabic sources: With Voyage's multilingual embeddings, Arabic texts (Al-Rāzī, Al-Zamakhsharī, Al-Alūsī, Kashf al-Murād, Musnad Aḥmad) sit in the same semantic vector space as English texts. A query in English retrieves relevant Arabic passages correctly.

Ingestion pattern: Every source follows the same pipeline: fetch text → chunk at ~800 chars with 100-char overlap → embed in batches → upsert to Qdrant → store chunk metadata in Postgres. Scripts are resumable: they check Qdrant for existing IDs before embedding, so a mid-run failure just restarts from where it stopped.


LLM: Claude (Anthropic)

We use `claude-sonnet-4-6` for generation. The system prompt varies by mode:

  • Short: 1,024 max tokens, 2–4 sentences, one citation
  • Long: 4,096 tokens, full academic structure
  • Deep Research: 8,096 tokens, all sources cross-referenced
  • Mystic: 2,048 tokens, Sufi/esoteric lens, contemplative language

All modes share the same non-negotiable citation rules. Hadith citations must use `[bukhari:52]` format so the frontend can intercept them and render interactive isnad tooltips.


Hadith Grading & Isnad Tooltips

Every hadith in the DB has:

```python class HadithRecord(Base): book_slug: str # bukhari, tirmidhi, kulayni… compiler: str # Al-Bukhari, Al-Tirmidhi… hadith_number: int isnad_en: str # chain of narrators, English grade: str # sahih | hasan | daif | mawdu | unknown grade_source: str # Al-Bukhari, Al-Albani, Al-Majlisi… ```

When the model outputs `[bukhari:52]`, the `ChatMessage` component detects it with a regex, replaces it with a `HadithTooltip` React component, and on hover fetches `GET /api/hadith/bukhari/52`. The tooltip renders the narrator chain and a colour-coded grade badge:

  • 🟢 Sahih — fully authenticated
  • 🔵 Hasan — good, reliable
  • 🟡 Da'if — weak, disclosed to user
  • 🔴 Mawdū' — fabricated, never cited

Infrastructure

| Component | Service | Notes | |-----------|---------|-------| | Frontend | Vercel (Pro) | Auto-deploys from GitHub master | | Backend | Railway | Procfile: `uvicorn app.main:app --host 0.0.0.0 --port ${PORT}` | | Database | Neon (serverless Postgres) | pgvector extension, EU West region | | Vectors | Qdrant Cloud | Free tier (1M vectors), São Paulo | | Embeddings | Voyage AI | voyage-multilingual-2, 200M free tokens | | LLM | Anthropic Claude API | claude-sonnet-4-6 | | Auth | Clerk | Google + email, optional |

The Voyage and Qdrant regions aren't co-located (Voyage is US, Qdrant is BR), which adds ~60ms latency to the embedding step. A future optimisation would be to move Qdrant to a US region.


What's Next

  • Arabic tafsir ingestion — 33 more tafsirs, Arabic text, Voyage handles the bilingual embedding
  • Fanar-2-27B integration — QCRI's Arabic-focused LLM as an optional generation model for Arabic-language queries
  • Streaming responses — true server-sent events from Claude rather than the simulated typewriter effect
  • Source filtering UI — let users restrict retrieval to specific collections

The codebase is private but the architecture documented here should be reproducible. Questions welcome.