csabhay commited on
Commit
9af036a
Β·
1 Parent(s): 45e4316

feat: strict Graph-RAG reasoning engine with JSON path output + validation script

Browse files
Files changed (2) hide show
  1. backend/rag.py +132 -36
  2. validate_graphrag.py +76 -0
backend/rag.py CHANGED
@@ -16,11 +16,56 @@ FALLBACK_MODELS = [
16
  "mistralai/Mistral-Small-24B-Instruct-2501",
17
  ]
18
 
19
- SYSTEM_PROMPT = (
20
- "You are a helpful GraphRAG assistant. Use only the provided context when possible. "
21
- "If context is insufficient, say what is missing. Keep answers concise. "
22
- "When answering from context, cite supporting sources inline as [Chunk X]."
23
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
 
25
  STOPWORDS = {
26
  "a", "an", "and", "are", "as", "at", "be", "by", "for", "from", "has", "he", "in", "is", "it",
@@ -309,15 +354,19 @@ class RAGEngine:
309
 
310
  if answer_mode == "strict_grounded" and confidence_label == "Low":
311
  return {
312
- "answer": "Insufficient context from document.",
313
  "chunks": chunks_payload,
314
  "model_used": None,
315
  "top_score": round(top_score, 4),
316
  "confidence_label": confidence_label,
 
 
 
 
317
  }
318
 
319
  context_block = "\n\n".join(
320
- f"Chunk {c.index} (score={c.score:.4f}):\n{c.text}" for c in retrieved
321
  )
322
 
323
  hf_token = os.environ.get("HF_TOKEN")
@@ -328,6 +377,10 @@ class RAGEngine:
328
  "model_used": None,
329
  "top_score": round(top_score, 4),
330
  "confidence_label": confidence_label,
 
 
 
 
331
  }
332
 
333
  messages = self._build_messages(query, context_block, history)
@@ -341,46 +394,77 @@ class RAGEngine:
341
  yield {"type": "chunks", "data": chunks_payload}
342
 
343
  if answer_mode == "strict_grounded" and confidence_label == "Low":
344
- yield {"type": "token", "data": "Insufficient context from document."}
345
- yield {"type": "done", "model_used": None}
 
 
 
 
346
  return
347
 
348
  hf_token = os.environ.get("HF_TOKEN")
349
  if not hf_token:
350
  yield {"type": "token", "data": "HF_TOKEN not set."}
351
- yield {"type": "done", "model_used": None}
352
  return
353
 
354
  context_block = "\n\n".join(
355
- f"Chunk {c.index} (score={c.score:.4f}):\n{c.text}" for c in retrieved
356
  )
357
  messages = self._build_messages(query, context_block, history)
358
- yield from self._stream_llm(hf_token, messages)
359
 
360
- def _stream_llm(self, token: str, messages: list[dict]):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
361
  client = InferenceClient(api_key=token)
362
  candidates = list(dict.fromkeys([self.llm_model] + FALLBACK_MODELS))
363
-
364
  for model in candidates:
365
  try:
366
- for chunk in client.chat_completion(
367
- model=model,
368
- messages=messages,
369
- max_tokens=512,
370
- temperature=0.2,
371
- stream=True,
372
- ):
373
- delta = chunk.choices[0].delta.content
374
- if delta:
375
- yield {"type": "token", "data": delta}
376
- yield {"type": "done", "model_used": model}
377
- return
378
  except Exception as e:
379
- log.warning("Streaming LLM %s failed: %s", model, e)
380
  continue
381
-
382
- yield {"type": "token", "data": "All candidate models failed."}
383
- yield {"type": "done", "model_used": None}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
384
 
385
  def _build_messages(self, query: str, context: str, history: list[dict] | None) -> list[dict]:
386
  messages: list[dict] = [{"role": "system", "content": SYSTEM_PROMPT}]
@@ -391,11 +475,13 @@ class RAGEngine:
391
  "role": "user",
392
  "content": (
393
  f"Question:\n{query}\n\n"
 
 
394
  "Instructions:\n"
395
- "- Use only the provided context.\n"
396
- "- Cite supporting evidence inline as [Chunk X].\n"
397
- "- If context is insufficient, say context is insufficient.\n\n"
398
- f"Context:\n{context}"
399
  ),
400
  }
401
  )
@@ -414,13 +500,19 @@ class RAGEngine:
414
 
415
  for model in candidates:
416
  try:
417
- resp = client.chat_completion(model=model, messages=messages, max_tokens=350, temperature=0.2)
 
 
418
  return {
419
- "answer": resp.choices[0].message.content,
420
  "chunks": chunks_payload,
421
  "model_used": model,
422
  "top_score": round(top_score, 4),
423
  "confidence_label": confidence_label,
 
 
 
 
424
  }
