Spaces:
Running
Running
Commit ·
83aed13
1
Parent(s): 81392eb
Deploy GraphRAG benchmark backend
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitattributes +2 -0
- backend/Dockerfile +27 -0
- backend/__init__.py +0 -0
- backend/main.py +82 -0
- backend/models/schema.py +0 -0
- backend/routes/graph.py +63 -0
- backend/routes/ingestion.py +34 -0
- backend/routes/metrics.py +22 -0
- backend/routes/query.py +19 -0
- backend/routes/tigergraph.py +38 -0
- backend/security.py +92 -0
- backend/services/evaluation_service.py +0 -0
- backend/services/ingestion_service.py +88 -0
- backend/services/pipelines_service.py +112 -0
- data/chroma/12e587cb-7739-45a4-bb36-b1fba0ef511c/data_level0.bin +3 -0
- data/chroma/12e587cb-7739-45a4-bb36-b1fba0ef511c/header.bin +3 -0
- data/chroma/12e587cb-7739-45a4-bb36-b1fba0ef511c/index_metadata.pickle +3 -0
- data/chroma/12e587cb-7739-45a4-bb36-b1fba0ef511c/length.bin +3 -0
- data/chroma/12e587cb-7739-45a4-bb36-b1fba0ef511c/link_lists.bin +3 -0
- data/chroma/chroma.sqlite3 +3 -0
- data/eval/scientific_eval_questions.json +3 -0
- data/graph/graph_metadata.json +3 -0
- data/graph/graphrag_graph.pkl +3 -0
- data/results/final_summary.json +3 -0
- data/results/scientific_accuracy_report.json +3 -0
- data/results/scientific_benchmark_results.json +3 -0
- evaluation/__init__.py +0 -0
- evaluation/bertscore_eval.py +49 -0
- evaluation/evaluator.py +61 -0
- evaluation/ground_truth.json +3 -0
- evaluation/llm_judge.py +76 -0
- evaluation/metrics.py +2 -0
- ingestion/__init__.py +0 -0
- ingestion/build_embeddings.py +13 -0
- ingestion/build_graph.py +81 -0
- ingestion/build_graph_tigergraph.py +38 -0
- ingestion/chunk_data.py +10 -0
- ingestion/entity_extraction.py +47 -0
- ingestion/preprocess.py +38 -0
- ingestion/schema.py +62 -0
- pipelines/__init__.py +0 -0
- pipelines/basic_rag/__init__.py +0 -0
- pipelines/basic_rag/chunking.py +13 -0
- pipelines/basic_rag/embedding.py +14 -0
- pipelines/basic_rag/prompt_template.txt +0 -0
- pipelines/basic_rag/rag_pipeline.py +102 -0
- pipelines/basic_rag/retriever.py +19 -0
- pipelines/basic_rag/vector_store.py +93 -0
- pipelines/graphrag/__init__.py +0 -0
- 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
|