VedantDhavan commited on
Commit
83aed13
·
1 Parent(s): 81392eb

Deploy GraphRAG benchmark backend

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitattributes +2 -0
  2. backend/Dockerfile +27 -0
  3. backend/__init__.py +0 -0
  4. backend/main.py +82 -0
  5. backend/models/schema.py +0 -0
  6. backend/routes/graph.py +63 -0
  7. backend/routes/ingestion.py +34 -0
  8. backend/routes/metrics.py +22 -0
  9. backend/routes/query.py +19 -0
  10. backend/routes/tigergraph.py +38 -0
  11. backend/security.py +92 -0
  12. backend/services/evaluation_service.py +0 -0
  13. backend/services/ingestion_service.py +88 -0
  14. backend/services/pipelines_service.py +112 -0
  15. data/chroma/12e587cb-7739-45a4-bb36-b1fba0ef511c/data_level0.bin +3 -0
  16. data/chroma/12e587cb-7739-45a4-bb36-b1fba0ef511c/header.bin +3 -0
  17. data/chroma/12e587cb-7739-45a4-bb36-b1fba0ef511c/index_metadata.pickle +3 -0
  18. data/chroma/12e587cb-7739-45a4-bb36-b1fba0ef511c/length.bin +3 -0
  19. data/chroma/12e587cb-7739-45a4-bb36-b1fba0ef511c/link_lists.bin +3 -0
  20. data/chroma/chroma.sqlite3 +3 -0
  21. data/eval/scientific_eval_questions.json +3 -0
  22. data/graph/graph_metadata.json +3 -0
  23. data/graph/graphrag_graph.pkl +3 -0
  24. data/results/final_summary.json +3 -0
  25. data/results/scientific_accuracy_report.json +3 -0
  26. data/results/scientific_benchmark_results.json +3 -0
  27. evaluation/__init__.py +0 -0
  28. evaluation/bertscore_eval.py +49 -0
  29. evaluation/evaluator.py +61 -0
  30. evaluation/ground_truth.json +3 -0
  31. evaluation/llm_judge.py +76 -0
  32. evaluation/metrics.py +2 -0
  33. ingestion/__init__.py +0 -0
  34. ingestion/build_embeddings.py +13 -0
  35. ingestion/build_graph.py +81 -0
  36. ingestion/build_graph_tigergraph.py +38 -0
  37. ingestion/chunk_data.py +10 -0
  38. ingestion/entity_extraction.py +47 -0
  39. ingestion/preprocess.py +38 -0
  40. ingestion/schema.py +62 -0
  41. pipelines/__init__.py +0 -0
  42. pipelines/basic_rag/__init__.py +0 -0
  43. pipelines/basic_rag/chunking.py +13 -0
  44. pipelines/basic_rag/embedding.py +14 -0
  45. pipelines/basic_rag/prompt_template.txt +0 -0
  46. pipelines/basic_rag/rag_pipeline.py +102 -0
  47. pipelines/basic_rag/retriever.py +19 -0
  48. pipelines/basic_rag/vector_store.py +93 -0
  49. pipelines/graphrag/__init__.py +0 -0
  50. pipelines/graphrag/config.py +0 -0
