""" Query Pipeline: RAG → LLM Retrieves relevant documents and generates answers """ import logging from typing import List, Dict, Any, Optional import httpx from app.document_store import get_document_store from app.embedding import get_text_embedder from app.core.config import settings logger = logging.getLogger(__name__) async def answer_query( dao_id: str, question: str, top_k: Optional[int] = None, user_id: Optional[str] = None ) -> Dict[str, Any]: """ Answer query using RAG pipeline Args: dao_id: DAO identifier (for filtering) question: User question top_k: Number of documents to retrieve (default from settings) user_id: Optional user identifier Returns: Dictionary with answer, citations, and retrieved documents """ import time start_time = time.time() logger.info(f"Answering query: dao_id={dao_id}, question={question[:50]}...") top_k = top_k or settings.TOP_K try: # Retrieve relevant documents documents, retrieval_metrics = _retrieve_documents(dao_id, question, top_k) if not documents: logger.warning(f"No documents found for dao_id={dao_id}") elapsed_time = time.time() - start_time return { "answer": "На жаль, я не знайшов релевантної інформації в базі знань.", "citations": [], "documents": [], "metrics": { "documents_retrieved": 0, "citations_count": 0, "doc_ids": [], "query_time_seconds": round(elapsed_time, 2) } } logger.info(f"Retrieved {len(documents)} documents (method: {retrieval_metrics.get('retrieval_method')})") # Generate answer using LLM answer = await _generate_answer(question, documents, dao_id, user_id) # Build citations citations = _build_citations(documents) # Calculate metrics elapsed_time = time.time() - start_time doc_ids = list(set([doc.meta.get("doc_id", "unknown") for doc in documents])) # Merge metrics final_metrics = { **retrieval_metrics, "documents_retrieved": len(documents), "citations_count": len(citations), "doc_ids": doc_ids, "total_query_time_seconds": round(elapsed_time, 2), "answer_length": len(answer) } # Log metrics logger.info( f"RAG query completed: " f"dao_id={dao_id}, " f"documents_found={len(documents)}, " f"citations={len(citations)}, " f"doc_ids={doc_ids}, " f"retrieval_time={retrieval_metrics.get('retrieval_time_seconds', 0):.2f}s, " f"total_time={elapsed_time:.2f}s" ) return { "answer": answer, "citations": citations, "documents": [ { "content": doc.content[:200] + "..." if len(doc.content) > 200 else doc.content, "meta": doc.meta } for doc in documents ], "metrics": final_metrics } except Exception as e: logger.error(f"Failed to answer query: {e}", exc_info=True) elapsed_time = time.time() - start_time logger.error(f"RAG query failed after {elapsed_time:.2f}s: {e}") return { "answer": f"Помилка при обробці запиту: {str(e)}", "citations": [], "documents": [], "metrics": { "documents_retrieved": 0, "citations_count": 0, "doc_ids": [], "query_time_seconds": round(elapsed_time, 2), "error": str(e) } } def _retrieve_documents( dao_id: str, question: str, top_k: int ) -> tuple[List[Any], Dict[str, Any]]: """ Retrieve relevant documents from DocumentStore Args: dao_id: DAO identifier for filtering question: Query text top_k: Number of documents to retrieve Returns: Tuple of (List of Haystack Document objects, metrics dict) """ import time retrieval_start = time.time() # Get components embedder = get_text_embedder() document_store = get_document_store() # Embed query embedding_result = embedder.run(text=question) query_embedding = embedding_result["embedding"][0] if isinstance(embedding_result["embedding"], list) else embedding_result["embedding"] # Retrieve with filters using vector similarity search filters = {"dao_id": [dao_id]} try: documents = document_store.search( query_embedding=query_embedding, filters=filters, top_k=top_k, return_embedding=False ) retrieval_method = "vector_search" except Exception as e: logger.warning(f"Vector search failed: {e}, trying filter_documents") # Fallback to filter_documents documents = document_store.filter_documents( filters=filters, top_k=top_k, return_embedding=False ) retrieval_method = "filter_documents" # If no documents with filter, try without filter (fallback) if not documents: logger.warning(f"No documents found with dao_id={dao_id}, trying without filter") try: documents = document_store.search( query_embedding=query_embedding, filters=None, top_k=top_k, return_embedding=False ) retrieval_method = "vector_search_no_filter" except Exception: documents = document_store.filter_documents( filters=None, top_k=top_k, return_embedding=False ) retrieval_method = "filter_documents_no_filter" retrieval_time = time.time() - retrieval_start # Build metrics doc_ids = list(set([doc.meta.get("doc_id", "unknown") for doc in documents])) metrics = { "retrieval_method": retrieval_method, "retrieval_time_seconds": round(retrieval_time, 2), "documents_found": len(documents), "doc_ids": doc_ids, "filters_applied": {"dao_id": dao_id} } return documents, metrics async def _generate_answer( question: str, documents: List[Any], dao_id: str, user_id: Optional[str] = None ) -> str: """ Generate answer using LLM (via DAGI Router or OpenAI) Args: question: User question documents: Retrieved documents dao_id: DAO identifier user_id: Optional user identifier Returns: Generated answer text """ # Build context from documents context = "\n\n".join([ f"[Документ {idx+1}, сторінка {doc.meta.get('page', '?')}]: {doc.content[:500]}" for idx, doc in enumerate(documents[:3]) # Limit to first 3 documents ]) # Build prompt prompt = ( "Тобі надано контекст з бази знань та питання користувача.\n" "Відповідай на основі наданого контексту. Якщо в контексті немає відповіді, " "скажи що не знаєш.\n\n" f"Контекст:\n{context}\n\n" f"Питання: {question}\n\n" "Відповідь:" ) # Call LLM based on provider if settings.LLM_PROVIDER == "router": return await _call_router_llm(prompt, dao_id, user_id) elif settings.LLM_PROVIDER == "openai" and settings.OPENAI_API_KEY: return await _call_openai_llm(prompt) else: # Fallback: simple answer return f"Знайдено {len(documents)} релевантних документів. Перший фрагмент: {documents[0].content[:200]}..." async def _call_router_llm( prompt: str, dao_id: str, user_id: Optional[str] = None ) -> str: """Call DAGI Router LLM""" router_url = f"{settings.ROUTER_BASE_URL.rstrip('/')}/route" payload = { "mode": "chat", "dao_id": dao_id, "user_id": user_id or "rag-service", "payload": { "message": prompt } } try: async with httpx.AsyncClient(timeout=30.0) as client: resp = await client.post(router_url, json=payload) resp.raise_for_status() data = resp.json() return data.get("data", {}).get("text", "Не вдалося отримати відповідь") except Exception as e: logger.error(f"Router LLM call failed: {e}") return f"Помилка при виклику LLM: {str(e)}" async def _call_openai_llm(prompt: str) -> str: """Call OpenAI LLM""" # TODO: Implement OpenAI client return "OpenAI integration not yet implemented" def _build_citations(documents: List[Any]) -> List[Dict[str, Any]]: """ Build citations from retrieved documents Args: documents: List of Haystack Documents Returns: List of citation dictionaries """ citations = [] for doc in documents: meta = doc.meta citations.append({ "doc_id": meta.get("doc_id", "unknown"), "page": meta.get("page", 0), "section": meta.get("section"), "excerpt": doc.content[:200] + "..." if len(doc.content) > 200 else doc.content }) return citations