425
  except Exception as e:
426
  log.warning("LLM %s failed: %s", model, e)
@@ -432,4 +524,8 @@ class RAGEngine:
432
  "model_used": None,
433
  "top_score": round(top_score, 4),
434
  "confidence_label": confidence_label,
 
 
 
 
435
  }
 
16
  "mistralai/Mistral-Small-24B-Instruct-2501",
17
  ]
18
 
19
+ SYSTEM_PROMPT = """\
20
+ You are a Graph-RAG reasoning engine.
21
+
22
+ Your job is NOT to answer directly from text.
23
+ Your job is to construct answers by traversing entity relationships.
24
+
25
+ STRICT RULES:
26
+
27
+ 1. ENTITY EXTRACTION
28
+ - Identify all entities in the question.
29
+ - Do not answer yet.
30
+
31
+ 2. RELATIONSHIP TRAVERSAL
32
+ - Use retrieved context to find explicit relationships between entities.
33
+ - Build a step-by-step path using only these relationships.
34
+
35
+ 3. MULTI-HOP ENFORCEMENT
36
+ - If the question requires multiple steps, you MUST show intermediate entities.
37
+ - Do NOT skip steps even if the answer is obvious.
38
+
39
+ 4. NO SHORTCUTS
40
+ - Do NOT answer from a single chunk if it contains the full answer.
41
+ - You MUST validate at least one intermediate relationship (bridge).
42
+
43
+ 5. FAITHFULNESS
44
+ - Use ONLY retrieved context. Do NOT use prior knowledge.
45
+ - If the path cannot be constructed -> return INSUFFICIENT_CONTEXT.
46
+
47
+ 6. NEGATIVE GUARD
48
+ - If the question asks for unknown/private/unsupported info -> return INSUFFICIENT_CONTEXT.
49
+
50
+ 7. OUTPUT FORMAT (MANDATORY) - Return JSON ONLY:
51
+ {
52
+ "answer": "...",
53
+ "reasoning_type": "direct | multi-hop | insufficient",
54
+ "path": ["Entity1 -> Entity2", "Entity2 -> Entity3"],
55
+ "used_chunks": ["0", "1"],
56
+ "justification": "Explain briefly how the path leads to the answer using retrieved data only."
57
+ }
58
+
59
+ 8. FAILURE CONDITIONS - Return:
60
+ {
61
+ "answer": "INSUFFICIENT_CONTEXT",
62
+ "reasoning_type": "insufficient",
63
+ "path": [],
64
+ "used_chunks": [],
65
+ "justification": "No valid relationship path found in retrieved context."
66
+ }
67
+ IF: No relationship path exists | Only one-hop shortcut found | Information is missing
68
+ """
69
 
70
  STOPWORDS = {
71
  "a", "an", "and", "are", "as", "at", "be", "by", "for", "from", "has", "he", "in", "is", "it",
 
354
 
355
  if answer_mode == "strict_grounded" and confidence_label == "Low":
356
  return {
357
+ "answer": "INSUFFICIENT_CONTEXT",
358
  "chunks": chunks_payload,
359
  "model_used": None,
360
  "top_score": round(top_score, 4),
361
  "confidence_label": confidence_label,
362
+ "reasoning_type": "insufficient",
363
+ "path": [],
364
+ "used_chunks": [],
365
+ "justification": "Low retrieval confidence; no valid path found.",
366
  }
367
 
368
  context_block = "\n\n".join(
369
+ f"[chunk_id: {c.index}]\n{c.text}" for c in retrieved
370
  )
371
 
372
  hf_token = os.environ.get("HF_TOKEN")
 
377
  "model_used": None,
378
  "top_score": round(top_score, 4),
379
  "confidence_label": confidence_label,
380
+ "reasoning_type": "insufficient",
381
+ "path": [],
382
+ "used_chunks": [],
383
+ "justification": "",
384
  }
385
 
386
  messages = self._build_messages(query, context_block, history)
 
394
  yield {"type": "chunks", "data": chunks_payload}
395
 
396
  if answer_mode == "strict_grounded" and confidence_label == "Low":
397
+ yield {"type": "token", "data": "INSUFFICIENT_CONTEXT"}
398
+ yield {
399
+ "type": "done", "model_used": None,
400
+ "reasoning_type": "insufficient", "path": [],
401
+ "used_chunks": [], "justification": "Low retrieval confidence; no valid path found.",
402
+ }
403
  return
404
 
405
  hf_token = os.environ.get("HF_TOKEN")
406
  if not hf_token:
407
  yield {"type": "token", "data": "HF_TOKEN not set."}
408
+ yield {"type": "done", "model_used": None, "reasoning_type": "insufficient", "path": [], "used_chunks": [], "justification": ""}
409
  return