.gitattributes CHANGED
@@ -33,3 +33,5 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ *.sqlite3 filter=lfs diff=lfs merge=lfs -text
37
+ *.json filter=lfs diff=lfs merge=lfs -text
backend/Dockerfile ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ WORKDIR /app
4
+
5
+ # system deps (small set; keep slim)
6
+ RUN apt-get update && apt-get install -y --no-install-recommends \
7
+ build-essential \
8
+ && rm -rf /var/lib/apt/lists/*
9
+
10
+ COPY requirements.txt /app/requirements.txt
11
+ RUN pip install --no-cache-dir -r /app/requirements.txt
12
+
13
+ # Copy code (keep repo structure)
14
+ COPY backend /app/backend
15
+ COPY ingestion /app/ingestion
16
+ COPY evaluation /app/evaluation
17
+ COPY pipelines /app/pipelines
18
+ COPY utils /app/utils
19
+ COPY services /app/services
20
+ COPY scripts /app/scripts
21
+
22
+ ENV PYTHONUNBUFFERED=1
23
+
24
+ EXPOSE 8000
25
+
26
+ # Render sets $PORT; local default remains 8000
27
+ CMD ["sh", "-c", "uvicorn backend.main:app --host 0.0.0.0 --port ${PORT:-8000}"]
backend/__init__.py ADDED
File without changes
backend/main.py ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from pathlib import Path
3
+ from fastapi.middleware.cors import CORSMiddleware
4
+ from fastapi import FastAPI
5
+
6
+ from backend.routes import ingestion, metrics, query
7
+ from backend.routes.graph import router as graph_router
8
+ from backend.security import (
9
+ path_writable,
10
+ production_config_errors,
11
+ rate_limit_middleware,
12
+ request_size_middleware,
13
+ )
14
+ from utils.paths import chroma_path, graph_path, upload_dir
15
+
16
+ app = FastAPI(title="GraphRAG Benchmark API")
17
+
18
+ frontend_url = os.getenv("FRONTEND_URL", "").strip()
19
+ cors_origins_env = os.getenv("CORS_ORIGINS", frontend_url).strip()
20
+ cors_origins = [o.strip() for o in cors_origins_env.split(",") if o.strip()]
21
+ allow_origin_regex = os.getenv("CORS_ORIGIN_REGEX") # optional
22
+
23
+ app.add_middleware(
24
+ CORSMiddleware,
25
+ allow_origins=cors_origins if cors_origins else ["http://localhost:3000", "http://localhost:3005"],
26
+ allow_origin_regex=allow_origin_regex,
27
+ allow_credentials=True,
28
+ allow_methods=["*"],
29
+ allow_headers=["*"],
30
+ )
31
+ app.middleware("http")(rate_limit_middleware)
32
+ app.middleware("http")(request_size_middleware)
33
+
34
+ app.include_router(ingestion.router, prefix="/api")
35
+ app.include_router(metrics.router, prefix="/api")
36
+ app.include_router(query.router, prefix="/api")
37
+ app.include_router(graph_router)
38
+
39
+
40
+ @app.on_event("startup")
41
+ def validate_startup_config():
42
+ errors = production_config_errors()
43
+ if errors:
44
+ raise RuntimeError("; ".join(errors))
45
+
46
+
47
+ @app.get("/health")
48
+ def health_check():
49
+ return {"status": "ok"}
50
+
51
+
52
+ @app.get("/ready")
53
+ def ready_check():
54
+ graph_file = Path(graph_path())
55
+ checks = {
56
+ "production_config": production_config_errors(),
57
+ "chroma_path_writable": path_writable(chroma_path()),
58
+ "upload_dir_writable": path_writable(upload_dir()),
59
+ "graph_artifact_present": graph_file.exists(),
60
+ "tigergraph_enabled": os.getenv("TIGERGRAPH_ENABLED", "false").lower() == "true",
61
+ "networkx_primary": True,
62
+ }
63
+ if checks["tigergraph_enabled"]:
64
+ checks["tigergraph_configured"] = all(
65
+ os.getenv(key)
66
+ for key in (
67
+ "TIGERGRAPH_HOST",
68
+ "TIGERGRAPH_GRAPH",
69
+ "TIGERGRAPH_USERNAME",
70
+ "TIGERGRAPH_PASSWORD",
71
+ )
72
+ )
73
+ else:
74
+ checks["tigergraph_configured"] = "skipped"
75
+
76
+ ready = (
77
+ not checks["production_config"]
78
+ and checks["chroma_path_writable"]
79
+ and checks["upload_dir_writable"]
80
+ and (checks["tigergraph_configured"] is True or checks["tigergraph_configured"] == "skipped")
81
+ )
82
+ return {"status": "ready" if ready else "not_ready", "checks": checks}
backend/models/schema.py ADDED
File without changes
backend/routes/graph.py ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter
2
+ import pickle
3
+ import os
4
+
5
+ from utils.paths import graph_path
6
+
7
+ router = APIRouter(prefix="/api/graph", tags=["graph"])
8
+
9
+
10
+ @router.get("/view")
11
+ def get_graph_view(limit: int = 200):
12
+ graph_file = graph_path()
13
+ if not os.path.exists(graph_file):
14
+ return {
15
+ "status": "error",
16
+ "message": "Graph file not found",
17
+ "nodes": [],
18
+ "edges": []
19
+ }
20
+
21
+ with open(graph_file, "rb") as f:
22
+ graph = pickle.load(f)
23
+
24
+ nodes = []
25
+ edges = []
26
+
27
+ for node_id, attrs in list(graph.nodes(data=True))[:limit]:
28
+ nodes.append({
29
+ "data": {
30
+ "id": str(node_id),
31
+ "label": attrs.get("label")
32
+ or attrs.get("name")
33
+ or attrs.get("title")
34
+ or attrs.get("text", "")[:40]
35
+ or str(node_id),
36
+ "type": attrs.get("node_type", attrs.get("type", "unknown"))
37
+ }
38
+ })
39
+
40
+ node_ids = set(n["data"]["id"] for n in nodes)
41
+
42
+ for source, target, attrs in graph.edges(data=True):
43
+ if str(source) in node_ids and str(target) in node_ids:
44
+ edges.append({
45
+ "data": {
46
+ "id": f"{source}-{target}",
47
+ "source": str(source),
48
+ "target": str(target),
49
+ "label": attrs.get("relation", attrs.get("type", "RELATED"))
50
+ }
51
+ })
52
+
53
+ return {
54
+ "status": "success",
55
+ "nodes": nodes,
56
+ "edges": edges,
57
+ "stats": {
58
+ "total_nodes": graph.number_of_nodes(),
59
+ "total_edges": graph.number_of_edges(),
60
+ "shown_nodes": len(nodes),
61
+ "shown_edges": len(edges)
62
+ }
63
+ }
backend/routes/ingestion.py ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, UploadFile, File, HTTPException
2
+
3
+ from backend.security import AdminDependency
4
+
5
+ router = APIRouter()
6
+
7
+
8
+ @router.post("/upload")
9
+ async def upload_file(file: UploadFile = File(...), _: None = AdminDependency):
10
+ allowed = (".pdf", ".txt", ".csv")
11
+ if not any(file.filename.lower().endswith(ext) for ext in allowed):
12
+ raise HTTPException(status_code=400, detail="Only PDF, TXT, CSV supported")
13
+
14
+ content = await file.read()
15
+
16
+ from backend.services.ingestion_service import ingest_document
17
+
18
+ try:
19
+ result = await ingest_document(file.filename, content)
20
+ except ValueError as exc:
21
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
22
+ except Exception as exc:
23
+ raise HTTPException(
24
+ status_code=500,
25
+ detail={
26
+ "stage": "ingestion",
27
+ "message": "Ingestion failed",
28
+ },
29
+ ) from exc
30
+
31
+ return {
32
+ "status": result.get("status", "success"),
33
+ "data": result,
34
+ }
backend/routes/metrics.py ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ from pathlib import Path
3
+
4
+ from fastapi import APIRouter
5
+
6
+ router = APIRouter()
7
+
8
+ FINAL_SUMMARY_PATH = Path("data/results/final_summary.json")
9
+
10
+
11
+ @router.get("/metrics/final-summary")
12
+ def final_summary():
13
+ if not FINAL_SUMMARY_PATH.exists():
14
+ return {"status": "missing", "summary": {}}
15
+
16
+ try:
17
+ return {
18
+ "status": "ok",
19
+ "summary": json.loads(FINAL_SUMMARY_PATH.read_text(encoding="utf-8")),
20
+ }
21
+ except json.JSONDecodeError:
22
+ return {"status": "error", "summary": {}, "error": "Invalid final_summary.json"}
backend/routes/query.py ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, HTTPException
2
+ from pydantic import BaseModel, Field
3
+
4
+ from backend.services.pipelines_service import run_all_pipelines
5
+
6
+ router = APIRouter()
7
+
8
+
9
+ class QueryRequest(BaseModel):
10
+ query: str = Field(..., min_length=1)
11
+
12
+
13
+ @router.post("/query")
14
+ def query_all(request: QueryRequest):
15
+ question = request.query.strip()
16
+ if not question:
17
+ raise HTTPException(status_code=400, detail="Query cannot be empty")
18
+
19
+ return run_all_pipelines(question)
backend/routes/tigergraph.py ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter
2
+ from pydantic import BaseModel
3
+
4
+ from pipelines.graphrag.graphrag_client import TigerGraphClient
5
+
6
+ router = APIRouter()
7
+
8
+
9
+ class TigerGraphHealth(BaseModel):
10
+ ok: bool
11
+ host: str
12
+ graph: str
13
+ restpp_version_ok: bool = False
14
+ graph_exists: bool = False
15
+ error: str | None = None
16
+
17
+
18
+ @router.get("/tigergraph/health", response_model=TigerGraphHealth)
19
+ def tigergraph_health():
20
+ client = TigerGraphClient()
21
+
22
+ health = TigerGraphHealth(ok=False, host=client.host, graph=client.graph)
23
+
24
+ try:
25
+ # Use RESTPP /version which does not require a graph.
26
+ version = client._get_version()
27
+ health.restpp_version_ok = bool(version) and not version.get("error", False)
28
+
29
+ # Check the benchmark graph exists by listing one vertex type with limit=1.
30
+ # If graph does not exist, RESTPP returns an error.
31
+ client._get("/vertices/BenchDocument", params={"limit": 1})
32
+ health.graph_exists = True
33
+
34
+ health.ok = health.restpp_version_ok and health.graph_exists
35
+ return health
36
+ except Exception as exc:
37
+ health.error = str(exc)
38
+ return health
backend/security.py ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import time
3
+ from collections import defaultdict, deque
4
+ from pathlib import Path
5
+ from typing import Callable
6
+
7
+ from fastapi import Depends, HTTPException, Request, status
8
+ from fastapi.responses import JSONResponse
9
+
10
+
11
+ MAX_REQUEST_BYTES = int(os.getenv("MAX_REQUEST_BYTES", str(25 * 1024 * 1024)))
12
+ RATE_LIMIT_REQUESTS = int(os.getenv("RATE_LIMIT_REQUESTS", "120"))
13
+ RATE_LIMIT_WINDOW_SECONDS = int(os.getenv("RATE_LIMIT_WINDOW_SECONDS", "60"))
14
+
15
+ _requests: dict[str, deque[float]] = defaultdict(deque)
16
+
17
+
18
+ def admin_auth_required() -> bool:
19
+ return bool(os.getenv("ADMIN_API_KEY")) or os.getenv("ENV", "").lower() == "production"
20
+
21
+
22
+ def require_admin(request: Request) -> None:
23
+ expected = os.getenv("ADMIN_API_KEY")
24
+ if not admin_auth_required():
25
+ return
26
+ if not expected:
27
+ raise HTTPException(
28
+ status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
29
+ detail="ADMIN_API_KEY is required in production.",
30
+ )
31
+
32
+ auth = request.headers.get("authorization", "")
33
+ if not auth.lower().startswith("bearer "):
34
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing bearer token")
35
+ token = auth.split(" ", 1)[1].strip()
36
+ if token != expected:
37
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invalid bearer token")
38
+
39
+
40
+ AdminDependency = Depends(require_admin)
41
+
42
+
43
+ async def request_size_middleware(request: Request, call_next: Callable):
44
+ content_length = request.headers.get("content-length")
45
+ if content_length and int(content_length) > MAX_REQUEST_BYTES:
46
+ return JSONResponse(
47
+ status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
48
+ content={"detail": "Request body too large"},
49
+ )
50
+ return await call_next(request)
51
+
52
+
53
+ async def rate_limit_middleware(request: Request, call_next: Callable):
54
+ client = request.client.host if request.client else "unknown"
55
+ now = time.time()
56
+ window_start = now - RATE_LIMIT_WINDOW_SECONDS
57
+ bucket = _requests[client]
58
+ while bucket and bucket[0] < window_start:
59
+ bucket.popleft()
60
+ if len(bucket) >= RATE_LIMIT_REQUESTS:
61
+ return JSONResponse(
62
+ status_code=status.HTTP_429_TOO_MANY_REQUESTS,
63
+ content={"detail": "Rate limit exceeded"},
64
+ )
65
+ bucket.append(now)
66
+ return await call_next(request)
67
+
68
+
69
+ def production_config_errors() -> list[str]:
70
+ if os.getenv("ENV", "").lower() != "production":
71
+ return []
72
+
73
+ errors = []
74
+ for key in ("OPENAI_API_KEY", "FRONTEND_URL", "BACKEND_URL", "ADMIN_API_KEY"):
75
+ if not os.getenv(key):
76
+ errors.append(f"{key} is required in production")
77
+ if not (os.getenv("JWT_SECRET") or os.getenv("SESSION_SECRET")):
78
+ errors.append("JWT_SECRET or SESSION_SECRET is required in production")
79
+ return errors
80
+
81
+
82
+ def path_writable(path: str) -> bool:
83
+ p = Path(path)
84
+ target = p if p.suffix == "" else p.parent
85
+ try:
86
+ target.mkdir(parents=True, exist_ok=True)
87
+ probe = target / ".write_probe"
88
+ probe.write_text("ok", encoding="utf-8")
89
+ probe.unlink(missing_ok=True)
90
+ return True
91
+ except Exception:
92
+ return False
backend/services/evaluation_service.py ADDED
File without changes
backend/services/ingestion_service.py ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import uuid
2
+ import os
3
+ from pathlib import Path
4
+
5
+ from ingestion.preprocess import extract_text_from_file
6
+ from ingestion.chunk_data import chunk_text
7
+ from ingestion.build_embeddings import add_to_vector_store
8
+ from ingestion.entity_extraction import extract_entities
9
+ from ingestion.build_graph import build_graph
10
+ from utils.paths import upload_dir
11
+
12
+
13
+ async def ingest_document(file_name: str, file_bytes: bytes):
14
+ """
15
+ Unified ingestion for:
16
+ - Basic RAG (vector store)
17
+ - GraphRAG (local NetworkX graph)
18
+ """
19
+
20
+ doc_id = str(uuid.uuid4())
21
+
22
+ # Persist raw uploads (useful for debugging / re-processing). This does not affect pipeline outputs.
23
+ try:
24
+ up_dir = upload_dir()
25
+ os.makedirs(up_dir, exist_ok=True)
26
+ safe_name = Path(file_name).name.replace("\\", "_").replace("/", "_")
27
+ raw_path = os.path.join(up_dir, f"{doc_id}__{safe_name}")
28
+ with open(raw_path, "wb") as f:
29
+ f.write(file_bytes)
30
+ except Exception:
31
+ # Non-fatal: ingestion should still proceed.
32
+ pass
33
+
34
+ # -------------------------
35
+ # 1. PREPROCESS
36
+ # -------------------------
37
+ pages, file_type = extract_text_from_file(file_name, file_bytes)
38
+ if not pages or not any(p.strip() for p in pages):
39
+ raise ValueError("No extractable text found in file")
40
+
41
+ # -------------------------
42
+ # 2. CHUNKING
43
+ # -------------------------
44
+ chunk_records = []
45
+ chunk_index = 0
46
+ for page_num, page_text in enumerate(pages, start=1):
47
+ if not page_text.strip():
48
+ continue
49
+ page_chunks = chunk_text(page_text)
50
+ for chunk in page_chunks:
51
+ chunk_records.append(
52
+ {
53
+ "chunk_id": f"{doc_id}_chunk_{chunk_index}",
54
+ "doc_id": doc_id,
55
+ "text": chunk,
56
+ "source_file": file_name,
57
+ "page": page_num if file_type == "pdf" else None,
58
+ }
59
+ )
60
+ chunk_index += 1
61
+
62
+ # -------------------------
63
+ # 3. EMBEDDING + STORE (ONLY ONCE)
64
+ # -------------------------
65
+ add_to_vector_store(chunk_records)
66
+
67
+ # -------------------------
68
+ # 4. GRAPH RAG PIPELINE
69
+ # -------------------------
70
+ entities = extract_entities([c["text"] for c in chunk_records])
71
+
72
+ graph_result = build_graph(
73
+ doc_id=doc_id,
74
+ title=file_name,
75
+ chunks=chunk_records,
76
+ entities=entities
77
+ )
78
+
79
+ # -------------------------
80
+ # RETURN METADATA
81
+ # -------------------------
82
+ return {
83
+ "status": "success",
84
+ "doc_id": doc_id,
85
+ "chunks_count": len(chunk_records),
86
+ "entities_count": len(entities),
87
+ "relations_count": graph_result.get("relations_edges", 0),
88
+ }
backend/services/pipelines_service.py ADDED
@@ -0,0 +1,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import os
3
+ from importlib import import_module
4
+ from pathlib import Path
5
+ from typing import Callable, Dict
6
+
7
+
8
+ GROUND_TRUTH_PATH = Path("evaluation/ground_truth.json")
9
+
10
+
11
+ def _load_reference_answer(question: str) -> str:
12
+ if not GROUND_TRUTH_PATH.exists():
13
+ return ""
14
+
15
+ try:
16
+ data = json.loads(GROUND_TRUTH_PATH.read_text(encoding="utf-8"))
17
+ except (OSError, json.JSONDecodeError):
18
+ return ""
19
+
20
+ if isinstance(data, dict):
21
+ return data.get(question, "")
22
+
23
+ if isinstance(data, list):
24
+ for row in data:
25
+ if not isinstance(row, dict):
26
+ continue
27
+ row_question = row.get("question", row.get("query", ""))
28
+ if row_question == question:
29
+ return row.get("correct_answer", row.get("answer", ""))
30
+
31
+ return ""
32
+
33
+
34
+ def _safe_run(name: str, runner: Callable[[str], Dict], question: str) -> Dict:
35
+ try:
36
+ result = runner(question)
37
+ if not isinstance(result, dict):
38
+ return {
39
+ "status": "error",
40
+ "answer": "",
41
+ "error": f"{name} returned {type(result).__name__}, expected dict",
42
+ }
43
+
44
+ return {
45
+ "status": "success",
46
+ **result,
47
+ }
48
+ except Exception as exc:
49
+ return {
50
+ "status": "error",
51
+ "answer": "",
52
+ "error": str(exc),
53
+ }
54
+
55
+
56
+ def _run_pipeline(name: str, module_path: str, function_name: str, question: str) -> Dict:
57
+ try:
58
+ module = import_module(module_path)
59
+ runner = getattr(module, function_name)
60
+ except Exception as exc:
61
+ return {
62
+ "status": "error",
63
+ "answer": "",
64
+ "error": f"Failed to load {name}: {exc}",
65
+ }
66
+
67
+ return _safe_run(name, runner, question)
68
+
69
+
70
+ def run_all_pipelines(question: str) -> Dict:
71
+ """
72
+ Lazily import pipelines so the API can boot even when optional runtime
73
+ dependencies, model files, or external services are not ready yet.
74
+ """
75
+ response = {
76
+ "query": question,
77
+ "pipelines": {
78
+ "llm_only": _run_pipeline(
79
+ "llm_only",
80
+ "pipelines.llm_only.llm_pipeline",
81
+ "run_llm_only",
82
+ question,
83
+ ),
84
+ "basic_rag": _run_pipeline(
85
+ "basic_rag",
86
+ "pipelines.basic_rag.rag_pipeline",
87
+ "run_basic_rag",
88
+ question,
89
+ ),
90
+ "graphrag": _run_pipeline(
91
+ "graphrag",
92
+ "pipelines.graphrag.graphrag_pipeline",
93
+ "run_graphrag",
94
+ question,
95
+ ),
96
+ },
97
+ }
98
+
99
+ if os.getenv("ENABLE_LIVE_ACCURACY", "").lower() in {"1", "true", "yes"}:
100
+ correct_answer = _load_reference_answer(question)
101
+ if correct_answer:
102
+ from evaluation.evaluator import evaluate_single_answer
103
+
104
+ for result in response["pipelines"].values():
105
+ if result.get("status") == "success":
106
+ result["accuracy"] = evaluate_single_answer(
107
+ question,
108
+ correct_answer,
109
+ result.get("answer", ""),
110
+ )
111
+
112
+ return response
data/chroma/12e587cb-7739-45a4-bb36-b1fba0ef511c/data_level0.bin ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:436726978ef423c6bf7020dbaefd11604b8f4b725fbe7ae7459c2b45f6ccd4db
3
+ size 6764336
data/chroma/12e587cb-7739-45a4-bb36-b1fba0ef511c/header.bin ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:07205de30b67ddebefe7a25e1874717fd92d7d75e0fec53f4bcb0edf2fcea994
3
+ size 100
data/chroma/12e587cb-7739-45a4-bb36-b1fba0ef511c/index_metadata.pickle ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:fdf9384ba9da795eedc06a313c97b519652b320e9fff1c9abe2fd765315e6a96
3
+ size 258448
data/chroma/12e587cb-7739-45a4-bb36-b1fba0ef511c/length.bin ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:b9c4aa95b8a4c614039830f12760d7e4793fbd5f638feb215f2ad06bdf7ea2b3
3
+ size 16144
data/chroma/12e587cb-7739-45a4-bb36-b1fba0ef511c/link_lists.bin ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:3b82f70ae47004403898f0f8fdd8e40f36a687bb917a3f7e68bf258c8dd27007
3
+ size 34368
data/chroma/chroma.sqlite3 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:022f2b2e044df94587b5b6de6ebb0874d0857fa205c62fe4e50cfac9d5e1f105
3
+ size 72962048
data/eval/scientific_eval_questions.json ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:1f4fdad1a16718a12085c81771a9b76550c852ee2d740b708712a0a9231883cb
3
+ size 31464
data/graph/graph_metadata.json ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:16bd6977675d8a9096ba4968066526b015855159c6b4e44014d6c58bcf111cb8
3
+ size 113
data/graph/graphrag_graph.pkl ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:a3b675daf240d1a0bfe7fe1bfad4f05eabf6c8a718c0254bf87ceed461e19683
3
+ size 37461540
data/results/final_summary.json ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:76b04c2f30efdb9924e0ab0c01b508251cd2bd76a8f0858ac8183dcbcfc36e98
3
+ size 1140
data/results/scientific_accuracy_report.json ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:a5cca828fecdf0b28ca03a5cc4fe059c9f3975e176ea9bf894d42fe34759f0b5
3
+ size 377
data/results/scientific_benchmark_results.json ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:e27bff660544f05e8f9006dc6716c2398ed98faeb77f6c4d4543a3d2da1ae055
3
+ size 176379
evaluation/__init__.py ADDED
File without changes
evaluation/bertscore_eval.py ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ def compute_bertscore(predictions, references, lang="en"):
2
+ pairs = [
3
+ (prediction or "", reference or "")
4
+ for prediction, reference in zip(predictions, references)
5
+ if reference
6
+ ]
7
+ if not pairs:
8
+ return {
9
+ "f1": [],
10
+ "mean_f1": None,
11
+ "status": "SKIP",
12
+ "error": "No reference answers supplied.",
13
+ }
14
+
15
+ try:
16
+ import evaluate
17
+ except ImportError:
18
+ return {
19
+ "f1": [],
20
+ "mean_f1": None,
21
+ "status": "SKIP",
22
+ "error": "Install the evaluate package to compute BERTScore.",
23
+ }
24
+
25
+ filtered_predictions = [prediction for prediction, _ in pairs]
26
+ filtered_references = [reference for _, reference in pairs]
27
+ try:
28
+ bertscore = evaluate.load("bertscore")
29
+ result = bertscore.compute(
30
+ predictions=filtered_predictions,
31
+ references=filtered_references,
32
+ lang=lang,
33
+ rescale_with_baseline=True,
34
+ )
35
+ except Exception as exc:
36
+ return {
37
+ "f1": [],
38
+ "mean_f1": None,
39
+ "status": "SKIP",
40
+ "error": str(exc),
41
+ }
42
+
43
+ f1 = result.get("f1", [])
44
+ return {
45
+ "f1": f1,
46
+ "mean_f1": sum(f1) / len(f1) if f1 else None,
47
+ "status": "OK",
48
+ "error": None,
49
+ }
evaluation/evaluator.py ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from .bertscore_eval import compute_bertscore
2
+ from .llm_judge import judge_answer, judge_answers
3
+ from .metrics import compute_cost
4
+
5
+ def evaluate(query, outputs, ground_truth):
6
+ results = {}
7
+
8
+ for name, out in outputs.items():
9
+ answer = out.get("answer", "")
10
+ tokens = out.get("tokens", 0)
11
+ latency = out.get("latency", 0)
12
+ results[name] = {
13
+ "answer": answer,
14
+ "tokens": tokens,
15
+ "latency": latency,
16
+ "cost": compute_cost(tokens),
17
+ "judge": judge_answer(answer, ground_truth, query)
18
+ }
19
+
20
+ return results
21
+
22
+
23
+ def evaluate_single_answer(question, correct_answer, system_answer):
24
+ verdict = judge_answer(system_answer, correct_answer, question)
25
+ bert = compute_bertscore([system_answer], [correct_answer])
26
+ return {
27
+ "llm_judge": verdict,
28
+ "llm_judge_pass": verdict == "PASS",
29
+ "bertscore_f1": bert["mean_f1"],
30
+ }
31
+
32
+
33
+ def evaluate_batch(pipeline_answers, ground_truth):
34
+ references = [row.get("correct_answer", "") for row in ground_truth]
35
+ questions = [row.get("question", row.get("query", "")) for row in ground_truth]
36
+ metrics = {}
37
+
38
+ for pipeline_name, answers in pipeline_answers.items():
39
+ rows = [
40
+ {
41
+ "question": question,
42
+ "correct_answer": reference,
43
+ "system_answer": answer,
44
+ }
45
+ for question, reference, answer in zip(questions, references, answers)
46
+ ]
47
+ verdicts = judge_answers(rows)
48
+ pass_fail = [verdict == "PASS" for verdict in verdicts if verdict != "SKIP"]
49
+ bert = compute_bertscore(answers, references)
50
+
51
+ metrics[pipeline_name] = {
52
+ "llm_judge_pass_rate": (
53
+ sum(pass_fail) / len(pass_fail) if pass_fail else None
54
+ ),
55
+ "llm_judge_verdicts": verdicts,
56
+ "bertscore_f1": bert["mean_f1"],
57
+ "bertscore_status": bert["status"],
58
+ "bertscore_error": bert["error"],
59
+ }
60
+
61
+ return metrics
evaluation/ground_truth.json ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:37517e5f3dc66819f61f5a7bb8ace1921282415f10551d2defa5c3eb0985b570
3
+ size 3
evaluation/llm_judge.py ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+
3
+ JUDGE_MODEL = os.getenv("HF_JUDGE_MODEL", "meta-llama/Llama-3.1-8B-Instruct")
4
+
5
+ JUDGE_PROMPT = """Grade the system's answer.
6
+ Question: {question}
7
+ Correct answer: {correct_answer}
8
+ System answer: {system_answer}
9
+
10
+ Reply with only PASS or FAIL.
11
+ PASS = the system answer correctly addresses the question with no major errors.
12
+ FAIL = the answer is wrong, missing, or contradicts the correct answer."""
13
+
14
+
15
+ def _verdict_from_text(text):
16
+ normalized = (text or "").strip().upper()
17
+ if normalized.startswith("PASS") or "PASS" in normalized:
18
+ return "PASS"
19
+ return "FAIL"
20
+
21
+
22
+ def judge_answer(system_answer, correct_answer, question="", client=None):
23
+ if not correct_answer:
24
+ return "SKIP"
25
+
26
+ if client is None:
27
+ token = os.getenv("HF_TOKEN")
28
+ if not token:
29
+ return "SKIP"
30
+
31
+ try:
32
+ from huggingface_hub import InferenceClient
33
+ except ImportError:
34
+ return "SKIP"
35
+
36
+ client = InferenceClient(model=JUDGE_MODEL, token=token)
37
+
38
+ prompt = JUDGE_PROMPT.format(
39
+ question=question,
40
+ correct_answer=correct_answer,
41
+ system_answer=system_answer or "",
42
+ )
43
+ try:
44
+ response = client.chat_completion(
45
+ [{"role": "user", "content": prompt}],
46
+ max_tokens=10,
47
+ temperature=0.0,
48
+ )
49
+ except Exception:
50
+ return "SKIP"
51
+
52
+ return _verdict_from_text(response.choices[0].message.content)
53
+
54
+
55
+ def judge_answers(rows, model=JUDGE_MODEL):
56
+ token = os.getenv("HF_TOKEN")
57
+ if not token:
58
+ return ["SKIP" for _ in rows]
59
+
60
+ try:
61
+ from huggingface_hub import InferenceClient
62
+ except ImportError:
63
+ return ["SKIP" for _ in rows]
64
+
65
+ client = InferenceClient(model=model, token=token)
66
+ verdicts = []
67
+ for row in rows:
68
+ verdicts.append(
69
+ judge_answer(
70
+ row.get("system_answer", ""),
71
+ row.get("correct_answer", ""),
72
+ row.get("question", ""),
73
+ client=client,
74
+ )
75
+ )
76
+ return verdicts
evaluation/metrics.py ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ def compute_cost(tokens, cost_per_1k=0.002):
2
+ return (tokens/1000) * cost_per_1k
ingestion/__init__.py ADDED
File without changes
ingestion/build_embeddings.py ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pipelines.basic_rag.vector_store import VectorStore
2
+
3
+
4
+ def add_to_vector_store(chunk_records):
5
+ """
6
+ Real-time ingestion into persisted ChromaDB
7
+ """
8
+ if not chunk_records:
9
+ raise ValueError("Cannot build embeddings for an empty document")
10
+
11
+ store = VectorStore.load()
12
+ store.add_documents(chunk_records)
13
+ return None
ingestion/build_graph.py ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from collections import defaultdict
2
+
3
+ from pipelines.graphrag.graphrag_client import NetworkXGraphClient, normalize_text
4
+ from ingestion.entity_extraction import extract_entities
5
+
6
+ MAX_ENTITIES_PER_CHUNK = 12
7
+
8
+
9
+ def build_graph(doc_id: str, title: str, chunks: list[dict], entities: list[tuple[str, str]]):
10
+ """
11
+ Local GraphRAG graph builder using NetworkXGraphClient.
12
+
13
+ Nodes:
14
+ - Document
15
+ - Chunk
16
+ - Entity
17
+
18
+ Edges:
19
+ - Document -> Chunk: HAS_CHUNK
20
+ - Chunk -> Entity: MENTIONS
21
+ - Entity -> Entity: RELATED_TO (co-mentions, capped per chunk)
22
+ """
23
+ client = NetworkXGraphClient()
24
+ client.load_graph()
25
+
26
+ source_file = chunks[0].get("source_file", title) if chunks else title
27
+ client.add_document(doc_id, title=title, source_file=source_file)
28
+
29
+ entity_type_map = {normalize_text(name): entity_type for name, entity_type in entities}
30
+
31
+ relations_edges = 0
32
+ mentions_edges = 0
33
+ co_mentions: dict[tuple[str, str], set[str]] = defaultdict(set)
34
+
35
+ for chunk in chunks:
36
+ chunk_id = chunk["chunk_id"]
37
+ text = chunk["text"]
38
+ client.add_chunk(chunk_id=chunk_id, doc_id=doc_id, text=text)
39
+
40
+ # Extract entities from this chunk, cap to avoid relation explosion.
41
+ chunk_entities_raw = [name for name, _ in extract_entities([text])]
42
+ chunk_entities: list[str] = []
43
+ seen = set()
44
+ for name in chunk_entities_raw:
45
+ ent = normalize_text(name)
46
+ if not ent or ent in seen:
47
+ continue
48
+ chunk_entities.append(ent)
49
+ seen.add(ent)
50
+ if len(chunk_entities) >= MAX_ENTITIES_PER_CHUNK:
51
+ break
52
+
53
+ # Add entity nodes + mentions edges.
54
+ for ent in chunk_entities:
55
+ client.add_entity(ent, entity_type=entity_type_map.get(ent, "Concept"))
56
+ client.add_mentions_edge(chunk_id=chunk_id, entity_id=ent)
57
+ mentions_edges += 1
58
+
59
+ # Record co-mentions for RELATED_TO edges.
60
+ unique = sorted(set(chunk_entities))
61
+ for i in range(len(unique)):
62
+ for j in range(i + 1, len(unique)):
63
+ a, b = unique[i], unique[j]
64
+ co_mentions[(a, b)].add(chunk_id)
65
+
66
+ for (a, b), evidence_chunks in co_mentions.items():
67
+ # Store one evidence chunk for this relation (keeps edges lean).
68
+ evidence = next(iter(evidence_chunks)) if evidence_chunks else None
69
+ client.add_related_edge(a, b, evidence_chunk_id=evidence)
70
+ relations_edges += 1
71
+
72
+ client.save_graph()
73
+
74
+ return {
75
+ "documents": 1,
76
+ "chunks": len(chunks),
77
+ "entities": len(entities),
78
+ "mentions_edges": mentions_edges,
79
+ "relations_edges": relations_edges,
80
+ }
81
+
ingestion/build_graph_tigergraph.py ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pipelines.graphrag.graphrag_client import get_connection
2
+ from ingestion.entity_extraction import extract_entities
3
+
4
+
5
+ def build_graph(doc_id, title, chunks, entities):
6
+ """
7
+ Legacy TigerGraph graph builder (kept for future compatibility).
8
+ Not used in the local NetworkX GraphRAG pivot.
9
+ """
10
+ conn = get_connection()
11
+ mention_edges = 0
12
+ entity_ids = {entity_name for entity_name, _ in entities}
13
+
14
+ conn.upsert_document(doc_id, title)
15
+
16
+ for chunk in chunks:
17
+ conn.upsert_chunk(chunk["chunk_id"], chunk["text"])
18
+ conn.link_doc_chunk(doc_id, chunk["chunk_id"])
19
+
20
+ for entity_name, entity_type in entities:
21
+ entity_id = entity_name.lower()
22
+ conn.upsert_entity(entity_id, entity_name, entity_type)
23
+
24
+ for chunk in chunks:
25
+ chunk_entities = extract_entities([chunk["text"]])
26
+ for entity_name, _ in chunk_entities:
27
+ if entity_name in entity_ids:
28
+ conn.link_chunk_entity(chunk["chunk_id"], entity_name)
29
+ mention_edges += 1
30
+
31
+ return {
32
+ "documents": 1,
33
+ "chunks": len(chunks),
34
+ "entities": len(entities),
35
+ "has_chunk_edges": len(chunks),
36
+ "mentions_edges": mention_edges,
37
+ }
38
+
ingestion/chunk_data.py ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ def chunk_text(text, chunk_size=500, overlap=50):
2
+ chunks = []
3
+
4
+ start = 0
5
+ while start < len(text):
6
+ end = start + chunk_size
7
+ chunks.append(text[start:end])
8
+ start += chunk_size - overlap
9
+
10
+ return chunks
ingestion/entity_extraction.py ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import re
2
+
3
+ STOPWORDS = {
4
+ "about",
5
+ "after",
6
+ "also",
7
+ "because",
8
+ "being",
9
+ "between",
10
+ "could",
11
+ "does",
12
+ "from",
13
+ "for",
14
+ "have",
15
+ "into",
16
+ "more",
17
+ "other",
18
+ "paper",
19
+ "than",
20
+ "that",
21
+ "their",
22
+ "there",
23
+ "these",
24
+ "this",
25
+ "through",
26
+ "using",
27
+ "what",
28
+ "when",
29
+ "where",
30
+ "which",
31
+ "with",
32
+ }
33
+
34
+
35
+ def extract_entities(chunks):
36
+ entities = set()
37
+
38
+ for text in chunks:
39
+ words = re.findall(r"\b[A-Z][A-Za-z0-9]*(?:-[A-Z0-9][A-Za-z0-9]*)*\b", text)
40
+ words.extend(re.findall(r"\b[a-z][a-z0-9-]{4,}\b", text))
41
+
42
+ for w in words:
43
+ normalized = w.strip("-").lower()
44
+ if normalized and normalized not in STOPWORDS:
45
+ entities.add((normalized, "Concept"))
46
+
47
+ return list(entities)
ingestion/preprocess.py ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import fitz # PyMuPDF
2
+
3
+ def extract_text_from_pdf(file_bytes):
4
+ doc = fitz.open(stream=file_bytes, filetype="pdf")
5
+
6
+ text = ""
7
+ for page in doc:
8
+ text += page.get_text()
9
+
10
+ return text
11
+
12
+
13
+ def extract_pages_from_pdf(file_bytes):
14
+ doc = fitz.open(stream=file_bytes, filetype="pdf")
15
+ pages = []
16
+ for page in doc:
17
+ pages.append(page.get_text())
18
+ return pages
19
+
20
+
21
+ def extract_text_from_text_bytes(file_bytes: bytes) -> str:
22
+ for enc in ("utf-8", "utf-8-sig", "latin-1"):
23
+ try:
24
+ return file_bytes.decode(enc)
25
+ except Exception:
26
+ continue
27
+ # fallback: replace undecodable bytes
28
+ return file_bytes.decode("utf-8", errors="replace")
29
+
30
+
31
+ def extract_text_from_file(file_name: str, file_bytes: bytes):
32
+ name = (file_name or "").lower()
33
+ if name.endswith(".pdf"):
34
+ return extract_pages_from_pdf(file_bytes), "pdf"
35
+ if name.endswith(".txt") or name.endswith(".csv"):
36
+ return [extract_text_from_text_bytes(file_bytes)], "text"
37
+ # default: try as text
38
+ return [extract_text_from_text_bytes(file_bytes)], "text"
ingestion/schema.py ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+
3
+ from dotenv import load_dotenv
4
+ from pyTigerGraph import TigerGraphConnection
5
+
6
+ load_dotenv()
7
+
8
+
9
+ def create_schema():
10
+ conn = TigerGraphConnection(
11
+ host=os.getenv("TG_GSQL_HOST", "http://localhost"),
12
+ graphname="",
13
+ username=os.getenv("TG_USERNAME", "tigergraph"),
14
+ password=os.getenv("TG_PASSWORD", "tigergraph"),
15
+ restppPort=os.getenv("TG_RESTPP_PORT", "9000"),
16
+ gsPort=os.getenv("TG_GSQL_PORT", "14240"),
17
+ )
18
+
19
+ graph = os.getenv("TG_GRAPH", "graphrag_benchmark")
20
+
21
+ commands = [
22
+ """
23
+ CREATE VERTEX BenchDocument (
24
+ PRIMARY_ID doc_id STRING,
25
+ title STRING
26
+ ) WITH primary_id_as_attribute="true"
27
+ """,
28
+ """
29
+ CREATE VERTEX BenchChunk (
30
+ PRIMARY_ID chunk_id STRING,
31
+ text STRING
32
+ ) WITH primary_id_as_attribute="true"
33
+ """,
34
+ """
35
+ CREATE VERTEX BenchEntity (
36
+ PRIMARY_ID entity_id STRING,
37
+ name STRING,
38
+ entity_type STRING
39
+ ) WITH primary_id_as_attribute="true"
40
+ """,
41
+ "CREATE UNDIRECTED EDGE BenchHasChunk (FROM BenchDocument, TO BenchChunk)",
42
+ "CREATE UNDIRECTED EDGE BenchMentions (FROM BenchChunk, TO BenchEntity)",
43
+ "CREATE UNDIRECTED EDGE BenchRelated (FROM BenchEntity, TO BenchEntity)",
44
+ f"CREATE GRAPH {graph} (BenchDocument, BenchChunk, BenchEntity, BenchHasChunk, BenchMentions, BenchRelated)",
45
+ ]
46
+
47
+ output = []
48
+ for command in commands:
49
+ try:
50
+ result = conn.gsql(command)
51
+ except Exception as exc:
52
+ message = str(exc)
53
+ if "already exists" not in message and "existed" not in message:
54
+ raise
55
+ result = message
56
+ output.append(result)
57
+
58
+ return "\n".join(output)
59
+
60
+
61
+ if __name__ == "__main__":
62
+ print(create_schema())
pipelines/__init__.py ADDED
File without changes
pipelines/basic_rag/__init__.py ADDED
File without changes
pipelines/basic_rag/chunking.py ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import re
2
+
3
+ def chunk_text(text, chunk_size=2):
4
+ # Split into sentences
5
+ sentences = re.split(r'(?<=[.!?]) +', text)
6
+
7
+ chunks = []
8
+
9
+ for i in range(0, len(sentences), chunk_size):
10
+ chunk = " ".join(sentences[i:i + chunk_size])
11
+ chunks.append(chunk.strip())
12
+
13
+ return chunks
pipelines/basic_rag/embedding.py ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MODEL_NAME = "all-MiniLM-L6-v2"
2
+ model = None
3
+
4
+
5
+ def get_model():
6
+ global model
7
+ if model is None:
8
+ from sentence_transformers import SentenceTransformer
9
+
10
+ model = SentenceTransformer(MODEL_NAME)
11
+ return model
12
+
13
+ def embed_text(texts):
14
+ return get_model().encode(texts)
pipelines/basic_rag/prompt_template.txt ADDED
File without changes
pipelines/basic_rag/rag_pipeline.py ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from .retriever import retrieve
2
+ from .vector_store import VectorStore
3
+ from utils.llm import generate
4
+
5
+ VECTOR_STORE = None
6
+ MAX_CONTEXT_CHARS = 2500
7
+ OVERVIEW_TERMS = (
8
+ "what is this paper about",
9
+ "what is the paper about",
10
+ "summarize the paper",
11
+ "summary of the paper",
12
+ "paper summary",
13
+ "abstract",
14
+ "main idea",
15
+ )
16
+
17
+
18
+ def get_store():
19
+ global VECTOR_STORE
20
+ if VECTOR_STORE is None:
21
+ VECTOR_STORE = VectorStore.load()
22
+ return VECTOR_STORE
23
+
24
+
25
+ def _chunk_number(record):
26
+ chunk_id = record.get("chunk_id", "")
27
+ try:
28
+ return int(chunk_id.rsplit("_chunk_", 1)[1])
29
+ except (IndexError, ValueError):
30
+ return 10**9
31
+
32
+
33
+ def _is_overview_query(query):
34
+ normalized = " ".join(query.lower().split())
35
+ return any(term in normalized for term in OVERVIEW_TERMS)
36
+
37
+
38
+ def _overview_chunks(store, limit=3):
39
+ # Chroma doesn't expose raw metadata list like the old FAISS store.
40
+ # Overview queries will rely on semantic retrieval for now.
41
+ return []
42
+
43
+
44
+ def _merge_chunks(primary, secondary, limit=5):
45
+ merged = []
46
+ seen = set()
47
+
48
+ for record in [*primary, *secondary]:
49
+ text = record.get("text", "")
50
+ dedupe_key = " ".join(text.split()).lower() or record.get("chunk_id")
51
+ if dedupe_key not in seen:
52
+ merged.append(record)
53
+ seen.add(dedupe_key)
54
+
55
+ if len(merged) == limit:
56
+ break
57
+
58
+ return merged
59
+
60
+
61
+ def run_basic_rag(query):
62
+ store = get_store()
63
+ semantic_chunks = retrieve(query, store, k=3)
64
+ if _is_overview_query(query):
65
+ retrieved_chunks = _merge_chunks(_overview_chunks(store), semantic_chunks)
66
+ else:
67
+ retrieved_chunks = semantic_chunks
68
+
69
+ context = "\n\n".join(
70
+ f"[{i + 1}] {chunk.get('text', '')}"
71
+ for i, chunk in enumerate(retrieved_chunks)
72
+ )
73
+ context = context[:MAX_CONTEXT_CHARS]
74
+
75
+ prompt = f"""
76
+ You are an AI assistant.
77
+
78
+ Use ONLY the provided context to answer the question.
79
+ If the context does not contain enough information, say so.
80
+
81
+ Context:
82
+ {context}
83
+
84
+ Question:
85
+ {query}
86
+
87
+ Answer:
88
+ """
89
+
90
+ res = generate(prompt)
91
+
92
+ return {
93
+ "answer": res["text"],
94
+ "context": context,
95
+ "tokens": res["total_tokens"],
96
+ "latency": res["latency"],
97
+ "details": {
98
+ "prompt_tokens": res["prompt_tokens"],
99
+ "completion_tokens": res["completion_tokens"],
100
+ "retrieved_chunks": retrieved_chunks,
101
+ },
102
+ }
pipelines/basic_rag/retriever.py ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ def retrieve(query, vector_store, k=3):
2
+ results = vector_store.search(query, k=k * 10)
3
+
4
+ unique = []
5
+ seen = set()
6
+
7
+ for record in results:
8
+ chunk_id = record.get("chunk_id")
9
+ text = record.get("text", "")
10
+ dedupe_key = " ".join(text.split()).lower() or chunk_id
11
+
12
+ if dedupe_key not in seen:
13
+ unique.append(record)
14
+ seen.add(dedupe_key)
15
+
16
+ if len(unique) == k:
17
+ break
18
+
19
+ return unique
pipelines/basic_rag/vector_store.py ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from typing import Any, Dict, List, Optional
3
+
4
+ import chromadb
5
+
6
+ from pipelines.basic_rag.embedding import embed_text
7
+ from utils.paths import chroma_path
8
+
9
+ COLLECTION_NAME = "chunks"
10
+
11
+
12
+ class VectorStore:
13
+ """
14
+ ChromaDB-backed persistent vector store.
15
+
16
+ Public API (used by ingestion/pipelines):
17
+ - add_documents(chunk_records)
18
+ - search(query, k=5, filters=None)
19
+
20
+ Notes:
21
+ - Uses chunk_id as the Chroma record id.
22
+ - Stores chunk text as document, plus metadata fields.
23
+ """
24
+
25
+ def __init__(self):
26
+ chroma_dir = chroma_path()
27
+ os.makedirs(chroma_dir, exist_ok=True)
28
+ self.client = chromadb.PersistentClient(path=chroma_dir)
29
+ self.collection = self.client.get_or_create_collection(name=COLLECTION_NAME)
30
+
31
+ @classmethod
32
+ def load(cls, path: Optional[str] = None):
33
+ # Keep signature stable; `path` is unused for Chroma.
34
+ return cls()
35
+
36
+ def add_documents(self, chunk_records: List[Dict[str, Any]]) -> int:
37
+ if not chunk_records:
38
+ return 0
39
+
40
+ ids: List[str] = []
41
+ documents: List[str] = []
42
+ metadatas: List[Dict[str, Any]] = []
43
+
44
+ for record in chunk_records:
45
+ chunk_id = record["chunk_id"]
46
+ ids.append(chunk_id)
47
+ documents.append(record["text"])
48
+ metadatas.append(
49
+ {
50
+ "doc_id": record.get("doc_id", ""),
51
+ "chunk_id": chunk_id,
52
+ "source_file": record.get("source_file", ""),
53
+ "page": record.get("page"),
54
+ }
55
+ )
56
+
57
+ embeddings = embed_text(documents)
58
+ embeddings_list = [e.tolist() for e in embeddings]
59
+
60
+ # Chroma raises if ids already exist. For ingestion re-runs, upsert.
61
+ self.collection.upsert(
62
+ ids=ids,
63
+ documents=documents,
64
+ embeddings=embeddings_list,
65
+ metadatas=metadatas,
66
+ )
67
+ return len(ids)
68
+
69
+ def search(self, query: str, k: int = 5, filters: Optional[Dict[str, Any]] = None) -> List[Dict[str, Any]]:
70
+ query_embedding = embed_text([query])[0].tolist()
71
+
72
+ result = self.collection.query(
73
+ query_embeddings=[query_embedding],
74
+ n_results=k,
75
+ where=filters,
76
+ include=["documents", "metadatas", "distances"],
77
+ )
78
+
79
+ documents = (result.get("documents") or [[]])[0]
80
+ metadatas = (result.get("metadatas") or [[]])[0]
81
+ distances = (result.get("distances") or [[]])[0]
82
+
83
+ out: List[Dict[str, Any]] = []
84
+ for doc, meta, dist in zip(documents, metadatas, distances):
85
+ record = dict(meta or {})
86
+ record["text"] = doc
87
+ record["score"] = float(dist) if dist is not None else None
88
+ out.append(record)
89
+ return out
90
+
91
+ # Back-compat helpers (old FAISS codepaths)
92
+ def search_text(self, query: str, k: int = 5):
93
+ return self.search(query, k=k)
pipelines/graphrag/__init__.py ADDED
File without changes
pipelines/graphrag/config.py ADDED
File without changes