rag-system / api.py
joshsears's picture
Polish: BGE-large embeddings, contextual retrieval, 142 tests passing, lint clean
21ca2ea
Raw
History Blame Contribute Delete
67.8 kB
"""
FastAPI REST API for the RAG system β€” fully expanded.
Endpoints:
POST /ingest β€” ingest a document
POST /ingest/multimodal β€” ingest PDF with tables + figures (vision LLM)
POST /raptor/ingest β€” RAPTOR recursive tree ingestion
POST /query β€” standard Q&A with citations
POST /query/adaptive β€” adaptive RAG (auto-selects strategy)
POST /query/stream β€” streaming token-by-token response (SSE)
POST /chat/{session_id} β€” multi-turn conversation
DELETE /chat/{session_id} β€” clear a conversation session
GET /chat/{session_id}/history β€” get conversation history
GET /collections β€” list all knowledge bases
DELETE /collection/{name} β€” delete a knowledge base
GET /health β€” health check
GET /cache/stats β€” semantic cache stats
DELETE /cache β€” clear cache
GET /graph/stats β€” knowledge graph stats
GET /graph/entity/{name} β€” entity relationships from knowledge graph
GET /metrics β€” Prometheus metrics (if installed)
POST /route β€” auto-route a query to the best collection
GET /document/analyze β€” analyze a document without ingesting
POST /feedback β€” record user feedback on a response
GET /feedback/summary β€” feedback analytics
GET /feedback/export β€” export feedback as JSONL
POST /finetune β€” trigger embedding fine-tuning pipeline
"""
from __future__ import annotations
import asyncio
import logging
from collections.abc import AsyncGenerator, AsyncIterator
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI, HTTPException
from fastapi import Path as FastAPIPath
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from config import settings
from models import (
CollectionInfo,
CollectionListResponse,
DeleteCollectionResponse,
IngestRequest,
IngestResult,
QueryRequest,
QueryResponse,
)
from monitoring import instrument_app, log_ingest_event, log_query_event, record_query
logger = logging.getLogger(__name__)
# ── Lifespan ──────────────────────────────────────────────────────────────────
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
logger.info("API starting β€” warming up models…")
try:
from core.ingestion import get_embedding_model
get_embedding_model()
except Exception as e:
logger.warning("Embedding warm-up failed: %s", e)
try:
from core.generation import get_backend
get_backend()
except Exception as e:
logger.warning("LLM backend warm-up failed: %s", e)
try:
from core.graph_rag import get_knowledge_graph
get_knowledge_graph()
except Exception as e:
logger.warning("Graph warm-up failed: %s", e)
yield
logger.info("API shutting down.")
# ── App ───────────────────────────────────────────────────────────────────────
app = FastAPI(
title="RAG System API",
description=(
"Production-grade Retrieval-Augmented Generation. "
"Multi-backend (Ollama/Claude/OpenAI), hybrid retrieval, cross-encoder reranking, "
"GraphRAG, streaming, multi-turn conversation, semantic caching, RAGAS evaluation, "
"Adaptive RAG (Self-RAG), RAPTOR tree ingestion, multi-modal PDF (tables + vision), "
"user feedback loop, and embedding fine-tuning."
),
version="3.0.0",
lifespan=lifespan,
)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins,
allow_credentials=False,
allow_methods=["GET", "POST", "DELETE", "OPTIONS"],
allow_headers=["Content-Type", "Authorization"],
)
# Attach Prometheus metrics if available
instrument_app(app)
# ── Health ────────────────────────────────────────────────────────────────────
class HealthResponse(BaseModel):
status: str
llm_backend: str
embedding_model: str
chroma_path: str
cache_enabled: bool
graph_nodes: int = 0
graph_edges: int = 0
version: str = "4.0.0"
@app.get("/health", response_model=HealthResponse, tags=["System"])
async def health_check() -> HealthResponse:
"""System health check with component status."""
graph_stats = {"nodes": 0, "edges": 0}
try:
from core.graph_rag import get_knowledge_graph
graph_stats = get_knowledge_graph().stats()
except Exception:
pass
return HealthResponse(
status="ok",
llm_backend=settings.llm_backend.value,
embedding_model=settings.embedding_model,
chroma_path=str(settings.chroma_persist_dir),
cache_enabled=settings.enable_cache,
graph_nodes=graph_stats.get("nodes", 0),
graph_edges=graph_stats.get("edges", 0),
)
# ── Ingest ────────────────────────────────────────────────────────────────────
class IngestRequestExtended(IngestRequest):
"""Extended ingest request with advanced options."""
chunking_strategy: str = Field(
default="recursive", description="recursive | semantic | hierarchical"
)
extract_graph: bool = Field(
default=False, description="Extract entities/relations into knowledge graph"
)
generate_summary: bool = Field(default=False, description="Generate LLM summary at ingest time")
analyze_document: bool = Field(default=True, description="Run document quality analysis")
class IngestResultExtended(IngestResult):
"""Extended ingest result with analysis and graph stats."""
quality_score: float = Field(default=1.0)
language: str = Field(default="unknown")
pii_warnings: list[str] = Field(default_factory=list)
sections_detected: int = Field(default=0)
graph_triples_added: int = Field(default=0)
summary: str = Field(default="")
@app.post("/ingest", response_model=IngestResultExtended, tags=["Ingestion"])
async def ingest(request: IngestRequestExtended) -> IngestResultExtended:
"""
Ingest a document with optional document analysis and knowledge graph extraction.
Supports PDF, TXT, DOCX, Markdown, and URLs.
"""
from core.document_processor import analyze_document
from core.graph_rag import extract_triples, get_knowledge_graph
from core.ingestion import ingest_document, load_document
# Reject path traversal attempts on local file paths
src = request.file_path
if not src.startswith(("http://", "https://")):
resolved = Path(src).resolve()
allowed = Path(".").resolve()
if not str(resolved).startswith(str(allowed)):
raise HTTPException(
status_code=400, detail="file_path must be within the working directory or a URL"
)
try:
result = ingest_document(
source=request.file_path,
collection_name=request.collection,
overwrite=request.overwrite,
chunking_strategy=request.chunking_strategy,
)
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e)) from e
except ValueError as e:
raise HTTPException(status_code=422, detail=str(e)) from e
except RuntimeError as e:
raise HTTPException(status_code=500, detail=str(e)) from e
# Document analysis
quality_score, language, pii_warnings, sections_count, summary = 1.0, "unknown", [], 0, ""
if request.analyze_document or request.generate_summary:
try:
pages, _ = load_document(request.file_path)
full_text = "\n\n".join(text for text, _ in pages)
llm_fn = None
if request.generate_summary:
from core.generation import get_backend
llm_fn = get_backend().complete_raw
analysis = analyze_document(
full_text,
request.file_path,
llm_fn=llm_fn,
generate_summary=request.generate_summary,
)
quality_score = analysis.quality_score
language = analysis.language
pii_warnings = analysis.pii_warnings
sections_count = len(analysis.detected_sections)
summary = analysis.summary
except Exception as e:
logger.warning("Document analysis failed: %s", e)
# Knowledge graph extraction
graph_triples_added = 0
if request.extract_graph and result.chunks_added > 0:
try:
from core.generation import get_backend
graph = get_knowledge_graph()
backend = get_backend()
pages, _ = load_document(request.file_path)
# Sample first 5 pages to extract graph triples
for page_text, _ in pages[:5]:
triples = extract_triples(page_text, request.file_path, backend.complete_raw)
graph_triples_added += graph.add_triples(triples)
graph.save()
logger.info("Graph: added %d triples from '%s'", graph_triples_added, request.file_path)
except Exception as e:
logger.warning("Graph extraction failed: %s", e)
log_ingest_event(
request.file_path, request.collection, result.chunks_added, result.elapsed_seconds
)
return IngestResultExtended(
**result.model_dump(),
quality_score=quality_score,
language=language,
pii_warnings=pii_warnings,
sections_detected=sections_count,
graph_triples_added=graph_triples_added,
summary=summary,
)
# ── Query ─────────────────────────────────────────────────────────────────────
class QueryRequestExtended(QueryRequest):
"""Extended query with graph RAG and routing options."""
use_graph: bool = Field(default=False, description="Augment with knowledge graph context")
auto_route: bool = Field(default=False, description="Auto-select collection based on query")
session_id: str | None = Field(default=None, description="Session ID for conversation context")
class QueryResponseExtended(QueryResponse):
"""Extended response with graph and routing info."""
graph_entities_found: list[str] = Field(default_factory=list)
graph_triples_used: int = Field(default=0)
routed_to: str | None = Field(default=None)
@app.post("/query", response_model=QueryResponseExtended, tags=["Query"])
async def query(request: QueryRequestExtended) -> QueryResponseExtended:
"""
Query with hybrid retrieval, reranking, optional GraphRAG, and conversation context.
"""
from core.conversation import get_or_create_session
from core.generation import answer_question, get_backend
from core.graph_rag import get_knowledge_graph, retrieve_graph_context
from core.router import get_router
backend = get_backend()
# Auto-routing
routed_to = None
if request.auto_route:
router = get_router()
routed_to = router.route_single(request.question, use_llm=True, llm_fn=backend.complete_raw)
request = request.model_copy(update={"collection": routed_to})
# Conversation context
if request.session_id:
session = get_or_create_session(request.session_id)
# Resolve references ("it", "that") using conversation history
resolved_q = session.resolve_references(request.question, backend.complete_raw)
request = request.model_copy(update={"question": resolved_q})
session.build_context_prompt()
try:
response = answer_question(request)
except RuntimeError as e:
raise HTTPException(status_code=500, detail=str(e)) from e
# Graph RAG augmentation
graph_entities: list[str] = []
graph_triples_used = 0
if request.use_graph:
try:
graph = get_knowledge_graph()
graph_ctx = retrieve_graph_context(request.question, graph, hops=2)
graph_entities = graph_ctx.entities_found
graph_triples_used = len(graph_ctx.triples)
if graph_ctx.narrative and not response.answer.startswith("I don't have"):
# Append graph context note to answer
response = response.model_copy(
update={
"answer": response.answer
+ f"\n\n---\n*Graph context: {graph_ctx.narrative[:500]}*"
}
)
except Exception as e:
logger.warning("GraphRAG augmentation failed: %s", e)
# Store in conversation memory
if request.session_id:
from core.conversation import ConversationTurn, get_or_create_session
session = get_or_create_session(request.session_id)
session.add_turn(
ConversationTurn(
question=request.question,
answer=response.answer,
sources=[s.source for s in response.sources],
collection=request.collection,
tokens_used=response.tokens_used,
latency_ms=response.latency_ms,
)
)
# Prometheus metrics
avg_sim = sum(s.similarity_score for s in response.sources) / max(len(response.sources), 1)
record_query(
len(response.sources),
avg_sim,
response.tokens_used,
settings.llm_backend.value,
response.model_used,
response.cache_hit,
)
log_query_event(
request.question,
request.collection,
len(response.sources),
response.tokens_used,
response.latency_ms,
response.cache_hit,
settings.llm_backend.value,
)
return QueryResponseExtended(
**response.model_dump(),
graph_entities_found=graph_entities,
graph_triples_used=graph_triples_used,
routed_to=routed_to,
)
# ── Streaming query ───────────────────────────────────────────────────────────
@app.post("/query/stream", tags=["Query"])
async def query_stream(request: QueryRequest) -> StreamingResponse:
"""
Streaming RAG query using Server-Sent Events (SSE).
Retrieval happens synchronously upfront, then tokens stream in real-time
from the LLM. Compatible with Ollama streaming and Claude streaming APIs.
Client usage:
const es = new EventSource('/query/stream', {method: 'POST', body: JSON.stringify(req)})
es.onmessage = (e) => { if (e.data !== '[DONE]') appendToken(JSON.parse(e.data).token) }
"""
import json as _json
from core.generation import SYSTEM_PROMPT, build_user_prompt, extract_sources, get_backend
from core.retrieval import retrieve
async def event_stream() -> AsyncGenerator[str, None]:
try:
backend = get_backend()
# Retrieval (non-streaming)
context = retrieve(request, generate_fn=backend.complete_raw)
# Send retrieval metadata first
sources = extract_sources(context)
meta_event = _json.dumps(
{
"event": "metadata",
"sources": [s.model_dump() for s in sources],
"chunks_retrieved": len(context.results),
}
)
yield f"data: {meta_event}\n\n"
user_prompt = build_user_prompt(context)
# Stream from Ollama
if settings.llm_backend.value == "ollama":
import requests as _req
payload = {
"model": settings.ollama_model,
"messages": [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": user_prompt},
],
"stream": True,
"options": {"temperature": settings.temperature},
}
with _req.post(
f"{settings.ollama_base_url}/api/chat", json=payload, stream=True, timeout=120
) as resp:
resp.raise_for_status()
for line in resp.iter_lines():
if line:
chunk = _json.loads(line)
token = chunk.get("message", {}).get("content", "")
if token:
yield f"data: {_json.dumps({'token': token})}\n\n"
if chunk.get("done"):
break
# Stream from Claude
elif settings.llm_backend.value == "claude":
import anthropic
client = anthropic.Anthropic(api_key=settings.anthropic_api_key)
with client.messages.stream(
model=settings.claude_model,
max_tokens=settings.max_tokens,
system=SYSTEM_PROMPT,
messages=[{"role": "user", "content": user_prompt}],
) as stream:
for text in stream.text_stream:
yield f"data: {_json.dumps({'token': text})}\n\n"
await asyncio.sleep(0) # yield to event loop
else:
# OpenAI streaming
from openai import OpenAI
client = OpenAI(api_key=settings.openai_api_key)
stream = client.chat.completions.create(
model=settings.openai_model,
messages=[
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": user_prompt},
],
stream=True,
max_tokens=settings.max_tokens,
temperature=settings.temperature,
)
for chunk in stream:
token = chunk.choices[0].delta.content or ""
if token:
yield f"data: {_json.dumps({'token': token})}\n\n"
await asyncio.sleep(0)
yield "data: [DONE]\n\n"
except Exception as e:
import json as _j
yield f"data: {_j.dumps({'error': str(e)})}\n\n"
yield "data: [DONE]\n\n"
return StreamingResponse(
event_stream(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
},
)
# ── Conversation (multi-turn chat) ────────────────────────────────────────────
class ChatRequest(BaseModel):
question: str = Field(..., min_length=1)
collection: str = Field(default="default")
top_k: int = Field(default=6, ge=1, le=50)
class ChatResponse(BaseModel):
session_id: str
question: str
answer: str
sources: list[dict] = Field(default_factory=list)
tokens_used: int = 0
latency_ms: float = 0.0
turn_number: int = 0
@app.post("/chat/{session_id}", response_model=ChatResponse, tags=["Conversation"])
async def chat(session_id: str, request: ChatRequest) -> ChatResponse:
"""
Multi-turn conversational RAG with automatic reference resolution.
Maintains conversation history across calls using the session_id.
Automatically resolves references like "it", "that document", "the above".
Compresses history when it grows too long to stay within context limits.
"""
from core.conversation import ConversationTurn, get_or_create_session
from core.generation import answer_question, get_backend
from models import QueryMode
session = get_or_create_session(session_id)
backend = get_backend()
# Resolve ambiguous references
resolved_q = session.resolve_references(request.question, backend.complete_raw)
q_request = QueryRequest(
question=resolved_q,
collection=request.collection,
top_k=request.top_k,
mode=QueryMode.HYBRID,
)
try:
response = answer_question(q_request)
except RuntimeError as e:
raise HTTPException(status_code=500, detail=str(e)) from e
# Store in session
session.add_turn(
ConversationTurn(
question=request.question,
answer=response.answer,
sources=[s.source for s in response.sources],
collection=request.collection,
tokens_used=response.tokens_used,
latency_ms=response.latency_ms,
)
)
# Auto-compress if needed
if len(session.turns) > session.summarize_after:
try:
session.compress(backend.complete_raw)
except Exception as e:
logger.warning("Session compression failed: %s", e)
return ChatResponse(
session_id=session_id,
question=request.question,
answer=response.answer,
sources=[s.model_dump() for s in response.sources],
tokens_used=response.tokens_used,
latency_ms=response.latency_ms,
turn_number=len(session.turns),
)
@app.get("/chat/{session_id}/history", tags=["Conversation"])
async def get_chat_history(session_id: str) -> dict:
"""Get the full conversation history for a session."""
from core.conversation import get_or_create_session
session = get_or_create_session(session_id)
return session.to_dict()
@app.delete("/chat/{session_id}", tags=["Conversation"])
async def clear_chat(session_id: str) -> dict:
"""Clear a conversation session's history."""
from core.conversation import delete_session
deleted = delete_session(session_id)
return {"session_id": session_id, "cleared": deleted}
# ── Collections ───────────────────────────────────────────────────────────────
@app.get("/collections", response_model=CollectionListResponse, tags=["Collections"])
async def list_collections() -> CollectionListResponse:
from core.ingestion import list_collections as _list
try:
raw = _list()
except Exception as e:
raise HTTPException(status_code=500, detail=str(e)) from e
collections = [
CollectionInfo(
name=c["name"], document_count=c["document_count"], embedding_model=c["embedding_model"]
)
for c in raw
]
return CollectionListResponse(collections=collections, total=len(collections))
@app.delete("/collection/{name}", response_model=DeleteCollectionResponse, tags=["Collections"])
async def delete_collection(name: str = FastAPIPath(..., min_length=1)) -> DeleteCollectionResponse:
from core.ingestion import delete_collection as _delete
try:
deleted = _delete(name)
except RuntimeError as e:
raise HTTPException(status_code=500, detail=str(e)) from e
if not deleted:
raise HTTPException(status_code=404, detail=f"Collection '{name}' not found.")
return DeleteCollectionResponse(
name=name, deleted=True, message=f"Collection '{name}' deleted."
)
# ── Knowledge Graph ───────────────────────────────────────────────────────────
@app.get("/graph/stats", tags=["Knowledge Graph"])
async def graph_stats() -> dict:
"""Return knowledge graph statistics."""
from core.graph_rag import get_knowledge_graph
graph = get_knowledge_graph()
return graph.stats()
@app.get("/graph/entity/{entity_name}", tags=["Knowledge Graph"])
async def graph_entity(entity_name: str) -> dict:
"""Get all relationships for a specific entity from the knowledge graph."""
from core.graph_rag import get_knowledge_graph
graph = get_knowledge_graph()
return graph.get_entity_summary(entity_name)
# ── Routing ───────────────────────────────────────────────────────────────────
class RouteRequest(BaseModel):
question: str
top_n: int = Field(default=1, ge=1, le=5)
@app.post("/route", tags=["Collections"])
async def route_query(request: RouteRequest) -> dict:
"""Auto-select the best collection(s) for a query using embedding similarity."""
from core.router import get_router
router = get_router()
router.auto_register()
collections = router.route(request.question, top_n=request.top_n)
return {"question": request.question, "recommended_collections": collections}
# ── Cache ─────────────────────────────────────────────────────────────────────
class CacheStats(BaseModel):
enabled: bool
size: int
max_size: int
threshold: float
@app.get("/cache/stats", response_model=CacheStats, tags=["System"])
async def cache_stats() -> CacheStats:
from core.retrieval import get_cache
cache = get_cache()
return CacheStats(
enabled=settings.enable_cache,
size=len(cache) if cache else 0,
max_size=settings.cache_max_size,
threshold=settings.cache_similarity_threshold,
)
@app.delete("/cache", tags=["System"])
async def clear_cache() -> dict:
from core.retrieval import get_cache
cache = get_cache()
if cache:
size = len(cache)
cache.clear()
return {"message": f"Cleared {size} entries."}
return {"message": "Cache empty or disabled."}
# ── Multi-modal ingestion ─────────────────────────────────────────────────────
class MultimodalIngestRequest(BaseModel):
pdf_path: str = Field(..., description="Absolute or relative path to a PDF file")
collection: str = Field(default="default")
extract_tables: bool = Field(default=True, description="Extract and store tables as markdown")
extract_figures: bool = Field(default=True, description="Extract embedded images")
describe_figures: bool = Field(
default=True, description="Use Claude vision to describe figures"
)
class MultimodalIngestResponse(BaseModel):
pdf: str
tables_found: int
figures_found: int
elements_stored: int
collection: str
@app.post("/ingest/multimodal", response_model=MultimodalIngestResponse, tags=["Ingestion"])
async def ingest_multimodal(request: MultimodalIngestRequest) -> MultimodalIngestResponse:
"""
Extract tables (as markdown) and figures (described via vision LLM) from a PDF
and store them as searchable chunks alongside text content.
Requires pdfplumber and pymupdf: pip install pdfplumber pymupdf
"""
from core.multimodal import ingest_pdf_multimodal
try:
summary = ingest_pdf_multimodal(
pdf_path=request.pdf_path,
collection_name=request.collection,
extract_tables=request.extract_tables,
extract_figures=request.extract_figures,
describe_figures=request.describe_figures,
)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e)) from e
return MultimodalIngestResponse(**summary, collection=request.collection)
# ── RAPTOR ingestion ──────────────────────────────────────────────────────────
class RaptorIngestRequest(BaseModel):
collection: str = Field(..., description="Source collection to build RAPTOR tree from")
output_collection: str | None = Field(
default=None,
description="Target collection for summaries (defaults to <collection>_raptor)",
)
max_levels: int = Field(default=3, ge=1, le=5)
cluster_size: int = Field(default=10, ge=3, le=50)
class RaptorIngestResponse(BaseModel):
source_collection: str
raptor_collection: str
levels_built: int
summaries_stored: int
total_nodes: int
@app.post("/raptor/ingest", response_model=RaptorIngestResponse, tags=["Ingestion"])
async def raptor_ingest(request: RaptorIngestRequest) -> RaptorIngestResponse:
"""
Build a RAPTOR recursive tree from an existing collection.
Clusters semantically similar chunks, summarizes each cluster with LLM,
then recursively builds higher-level summaries. Enables multi-granularity
retrieval: specific facts, section summaries, and document-level overviews.
"""
from core.generation import get_backend
from core.raptor import build_raptor_tree, ingest_raptor_tree
backend = get_backend()
out_col = request.output_collection or f"{request.collection}_raptor"
try:
tree = build_raptor_tree(
collection_name=request.collection,
llm_fn=backend.complete_raw,
max_levels=request.max_levels,
cluster_size=request.cluster_size,
)
stored = ingest_raptor_tree(tree, out_col)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e)) from e
return RaptorIngestResponse(
source_collection=request.collection,
raptor_collection=out_col,
levels_built=len(tree.levels),
summaries_stored=stored,
total_nodes=sum(len(nodes) for nodes in tree.levels.values()),
)
# ── Adaptive RAG query ────────────────────────────────────────────────────────
class AdaptiveQueryRequest(BaseModel):
question: str = Field(..., min_length=1)
collection: str = Field(default="default")
top_k: int = Field(default=6, ge=1, le=50)
use_self_rag: bool = Field(default=True, description="Apply Self-RAG reflection tokens")
max_hops: int = Field(default=3, ge=1, le=5, description="Max iterative retrieval hops")
class AdaptiveQueryResponse(BaseModel):
answer: str
strategy_used: str
queries_used: list[str]
hops: int
latency_ms: float
self_rag_flags: dict
chunks_retrieved: int
@app.post("/query/adaptive", response_model=AdaptiveQueryResponse, tags=["Query"])
async def query_adaptive(request: AdaptiveQueryRequest) -> AdaptiveQueryResponse:
"""
Adaptive RAG query that automatically selects the optimal retrieval strategy:
- **NO_RETRIEVAL**: answers from model knowledge (math, general facts)
- **SINGLE_STEP**: standard single-pass vector retrieval
- **ITERATIVE**: multi-hop chained retrieval for complex questions
Includes Self-RAG reflection: [Retrieve], [IsREL], [IsSUP], [IsUSE] tokens.
"""
from core.adaptive_rag import adaptive_answer
from core.generation import get_backend
from core.retrieval import retrieve
from models import QueryMode, QueryRequest
backend = get_backend()
def _retrieve_fn(q: str, col: str, k: int) -> list:
req = QueryRequest(question=q, collection=col, top_k=k, mode=QueryMode.HYBRID)
ctx = retrieve(req, generate_fn=backend.complete_raw)
return ctx.results
def _generate_fn(system: str, user: str) -> str:
return backend.complete_raw(f"{system}\n\n{user}")
try:
result = adaptive_answer(
question=request.question,
collection=request.collection,
llm_fn=backend.complete_raw,
retrieve_fn=_retrieve_fn,
generate_fn=_generate_fn,
top_k=request.top_k,
use_self_rag=request.use_self_rag,
use_iterative=True,
max_hops=request.max_hops,
)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e)) from e
return AdaptiveQueryResponse(
answer=result.answer,
strategy_used=result.strategy_used.value,
queries_used=result.queries_used,
hops=result.hops,
latency_ms=result.latency_ms,
self_rag_flags=result.self_rag_flags,
chunks_retrieved=len(result.chunks_retrieved),
)
# ── Feedback ──────────────────────────────────────────────────────────────────
class FeedbackRequest(BaseModel):
question: str
answer: str
collection: str = Field(default="default")
feedback_type: str = Field(
...,
description="thumbs_up | thumbs_down | correction | source_irrelevant | source_helpful | incomplete",
)
correction: str | None = Field(
default=None, description="Correct answer (for correction feedback)"
)
source_feedback: str | None = Field(
default=None, description="Specific source file (for source feedback)"
)
rating: int | None = Field(default=None, ge=1, le=5, description="Optional 1-5 star rating")
sources_used: list[str] = Field(default_factory=list)
session_id: str | None = None
class FeedbackResponse(BaseModel):
feedback_id: str
recorded: bool
@app.post("/feedback", response_model=FeedbackResponse, tags=["Feedback"])
async def record_feedback(request: FeedbackRequest) -> FeedbackResponse:
"""
Record user feedback on a RAG response.
Feedback is persisted to SQLite and used for:
- Analytics (satisfaction rate, failing queries, source quality)
- Embedding fine-tuning via contrastive learning
- Retrieval reranking bias (boost good sources, penalize bad ones)
"""
from core.feedback import FeedbackEntry, FeedbackType, get_feedback_store
try:
ft = FeedbackType(request.feedback_type)
except ValueError as exc:
raise HTTPException(
status_code=422, detail=f"Invalid feedback_type: {request.feedback_type}"
) from exc
store = get_feedback_store()
entry = FeedbackEntry(
question=request.question,
answer=request.answer,
collection=request.collection,
sources_used=request.sources_used,
feedback_type=ft,
correction=request.correction,
source_feedback=request.source_feedback,
rating=request.rating,
session_id=request.session_id,
)
fid = store.record(entry)
return FeedbackResponse(feedback_id=fid, recorded=True)
@app.get("/feedback/summary", tags=["Feedback"])
async def feedback_summary(collection: str | None = None) -> dict:
"""
Aggregate feedback analytics: satisfaction rate, top failing queries,
best and worst sources.
"""
from core.feedback import get_feedback_store
store = get_feedback_store()
summary = store.get_summary(collection)
return summary.model_dump()
@app.get("/feedback/export", tags=["Feedback"])
async def feedback_export(collection: str | None = None) -> dict:
"""Export all feedback entries as a list (for offline analysis or fine-tuning)."""
import json as _json
import tempfile
from core.feedback import get_feedback_store
store = get_feedback_store()
records = []
with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".jsonl") as tf:
tmp_path = Path(tf.name)
try:
n = store.export_jsonl(tmp_path, collection)
if tmp_path.exists():
with open(tmp_path) as f:
for line in f:
records.append(_json.loads(line))
finally:
tmp_path.unlink(missing_ok=True)
return {"count": n, "records": records}
@app.get("/feedback/boost-factors", tags=["Feedback"])
async def feedback_boost_factors(collection: str = "default") -> dict:
"""
Per-source boost/penalty factors computed from historical feedback.
Sources > 1.0 are boosted (high thumbs-up). Sources < 1.0 are penalized.
"""
from core.feedback import get_feedback_store, get_source_boost_factors
store = get_feedback_store()
factors = get_source_boost_factors(collection, store)
return {"collection": collection, "boost_factors": factors}
# ── Embedding fine-tuning ─────────────────────────────────────────────────────
class FinetuneRequest(BaseModel):
collection: str = Field(..., description="Collection to generate synthetic pairs from")
epochs: int = Field(default=3, ge=1, le=20)
use_feedback: bool = Field(default=True)
use_synthetic: bool = Field(default=True)
class FinetuneResponse(BaseModel):
model_path: str
training_pairs: int
test_pairs: int
baseline_mrr: float
finetuned_mrr: float
improvement_pct: float
@app.post("/finetune", response_model=FinetuneResponse, tags=["Training"])
async def finetune_embeddings(request: FinetuneRequest) -> FinetuneResponse:
"""
Fine-tune the embedding model on domain-specific data.
1. Collects training pairs from feedback corrections and synthetic LLM-generated Q&A
2. Mines hard negatives (semantically similar but wrong chunks)
3. Fine-tunes with MultipleNegativesRankingLoss
4. Evaluates improvement via MRR
This is a long-running operation (minutes). Consider running async in production.
Requires: pip install sentence-transformers[train]
"""
from core.embedding_finetuner import run_finetuning_pipeline
from core.generation import get_backend
from core.ingestion import embed_texts
backend = get_backend()
try:
results = run_finetuning_pipeline(
collection_name=request.collection,
llm_fn=backend.complete_raw,
embed_fn=embed_texts,
use_feedback=request.use_feedback,
use_synthetic=request.use_synthetic,
epochs=request.epochs,
)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e)) from e
if "error" in results:
raise HTTPException(status_code=422, detail=results["error"])
return FinetuneResponse(
model_path=results.get("model_path", ""),
training_pairs=results.get("training_pairs", 0),
test_pairs=results.get("test_pairs", 0),
baseline_mrr=results.get("baseline_mrr", 0.0),
finetuned_mrr=results.get("finetuned_mrr", 0.0),
improvement_pct=results.get("improvement_pct", 0.0),
)
# ── Agentic RAG ───────────────────────────────────────────────────────────────
class AgentRequest(BaseModel):
question: str = Field(..., min_length=1)
collection: str = Field(default="default")
max_iterations: int = Field(default=8, ge=1, le=20)
class AgentResponse(BaseModel):
answer: str
tool_calls: list[dict] = Field(default_factory=list)
total_tokens: int = 0
latency_ms: float = 0.0
iterations: int = 0
model_used: str = ""
@app.post("/query/agent", response_model=AgentResponse, tags=["Query"])
async def query_agent(request: AgentRequest) -> AgentResponse:
"""
Agentic RAG β€” the LLM decides which tools to call in sequence.
Available tools: search_docs, search_web, query_sql, calculate, get_date, summarize_collection.
The agent chains tools until it has a complete answer. Requires ANTHROPIC_API_KEY.
"""
from core.agent import run_agent
from core.generation import get_backend
from core.retrieval import retrieve
backend = get_backend()
def _retrieve_fn(req):
return retrieve(req, generate_fn=backend.complete_raw)
try:
result = run_agent(
question=request.question,
collection=request.collection,
retrieve_fn=_retrieve_fn,
max_iterations=request.max_iterations,
)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e)) from e
return AgentResponse(
answer=result.answer,
tool_calls=[
{
"tool": tc.tool_name,
"input": tc.tool_input,
"result_preview": tc.result[:200],
"latency_ms": tc.latency_ms,
}
for tc in result.tool_calls
],
total_tokens=result.total_tokens,
latency_ms=result.latency_ms,
iterations=result.iterations,
model_used=result.model_used,
)
# ── Structured extraction ─────────────────────────────────────────────────────
class StructuredQueryRequest(BaseModel):
question: str = Field(..., min_length=1)
collection: str = Field(default="default")
top_k: int = Field(default=6)
output_schema: dict = Field(
default_factory=dict,
description="JSON Schema describing the desired output structure",
examples=[{"revenue": {"type": "number"}, "period": {"type": "string"}}],
)
@app.post("/query/structured", tags=["Query"])
async def query_structured(request: StructuredQueryRequest) -> dict:
"""
Extract structured JSON from retrieved context instead of prose.
Useful for dashboards, downstream APIs, or automated pipelines that
need typed data rather than text answers.
Example: revenue figures β†’ {"revenue": 2.3, "unit": "billion", "change": "+15%"}
"""
from core.generation import answer_structured
from models import QueryMode
q_request = QueryRequest(
question=request.question,
collection=request.collection,
top_k=request.top_k,
mode=QueryMode.HYBRID,
)
try:
result = answer_structured(q_request, request.output_schema)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e)) from e
return result
# ── SQL / Structured data ─────────────────────────────────────────────────────
class SQLQueryRequest(BaseModel):
question: str = Field(..., description="Natural language question")
database: str | None = Field(
default=None, description="DB URL or path (uses SQL_DATABASE_URL from config if omitted)"
)
class SQLQueryResponse(BaseModel):
question: str
result: str
database: str
@app.post("/sql/query", response_model=SQLQueryResponse, tags=["SQL"])
async def sql_query(request: SQLQueryRequest) -> SQLQueryResponse:
"""
Text-to-SQL: generate and execute a SQL query from natural language.
Combines with vector retrieval for hybrid structured+unstructured answers.
"""
from core.sql_retrieval import get_db_url, query_natural_language
try:
result = query_natural_language(request.question, request.database)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e)) from e
return SQLQueryResponse(
question=request.question, result=result, database=get_db_url(request.database)
)
@app.get("/sql/schema", tags=["SQL"])
async def sql_schema(database: str | None = None) -> dict:
"""Return the schema of the configured SQL database."""
from core.sql_retrieval import get_db_url, get_schema
return {"schema": get_schema(database), "database": get_db_url(database)}
@app.post("/sql/setup-sample", tags=["SQL"])
async def sql_setup_sample() -> dict:
"""Create a sample SQLite database with products/customers/orders for demos."""
from core.sql_retrieval import create_sample_db
path = create_sample_db()
return {
"message": "Sample database created.",
"path": str(path),
"hint": f"Set SQL_DATABASE_URL=sqlite:///{path} in .env",
}
# ── Security ──────────────────────────────────────────────────────────────────
@app.get("/security/audit", tags=["Security"])
async def security_audit(days: int = 7) -> dict:
"""Return security audit summary: PII rates, injection attempts, sensitive queries."""
from core.security import get_audit_summary
return get_audit_summary(days)
class ScanRequest(BaseModel):
text: str
redact: bool = Field(default=False, description="If true, return redacted version of the text")
@app.post("/security/scan", tags=["Security"])
async def security_scan(request: ScanRequest) -> dict:
"""Scan text for PII patterns and prompt injection attempts."""
from core.security import detect_injection, detect_pii, redact_pii
pii = redact_pii(request.text) if request.redact else detect_pii(request.text)
inj = detect_injection(request.text)
return {
"has_pii": pii.has_pii,
"pii_types": pii.pii_types,
"redacted_text": pii.redacted_text if request.redact else None,
"redaction_count": pii.redaction_count if request.redact else 0,
"is_injection": inj.is_injection,
"injection_patterns": inj.matched_patterns,
"injection_risk_score": inj.risk_score,
}
# ── GraphRAG Communities ──────────────────────────────────────────────────────
@app.post("/graph/communities", tags=["Knowledge Graph"])
async def graph_communities(summarize: bool = False) -> dict:
"""
Detect entity communities in the knowledge graph using Louvain method.
Optionally generate LLM summaries for each community (Microsoft GraphRAG pattern).
"""
from core.graph_rag import get_knowledge_graph
graph = get_knowledge_graph()
communities = graph.detect_communities()
result = {
"community_count": len(communities),
"communities": {
str(k): v[:10] for k, v in communities.items()
}, # top 10 entities per community
}
if summarize:
from core.generation import get_backend
backend = get_backend()
summaries = graph.build_community_summaries(backend.complete_raw)
result["summaries"] = {str(k): v for k, v in summaries.items()}
return result
class GlobalQueryRequest(BaseModel):
question: str = Field(..., description="High-level question to answer via community summaries")
top_communities: int = Field(default=5, ge=1, le=20)
@app.post("/graph/global-query", tags=["Knowledge Graph"])
async def graph_global_query(request: GlobalQueryRequest) -> dict:
"""
Answer a global question using GraphRAG community summaries.
Better than entity lookup for thematic questions like 'What are the main topics?'
"""
from core.generation import get_backend
from core.graph_rag import get_knowledge_graph
graph = get_knowledge_graph()
backend = get_backend()
summaries = graph.build_community_summaries(backend.complete_raw)
answer = graph.global_query(
request.question, summaries, backend.complete_raw, top_communities=request.top_communities
)
return {
"question": request.question,
"answer": answer,
"communities_used": min(request.top_communities, len(summaries)),
}
# ── Observability ─────────────────────────────────────────────────────────────
@app.get("/observability/status", tags=["System"])
async def observability_status() -> dict:
"""Check if Langfuse tracing is active."""
from core.observability import is_enabled
return {"langfuse_enabled": is_enabled()}
@app.post("/observability/score", tags=["System"])
async def score_trace_endpoint(trace_id: str, score: float, name: str = "user_feedback") -> dict:
"""Attach a user feedback score to a Langfuse trace (1.0=thumbs up, 0.0=thumbs down)."""
from core.observability import score_trace
score_trace(trace_id, score, name)
return {"trace_id": trace_id, "score": score, "name": name}
# ── CoT-RAG (Chain-of-Thought, EMNLP 2025) ────────────────────────────────────
class CoTRequest(BaseModel):
question: str = Field(
..., min_length=1, description="Question to answer with chain-of-thought reasoning"
)
collection: str = Field(default="default")
top_k: int = Field(default=6, ge=1, le=30)
max_steps: int = Field(
default=4, ge=1, le=8, description="Max reasoning steps to decompose into"
)
top_k_per_step: int = Field(default=3, ge=1, le=10, description="Chunks to retrieve per step")
class CoTStepResponse(BaseModel):
step_number: int
thought: str
sub_query: str
retrieved_count: int
intermediate: str
latency_ms: float
class CoTResponse(BaseModel):
question: str
answer: str
reasoning_steps: list[CoTStepResponse]
all_sources: list[str]
total_chunks: int
tokens_used: int
latency_ms: float
num_steps: int
warnings: list[str] = Field(default_factory=list)
@app.post("/query/cot", response_model=CoTResponse, tags=["Query"])
async def query_cot(request: CoTRequest) -> CoTResponse:
"""
Chain-of-Thought RAG (CoT-RAG) β€” EMNLP 2025.
Decomposes the question into explicit reasoning steps, retrieves targeted
context for each step, and synthesizes with the full reasoning trace.
Best for: multi-hop questions, questions requiring facts from multiple
document sections, complex analytical queries.
Returns both the answer and the step-by-step reasoning trace.
"""
from core.cot_rag import run_cot_rag
from core.generation import get_backend
from core.retrieval import retrieve
backend = get_backend()
try:
result = run_cot_rag(
question=request.question,
collection=request.collection,
retrieve_fn=retrieve,
llm_fn=backend.complete_raw,
max_steps=request.max_steps,
top_k_per_step=request.top_k_per_step,
)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e)) from e
return CoTResponse(
question=result.question,
answer=result.answer,
reasoning_steps=[
CoTStepResponse(
step_number=s.step_number,
thought=s.thought,
sub_query=s.sub_query,
retrieved_count=len(s.retrieved),
intermediate=s.intermediate,
latency_ms=s.latency_ms,
)
for s in result.reasoning_steps
],
all_sources=result.all_sources,
total_chunks=result.total_chunks,
tokens_used=result.tokens_used,
latency_ms=result.latency_ms,
num_steps=result.num_steps,
warnings=result.warnings,
)
# ── TTRAG (ICLR 2025) ─────────────────────────────────────────────────────────
class TTRAGRequest(BaseModel):
question: str = Field(..., min_length=1)
collection: str = Field(default="default")
max_iterations: int = Field(default=4, ge=1, le=8)
top_k: int = Field(default=6, ge=1, le=20)
sufficiency_threshold: float = Field(default=0.55, ge=0.1, le=0.95)
class TTRAGIterationResponse(BaseModel):
iteration: int
query_used: str
rewrite_reason: str
new_chunks_retrieved: int
sufficiency_score: float
latency_ms: float
class TTRAGResponse(BaseModel):
question: str
answer: str
iterations: list[TTRAGIterationResponse]
num_iterations: int
unique_chunks_used: int
final_sufficiency: float
converged: bool
tokens_used: int
latency_ms: float
@app.post("/query/ttrag", response_model=TTRAGResponse, tags=["Query"])
async def query_ttrag(request: TTRAGRequest) -> TTRAGResponse:
"""
TTRAG β€” Test-Time Compute Scaling for RAG (ICLR 2025).
Iteratively rewrites the query and re-retrieves until sufficient context
is found. Each iteration uses LLM-guided query rewriting to target gaps
in the previously retrieved content.
Best for: hard questions where one-shot retrieval misses the right chunks,
queries with ambiguous terminology, and cases where Sufficient Context
would otherwise abstain.
"""
from core.generation import SYSTEM_PROMPT, build_user_prompt, get_backend
from core.retrieval import retrieve
from core.ttrag import run_ttrag
backend = get_backend()
def _generate(q: str, ctx) -> tuple[str, int]:
prompt = build_user_prompt(ctx)
answer, tokens, _ = backend.complete(SYSTEM_PROMPT, prompt)
return answer, tokens
try:
result = run_ttrag(
question=request.question,
collection=request.collection,
retrieve_fn=retrieve,
llm_fn=backend.complete_raw,
generate_fn=_generate,
max_iterations=request.max_iterations,
top_k=request.top_k,
sufficiency_threshold=request.sufficiency_threshold,
)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e)) from e
return TTRAGResponse(
question=result.question,
answer=result.answer,
iterations=[
TTRAGIterationResponse(
iteration=it.iteration,
query_used=it.query_used,
rewrite_reason=it.rewrite_reason,
new_chunks_retrieved=len(it.retrieved),
sufficiency_score=it.sufficiency.overall_score,
latency_ms=it.latency_ms,
)
for it in result.iterations
],
num_iterations=result.num_iterations,
unique_chunks_used=result.unique_chunks_used,
final_sufficiency=result.final_sufficiency,
converged=result.converged,
tokens_used=result.tokens_used,
latency_ms=result.latency_ms,
)
# ── Speculative RAG (Google Research 2024) ───────────────────────────────────
class SpeculativeRAGRequest(BaseModel):
question: str = Field(..., min_length=1)
collection: str = Field(default="default")
num_drafts: int = Field(default=3, ge=2, le=6)
top_k: int = Field(default=9, ge=3, le=30)
class SpeculativeDraftResponse(BaseModel):
draft_id: int
confidence_score: float
answer: str
num_chunks: int
latency_ms: float
class SpeculativeRAGResponse(BaseModel):
question: str
answer: str
selected_draft_id: int
all_drafts: list[SpeculativeDraftResponse]
num_drafts: int
total_chunks_retrieved: int
latency_reduction_pct: float
tokens_used: int
latency_ms: float
@app.post("/query/speculative", response_model=SpeculativeRAGResponse, tags=["Query"])
async def query_speculative(request: SpeculativeRAGRequest) -> SpeculativeRAGResponse:
"""
Speculative RAG β€” Google Research (2024).
Generates N independent draft answers from document subsets, scores each
with LLM self-rating, and returns the highest-confidence draft.
Achieves ~51% latency reduction vs. full-context generation.
"""
from core.generation import get_backend
from core.retrieval import retrieve
from core.speculative_rag import run_speculative_rag
backend = get_backend()
try:
result = run_speculative_rag(
question=request.question,
collection=request.collection,
retrieve_fn=retrieve,
llm_complete_fn=backend.complete,
llm_raw_fn=backend.complete_raw,
num_drafts=request.num_drafts,
top_k=request.top_k,
)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e)) from e
return SpeculativeRAGResponse(
question=result.question,
answer=result.answer,
selected_draft_id=result.selected_draft_id,
all_drafts=[
SpeculativeDraftResponse(
draft_id=d.draft_id,
confidence_score=d.confidence_score,
answer=d.answer,
num_chunks=len(d.chunks),
latency_ms=d.latency_ms,
)
for d in result.all_drafts
],
num_drafts=result.num_drafts,
total_chunks_retrieved=result.total_chunks_retrieved,
latency_reduction_pct=result.latency_reduction_pct,
tokens_used=result.tokens_used,
latency_ms=result.latency_ms,
)
# ── A-RAG (Feb 2026) ─────────────────────────────────────────────────────────
class ARAGRequest(BaseModel):
question: str = Field(..., min_length=1)
collection: str = Field(default="default")
max_steps: int = Field(default=5, ge=1, le=10)
top_k_per_step: int = Field(default=4, ge=1, le=10)
class ARAGStepResponse(BaseModel):
step: int
tool_chosen: str
query: str
reasoning: str
new_chunks_retrieved: int
latency_ms: float
class ARAGResponse(BaseModel):
question: str
answer: str
steps: list[ARAGStepResponse]
num_steps: int
unique_chunks: int
tools_used: list[str]
tokens_used: int
latency_ms: float
@app.post("/query/arag", response_model=ARAGResponse, tags=["Query"])
async def query_arag(request: ARAGRequest) -> ARAGResponse:
"""
A-RAG β€” Hierarchical Retrieval Interfaces (Feb 2026).
The agent dynamically selects the retrieval interface at each step:
keyword_search (BM25), semantic_search (dense), hybrid_search, or
read_section (fetch from a specific source). Most cutting-edge agentic
RAG pattern β€” treats retrieval as a decision, not a fixed pipeline.
"""
from core.arag import run_arag
from core.generation import get_backend
from core.retrieval import retrieve
backend = get_backend()
try:
result = run_arag(
question=request.question,
collection=request.collection,
retrieve_fn=retrieve,
llm_raw_fn=backend.complete_raw,
llm_complete_fn=backend.complete,
max_steps=request.max_steps,
top_k_per_step=request.top_k_per_step,
)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e)) from e
return ARAGResponse(
question=result.question,
answer=result.answer,
steps=[
ARAGStepResponse(
step=s.step,
tool_chosen=s.tool_chosen,
query=s.query,
reasoning=s.reasoning,
new_chunks_retrieved=len(s.retrieved),
latency_ms=s.latency_ms,
)
for s in result.steps
],
num_steps=result.num_steps,
unique_chunks=result.unique_chunks,
tools_used=result.tools_used,
tokens_used=result.tokens_used,
latency_ms=result.latency_ms,
)
# ── LightRAG (EMNLP 2025) ─────────────────────────────────────────────────────
class LightRAGRequest(BaseModel):
question: str = Field(..., min_length=1)
level: str = Field(
default="auto",
description="Retrieval level: auto | low | high | combined",
pattern="^(auto|low|high|combined)$",
)
class LightRAGResponse(BaseModel):
question: str
level: str
resolved_level: str
context_chunks: list[str]
entities_used: list[str]
communities_used: list[str]
confidence: float
@app.post("/lightrag/query", response_model=LightRAGResponse, tags=["Knowledge Graph"])
async def lightrag_query(request: LightRAGRequest) -> LightRAGResponse:
"""
LightRAG dual-level graph retrieval (EMNLP 2025).
Two retrieval modes over the knowledge graph:
- **low**: precise entity/relationship queries (specific facts)
- **high**: thematic community-level queries (broad concepts)
- **auto**: automatically routes based on query type
- **combined**: merges both modes (maximum coverage)
Requires a knowledge graph built via POST /ingest with extract_graph=true.
"""
from core.generation import get_backend
from core.light_rag import get_light_rag
lr = get_light_rag()
backend = get_backend()
try:
if request.level == "low":
chunks = lr.low_level_retrieve(request.question)
entities = lr._match_entities(request.question)
comms: list[str] = []
resolved = "low"
elif request.level == "high":
chunks = lr.high_level_retrieve(request.question)
entities = []
comms = [f"community_{i}" for i in range(len(chunks))]
resolved = "high"
elif request.level == "combined":
result = lr.combined_retrieve(request.question)
chunks = result.context_chunks
entities = result.entities_used
comms = result.communities_used
resolved = "combined"
else: # auto
result = lr.auto_retrieve(request.question, llm_fn=backend.complete_raw)
chunks = result.context_chunks
entities = result.entities_used
comms = result.communities_used
resolved = result.resolved_level
except Exception as e:
raise HTTPException(status_code=500, detail=str(e)) from e
confidence = min(1.0, len(chunks) / max(10, 1))
return LightRAGResponse(
question=request.question,
level=request.level,
resolved_level=resolved,
context_chunks=chunks[:10], # cap for API response size
entities_used=entities,
communities_used=comms,
confidence=round(confidence, 3),
)
@app.get("/lightrag/stats", tags=["Knowledge Graph"])
async def lightrag_stats() -> dict:
"""LightRAG graph and index statistics."""
from core.light_rag import get_light_rag
return get_light_rag().stats()
# ── Sufficient Context check (Google ICLR 2025) ───────────────────────────────
class SufficiencyRequest(BaseModel):
question: str = Field(..., min_length=1)
collection: str = Field(default="default")
top_k: int = Field(default=6, ge=1, le=30)
enable_self_rating: bool = Field(
default=False,
description="Ask LLM to self-rate confidence (adds ~200ms latency)",
)
class SufficiencyResponse(BaseModel):
is_sufficient: bool
overall_score: float
density_score: float
coverage_score: float
num_chunks: int
recommendation: str
explanation: str
component_scores: dict
@app.post("/check-context", response_model=SufficiencyResponse, tags=["Query"])
async def check_context_sufficiency(request: SufficiencyRequest) -> SufficiencyResponse:
"""
Sufficient Context check β€” Google ICLR 2025.
Scores whether retrieved context is sufficient to answer the question
before committing to a full LLM generation call.
Returns a recommendation: generate | retrieve_more | web_search | abstain.
Use this to build confidence-gated UIs, route low-confidence queries
to escalation paths, or implement cost controls.
"""
from core.generation import get_backend, make_crag_evaluator
from core.retrieval import retrieve
from core.sufficient_context import check_sufficiency
from models import QueryMode
backend = get_backend()
req = QueryRequest(
question=request.question,
collection=request.collection,
top_k=request.top_k,
mode=QueryMode.HYBRID,
)
try:
evaluate_fn = make_crag_evaluator(backend) if settings.use_hybrid_search else None
context = retrieve(req, generate_fn=backend.complete_raw, evaluate_fn=evaluate_fn)
result = check_sufficiency(
question=request.question,
context=context,
llm_fn=backend.complete_raw if request.enable_self_rating else None,
enable_self_rating=request.enable_self_rating,
)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e)) from e
return SufficiencyResponse(
is_sufficient=result.is_sufficient,
overall_score=result.overall_score,
density_score=result.density_score,
coverage_score=result.coverage_score,
num_chunks=result.num_chunks,
recommendation=result.recommendation,
explanation=result.explanation,
component_scores=result.component_scores,
)
# ── Token budget diagnostics ──────────────────────────────────────────────────
@app.post("/debug/token-budget", tags=["System"])
async def debug_token_budget(question: str, collection: str = "default", top_k: int = 10) -> dict:
"""
Debug endpoint: show token budget optimization results for a query.
Returns how many chunks were kept/dropped, token savings percentage,
and estimated token counts before/after optimization.
"""
from core.generation import SYSTEM_PROMPT, get_backend
from core.retrieval import retrieve
from core.token_budget import estimate_tokens, optimize_context
from models import QueryMode
backend = get_backend()
req = QueryRequest(
question=question,
collection=collection,
top_k=top_k,
mode=QueryMode.HYBRID,
)
context = retrieve(req, generate_fn=backend.complete_raw)
before_tokens = sum(estimate_tokens(r.chunk_text) for r in context.results)
optimized, budget_info = optimize_context(
context=context,
question=question,
system_prompt=SYSTEM_PROMPT,
model_name=settings.claude_model
if settings.llm_backend.value == "claude"
else settings.ollama_model,
)
return {
"question": question,
"chunks_before": len(context.results),
"chunks_after": budget_info.included,
"chunks_dropped": budget_info.excluded,
"tokens_before": before_tokens,
"tokens_after": budget_info.estimated_tokens,
"savings_pct": budget_info.savings_pct,
"budget_tokens": budget_info.budget_tokens,
"truncated": budget_info.truncated,
}