MuhammadHamza33 commited on
Commit
6284bdd
·
verified ·
1 Parent(s): f966549

Update rag_pipeline.py

Browse files
Files changed (1) hide show
  1. rag_pipeline.py +910 -897
rag_pipeline.py CHANGED
@@ -1,898 +1,911 @@
1
- # ─────────────────────────────────────────────────────────────────────────────
2
- # RAG-e-Qanoon | rag_backend/rag_pipeline.py
3
- # Best config from ablation study:
4
- # Chunking : fixed, 512 chars / 80 overlap
5
- # RRF : semantic=0.5, bm25=0.5
6
- # Top-k : 5 final chunks
7
- # Temperature: 0.3
8
- # Re-ranker : ON (bge-reranker-v2-m3)
9
- # LLM : Qwen/Qwen2.5-14B-Instruct (fallback → Qwen2.5-7B-Instruct)
10
- # ─────────────────────────────────────────────────────────────────────────────
11
-
12
- # ─────────────────────────────────────────────
13
- # 1. IMPORTS & CONFIGURATION
14
- # ─────────────────────────────────────────────
15
- import os
16
- import re
17
- import time
18
- import json
19
- import numpy as np
20
- from typing import List, Dict, Tuple, Optional
21
-
22
- from sentence_transformers import SentenceTransformer, CrossEncoder
23
- from rank_bm25 import BM25Okapi
24
- from pinecone import Pinecone, ServerlessSpec
25
- from huggingface_hub import InferenceClient
26
- from sklearn.metrics.pairwise import cosine_similarity
27
- from langchain_text_splitters import RecursiveCharacterTextSplitter
28
- import traceback
29
-
30
- DEBUG = True
31
-
32
- def dbg(*args):
33
- if DEBUG:
34
- print("[DEBUG]", *args)
35
- # ── On HF Spaces, secrets are injected directly as env vars.
36
- # ── Locally, you can still use a .env file — just call load_dotenv() before
37
- # ── importing this module. We do NOT call load_dotenv() here so that HF
38
- # ── Spaces (which has no .env) doesn't throw an import error.
39
-
40
- # ── LLM model registry ────────────────────────────────────────────────────
41
- # ── LLM model registry ────────────────────────────────────────────────────
42
- LLM_PRIMARY = "Qwen/Qwen3-14B:nscale"
43
- LLM_FALLBACK = "Qwen/Qwen2.5-7B-Instruct"
44
- # ── Embedding / re-ranker ─────────────────────────────────────────────────
45
- EMBEDDING_MODEL_NAME = "BAAI/bge-m3"
46
- EMBEDDING_DIM = 1024
47
- RERANKER_MODEL_NAME = "BAAI/bge-reranker-v2-m3"
48
-
49
- # ── Pinecone ──────────────────────────────────────────────────────────────
50
- PINECONE_INDEX_NAME = "legal-urdu-rag-v3"
51
-
52
- # ── BEST chunking config (ablation winner: fixed 512/80) ─────────────────
53
- FIXED_CHUNK_SIZE = 512
54
- FIXED_CHUNK_OVERLAP = 80
55
-
56
- # ── Kept for ingestion flexibility ───────────────────────────────────────
57
- RECURSIVE_CHUNK_SIZE = 400
58
- RECURSIVE_CHUNK_OVERLAP = 80
59
-
60
- # ── Retrieval ──────────────────────────────────────────────────────────────
61
- TOP_K_RETRIEVAL = 20 # candidates fetched from each retriever
62
- TOP_K_FINAL = 5 # chunks handed to LLM after re-ranking
63
-
64
- # ── RRF weights (ablation winner: 0.5 / 0.5) ─────────────────────────────
65
- RRF_SEMANTIC_WEIGHT = 0.5
66
- RRF_BM25_WEIGHT = 0.5
67
-
68
- # ── Generation (ablation winner: temp 0.3) ────────────────────────────────
69
- GENERATION_TEMPERATURE = 0.3
70
- GENERATION_MAX_TOKENS = 512
71
-
72
- print("RAG-e-Qanoon configuration loaded.")
73
-
74
-
75
- # ─────────────────────────────────────────────
76
- # 2. LOAD API KEYS (env-first, no hard crash on missing .env)
77
- # ─────────────────────────────────────────────
78
- def load_api_keys() -> Tuple[str, str]:
79
- """
80
- Read HF_TOKEN and PINECONE_KEY from environment variables.
81
- On HF Spaces: set them in Settings → Secrets.
82
- Locally: export them, or call load_dotenv() before importing this module.
83
- """
84
- hf_token = os.environ.get("HF_TOKEN", "")
85
- pinecone_key = os.environ.get("PINECONE_KEY", "")
86
-
87
- if not hf_token:
88
- raise ValueError("HF_TOKEN not found in environment. Add it to HF Space Secrets.")
89
- if not pinecone_key:
90
- raise ValueError("PINECONE_KEY not found in environment. Add it to HF Space Secrets.")
91
-
92
- print(f"HF_TOKEN : {hf_token[:8]}…")
93
- print(f"PINECONE_KEY: {pinecone_key[:8]}…")
94
- return hf_token, pinecone_key
95
-
96
-
97
- # ─────────────────────────────────────────────
98
- # 3. LOAD CLEANED JSON DATA
99
- # ─────────────────────────────────────────────
100
- def load_documents_from_json(json_path: str) -> Tuple[List[str], List[Dict]]:
101
- """Load pre-processed cleaned Urdu text from JSON produced by cleancode.py."""
102
- print(f"Loading documents from {json_path}…")
103
- if not os.path.exists(json_path):
104
- raise FileNotFoundError(f"Cannot find {json_path}. Run cleancode.py first.")
105
-
106
- with open(json_path, "r", encoding="utf-8") as f:
107
- data = json.load(f)
108
-
109
- texts = [item["text"] for item in data]
110
- metadata = [item["meta"] for item in data]
111
- print(f"Loaded {len(texts)} documents.")
112
- return texts, metadata
113
-
114
-
115
- # ─────────────────────────────────────────────
116
- # 4. CHUNKING STRATEGIES
117
- # ─────────────────────────────────────────────
118
- def chunk_fixed(text: str,
119
- chunk_size: int = FIXED_CHUNK_SIZE,
120
- overlap: int = FIXED_CHUNK_OVERLAP) -> List[str]:
121
- chunks, start = [], 0
122
- text = text.strip()
123
- while start < len(text):
124
- chunk = text[start : start + chunk_size].strip()
125
- if chunk:
126
- chunks.append(chunk)
127
- start += chunk_size - overlap
128
- return chunks
129
-
130
-
131
- def chunk_recursive(text: str,
132
- chunk_size: int = RECURSIVE_CHUNK_SIZE,
133
- overlap: int = RECURSIVE_CHUNK_OVERLAP) -> List[str]:
134
- splitter = RecursiveCharacterTextSplitter(
135
- separators=["\n\n", "\n", "۔", "؟", "!", " ", ""],
136
- chunk_size=chunk_size,
137
- chunk_overlap=overlap,
138
- length_function=len,
139
- )
140
- return [c.strip() for c in splitter.split_text(text) if c.strip()]
141
-
142
-
143
- def chunk_sentence(text: str,
144
- min_length: int = 50,
145
- max_length: int = 500) -> List[str]:
146
- raw, chunks, current = re.split(r'(?<=[۔؟!])\s*', text.strip()), [], ""
147
- for sent in raw:
148
- sent = sent.strip()
149
- if not sent:
150
- continue
151
- if len(current) + len(sent) <= max_length:
152
- current = (current + " " + sent).strip()
153
- else:
154
- if len(current) >= min_length:
155
- chunks.append(current)
156
- current = sent
157
- if len(current) >= min_length:
158
- chunks.append(current)
159
- return chunks
160
-
161
-
162
- def chunk_documents(texts: List[str],
163
- strategy: str = "fixed",
164
- metadata: Optional[List[Dict]] = None) -> List[Dict]:
165
- if metadata is None:
166
- metadata = [{"source": f"doc_{i}"} for i in range(len(texts))]
167
-
168
- chunk_fn = {"fixed": chunk_fixed,
169
- "recursive": chunk_recursive,
170
- "sentence": chunk_sentence}.get(strategy, chunk_fixed)
171
-
172
- all_chunks, chunk_idx = [], 0
173
- for doc_idx, (text, meta) in enumerate(zip(texts, metadata)):
174
- for position, chunk_text in enumerate(chunk_fn(text)):
175
- all_chunks.append({
176
- "id": f"chunk_{chunk_idx}",
177
- "text": chunk_text,
178
- "metadata": {**meta, "doc_idx": doc_idx,
179
- "position": position, "strategy": strategy},
180
- })
181
- chunk_idx += 1
182
-
183
- print(f"Chunked {len(texts)} docs → {len(all_chunks)} chunks (strategy: {strategy})")
184
- return all_chunks
185
-
186
-
187
- # ─────────────────────────────────────────────
188
- # 5. EMBEDDING MODEL
189
- # ─────────────────────────────────────────────
190
- class EmbeddingModel:
191
-
192
- def __init__(self):
193
- print(f"Loading embedding model: {EMBEDDING_MODEL_NAME}")
194
- self.model = SentenceTransformer(EMBEDDING_MODEL_NAME)
195
- print("Embedding model loaded.")
196
-
197
- def embed(self, texts: List[str], batch_size: int = 32) -> np.ndarray:
198
- return self.model.encode(texts, batch_size=batch_size,
199
- show_progress_bar=True, normalize_embeddings=True)
200
-
201
- def embed_query(self, query: str) -> np.ndarray:
202
- return self.model.encode(query, normalize_embeddings=True)
203
-
204
-
205
- # ─────────────────────────────────────────────
206
- # 6. PINECONE VECTOR DATABASE
207
- # ───────���─────────────────────────────────────
208
- class PineconeDB:
209
-
210
- def __init__(self, api_key: str, index_name: str = PINECONE_INDEX_NAME):
211
- self.pc = Pinecone(api_key=api_key)
212
- existing = [idx.name for idx in self.pc.list_indexes()]
213
- if index_name not in existing:
214
- print(f"Creating Pinecone index '{index_name}'…")
215
- self.pc.create_index(
216
- name=index_name, dimension=EMBEDDING_DIM, metric="cosine",
217
- spec=ServerlessSpec(cloud="aws", region="us-east-1"),
218
- )
219
- time.sleep(30)
220
- else:
221
- print(f"Connected to existing Pinecone index: '{index_name}'")
222
- self.index = self.pc.Index(index_name)
223
-
224
- def upsert_chunks(self, chunks: List[Dict], embeddings: np.ndarray,
225
- batch_size: int = 100):
226
- vectors = [
227
- {"id": c["id"], "values": e.tolist(),
228
- "metadata": {**c["metadata"], "text": c["text"][:1000]}}
229
- for c, e in zip(chunks, embeddings)
230
- ]
231
- total = 0
232
- for i in range(0, len(vectors), batch_size):
233
- self.index.upsert(vectors=vectors[i : i + batch_size])
234
- total += len(vectors[i : i + batch_size])
235
- print(f" ↑ Upserted {total}/{len(vectors)} vectors")
236
- print(f"All {len(vectors)} chunks stored in Pinecone.")
237
-
238
- def semantic_search(self, query_embedding: np.ndarray,
239
- top_k: int = TOP_K_RETRIEVAL) -> List[Dict]:
240
- results = self.index.query(vector=query_embedding.tolist(),
241
- top_k=top_k, include_metadata=True)
242
- return [{"id": m.id, "score": m.score,
243
- "text": m.metadata.get("text", ""), "metadata": m.metadata}
244
- for m in results.matches]
245
-
246
- def get_stats(self) -> Dict:
247
- return self.index.describe_index_stats()
248
-
249
-
250
- # ─────────────────────────────────────────────
251
- # 7. BM25 KEYWORD SEARCH
252
- # ─────────────────────────────────────────────
253
- class BM25Retriever:
254
-
255
- def __init__(self):
256
- self.bm25 = None
257
- self.chunks = []
258
-
259
- def build_index(self, chunks: List[Dict]):
260
- self.chunks = chunks
261
- self.bm25 = BM25Okapi([self._tokenize(c["text"]) for c in chunks])
262
- print(f"BM25 index built on {len(chunks)} chunks.")
263
-
264
- def _tokenize(self, text: str) -> List[str]:
265
- text = re.sub(r"[^\w\s\u0600-\u06FF]", " ", text)
266
- return [t for t in text.split() if t]
267
-
268
- def search(self, query: str, top_k: int = TOP_K_RETRIEVAL) -> List[Dict]:
269
- if self.bm25 is None:
270
- raise RuntimeError("BM25 index not built yet.")
271
- scores = self.bm25.get_scores(self._tokenize(query))
272
- top_indices = np.argsort(scores)[::-1][:top_k]
273
- return [{"id": self.chunks[i]["id"], "score": float(scores[i]),
274
- "text": self.chunks[i]["text"],
275
- "metadata": self.chunks[i].get("metadata", {})}
276
- for i in top_indices]
277
-
278
-
279
- # ─────────────────────────────────────────────
280
- # 8. RECIPROCAL RANK FUSION (best: 0.5 / 0.5)
281
- # ─────────────────────────────────────────────
282
- def reciprocal_rank_fusion(
283
- semantic_hits: List[Dict],
284
- bm25_hits: List[Dict],
285
- k: int = 60,
286
- semantic_weight: float = RRF_SEMANTIC_WEIGHT,
287
- bm25_weight: float = RRF_BM25_WEIGHT,
288
- ) -> List[Dict]:
289
- fused_scores: Dict[str, float] = {}
290
- chunk_store: Dict[str, Dict] = {}
291
-
292
- for rank, hit in enumerate(semantic_hits, 1):
293
- cid = hit["id"]
294
- fused_scores[cid] = fused_scores.get(cid, 0.0) + semantic_weight / (k + rank)
295
- chunk_store[cid] = hit
296
-
297
- for rank, hit in enumerate(bm25_hits, 1):
298
- cid = hit["id"]
299
- fused_scores[cid] = fused_scores.get(cid, 0.0) + bm25_weight / (k + rank)
300
- chunk_store.setdefault(cid, hit)
301
-
302
- results = []
303
- for cid in sorted(fused_scores, key=fused_scores.get, reverse=True):
304
- hit = chunk_store[cid].copy()
305
- hit["rrf_score"] = fused_scores[cid]
306
- results.append(hit)
307
- return results
308
-
309
-
310
- # ─────────────────────────────────────────────
311
- # 9. CROSS-ENCODER RE-RANKER (always ON)
312
- # ─────────────────────────────────────────────
313
- class Reranker:
314
-
315
- def __init__(self):
316
- print("Loading CrossEncoder re-ranker…")
317
- self.model = CrossEncoder(RERANKER_MODEL_NAME)
318
- print("Re-ranker loaded.")
319
-
320
- def rerank(self, query: str, candidates: List[Dict],
321
- top_k: int = TOP_K_FINAL) -> List[Dict]:
322
- if not candidates:
323
- return []
324
- scores = self.model.predict([(query, c["text"]) for c in candidates])
325
- for chunk, score in zip(candidates, scores):
326
- chunk["rerank_score"] = float(score)
327
- return sorted(candidates, key=lambda x: x["rerank_score"], reverse=True)[:top_k]
328
-
329
-
330
- # ─────────────────────────────────────────────
331
- # 10. LLM GENERATOR (14B primary, 7B fallback)
332
- # ─────────────────────────────────────────────
333
- class LLMGenerator:
334
- """
335
- Calls the HF Inference API.
336
- Tries Qwen2.5-14B-Instruct first; falls back to 7B if the free tier
337
- returns a model-too-large / memory error.
338
- Conversation memory (last N turns from MongoDB) is injected into the
339
- system prompt so the model has cross-turn context.
340
- """
341
-
342
- def __init__(self, hf_token: str):
343
- self.hf_token = hf_token
344
- self.model_name = LLM_PRIMARY
345
- self.client = InferenceClient(model=self.model_name, token=hf_token)
346
- print(f"LLM primary: {self.model_name}")
347
-
348
- def _switch_to_fallback(self):
349
- print(f"Switching to fallback LLM: {LLM_FALLBACK}")
350
- self.model_name = LLM_FALLBACK
351
- self.client = InferenceClient(model=self.model_name, token=self.hf_token)
352
-
353
- # ── Prompt builders ────────────────────────────────────────────────────
354
-
355
- @staticmethod
356
- def _format_context(chunks: List[Dict]) -> str:
357
- return "\n".join(
358
- f"[دستاویز {i}]:\n{c['text']}" for i, c in enumerate(chunks, 1)
359
- )
360
-
361
- @staticmethod
362
- def _format_memory(conversation_history: List[Dict]) -> str:
363
- """
364
- Converts the last N messages from MongoDB into a readable Urdu
365
- conversation string that is injected into the system prompt.
366
- Each entry: {"role": "user"|"assistant", "content": "..."}
367
- """
368
- if not conversation_history:
369
- return ""
370
- lines = []
371
- for msg in conversation_history:
372
- role_label = "صارف" if msg["role"] == "user" else "معاون"
373
- lines.append(f"{role_label}: {msg['content']}")
374
- return "\n".join(lines)
375
-
376
- def _build_messages(self, query: str, context_chunks: List[Dict],
377
- conversation_history: List[Dict]) -> List[Dict]:
378
- """
379
- Returns a messages list for chat_completion.
380
-
381
- System prompt contains:
382
- - Role definition (Urdu legal advisor)
383
- - Retrieved legal context
384
- - Recent conversation memory (from MongoDB)
385
-
386
- User turn contains the current query only.
387
- """
388
- context_text = self._format_context(context_chunks)
389
- memory_text = self._format_memory(conversation_history)
390
-
391
- memory_section = ""
392
- if memory_text:
393
- memory_section = (
394
- "\n\n=== گزشتہ گفتگو (حوالے کے لیے) ===\n"
395
- f"{memory_text}\n"
396
- "=== گزشتہ گفتگو ختم ===\n"
397
- )
398
-
399
- system_prompt = (
400
- "آپ ایک ماہر پاکستانی قانونی مشیر ہیں۔ "
401
- "آپ کا کام صرف نیچے دیے گئے قانونی دستاویزات کی بنیاد پر سوال کا جواب دینا ہے۔\n"
402
- "اگر جواب دستاویزات میں موجود نہ ہو تو صاف کہیں: "
403
- "\"یہ معلومات دستیاب دستاویزات میں موجود نہیں ہیں۔\"\n"
404
- "اپنی طرف سے کوئی بات نہ بنائیں۔\n\n"
405
- "اگر ممکن ہو تو جواب مختصر اور سادہ رکھیں، اور دستاویز نمبر یا غیر ضروری حوالہ نہ دیں جب تک وہ بالکل واضح نہ ہو۔\n"
406
- "=== قانونی دستاویزات ===\n"
407
- f"{context_text}\n"
408
- "=== دستاویزات ختم ==="
409
- f"{memory_section}"
410
- )
411
-
412
- return [
413
- {"role": "system", "content": system_prompt},
414
- {"role": "user", "content": query},
415
- ]
416
-
417
- # ── Public generate method ─────────────────────────────────────────────
418
-
419
- def generate(self, query: str, context_chunks: List[Dict],
420
- conversation_history: Optional[List[Dict]] = None,
421
- max_new_tokens: int = GENERATION_MAX_TOKENS) -> Tuple[str, float]:
422
-
423
- if conversation_history is None:
424
- conversation_history = []
425
-
426
- messages = self._build_messages(query, context_chunks, conversation_history)
427
- start = time.time()
428
-
429
- dbg("=" * 80)
430
- dbg("GENERATION START")
431
- dbg("model:", self.model_name)
432
- dbg("query:", query)
433
- dbg("context chunk count:", len(context_chunks))
434
- dbg("history count:", len(conversation_history))
435
- for i, c in enumerate(context_chunks[:3]):
436
- dbg(f"context[{i}] text={c.get('text','')[:200]}")
437
- dbg("system prompt preview:", messages[0]["content"][:500])
438
- dbg("user prompt preview:", messages[1]["content"][:300])
439
-
440
- try:
441
- response = self.client.chat_completion(
442
- messages=messages,
443
- max_tokens=max_new_tokens,
444
- temperature=GENERATION_TEMPERATURE,
445
- )
446
- answer = response.choices[0].message["content"].strip()
447
- dbg("raw answer preview:", answer[:500])
448
-
449
- except Exception as e:
450
- print("[ERROR] primary LLM call failed:", e)
451
- traceback.print_exc()
452
- err_str = str(e).lower()
453
-
454
- if any(kw in err_str for kw in
455
- ["model too large", "out of memory", "oom", "422",
456
- "loading", "quota", "rate limit", "too large"]):
457
- self._switch_to_fallback()
458
- dbg("switched to fallback:", self.model_name)
459
- try:
460
- response = self.client.chat_completion(
461
- messages=messages,
462
- max_tokens=max_new_tokens,
463
- temperature=GENERATION_TEMPERATURE,
464
- )
465
- answer = response.choices[0].message["content"].strip()
466
- dbg("fallback answer preview:", answer[:500])
467
- except Exception as e2:
468
- print("[ERROR] fallback LLM call failed:", e2)
469
- traceback.print_exc()
470
- answer = f"[LLM Error (fallback): {e2}]"
471
- else:
472
- answer = f"[LLM Error: {e}]"
473
-
474
- dbg("GENERATION END")
475
- dbg("=" * 80)
476
- return answer, round(time.time() - start, 2)
477
-
478
- # ─────────────────────────────────────────────
479
- # 11. LLM-AS-A-JUDGE
480
- # ─────────────────────────────────────────────
481
- class LLMJudge:
482
- """Faithfulness (claim verification) + Relevancy (query similarity)."""
483
-
484
- def __init__(self, hf_token: str, embedding_model: EmbeddingModel):
485
- # Judge always uses the primary LLM; falls back transparently
486
- self.llm_gen = LLMGenerator(hf_token)
487
- self.embedding_model = embedding_model
488
- # Lightweight client for short judge calls (no memory needed)
489
- self.client = InferenceClient(model=self.llm_gen.model_name, token=hf_token)
490
- print(f"LLM Judge ready: {self.llm_gen.model_name}")
491
-
492
- def _judge_call(self, prompt: str, max_tokens: int = 300,
493
- temperature: float = 0.1) -> str:
494
- try:
495
- r = self.client.chat_completion(
496
- messages=[{"role": "user", "content": prompt}],
497
- max_tokens=max_tokens,
498
- temperature=temperature,
499
- )
500
-
501
- msg = r.choices[0].message
502
- content = None
503
-
504
- if isinstance(msg, dict):
505
- content = msg.get("content")
506
- else:
507
- content = getattr(msg, "content", None)
508
-
509
- if content is None:
510
- return ""
511
-
512
- if isinstance(content, list):
513
- parts = []
514
- for item in content:
515
- if isinstance(item, dict):
516
- parts.append(item.get("text", ""))
517
- else:
518
- parts.append(str(item))
519
- content = "".join(parts)
520
-
521
- return str(content).strip()
522
-
523
- except Exception as e:
524
- print("[ERROR] judge call failed:", e)
525
- return ""
526
-
527
- def extract_claims(self, answer: str) -> List[str]:
528
- prompt = (
529
- "نیچے دیے گئے جواب سے تمام حقائق اور دعوے نکالیں۔\n"
530
- "ہر دعوہ الگ لائن پر نمبر کے ساتھ لکھیں (1. 2. 3.)\n"
531
- "صرف فہرست لکھیں۔\n\nجواب:\n" + answer + "\n\nدعووں کی فہرست:"
532
- )
533
- text = self._judge_call(prompt, max_tokens=300)
534
- claims = []
535
- for line in text.split("\n"):
536
- line = line.strip()
537
- clean = re.sub(r"^[\d\-\.\)]+\s*", "", line).strip()
538
- if clean:
539
- claims.append(clean)
540
- return claims or [answer]
541
-
542
- def verify_claim(self, claim: str, context: str):
543
- prompt = (
544
- "نیچے دیا گیا دعویٰ متن سے ثابت ہوتا ہے؟\n"
545
- "صرف ایک لفظ میں جواب دیں: \"ہاں\" یا \"نہیں\"\n\n"
546
- f"متن:\n{context[:1800]}\n\nدعویٰ: {claim}\n\nجواب:"
547
- )
548
- answer = self._judge_call(prompt, max_tokens=20).lower().strip()
549
-
550
- if not answer:
551
- return None
552
- if any(w in answer for w in ["ہاں", "yes", "supported", "true", "han"]):
553
- return True
554
- if any(w in answer for w in ["نہیں", "no", "false", "unsupported"]):
555
- return False
556
- return None
557
- def compute_faithfulness(self, answer: str, context_chunks: List[Dict]) -> Dict:
558
- dbg("FAITHFULNESS START")
559
- dbg("answer preview:", answer[:300])
560
-
561
- ctx_text = " ".join(c["text"] for c in context_chunks)
562
- dbg("context length:", len(ctx_text))
563
-
564
- claims = self.extract_claims(answer)
565
- dbg("claims extracted:", claims)
566
-
567
- verified = []
568
- raw_verifications = []
569
-
570
- for c in claims:
571
- v = self.verify_claim(c, ctx_text)
572
- dbg("claim check:", c[:120], "=>", v)
573
- raw_verifications.append(v)
574
-
575
- if v is not None:
576
- verified.append(v)
577
-
578
- if not verified:
579
- return {
580
- "score": None,
581
- "claims": claims,
582
- "verifications": raw_verifications,
583
- "supported_count": 0,
584
- "total_claims": 0,
585
- "error": "Judge returned no usable verification output"
586
- }
587
-
588
- supported = sum(verified)
589
- total = len(verified)
590
-
591
- result = {
592
- "score": round(supported / total, 3),
593
- "claims": claims,
594
- "verifications": raw_verifications,
595
- "supported_count": supported,
596
- "total_claims": total,
597
- }
598
- dbg("faithfulness result:", result)
599
- dbg("FAITHFULNESS END")
600
- return result
601
-
602
-
603
- def compute_relevancy(self, original_query: str, answer: str) -> Dict:
604
- dbg("RELEVANCY START")
605
- dbg("original query:", original_query)
606
- dbg("answer preview:", answer[:300])
607
-
608
- questions = self.generate_questions_from_answer(answer)
609
- dbg("generated questions:", questions)
610
-
611
- if not questions:
612
- dbg("no generated questions -> relevancy 0.0")
613
- return {"score": 0.0, "generated_questions": [], "similarities": []}
614
-
615
- q_emb = self.embedding_model.embed_query(original_query)
616
- q_embs = self.embedding_model.embed(questions)
617
-
618
- sims = [
619
- round(float(cosine_similarity(q_emb.reshape(1, -1), e.reshape(1, -1))[0][0]), 3)
620
- for e in q_embs
621
- ]
622
-
623
- result = {
624
- "score": round(float(np.mean(sims)), 3) if sims else 0.0,
625
- "generated_questions": questions,
626
- "similarities": sims,
627
- }
628
- dbg("relevancy result:", result)
629
- dbg("RELEVANCY END")
630
- return result
631
-
632
- def generate_questions_from_answer(self, answer: str) -> List[str]:
633
- prompt = (
634
- "نیچے دیے گئے جواب سے 3 سوالات بنائیں۔\n"
635
- "ہر سوال نئی لائن پر۔ صرف سوالات لکھیں۔\n\n"
636
- "جواب:\n" + answer + "\n\nتین سوالات:\n1."
637
- )
638
- full_text = "1." + self._judge_call(prompt, max_tokens=200, temperature=0.4)
639
- questions = []
640
- for line in full_text.split("\n"):
641
- clean = re.sub(r"^[\d\.\)]+\s*", "", line.strip()).strip()
642
- if clean and len(clean) > 5:
643
- questions.append(clean)
644
- return questions[:3]
645
-
646
-
647
-
648
- # ─────────────────────────────────────────────
649
- # 12. MAIN RAGPipeline CLASS
650
- # ─────────────────────────────────────────────
651
- class RAGPipeline:
652
- """
653
- Full end-to-end Urdu Legal RAG pipeline.
654
-
655
- Typical usage (HF Spaces / production):
656
- rag = RAGPipeline()
657
- # Documents must already be indexed in Pinecone + BM25 built from JSON
658
- rag.load_bm25_from_json("scrapper/cleaned_ocr_output.json")
659
- result = rag.query("خلع کے لیے کیا کرنا ہوگا؟",
660
- conversation_history=[...])
661
-
662
- Ingestion (run once offline):
663
- rag.ingest_documents(texts, metadata)
664
- """
665
-
666
- def __init__(self, chunking_strategy: str = "fixed"):
667
- print("\n" + "="*60)
668
- print(" Initializing RAG-e-Qanoon Pipeline")
669
- print("="*60)
670
-
671
- self.chunking_strategy = chunking_strategy
672
- self.hf_token, pinecone_key = load_api_keys()
673
-
674
- self.embedding_model = EmbeddingModel()
675
- self.pinecone_db = PineconeDB(api_key=pinecone_key)
676
- self.bm25_retriever = BM25Retriever()
677
- self.reranker = Reranker()
678
- self.llm = LLMGenerator(self.hf_token)
679
- self.judge = LLMJudge(self.hf_token, self.embedding_model)
680
- self.all_chunks: List[Dict] = []
681
-
682
- print("\nRAG Pipeline initialized.\n")
683
-
684
- # ── BM25 warm-up (call at app startup, after Pinecone is already filled) ──
685
- def load_bm25_from_json(self, json_path: str,
686
- strategy: Optional[str] = None):
687
- """
688
- Re-builds the in-memory BM25 index from the cleaned JSON file.
689
- Call this at startup so the pipeline is ready to serve queries
690
- without re-ingesting into Pinecone.
691
- """
692
- texts, metadata = load_documents_from_json(json_path)
693
- strategy = strategy or self.chunking_strategy
694
- self.all_chunks = chunk_documents(texts, strategy=strategy, metadata=metadata)
695
- self.bm25_retriever.build_index(self.all_chunks)
696
- print(f"BM25 index warm-up complete ({len(self.all_chunks)} chunks).")
697
-
698
- # ── Full ingestion (offline, run once) ────────────────────────────────
699
- def ingest_documents(self, texts: List[str],
700
- metadata: Optional[List[Dict]] = None,
701
- chunking_strategy: Optional[str] = None):
702
- """Chunk → Embed → Upsert to Pinecone → Build BM25."""
703
- strategy = chunking_strategy or self.chunking_strategy
704
- print(f"\nIngesting {len(texts)} documents (strategy: {strategy})…")
705
-
706
- chunks = chunk_documents(texts, strategy=strategy, metadata=metadata)
707
- embeddings = self.embedding_model.embed([c["text"] for c in chunks])
708
- self.pinecone_db.upsert_chunks(chunks, embeddings)
709
- self.all_chunks.extend(chunks)
710
- self.bm25_retriever.build_index(self.all_chunks)
711
- print(f"Ingestion complete. Total chunks: {len(self.all_chunks)}")
712
-
713
- # ── Retrieval ─────────────────────────────────────────────────────────
714
- def retrieve(self, query: str, use_reranker: bool = True) -> Tuple[List[Dict], Dict]:
715
- timings = {}
716
-
717
- dbg("=" * 80)
718
- dbg("RETRIEVE START")
719
- dbg("query:", query)
720
-
721
- try:
722
- t0 = time.time()
723
- q_emb = self.embedding_model.embed_query(query)
724
- timings["semantic_embed"] = round(time.time() - t0, 3)
725
- dbg("query embedding shape:", getattr(q_emb, "shape", None))
726
- except Exception as e:
727
- print("[ERROR] embed_query failed:", e)
728
- traceback.print_exc()
729
- raise
730
-
731
- try:
732
- t0 = time.time()
733
- semantic_hits = self.pinecone_db.semantic_search(q_emb, top_k=TOP_K_RETRIEVAL)
734
- timings["semantic_search"] = round(time.time() - t0, 3)
735
- dbg("semantic hits:", len(semantic_hits))
736
- for i, hit in enumerate(semantic_hits[:3]):
737
- dbg(f"semantic[{i}] id={hit.get('id')} score={hit.get('score')} text={hit.get('text','')[:120]}")
738
- except Exception as e:
739
- print("[ERROR] semantic_search failed:", e)
740
- traceback.print_exc()
741
- raise
742
-
743
- try:
744
- t0 = time.time()
745
- bm25_hits = self.bm25_retriever.search(query, top_k=TOP_K_RETRIEVAL)
746
- timings["bm25"] = round(time.time() - t0, 3)
747
- dbg("bm25 hits:", len(bm25_hits))
748
- for i, hit in enumerate(bm25_hits[:3]):
749
- dbg(f"bm25[{i}] id={hit.get('id')} score={hit.get('score')} text={hit.get('text','')[:120]}")
750
- except Exception as e:
751
- print("[ERROR] bm25 search failed:", e)
752
- traceback.print_exc()
753
- raise
754
-
755
- try:
756
- t0 = time.time()
757
- fused = reciprocal_rank_fusion(semantic_hits, bm25_hits)
758
- timings["rrf"] = round(time.time() - t0, 3)
759
- dbg("fused hits:", len(fused))
760
- for i, hit in enumerate(fused[:5]):
761
- dbg(f"fused[{i}] id={hit.get('id')} rrf={hit.get('rrf_score')} text={hit.get('text','')[:120]}")
762
- except Exception as e:
763
- print("[ERROR] RRF failed:", e)
764
- traceback.print_exc()
765
- raise
766
-
767
- try:
768
- if use_reranker and fused:
769
- t0 = time.time()
770
- final_chunks = self.reranker.rerank(query, fused, top_k=TOP_K_FINAL)
771
- timings["rerank"] = round(time.time() - t0, 3)
772
- dbg("reranked final chunks:", len(final_chunks))
773
- for i, hit in enumerate(final_chunks):
774
- dbg(f"final[{i}] id={hit.get('id')} rerank={hit.get('rerank_score')} text={hit.get('text','')[:120]}")
775
- else:
776
- final_chunks = fused[:TOP_K_FINAL]
777
- dbg("reranker skipped")
778
- except Exception as e:
779
- print("[ERROR] reranker failed:", e)
780
- traceback.print_exc()
781
- raise
782
-
783
- timings["total_retrieval"] = round(sum(timings.values()), 3)
784
- dbg("retrieval timings:", timings)
785
- dbg("RETRIEVE END")
786
- dbg("=" * 80)
787
-
788
- return final_chunks, timings
789
- # ── Main query entry-point ────────────────────────────────────────────
790
- def query(self, user_query: str,
791
- conversation_history: Optional[List[Dict]] = None,
792
- run_evaluation: bool = True,
793
- use_reranker: bool = True) -> Dict:
794
-
795
- if conversation_history is None:
796
- conversation_history = []
797
-
798
- print(f"\nQuery: {user_query}")
799
- total_start = time.time()
800
-
801
- retrieved_chunks, retrieval_timings = self.retrieve(
802
- user_query, use_reranker=use_reranker
803
- )
804
- print(f" Retrieved {len(retrieved_chunks)} chunks")
805
-
806
- t0 = time.time()
807
- answer, _ = self.llm.generate(
808
- user_query,
809
- retrieved_chunks,
810
- conversation_history=conversation_history,
811
- )
812
- generation_time = round(time.time() - t0, 3)
813
- print(f" Answer generated ({generation_time}s)")
814
- dbg("final answer preview:", answer[:500])
815
-
816
- faithfulness_result = {"score": None}
817
- relevancy_result = {"score": None}
818
-
819
- if run_evaluation and retrieved_chunks:
820
- print(" Running LLM-as-a-Judge…")
821
- try:
822
- faithfulness_result = self.judge.compute_faithfulness(answer, retrieved_chunks)
823
- relevancy_result = self.judge.compute_relevancy(user_query, answer)
824
- f_score = faithfulness_result.get("score")
825
- r_score = relevancy_result.get("score")
826
-
827
- f_text = f"{f_score:.2%}" if isinstance(f_score, float) else "N/A"
828
- r_text = f"{r_score:.2%}" if isinstance(r_score, float) else "N/A"
829
-
830
- print(f" Faithfulness: {f_text} Relevancy: {r_text}")
831
- except Exception as e:
832
- print("[ERROR] evaluation failed:", e)
833
- traceback.print_exc()
834
- faithfulness_result = {"score": None, "error": str(e)}
835
- relevancy_result = {"score": None, "error": str(e)}
836
-
837
- return {
838
- "query": user_query,
839
- "answer": answer,
840
- "retrieved_chunks": retrieved_chunks,
841
- "faithfulness": faithfulness_result,
842
- "relevancy": relevancy_result,
843
- "timings": {
844
- **retrieval_timings,
845
- "generation": generation_time,
846
- "total": round(time.time() - total_start, 3),
847
- },
848
- }
849
- def get_pinecone_stats(self) -> Dict:
850
- return self.pinecone_db.get_stats()
851
-
852
-
853
- # ─────────────────────────────────────────────
854
- # 13. CLI ENTRY POINT (python rag_pipeline.py)
855
- # ─────────────────────────────────────────────
856
- # ─────────────────────────────────────────────
857
- # 13. CLI ENTRY POINT (python rag_pipeline.py)
858
- # ─────────────────────────────────────────────
859
- if __name__ == "__main__":
860
- import argparse
861
- import os
862
- from dotenv import load_dotenv
863
-
864
- # Load local .env file so it finds your keys
865
- # This looks one folder up for the .env file
866
- load_dotenv(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), ".env"))
867
-
868
- # Dynamically find the scrapper JSON file one folder up
869
- base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
870
- default_json = os.path.join(base_dir, "scrapper", "cleaned_ocr_output.json")
871
-
872
- parser = argparse.ArgumentParser(description="RAG-e-Qanoon CLI")
873
- parser.add_argument("--json", default=default_json)
874
- parser.add_argument("--query", default="پاکستان کا ریاستی مذہب کیا ہے؟")
875
- parser.add_argument("--ingest", action="store_true",
876
- help="Re-ingest documents into Pinecone (run once).")
877
- args = parser.parse_args()
878
-
879
- rag = RAGPipeline(chunking_strategy="fixed")
880
- texts, metadata = load_documents_from_json(args.json)
881
-
882
- if args.ingest:
883
- rag.ingest_documents(texts, metadata=metadata)
884
- else:
885
- rag.load_bm25_from_json(args.json)
886
-
887
- result = rag.query(args.query, run_evaluation=False)
888
-
889
- print("\n" + "="*60)
890
- print("RESULT")
891
- print("="*60)
892
- print(f"Question : {result['query']}")
893
- print(f"\nAnswer :\n{result['answer']}")
894
- if result["faithfulness"]["score"] is not None:
895
- print(f"\nFaithfulness: {result['faithfulness']['score']:.2%}")
896
- print(f"Relevancy : {result['relevancy']['score']:.2%}")
897
- print(f"Total time: {result['timings']['total']}s")
 
 
 
 
 
 
 
 
 
 
 
 
 