410
 
411
  context_block = "\n\n".join(
412
+ f"[chunk_id: {c.index}]\n{c.text}" for c in retrieved
413
  )
414
  messages = self._build_messages(query, context_block, history)
 
415
 
416
+ # Buffer full LLM output so we can parse the JSON before emitting clean answer tokens.
417
+ full_text, model_used = self._buffer_llm(hf_token, messages)
418
+ graph = self._parse_graph_response(full_text)
419
+ answer_text = graph.get("answer", full_text)
420
+
421
+ # Emit answer text word-by-word so the frontend stream still assembles naturally.
422
+ for token_chunk in re.split(r'(\s+)', answer_text):
423
+ if token_chunk:
424
+ yield {"type": "token", "data": token_chunk}
425
+
426
+ yield {
427
+ "type": "done",
428
+ "model_used": model_used,
429
+ "reasoning_type": graph.get("reasoning_type", "direct"),
430
+ "path": graph.get("path", []),
431
+ "used_chunks": graph.get("used_chunks", []),
432
+ "justification": graph.get("justification", ""),
433
+ }
434
+
435
+ def _buffer_llm(self, token: str, messages: list[dict]) -> tuple[str, str | None]:
436
+ """Non-streaming call used by stream_answer to enable JSON parsing before token emission."""
437
  client = InferenceClient(api_key=token)
438
  candidates = list(dict.fromkeys([self.llm_model] + FALLBACK_MODELS))
 
439
  for model in candidates:
440
  try:
441
+ resp = client.chat_completion(model=model, messages=messages, max_tokens=600, temperature=0.2)
442
+ return resp.choices[0].message.content, model
 
 
 
 
 
 
 
 
 
 
443
  except Exception as e:
444
+ log.warning("Buffered LLM %s failed: %s", model, e)
445
  continue
446
+ return "All candidate models failed.", None
447
+
448
+ def _parse_graph_response(self, text: str) -> dict:
449
+ """Extract and parse the mandatory JSON object from the LLM response."""
450
+ import json
451
+ # Strip optional markdown fences
452
+ cleaned = re.sub(r'^```(?:json)?\s*|\s*```$', '', text.strip(), flags=re.MULTILINE)
453
+ # Grab outermost JSON object
454
+ match = re.search(r'\{.*\}', cleaned, re.DOTALL)
455
+ if match:
456
+ try:
457
+ return json.loads(match.group())
458
+ except json.JSONDecodeError:
459
+ pass
460
+ # Fallback: treat raw text as answer, mark as direct
461
+ return {
462
+ "answer": text.strip(),
463
+ "reasoning_type": "direct",
464
+ "path": [],
465
+ "used_chunks": [],
466
+ "justification": "JSON parse failed; raw answer returned.",
467
+ }
468
 
469
  def _build_messages(self, query: str, context: str, history: list[dict] | None) -> list[dict]:
470
  messages: list[dict] = [{"role": "system", "content": SYSTEM_PROMPT}]
 
475
  "role": "user",
476
  "content": (
477
  f"Question:\n{query}\n\n"
478
+ "Retrieved Context (use the chunk_id values in your used_chunks field):\n"
479
+ f"{context}\n\n"
480
  "Instructions:\n"
481
+ "- Step 1: Extract entities from the question.\n"
482
+ "- Step 2: Traverse relationships across chunks to build a path.\n"
483
+ "- Step 3: Return ONLY a valid JSON object matching the mandatory format.\n"
484
+ "- Do NOT include any text outside the JSON object."
485
  ),
486
  }
487
  )
 
500
 
501
  for model in candidates:
502
  try:
503
+ resp = client.chat_completion(model=model, messages=messages, max_tokens=600, temperature=0.2)
504
+ raw = resp.choices[0].message.content
505
+ graph = self._parse_graph_response(raw)
506
  return {
507
+ "answer": graph.get("answer", raw),
508
  "chunks": chunks_payload,
509
  "model_used": model,
510
  "top_score": round(top_score, 4),
511
  "confidence_label": confidence_label,
512
+ "reasoning_type": graph.get("reasoning_type", "direct"),
513
+ "path": graph.get("path", []),
514
+ "used_chunks": graph.get("used_chunks", []),
515
+ "justification": graph.get("justification", ""),
516
  }
517
  except Exception as e:
518
  log.warning("LLM %s failed: %s", model, e)
 
524
  "model_used": None,
525
  "top_score": round(top_score, 4),
526
  "confidence_label": confidence_label,
527
+ "reasoning_type": "insufficient",
528
+ "path": [],
529
+ "used_chunks": [],
530
+ "justification": "All LLM candidates failed.",
531
  }
