""" Core RAG query engine. Public API ---------- query(question, job_ids, user_id, db, settings) -> dict Full RAG pipeline: embed → hybrid search → confidence gate → Groq LLM → save QueryHistory → enqueue async RAGAS evaluation. Returns answer text, numbered citations, token counts, and latency. _resolve_chunks_and_context(question, job_ids, settings) -> dict Shared retrieval step used by both query() and the /v1/query/stream endpoint. Returns either an early-return payload (no chunks / low confidence) or the ranked chunk list and assembled user prompt. Hybrid Search Pipeline ---------------------- 1. Embed question with fastembed (BAAI/bge-small-en-v1.5). 2. Vector search in ChromaDB (cosine similarity, child chunks). 3. BM25 sparse search on the same corpus (cached in Redis). 4. Merge with Reciprocal Rank Fusion (k=60). 5. Cross-encoder rerank (sentence-transformers). 6. Confidence gate — if top vector score < CONFIDENCE_THRESHOLD, return early without calling the LLM (prevents hallucination). 7. Build numbered context block (parent chunk text, capped at 1200 chars). 8. Call Groq (GROQ_MODEL) with RAG_SYSTEM_PROMPT. Broad-query detection (_BROAD_QUERY_RE) doubles effective_top_k for questions that span many documents (e.g. "compare all clients"). """ import json import time from datetime import datetime import groq as groq_sdk from fastapi import HTTPException from app.observability.logging import get_logger, log_llm_call from app.rag.embedder import embed_query from app.rag.vectorstore import get_chroma_client, get_or_create_collection, search, rrf_merge from app.rag.bm25_index import load_bm25, build_bm25, search_bm25 from app.rag.reranker import rerank log = get_logger() RAG_SYSTEM_PROMPT = """You are a document Q&A assistant. Answer ONLY from the numbered context excerpts provided. Do NOT use any knowledge from outside these excerpts. RULES (each violation counts against faithfulness): 1. Every sentence in your answer MUST be directly supported by a specific excerpt. If a fact is not explicitly written in an excerpt, do not state it. 2. After every factual claim, immediately place the citation marker [n] matching the excerpt number. 3. If the excerpts contain PARTIAL information relevant to the question, share what IS available with citations, then note what is missing. Only say "The provided documents do not contain this information." when the excerpts contain absolutely nothing relevant. 4. Never infer, extrapolate, or combine information creatively. Stick to verbatim or near-verbatim facts from the excerpts. 5. Be concise. No preamble, no filler phrases. """ import re as _re _BROAD_QUERY_RE = _re.compile( r"\b(all|every|across|compare|list\s+all|summari[sz]e\s+all|each\s+client|" r"all\s+\d+|all\s+accounts?|all\s+clients?|entire|overall|full\s+list)\b", _re.IGNORECASE, ) def _resolve_chunks_and_context(question: str, job_ids: list[str] | None, settings) -> dict: """Embed question, search ChromaDB, run confidence gate, build prompt. Returns dict.""" # Broad cross-document queries ("compare all clients", "list all managers") # need more chunks than single-entity lookups. is_broad = bool(_BROAD_QUERY_RE.search(question)) effective_top_k = min(settings.RAG_TOP_K * 2, 20) if is_broad else settings.RAG_TOP_K q_embedding = embed_query(question, settings) client = get_chroma_client(settings) collection = get_or_create_collection(client, settings) # Hybrid search: vector + BM25 merged via Reciprocal Rank Fusion, then cross-encoder re-rank vector_chunks = search(collection, q_embedding, top_k=effective_top_k * 2, job_ids=job_ids) # Capture vector scores before rrf_merge mutates them in-place top_vector_score = vector_chunks[0]["score"] if vector_chunks else 0.0 avg_vector_score = sum(c["score"] for c in vector_chunks) / len(vector_chunks) if vector_chunks else 0.0 index_data = load_bm25(settings) or build_bm25(collection, settings) bm25_chunks = search_bm25(index_data, question, top_k=effective_top_k * 2, job_ids=job_ids) rrf_chunks = rrf_merge(vector_chunks, bm25_chunks, top_k=effective_top_k * 2) chunks = rerank(question, rrf_chunks, top_k=effective_top_k) if not chunks: if job_ids: msg = "The selected document(s) have no searchable text content. Try selecting different documents or search across all documents." else: msg = "No documents found to search. Please upload and process files first." return {"early_return": True, "payload": { "answer": msg, "citations": [], "confidence_gate_passed": False, "avg_similarity_score": 0.0, "prompt_tokens": 0, "completion_tokens": 0, "latency_ms": 0, "ragas_scores": None, }} # Confidence gate uses top vector cosine similarity (0–1 scale), not raw RRF score. if top_vector_score < settings.CONFIDENCE_THRESHOLD: return {"early_return": True, "payload": { "answer": "I couldn't find sufficiently relevant information in your documents to answer this question confidently.", "citations": [], "confidence_gate_passed": False, "avg_similarity_score": top_vector_score, "prompt_tokens": 0, "completion_tokens": 0, "latency_ms": 0, "ragas_scores": None, }} # Parent chunks ≈ 600 words (~3600 chars) — cap at 1200 chars for 6-chunk token budget _MAX_CHUNK_CHARS = 1200 context_parts = [ f"[{i}] Source: {c['filename']} ({c['page_or_segment']})\n{c['text'][:_MAX_CHUNK_CHARS]}" for i, c in enumerate(chunks, 1) ] user_prompt = f"Context:\n{chr(10).join(context_parts)}\n\nQuestion: {question}\n\nAnswer (with [n] citation markers):" return {"early_return": False, "chunks": chunks, "avg_score": avg_vector_score, "user_prompt": user_prompt} def query( question: str, job_ids: list[str] | None, user_id, db, settings, ) -> dict: start_total = time.time() resolved = _resolve_chunks_and_context(question, job_ids, settings) if resolved["early_return"]: payload = resolved["payload"] payload["latency_ms"] = int((time.time() - start_total) * 1000) log.info("rag_query_early_exit", question=question[:100], reason="no_chunks_or_low_confidence") return payload chunks = resolved["chunks"] avg_score = resolved["avg_score"] user_prompt = resolved["user_prompt"] # Call Groq groq_client = groq_sdk.Groq(api_key=settings.GROQ_API_KEY) try: response = groq_client.chat.completions.create( model=settings.GROQ_MODEL, messages=[ {"role": "system", "content": RAG_SYSTEM_PROMPT}, {"role": "user", "content": user_prompt}, ], max_tokens=512, temperature=0, ) except groq_sdk.RateLimitError as exc: log.warning("rag_rate_limit", model=settings.GROQ_MODEL, error=str(exc)[:200]) raise HTTPException(status_code=429, detail="AI model rate limit reached. Please wait a few minutes and try again.") except groq_sdk.APIStatusError as exc: if exc.status_code == 413: log.warning("rag_request_too_large", model=settings.GROQ_MODEL, error=str(exc)[:200]) raise HTTPException(status_code=503, detail="Query context too large for model. Try selecting fewer documents.") log.error("rag_api_error", model=settings.GROQ_MODEL, error=str(exc)[:200]) raise HTTPException(status_code=502, detail="AI model error. Please try again.") latency_ms = int((time.time() - start_total) * 1000) prompt_tokens = response.usage.prompt_tokens if response.usage else 0 completion_tokens = response.usage.completion_tokens if response.usage else 0 answer_text = response.choices[0].message.content # 7. Log usage log_llm_call( user_id=user_id, endpoint="rag_query", model=settings.GROQ_MODEL, prompt_tokens=prompt_tokens, completion_tokens=completion_tokens, latency_ms=latency_ms, query_text=question[:500], llm_response_preview=answer_text[:500], db=db, ) log.info( "rag_query", question=question[:100], retrieved_chunk_count=len(chunks), avg_similarity_score=round(avg_score, 4), prompt_tokens=prompt_tokens, completion_tokens=completion_tokens, latency_ms=latency_ms, ) # 8. Build citations list citations = [ { "index": i + 1, "filename": c["filename"], "page_or_segment": c["page_or_segment"], "excerpt": c["text"][:200], } for i, c in enumerate(chunks) ] # 9. Save to QueryHistory from app.models.db import QueryHistory qh = QueryHistory( user_id=user_id, question=question, answer=answer_text, citations=json.dumps(citations), job_ids_queried=json.dumps([str(j) for j in (job_ids or [])]), chunk_count_retrieved=len(chunks), avg_similarity_score=avg_score, confidence_gate_passed=True, prompt_tokens=prompt_tokens, completion_tokens=completion_tokens, latency_ms=latency_ms, created_at=datetime.utcnow(), ) db.add(qh) db.commit() db.refresh(qh) # 10. Enqueue async RAGAS evaluation from app.workers.tasks import compute_ragas compute_ragas.delay(str(qh.id)) return { "answer": answer_text, "citations": citations, "confidence_gate_passed": True, "avg_similarity_score": avg_score, "prompt_tokens": prompt_tokens, "completion_tokens": completion_tokens, "latency_ms": latency_ms, "ragas_scores": None, }