898
  print(f"\nPinecone stats: {rag.get_pinecone_stats()}")
 
1
+ # ─────────────────────────────────────────────────────────────────────────────
2
+ # RAG-e-Qanoon | rag_backend/rag_pipeline.py
3
+ # Best config from ablation study:
4
+ # Chunking : fixed, 512 chars / 80 overlap
5
+ # RRF : semantic=0.5, bm25=0.5
6
+ # Top-k : 5 final chunks
7
+ # Temperature: 0.3
8
+ # Re-ranker : ON (bge-reranker-v2-m3)
9
+ # LLM : Qwen/Qwen2.5-14B-Instruct (fallback → Qwen2.5-7B-Instruct)
10
+ # ─────────────────────────────────────────────────────────────────────────────
11
+
12
+ # ─────────────────────────────────────────────
13
+ # 1. IMPORTS & CONFIGURATION
14
+ # ─────────────────────────────────────────────
15
+ import os
16
+ import re
17
+ import time
18
+ import json
19
+ import numpy as np
20
+ from typing import List, Dict, Tuple, Optional
21
+
22
+ from sentence_transformers import SentenceTransformer, CrossEncoder
23
+ from rank_bm25 import BM25Okapi
24
+ from pinecone import Pinecone, ServerlessSpec
25
+ from huggingface_hub import InferenceClient
26
+ from sklearn.metrics.pairwise import cosine_similarity
27
+ from langchain_text_splitters import RecursiveCharacterTextSplitter
28
+ import traceback
29
+
30
+ DEBUG = False
31
+
32
+ def dbg(*args):
33
+ if DEBUG:
34
+ print("[DEBUG]", *args)
35
+ # ── On HF Spaces, secrets are injected directly as env vars.
36
+ # ── Locally, you can still use a .env file — just call load_dotenv() before
37
+ # ── importing this module. We do NOT call load_dotenv() here so that HF
38
+ # ── Spaces (which has no .env) doesn't throw an import error.
39
+
40
+ # ── LLM model registry ────────────────────────────────────────────────────
41
+ # ── LLM model registry ────────────────────────────────────────────────────
42
+ LLM_PRIMARY = "Qwen/Qwen3-14B:nscale"
43
+ LLM_FALLBACK = "Qwen/Qwen2.5-7B-Instruct"
44
+ # ── Embedding / re-ranker ─────────────────────────────────────────────────
45
+ EMBEDDING_MODEL_NAME = "BAAI/bge-m3"
46
+ EMBEDDING_DIM = 1024
47
+ RERANKER_MODEL_NAME = "BAAI/bge-reranker-v2-m3"
48
+
49
+ # ── Pinecone ──────────────────────────────────────────────────────────────
50
+ PINECONE_INDEX_NAME = "legal-urdu-rag-v3"
51
+
52
+ # ── BEST chunking config (ablation winner: fixed 512/80) ─────────────────
53
+ FIXED_CHUNK_SIZE = 512
54
+ FIXED_CHUNK_OVERLAP = 80
55
+
56
+ # ── Kept for ingestion flexibility ───────────────────────────────────────
57
+ RECURSIVE_CHUNK_SIZE = 400
58
+ RECURSIVE_CHUNK_OVERLAP = 80
59
+
60
+ # ── Retrieval ──────────────────────────────────────────────────────────────
61
+ TOP_K_RETRIEVAL = 20 # candidates fetched from each retriever
62
+ TOP_K_FINAL = 5 # chunks handed to LLM after re-ranking
63
+
64
+ # ── RRF weights (ablation winner: 0.5 / 0.5) ─────────────────────────────
65
+ RRF_SEMANTIC_WEIGHT = 0.5
66
+ RRF_BM25_WEIGHT = 0.5
67
+
68
+ # ── Generation (ablation winner: temp 0.3) ────────────────────────────────
69
+ GENERATION_TEMPERATURE = 0.3
70
+ GENERATION_MAX_TOKENS = 512
71
+
72
+ print("RAG-e-Qanoon configuration loaded.")
73
+
74
+
75
+ # ─────────────────────────────────────────────
76
+ # 2. LOAD API KEYS (env-first, no hard crash on missing .env)
77
+ # ─────────────────────────────────────────────
78
+ def load_api_keys() -> Tuple[str, str]:
79
+ """
80
+ Read HF_TOKEN and PINECONE_KEY from environment variables.
81
+ On HF Spaces: set them in Settings → Secrets.
82
+ Locally: export them, or call load_dotenv() before importing this module.
83
+ """
84
+ hf_token = os.environ.get("HF_TOKEN", "")
85
+ pinecone_key = os.environ.get("PINECONE_KEY", "")
86
+
87
+ if not hf_token:
88
+ raise ValueError("HF_TOKEN not found in environment. Add it to HF Space Secrets.")
89
+ if not pinecone_key:
90
+ raise ValueError("PINECONE_KEY not found in environment. Add it to HF Space Secrets.")
91
+
92
+ print(f"HF_TOKEN : {hf_token[:8]}…")
93
+ print(f"PINECONE_KEY: {pinecone_key[:8]}…")
94
+ return hf_token, pinecone_key
95
+
96
+
97
+ # ─────────────────────────────────────────────
98
+ # 3. LOAD CLEANED JSON DATA
99
+ # ─────────────────────────────────────────────
100
+ def load_documents_from_json(json_path: str) -> Tuple[List[str], List[Dict]]:
101
+ """Load pre-processed cleaned Urdu text from JSON produced by cleancode.py."""
102
+ print(f"Loading documents from {json_path}…")
103
+ if not os.path.exists(json_path):
104
+ raise FileNotFoundError(f"Cannot find {json_path}. Run cleancode.py first.")
105
+
106
+ with open(json_path, "r", encoding="utf-8") as f:
107
+ data = json.load(f)
108
+
109
+ texts = [item["text"] for item in data]
110
+ metadata = [item["meta"] for item in data]
111
+ print(f"Loaded {len(texts)} documents.")
112
+ return texts, metadata
113
+
114
+
115
+ # ─────────────────────────────────────────────
116
+ # 4. CHUNKING STRATEGIES
117
+ # ─────────────────────────────────────────────
118
+ def chunk_fixed(text: str,
119
+ chunk_size: int = FIXED_CHUNK_SIZE,
120
+ overlap: int = FIXED_CHUNK_OVERLAP) -> List[str]:
121
+ chunks, start = [], 0
122
+ text = text.strip()
123
+ while start < len(text):
124
+ chunk = text[start : start + chunk_size].strip()
125
+ if chunk:
126
+ chunks.append(chunk)
127
+ start += chunk_size - overlap
128
+ return chunks
129
+
130
+
131
+ def chunk_recursive(text: str,
132
+ chunk_size: int = RECURSIVE_CHUNK_SIZE,
133
+ overlap: int = RECURSIVE_CHUNK_OVERLAP) -> List[str]:
134
+ splitter = RecursiveCharacterTextSplitter(
135
+ separators=["\n\n", "\n", "۔", "؟", "!", " ", ""],
136
+ chunk_size=chunk_size,
137
+ chunk_overlap=overlap,
138
+ length_function=len,
139
+ )
140
+ return [c.strip() for c in splitter.split_text(text) if c.strip()]
141
+
142
+
143
+ def chunk_sentence(text: str,
144
+ min_length: int = 50,
145
+ max_length: int = 500) -> List[str]:
146
+ raw, chunks, current = re.split(r'(?<=[۔؟!])\s*', text.strip()), [], ""
147
+ for sent in raw:
148
+ sent = sent.strip()
149
+ if not sent:
150
+ continue
151
+ if len(current) + len(sent) <= max_length:
152
+ current = (current + " " + sent).strip()
153
+ else:
154
+ if len(current) >= min_length:
155
+ chunks.append(current)
156
+ current = sent
157
+ if len(current) >= min_length:
158
+ chunks.append(current)
159
+ return chunks
160
+
161
+
162
+ def chunk_documents(texts: List[str],
163
+ strategy: str = "fixed",
164
+ metadata: Optional[List[Dict]] = None) -> List[Dict]:
165
+ if metadata is None:
166
+ metadata = [{"source": f"doc_{i}"} for i in range(len(texts))]
167
+
168
+ chunk_fn = {"fixed": chunk_fixed,
169
+ "recursive": chunk_recursive,
170
+ "sentence": chunk_sentence}.get(strategy, chunk_fixed)
171
+
172
+ all_chunks, chunk_idx = [], 0
173
+ for doc_idx, (text, meta) in enumerate(zip(texts, metadata)):
174
+ for position, chunk_text in enumerate(chunk_fn(text)):
175
+ all_chunks.append({
176
+ "id": f"chunk_{chunk_idx}",
177
+ "text": chunk_text,
178
+ "metadata": {**meta, "doc_idx": doc_idx,
179
+ "position": position, "strategy": strategy},
180
+ })
181
+ chunk_idx += 1
182
+
183
+ print(f"Chunked {len(texts)} docs → {len(all_chunks)} chunks (strategy: {strategy})")
184
+ return all_chunks
185
+
186
+
187
+ # ─────────────────────────────────────────────
188
+ # 5. EMBEDDING MODEL
189
+ # ─────────────────────────────────────────────
190
+ class EmbeddingModel:
191
+
192
+ def __init__(self):
193
+ print(f"Loading embedding model: {EMBEDDING_MODEL_NAME}")
194
+ self.model = SentenceTransformer(EMBEDDING_MODEL_NAME)
195
+ print("Embedding model loaded.")
196
+
197
+ def embed(self, texts: List[str], batch_size: int = 32) -> np.ndarray:
198
+ return self.model.encode(texts, batch_size=batch_size,
199
+ show_progress_bar=True, normalize_embeddings=True)
200
+
201
+ def embed_query(self, query: str) -> np.ndarray:
202
+ return self.model.encode(query, normalize_embeddings=True)
203
+
204
+
205
+ # ─────────────────────────────────────────────
206
+ # 6. PINECONE VECTOR DATABASE
207
+ # ─────────────────────────────────────────────
208
+ class PineconeDB:
209
+
210
+ def __init__(self, api_key: str, index_name: str = PINECONE_INDEX_NAME):
211
+ self.pc = Pinecone(api_key=api_key)
212
+ existing = [idx.name for idx in self.pc.list_indexes()]
213
+ if index_name not in existing:
214
+ print(f"Creating Pinecone index '{index_name}'…")
215
+ self.pc.create_index(
216
+ name=index_name, dimension=EMBEDDING_DIM, metric="cosine",
217
+ spec=ServerlessSpec(cloud="aws", region="us-east-1"),
218
+ )
219
+ time.sleep(30)
220
+ else:
221
+ print(f"Connected to existing Pinecone index: '{index_name}'")
222
+ self.index = self.pc.Index(index_name)
223
+
224
+ def upsert_chunks(self, chunks: List[Dict], embeddings: np.ndarray,
225
+ batch_size: int = 100):
226
+ vectors = [
227
+ {"id": c["id"], "values": e.tolist(),
228
+ "metadata": {**c["metadata"], "text": c["text"][:1000]}}
229
+ for c, e in zip(chunks, embeddings)
230
+ ]
231
+ total = 0
232
+ for i in range(0, len(vectors), batch_size):
233
+ self.index.upsert(vectors=vectors[i : i + batch_size])
234
+ total += len(vectors[i : i + batch_size])
235
+ print(f" ↑ Upserted {total}/{len(vectors)} vectors")
236
+ print(f"All {len(vectors)} chunks stored in Pinecone.")
237
+
238
+ def semantic_search(self, query_embedding: np.ndarray,
239
+ top_k: int = TOP_K_RETRIEVAL) -> List[Dict]:
240
+ results = self.index.query(vector=query_embedding.tolist(),
241
+ top_k=top_k, include_metadata=True)
242
+ return [{"id": m.id, "score": m.score,
243
+ "text": m.metadata.get("text", ""), "metadata": m.metadata}
244
+ for m in results.matches]
245
+
246
+ def get_stats(self) -> Dict:
247
+ return self.index.describe_index_stats()
248
+
249
+
250
+ # ─────────────────────────────────────────────
251
+ # 7. BM25 KEYWORD SEARCH
252
+ # ─────────────────────────────────────────────
253
+ class BM25Retriever:
254
+
255
+ def __init__(self):
256
+ self.bm25 = None
257
+ self.chunks = []
258
+
259
+ def build_index(self, chunks: List[Dict]):
260
+ self.chunks = chunks
261
+ self.bm25 = BM25Okapi([self._tokenize(c["text"]) for c in chunks])
262
+ print(f"BM25 index built on {len(chunks)} chunks.")
263
+
264
+ def _tokenize(self, text: str) -> List[str]:
265
+ text = re.sub(r"[^\w\s\u0600-\u06FF]", " ", text)
266
+ return [t for t in text.split() if t]
267
+
268
+ def search(self, query: str, top_k: int = TOP_K_RETRIEVAL) -> List[Dict]:
269
+ if self.bm25 is None:
270
+ raise RuntimeError("BM25 index not built yet.")
271
+ scores = self.bm25.get_scores(self._tokenize(query))
272
+ top_indices = np.argsort(scores)[::-1][:top_k]
273
+ return [{"id": self.chunks[i]["id"], "score": float(scores[i]),
274
+ "text": self.chunks[i]["text"],
275
+ "metadata": self.chunks[i].get("metadata", {})}
276
+ for i in top_indices]
277
+
278
+
279
+ # ─────────────────────────────────────────────
280
+ # 8. RECIPROCAL RANK FUSION (best: 0.5 / 0.5)
281
+ # ─────────────────────────────────────────────
282
+ def reciprocal_rank_fusion(
283
+ semantic_hits: List[Dict],
284
+ bm25_hits: List[Dict],
285
+ k: int = 60,
286
+ semantic_weight: float = RRF_SEMANTIC_WEIGHT,
287
+ bm25_weight: float = RRF_BM25_WEIGHT,
288
+ ) -> List[Dict]:
289
+ fused_scores: Dict[str, float] = {}
290
+ chunk_store: Dict[str, Dict] = {}
291
+
292
+ for rank, hit in enumerate(semantic_hits, 1):
293
+ cid = hit["id"]
294
+ fused_scores[cid] = fused_scores.get(cid, 0.0) + semantic_weight / (k + rank)
295
+ chunk_store[cid] = hit
296
+
297
+ for rank, hit in enumerate(bm25_hits, 1):
298
+ cid = hit["id"]
299
+ fused_scores[cid] = fused_scores.get(cid, 0.0) + bm25_weight / (k + rank)
300
+ chunk_store.setdefault(cid, hit)
301
+
302
+ results = []
303
+ for cid in sorted(fused_scores, key=fused_scores.get, reverse=True):
304
+ hit = chunk_store[cid].copy()
305
+ hit["rrf_score"] = fused_scores[cid]
306
+ results.append(hit)
307
+ return results
308
+
309
+
310
+ # ─────────────────────────────────────────────
311
+ # 9. CROSS-ENCODER RE-RANKER (always ON)
312
+ # ─────────────────────────────────────────────
313
+ class Reranker:
314
+
315
+ def __init__(self):
316
+ print("Loading CrossEncoder re-ranker…")
317
+ self.model = CrossEncoder(RERANKER_MODEL_NAME)
318
+ print("Re-ranker loaded.")
319
+
320
+ def rerank(self, query: str, candidates: List[Dict],
321
+ top_k: int = TOP_K_FINAL) -> List[Dict]:
322
+ if not candidates:
323
+ return []
324
+ scores = self.model.predict([(query, c["text"]) for c in candidates])
325
+ for chunk, score in zip(candidates, scores):
326
+ chunk["rerank_score"] = float(score)
327
+ return sorted(candidates, key=lambda x: x["rerank_score"], reverse=True)[:top_k]
328
+
329
+
330
+ # ─────────────────────────────────────────────
331
+ # 10. LLM GENERATOR (14B primary, 7B fallback)
332
+ # ─────────────────────────────────────────────
333
+ class LLMGenerator:
334
+ """
335
+ Calls the HF Inference API.
336
+ Tries Qwen2.5-14B-Instruct first; falls back to 7B if the free tier
337
+ returns a model-too-large / memory error.
338
+ Conversation memory (last N turns from MongoDB) is injected into the
339
+ system prompt so the model has cross-turn context.
340
+ """
341
+
342
+ def __init__(self, hf_token: str):
343
+ self.hf_token = hf_token
344
+ self.model_name = LLM_PRIMARY
345
+ self.client = InferenceClient(model=self.model_name, token=hf_token)
346
+ print(f"LLM primary: {self.model_name}")
347
+
348
+ def _switch_to_fallback(self):
349
+ print(f"Switching to fallback LLM: {LLM_FALLBACK}")
350
+ self.model_name = LLM_FALLBACK
351
+ self.client = InferenceClient(model=self.model_name, token=self.hf_token)
352
+
353
+ # ── Prompt builders ────────────────────────────────────────────────────
354
+
355
+ @staticmethod
356
+ def _format_context(chunks: List[Dict]) -> str:
357
+ return "\n\n".join(
358
+ f"{c['text']}" for c in chunks
359
+ )
360
+
361
+ @staticmethod
362
+ def _format_memory(conversation_history: List[Dict]) -> str:
363
+ """
364
+ Converts the last N messages from MongoDB into a readable Urdu
365
+ conversation string that is injected into the system prompt.
366
+ Each entry: {"role": "user"|"assistant", "content": "..."}
367
+ """
368
+ if not conversation_history:
369
+ return ""
370
+ lines = []
371
+ for msg in conversation_history:
372
+ role_label = "صارف" if msg["role"] == "user" else "معاون"
373
+ lines.append(f"{role_label}: {msg['content']}")
374
+ return "\n".join(lines)
375
+
376
+ def _build_messages(self, query: str, context_chunks: List[Dict],
377
+ conversation_history: List[Dict]) -> List[Dict]:
378
+ """
379
+ Returns a messages list for chat_completion.
380
+
381
+ System prompt contains:
382
+ - Role definition (Urdu legal advisor)
383
+ - Retrieved legal context
384
+ - Recent conversation memory (from MongoDB)
385
+
386
+ User turn contains the current query only.
387
+ """
388
+ context_text = self._format_context(context_chunks)
389
+ memory_text = self._format_memory(conversation_history)
390
+
391
+ memory_section = ""
392
+ if memory_text:
393
+ memory_section = (
394
+ "\n\n=== گزشتہ گفتگو (حوالے کے لیے) ===\n"
395
+ f"{memory_text}\n"
396
+ "=== گزشتہ گفتگو ختم ===\n"
397
+ )
398
+
399
+ system_prompt = (
400
+ "آپ ایک ماہر پاکستانی قانونی مشیر ہیں۔ "
401
+ "آپ کا کام صرف نیچے دیے گئے قانونی دستاویزات کی بنیاد پر سوال کا جواب دینا ہے۔\n"
402
+ "اگر جواب دستاویزات میں موجود نہ ہو تو صاف کہیں: "
403
+ "\"یہ معلومات دستیاب دستاویزات میں موجود نہیں ہیں۔\"\n"
404
+ "اپنی طرف سے کوئی بات نہ بنائیں۔\n\n"
405
+ "اگر ممکن ہو تو جواب مختصر اور سادہ رکھیں، اور دستاویز نمبر یا غیر ضروری حوالہ نہ دیں جب تک وہ بالکل واضح نہ ہو۔\n"
406
+ "=== قانونی دستاویزات ===\n"
407
+ f"{context_text}\n"
408
+ "=== دستاویزات ختم ==="
409
+ f"{memory_section}"
410
+ )
411
+
412
+ return [
413
+ {"role": "system", "content": system_prompt},
414
+ {"role": "user", "content": query},
415
+ ]
416
+
417
+ # ── Public generate method ─────────────────────────────────────────────
418
+
419
+ def generate(self, query: str, context_chunks: List[Dict],
420
+ conversation_history: Optional[List[Dict]] = None,
421
+ max_new_tokens: int = GENERATION_MAX_TOKENS) -> Tuple[str, float]:
422
+
423
+ if conversation_history is None:
424
+ conversation_history = []
425
+
426
+ messages = self._build_messages(query, context_chunks, conversation_history)
427
+ start = time.time()
428
+
429
+ dbg("=" * 80)
430
+ dbg("GENERATION START")
431
+ dbg("model:", self.model_name)
432
+ dbg("query:", query)
433
+ dbg("context chunk count:", len(context_chunks))
434
+ dbg("history count:", len(conversation_history))
435
+ for i, c in enumerate(context_chunks[:3]):
436
+ dbg(f"context[{i}] text={c.get('text','')[:200]}")
437
+ dbg("system prompt preview:", messages[0]["content"][:500])
438
+ dbg("user prompt preview:", messages[1]["content"][:300])
439
+
440
+ try:
441
+ response = self.client.chat_completion(
442
+ messages=messages,
443
+ max_tokens=max_new_tokens,
444
+ temperature=GENERATION_TEMPERATURE,
445
+ )
446
+ answer = response.choices[0].message["content"].strip()
447
+ dbg("raw answer preview:", answer[:500])
448
+
449
+ except Exception as e:
450
+ print("[ERROR] primary LLM call failed:", e)
451
+ traceback.print_exc()
452
+ err_str = str(e).lower()
453
+
454
+ if any(kw in err_str for kw in
455
+ ["model too large", "out of memory", "oom", "422",
456
+ "loading", "quota", "rate limit", "too large"]):
457
+ self._switch_to_fallback()
458
+ dbg("switched to fallback:", self.model_name)
459
+ try:
460
+ response = self.client.chat_completion(
461
+ messages=messages,
462
+ max_tokens=max_new_tokens,
463
+ temperature=GENERATION_TEMPERATURE,
464
+ )
465
+ answer = response.choices[0].message["content"].strip()
466
+ dbg("fallback answer preview:", answer[:500])
467
+ except Exception as e2:
468
+ print("[ERROR] fallback LLM call failed:", e2)
469
+ traceback.print_exc()
470
+ answer = f"[LLM Error (fallback): {e2}]"
471
+ else:
472
+ answer = f"[LLM Error: {e}]"
473
+
474
+ dbg("GENERATION END")
475
+ dbg("=" * 80)
476
+ return answer, round(time.time() - start, 2)
477
+
478
+ # ─────────────────────────────────────────────
479
+ # 11. LLM-AS-A-JUDGE
480
+ # ─────────────────────────────────────────────
481
+ class LLMJudge:
482
+ """Faithfulness (claim verification) + Relevancy (query similarity)."""
483
+
484
+ def __init__(self, hf_token: str, embedding_model: EmbeddingModel):
485
+ # Judge always uses the primary LLM; falls back transparently
486
+ self.llm_gen = LLMGenerator(hf_token)
487
+ self.embedding_model = embedding_model
488
+ # Lightweight client for short judge calls (no memory needed)
489
+ self.client = InferenceClient(model=self.llm_gen.model_name, token=hf_token)
490
+ print(f"LLM Judge ready: {self.llm_gen.model_name}")
491
+
492
+ def _judge_call(self, prompt: str, max_tokens: int = 300,
493
+ temperature: float = 0.1) -> str:
494
+ try:
495
+ r = self.client.chat_completion(
496
+ messages=[{"role": "user", "content": prompt}],
497
+ max_tokens=max_tokens,
498
+ temperature=temperature,
499
+ )
500
+
501
+ msg = r.choices[0].message
502
+
503
+ # Extract content safely without re-using variable before assignment
504
+ if isinstance(msg, dict):
505
+ raw_content = msg.get("content")
506
+ else:
507
+ raw_content = getattr(msg, "content", None)
508
+
509
+ if raw_content is None:
510
+ return ""
511
+
512
+ if isinstance(raw_content, list):
513
+ parts = []
514
+ for item in raw_content:
515
+ if isinstance(item, dict):
516
+ parts.append(item.get("text", ""))
517
+ else:
518
+ parts.append(str(item))
519
+ raw_content = "".join(parts)
520
+
521
+ result = str(raw_content).strip()
522
+
523
+ # Strip Qwen3 thinking block
524
+ result = re.sub(r"<think>.*?</think>", "", result, flags=re.DOTALL).strip()
525
+
526
+ return result
527
+
528
+ except Exception as e:
529
+ print("[ERROR] judge call failed:", e)
530
+ traceback.print_exc()
531
+ return ""
532
+ def extract_claims(self, answer: str) -> List[str]:
533
+ prompt = (
534
+ "نیچے دیے گئے جواب سے تمام حقائق اور دعوے نکالیں۔\n"
535
+ "ہر دعوہ الگ لائن پر نمبر کے ساتھ لکھیں (1. 2. 3.)\n"
536
+ "صرف فہرست لکھیں۔\n\nجواب:\n" + answer + "\n\nدعووں کی فہرست:"
537
+ )
538
+ text = self._judge_call(prompt, max_tokens=300)
539
+ claims = []
540
+ for line in text.split("\n"):
541
+ line = line.strip()
542
+ clean = re.sub(r"^[\d\-\.\)]+\s*", "", line).strip()
543
+ if clean:
544
+ claims.append(clean)
545
+ return claims or [answer]
546
+
547
+ def verify_claim(self, claim: str, context: str):
548
+ prompt = (
549
+ "نیچے دیا گیا دعویٰ متن سے ثابت ہوتا ہے؟\n"
550
+ "صرف ایک لفظ میں جواب دیں: \"ہاں\" یا \"نہیں\"\n\n"
551
+ f"متن:\n{context[:1800]}\n\nدعویٰ: {claim}\n\nجواب:"
552
+ )
553
+ answer = self._judge_call(prompt, max_tokens=20).lower().strip()
554
+
555
+ if not answer:
556
+ return None
557
+ if any(w in answer for w in ["ہاں", "yes", "supported", "true", "han"]):
558
+ return True
559
+ if any(w in answer for w in ["نہیں", "no", "false", "unsupported"]):
560
+ return False
561
+ return None
562
+ def compute_faithfulness(self, answer: str, context_chunks: List[Dict]) -> Dict:
563
+ dbg("FAITHFULNESS START")
564
+ dbg("answer preview:", answer[:300])
565
+
566
+ ctx_text = " ".join(c["text"] for c in context_chunks)
567
+ dbg("context length:", len(ctx_text))
568
+
569
+ claims = self.extract_claims(answer)
570
+ dbg("claims extracted:", claims)
571
+
572
+ verified = []
573
+ raw_verifications = []
574
+
575
+ for c in claims:
576
+ v = self.verify_claim(c, ctx_text)
577
+ dbg("claim check:", c[:120], "=>", v)
578
+ raw_verifications.append(v)
579
+
580
+ if v is not None:
581
+ verified.append(v)
582
+
583
+ if not verified:
584
+ return {
585
+ "score": None,
586
+ "claims": claims,
587
+ "verifications": raw_verifications,
588
+ "supported_count": 0,
589
+ "total_claims": 0,
590
+ "error": "Judge returned no usable verification output"
591
+ }
592
+
593
+ supported = sum(verified)
594
+ total = len(verified)
595
+
596
+ result = {
597
+ "score": round(supported / total, 3),
598
+ "claims": claims,
599
+ "verifications": raw_verifications,
600
+ "supported_count": supported,
601
+ "total_claims": total,
602
+ }
603
+ dbg("faithfulness result:", result)
604
+ dbg("FAITHFULNESS END")
605
+ return result
606
+
607
+
608
+ def compute_relevancy(self, original_query: str, answer: str) -> Dict:
609
+ dbg("RELEVANCY START")
610
+ dbg("original query:", original_query)
611
+ dbg("answer preview:", answer[:300])
612
+
613
+ questions = self.generate_questions_from_answer(answer)
614
+ dbg("generated questions:", questions)
615
+
616
+ if not questions:
617
+ return {
618
+ "score": None,
619
+ "generated_questions": [],
620
+ "similarities": [],
621
+ "error": "Judge returned no usable questions"
622
+ }
623
+ q_emb = self.embedding_model.embed_query(original_query)
624
+ q_embs = self.embedding_model.embed(questions)
625
+
626
+ sims = [
627
+ round(float(cosine_similarity(q_emb.reshape(1, -1), e.reshape(1, -1))[0][0]), 3)
628
+ for e in q_embs
629
+ ]
630
+
631
+ result = {
632
+ "score": round(float(np.mean(sims)), 3) if sims else 0.0,
633
+ "generated_questions": questions,
634
+ "similarities": sims,
635
+ }
636
+ dbg("relevancy result:", result)
637
+ dbg("RELEVANCY END")
638
+ return result
639
+
640
+ def generate_questions_from_answer(self, answer: str) -> List[str]:
641
+ prompt = (
642
+ "نیچے دیے گئے جواب سے 3 سوالات بنائیں۔\n"
643
+ "ہر سوال نئی لائن پر۔ صرف سوالات لکھیں۔\n\n"
644
+ "جواب:\n" + answer + "\n\nتین سوالات:\n1."
645
+ )
646
+ raw = self._judge_call(prompt, max_tokens=200, temperature=0.4)
647
+
648
+ # ← NEW: strip Qwen3 thinking block before parsing
649
+ raw = re.sub(r"<think>.*?</think>", "", raw, flags=re.DOTALL).strip()
650
+
651
+ full_text = "1." + raw
652
+ questions = []
653
+ for line in full_text.split("\n"):
654
+ clean = re.sub(r"^[\d\.\)]+\s*", "", line.strip()).strip()
655
+ if clean and len(clean) > 5:
656
+ questions.append(clean)
657
+ return questions[:3]
658
+
659
+
660
+
661
+ # ─────────────────────────────────────────────
662
+ # 12. MAIN RAGPipeline CLASS
663
+ # ─────────────────────────────────────────────
664
+ class RAGPipeline:
665
+ """
666
+ Full end-to-end Urdu Legal RAG pipeline.
667
+
668
+ Typical usage (HF Spaces / production):
669
+ rag = RAGPipeline()
670
+ # Documents must already be indexed in Pinecone + BM25 built from JSON
671
+ rag.load_bm25_from_json("scrapper/cleaned_ocr_output.json")
672
+ result = rag.query("خلع کے لیے کیا کرنا ہوگا؟",
673
+ conversation_history=[...])
674
+
675
+ Ingestion (run once offline):
676
+ rag.ingest_documents(texts, metadata)
677
+ """
678
+
679
+ def __init__(self, chunking_strategy: str = "fixed"):
680
+ print("\n" + "="*60)
681
+ print(" Initializing RAG-e-Qanoon Pipeline")
682
+ print("="*60)
683
+
684
+ self.chunking_strategy = chunking_strategy
685
+ self.hf_token, pinecone_key = load_api_keys()
686
+
687
+ self.embedding_model = EmbeddingModel()
688
+ self.pinecone_db = PineconeDB(api_key=pinecone_key)
689
+ self.bm25_retriever = BM25Retriever()
690
+ self.reranker = Reranker()
691
+ self.llm = LLMGenerator(self.hf_token)
692
+ self.judge = LLMJudge(self.hf_token, self.embedding_model)
693
+ self.all_chunks: List[Dict] = []
694
+
695
+ print("\nRAG Pipeline initialized.\n")
696
+
697
+ # ── BM25 warm-up (call at app startup, after Pinecone is already filled) ──
698
+ def load_bm25_from_json(self, json_path: str,
699
+ strategy: Optional[str] = None):
700
+ """
701
+ Re-builds the in-memory BM25 index from the cleaned JSON file.
702
+ Call this at startup so the pipeline is ready to serve queries
703
+ without re-ingesting into Pinecone.
704
+ """
705
+ texts, metadata = load_documents_from_json(json_path)
706
+ strategy = strategy or self.chunking_strategy
707
+ self.all_chunks = chunk_documents(texts, strategy=strategy, metadata=metadata)
708
+ self.bm25_retriever.build_index(self.all_chunks)
709
+ print(f"BM25 index warm-up complete ({len(self.all_chunks)} chunks).")
710
+
711
+ # ── Full ingestion (offline, run once) ────────────────────────────────
712
+ def ingest_documents(self, texts: List[str],
713
+ metadata: Optional[List[Dict]] = None,
714
+ chunking_strategy: Optional[str] = None):
715
+ """Chunk → Embed → Upsert to Pinecone → Build BM25."""
716
+ strategy = chunking_strategy or self.chunking_strategy
717
+ print(f"\nIngesting {len(texts)} documents (strategy: {strategy})…")
718
+
719
+ chunks = chunk_documents(texts, strategy=strategy, metadata=metadata)
720
+ embeddings = self.embedding_model.embed([c["text"] for c in chunks])
721
+ self.pinecone_db.upsert_chunks(chunks, embeddings)
722
+ self.all_chunks.extend(chunks)
723
+ self.bm25_retriever.build_index(self.all_chunks)
724
+ print(f"Ingestion complete. Total chunks: {len(self.all_chunks)}")
725
+
726
+ # ── Retrieval ─────────────────────────────────────────────────────────
727
+ def retrieve(self, query: str, use_reranker: bool = True) -> Tuple[List[Dict], Dict]:
728
+ timings = {}
729
+
730
+ dbg("=" * 80)
731
+ dbg("RETRIEVE START")
732
+ dbg("query:", query)
733
+
734
+ try:
735
+ t0 = time.time()
736
+ q_emb = self.embedding_model.embed_query(query)
737
+ timings["semantic_embed"] = round(time.time() - t0, 3)
738
+ dbg("query embedding shape:", getattr(q_emb, "shape", None))
739
+ except Exception as e:
740
+ print("[ERROR] embed_query failed:", e)
741
+ traceback.print_exc()
742
+ raise
743
+
744
+ try:
745
+ t0 = time.time()
746
+ semantic_hits = self.pinecone_db.semantic_search(q_emb, top_k=TOP_K_RETRIEVAL)
747
+ timings["semantic_search"] = round(time.time() - t0, 3)
748
+ dbg("semantic hits:", len(semantic_hits))
749
+ for i, hit in enumerate(semantic_hits[:3]):
750
+ dbg(f"semantic[{i}] id={hit.get('id')} score={hit.get('score')} text={hit.get('text','')[:120]}")
751
+ except Exception as e:
752
+ print("[ERROR] semantic_search failed:", e)
753
+ traceback.print_exc()
754
+ raise
755
+
756
+ try:
757
+ t0 = time.time()
758
+ bm25_hits = self.bm25_retriever.search(query, top_k=TOP_K_RETRIEVAL)
759
+ timings["bm25"] = round(time.time() - t0, 3)
760
+ dbg("bm25 hits:", len(bm25_hits))
761
+ for i, hit in enumerate(bm25_hits[:3]):
762
+ dbg(f"bm25[{i}] id={hit.get('id')} score={hit.get('score')} text={hit.get('text','')[:120]}")
763
+ except Exception as e:
764
+ print("[ERROR] bm25 search failed:", e)
765
+ traceback.print_exc()
766
+ raise
767
+
768
+ try:
769
+ t0 = time.time()
770
+ fused = reciprocal_rank_fusion(semantic_hits, bm25_hits)
771
+ timings["rrf"] = round(time.time() - t0, 3)
772
+ dbg("fused hits:", len(fused))
773
+ for i, hit in enumerate(fused[:5]):
774
+ dbg(f"fused[{i}] id={hit.get('id')} rrf={hit.get('rrf_score')} text={hit.get('text','')[:120]}")
775
+ except Exception as e:
776
+ print("[ERROR] RRF failed:", e)
777
+ traceback.print_exc()
778
+ raise
779
+
780
+ try:
781
+ if use_reranker and fused:
782
+ t0 = time.time()
783
+ final_chunks = self.reranker.rerank(query, fused, top_k=TOP_K_FINAL)
784
+ timings["rerank"] = round(time.time() - t0, 3)
785
+ dbg("reranked final chunks:", len(final_chunks))
786
+ for i, hit in enumerate(final_chunks):
787
+ dbg(f"final[{i}] id={hit.get('id')} rerank={hit.get('rerank_score')} text={hit.get('text','')[:120]}")
788
+ else:
789
+ final_chunks = fused[:TOP_K_FINAL]
790
+ dbg("reranker skipped")
791
+ except Exception as e:
792
+ print("[ERROR] reranker failed:", e)
793
+ traceback.print_exc()
794
+ raise
795
+
796
+ timings["total_retrieval"] = round(sum(timings.values()), 3)
797
+ dbg("retrieval timings:", timings)
798
+ dbg("RETRIEVE END")
799
+ dbg("=" * 80)
800
+
801
+ return final_chunks, timings
802
+ # ── Main query entry-point ────────────────────────────────────────────
803
+ def query(self, user_query: str,
804
+ conversation_history: Optional[List[Dict]] = None,
805
+ run_evaluation: bool = True,
806
+ use_reranker: bool = True) -> Dict:
807
+
808
+ if conversation_history is None:
809
+ conversation_history = []
810
+
811
+ print(f"\nQuery: {user_query}")
812
+ total_start = time.time()
813
+
814
+ retrieved_chunks, retrieval_timings = self.retrieve(
815
+ user_query, use_reranker=use_reranker
816
+ )
817
+ print(f" Retrieved {len(retrieved_chunks)} chunks")
818
+
819
+ t0 = time.time()
820
+ answer, _ = self.llm.generate(
821
+ user_query,
822
+ retrieved_chunks,
823
+ conversation_history=conversation_history,
824
+ )
825
+ generation_time = round(time.time() - t0, 3)
826
+ print(f" Answer generated ({generation_time}s)")
827
+ dbg("final answer preview:", answer[:500])
828
+
829
+ faithfulness_result = {"score": None}
830
+ relevancy_result = {"score": None}
831
+
832
+ if run_evaluation and retrieved_chunks:
833
+ print(" Running LLM-as-a-Judge…")
834
+ try:
835
+ faithfulness_result = self.judge.compute_faithfulness(answer, retrieved_chunks)
836
+ relevancy_result = self.judge.compute_relevancy(user_query, answer)
837
+ f_score = faithfulness_result.get("score")
838
+ r_score = relevancy_result.get("score")
839
+
840
+ f_text = f"{f_score:.2%}" if isinstance(f_score, float) else "N/A"
841
+ r_text = f"{r_score:.2%}" if isinstance(r_score, float) else "N/A"
842
+
843
+ print(f" Faithfulness: {f_text} Relevancy: {r_text}")
844
+ except Exception as e:
845
+ print("[ERROR] evaluation failed:", e)
846
+ traceback.print_exc()
847
+ faithfulness_result = {"score": None, "error": str(e)}
848
+ relevancy_result = {"score": None, "error": str(e)}
849
+
850
+ return {
851
+ "query": user_query,
852
+ "answer": answer,
853
+ "retrieved_chunks": retrieved_chunks,
854
+ "faithfulness": faithfulness_result,
855
+ "relevancy": relevancy_result,
856
+ "timings": {
857
+ **retrieval_timings,
858
+ "generation": generation_time,
859
+ "total": round(time.time() - total_start, 3),
860
+ },
861
+ }
862
+ def get_pinecone_stats(self) -> Dict:
863
+ return self.pinecone_db.get_stats()
864
+
865
+
866
+ # ─────────────────────────────────────────────
867
+ # 13. CLI ENTRY POINT (python rag_pipeline.py)
868
+ # ─────────────────────────────────────────────
869
+ # ─────────────────────────────────────────────
870
+ # 13. CLI ENTRY POINT (python rag_pipeline.py)
871
+ # ─────────────────────────────────────────────
872
+ if __name__ == "__main__":
873
+ import argparse
874
+ import os
875
+ from dotenv import load_dotenv
876
+
877
+ # Load local .env file so it finds your keys
878
+ # This looks one folder up for the .env file
879
+ load_dotenv(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), ".env"))
880
+
881
+ # Dynamically find the scrapper JSON file one folder up
882
+ base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
883
+ default_json = os.path.join(os.path.dirname(os.path.abspath(__file__)), "cleaned_ocr_output.json")
884
+
885
+ parser = argparse.ArgumentParser(description="RAG-e-Qanoon CLI")
886
+ parser.add_argument("--json", default=default_json)
887
+ parser.add_argument("--query", default="پاکستان کا ریاستی مذہب کیا ہے؟")
888
+ parser.add_argument("--ingest", action="store_true",
889
+ help="Re-ingest documents into Pinecone (run once).")
890
+ args = parser.parse_args()
891
+
892
+ rag = RAGPipeline(chunking_strategy="fixed")
893
+ texts, metadata = load_documents_from_json(args.json)
894
+
895
+ if args.ingest:
896
+ rag.ingest_documents(texts, metadata=metadata)
897
+ else:
898
+ rag.load_bm25_from_json(args.json)
899
+
900
+ result = rag.query(args.query, run_evaluation=False)
901
+
902
+ print("\n" + "="*60)
903
+ print("RESULT")
904
+ print("="*60)
905
+ print(f"Question : {result['query']}")
906
+ print(f"\nAnswer :\n{result['answer']}")
907
+ if result["faithfulness"]["score"] is not None:
908
+ print(f"\nFaithfulness: {result['faithfulness']['score']:.2%}")
909
+ print(f"Relevancy : {result['relevancy']['score']:.2%}")
910
+ print(f"Total time: {result['timings']['total']}s")
911
  print(f"\nPinecone stats: {rag.get_pinecone_stats()}")