validate_graphrag.py ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Standalone validation: Graph-RAG engine (no LLM token required)."""
2
+ import sys, json
3
+ sys.path.insert(0, ".")
4
+ from backend.rag import RAGEngine
5
+
6
+ engine = RAGEngine(chunk_size=95, chunk_overlap=0)
7
+
8
+ doc = (
9
+ "Alpha Dynamics acquired Beta Labs in 2023 to expand its diagnostics portfolio.\n\n"
10
+ "Beta Labs later formed a strategic alliance with Orion Health for hospital analytics.\n\n"
11
+ "Orion Health announced a joint research program with Nova BioSystems focused on predictive care."
12
+ )
13
+ engine.ingest(doc)
14
+
15
+ # ── 1. Graph index ────────────────────────────────────────────────────────────
16
+ print("=== GRAPH INDEX ===")
17
+ nodes = len(engine._entity_to_chunks)
18
+ edges = sum(len(v) for v in engine._entity_graph.values()) // 2
19
+ print(f" nodes : {nodes}")
20
+ print(f" edges : {edges}")
21
+ print(f" chunks : {len(engine.chunks)}")
22
+ top = sorted(engine._entity_to_chunks.items(), key=lambda x: len(x[1]), reverse=True)[:5]
23
+ print(" top entities:", [(e, sorted(c)) for e, c in top])
24
+ assert nodes > 0, "Graph must have nodes"
25
+ assert edges > 0, "Graph must have edges"
26
+
27
+ # ── 2. Retrieval – 2-hop ──────────────────────────────────────────────────────
28
+ print("\n=== RETRIEVAL: 2-hop ===")
29
+ r = engine.retrieve("How is Orion Health connected to Alpha Dynamics?")
30
+ for c in r:
31
+ print(f" chunk {c.index} score={c.score:.4f} {c.text[:70]}")
32
+ assert any(c.index == 0 for c in r), "Must include Alpha Dynamics chunk"
33
+ assert any(c.index == 1 for c in r), "Must include bridge chunk (Beta Labs)"
34
+
35
+ # ── 3. Retrieval – 3-hop ──────────────────────────────────────────────────────
36
+ print("\n=== RETRIEVAL: 3-hop ===")
37
+ r2 = engine.retrieve("What links Nova BioSystems to Alpha Dynamics?")
38
+ for c in r2:
39
+ print(f" chunk {c.index} score={c.score:.4f} {c.text[:70]}")
40
+ assert len(r2) >= 2, "Needs at least 2 bridge chunks for 3-hop"
41
+
42
+ # ── 4. JSON parser – clean JSON ───────────────────────────────────────────────
43
+ print("\n=== JSON PARSER ===")
44
+ sample = {
45
+ "answer": "Alpha Dynamics -> Beta Labs -> Orion Health",
46
+ "reasoning_type": "multi-hop",
47
+ "path": ["Alpha Dynamics -> Beta Labs", "Beta Labs -> Orion Health"],
48
+ "used_chunks": ["0", "1"],
49
+ "justification": "Alpha acquired Beta, Beta allied with Orion.",
50
+ }
51
+ p = engine._parse_graph_response(json.dumps(sample))
52
+ assert p["reasoning_type"] == "multi-hop"
53
+ assert len(p["path"]) == 2
54
+ print(f" reasoning_type : {p['reasoning_type']}")
55
+ print(f" path : {p['path']}")
56
+ print(f" used_chunks : {p['used_chunks']}")
57
+
58
+ # ── 5. JSON parser – markdown fenced ─────────────────────────────────────────
59
+ fenced = "```json\n" + json.dumps(sample) + "\n```"
60
+ p2 = engine._parse_graph_response(fenced)
61
+ assert p2["reasoning_type"] == "multi-hop", "Must strip markdown fences"
62
+ print(f" fenced input parsed OK : {p2['reasoning_type']}")
63
+
64
+ # ── 6. JSON parser – fallback on non-JSON ────────────────────────────────────
65
+ p3 = engine._parse_graph_response("I cannot answer that question.")
66
+ assert p3["reasoning_type"] == "direct"
67
+ print(f" fallback answer: {p3['answer'][:50]}")
68
+
69
+ # ── 7. Negative: completely unknown entity (not in graph at all) ──────────────
70
+ print("\n=== RETRIEVAL: negative (unknown entity) ===")
71
+ r3 = engine.retrieve("Who is the founder of ZetaCorp Robotics?")
72
+ _, conf = engine._confidence_from_retrieved(r3)
73
+ print(f" confidence_label: {conf} (expected Low β€” ZetaCorp is not in graph)")
74
+ assert conf == "Low", "Unknown-entity queries should produce Low confidence"
75
+
76
+ print("\nALL ASSERTIONS PASSED - Graph-RAG engine fully validated.")