Spaces:
Sleeping
Sleeping
Vineetiitg commited on
Commit ·
aaa6ad5
1
Parent(s): 2d34555
feat: add cited answers and configurable retrieval
Browse files- app/engine/context_builder.py +58 -0
- app/engine/query_transform.py +15 -0
- app/engine/retriever.py +40 -24
- app/graph/workflow.py +10 -9
- app/main.py +18 -3
app/engine/context_builder.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from langchain_core.documents import Document
|
| 2 |
+
|
| 3 |
+
from app.core.config import settings
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def build_context(documents: list[Document]) -> str:
|
| 7 |
+
parts: list[str] = []
|
| 8 |
+
total_chars = 0
|
| 9 |
+
for document in dedupe_documents(documents):
|
| 10 |
+
source = document.metadata.get("source", "unknown")
|
| 11 |
+
page = document.metadata.get("page")
|
| 12 |
+
label = f"{source}, page {page}" if page else source
|
| 13 |
+
content = document.page_content.strip()
|
| 14 |
+
block = f"Source: {label}\n{content}"
|
| 15 |
+
if total_chars + len(block) > settings.MAX_CONTEXT_CHARS:
|
| 16 |
+
break
|
| 17 |
+
parts.append(block)
|
| 18 |
+
total_chars += len(block)
|
| 19 |
+
return "\n\n".join(parts)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def dedupe_documents(documents: list[Document]) -> list[Document]:
|
| 23 |
+
seen: set[str] = set()
|
| 24 |
+
unique: list[Document] = []
|
| 25 |
+
for document in documents:
|
| 26 |
+
key = document.metadata.get("chunk_id") or document.page_content[:120]
|
| 27 |
+
if key in seen:
|
| 28 |
+
continue
|
| 29 |
+
seen.add(key)
|
| 30 |
+
unique.append(document)
|
| 31 |
+
return unique
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def source_citations(documents: list[Document]) -> list[dict]:
|
| 35 |
+
citations: list[dict] = []
|
| 36 |
+
for document in dedupe_documents(documents):
|
| 37 |
+
snippet = " ".join(document.page_content.split())[:240]
|
| 38 |
+
citations.append(
|
| 39 |
+
{
|
| 40 |
+
"source": document.metadata.get("source", "unknown"),
|
| 41 |
+
"page": document.metadata.get("page"),
|
| 42 |
+
"chunk_id": document.metadata.get("chunk_id"),
|
| 43 |
+
"doc_id": document.metadata.get("doc_id"),
|
| 44 |
+
"snippet": snippet,
|
| 45 |
+
}
|
| 46 |
+
)
|
| 47 |
+
return citations
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def format_sources(documents: list[Document]) -> str:
|
| 51 |
+
citations = source_citations(documents)
|
| 52 |
+
if not citations:
|
| 53 |
+
return ""
|
| 54 |
+
lines = ["", "Sources:"]
|
| 55 |
+
for citation in citations:
|
| 56 |
+
page = f", page {citation['page']}" if citation.get("page") else ""
|
| 57 |
+
lines.append(f"- {citation['source']}{page}: {citation['snippet']}")
|
| 58 |
+
return "\n".join(lines)
|
app/engine/query_transform.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import re
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
def normalize_query(query: str) -> str:
|
| 5 |
+
return re.sub(r"\s+", " ", query).strip()
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def query_variants(query: str) -> list[str]:
|
| 9 |
+
normalized = normalize_query(query)
|
| 10 |
+
variants = [normalized]
|
| 11 |
+
if "error" in normalized.lower() and "troubleshoot" not in normalized.lower():
|
| 12 |
+
variants.append(f"troubleshoot {normalized}")
|
| 13 |
+
if "how" in normalized.lower() and "steps" not in normalized.lower():
|
| 14 |
+
variants.append(f"{normalized} steps")
|
| 15 |
+
return list(dict.fromkeys(variants))
|
app/engine/retriever.py
CHANGED
|
@@ -1,31 +1,47 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
from langchain_community.cross_encoders import HuggingFaceCrossEncoder
|
| 4 |
-
from langchain.retrievers.document_compressors import CrossEncoderReranker
|
| 5 |
from langchain.retrievers import ContextualCompressionRetriever
|
| 6 |
-
from
|
|
|
|
| 7 |
|
| 8 |
from app.core.config import settings
|
|
|
|
|
|
|
| 9 |
|
| 10 |
-
|
| 11 |
-
if settings.QDRANT_URL:
|
| 12 |
-
client = QdrantClient(url=settings.QDRANT_URL)
|
| 13 |
-
else:
|
| 14 |
-
client = QdrantClient(path=settings.QDRANT_LOCATION)
|
| 15 |
-
|
| 16 |
-
dense_embeddings = FastEmbedEmbeddings(model_name=settings.DENSE_EMBEDDING_MODEL)
|
| 17 |
-
sparse_embeddings = FastEmbedSparse(model_name=settings.SPARSE_EMBEDDING_MODEL)
|
| 18 |
-
|
| 19 |
-
qdrant = QdrantVectorStore(
|
| 20 |
-
client=client,
|
| 21 |
-
collection_name=settings.COLLECTION_NAME,
|
| 22 |
-
embedding=dense_embeddings,
|
| 23 |
-
sparse_embedding=sparse_embeddings,
|
| 24 |
-
retrieval_mode=RetrievalMode.HYBRID,
|
| 25 |
-
)
|
| 26 |
|
| 27 |
-
base_retriever = qdrant.as_retriever(search_kwargs={"k": 15})
|
| 28 |
-
model = HuggingFaceCrossEncoder(model_name=settings.RERANKER_MODEL)
|
| 29 |
-
compressor = CrossEncoderReranker(model=model, top_n=3)
|
| 30 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
return ContextualCompressionRetriever(base_compressor=compressor, base_retriever=base_retriever)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
|
|
|
|
|
|
|
| 3 |
from langchain.retrievers import ContextualCompressionRetriever
|
| 4 |
+
from langchain.retrievers.document_compressors import CrossEncoderReranker
|
| 5 |
+
from langchain_community.cross_encoders import HuggingFaceCrossEncoder
|
| 6 |
|
| 7 |
from app.core.config import settings
|
| 8 |
+
from app.engine.indexer import open_vector_store
|
| 9 |
+
from app.engine.query_transform import query_variants
|
| 10 |
|
| 11 |
+
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
+
def get_retriever():
|
| 15 |
+
qdrant = open_vector_store()
|
| 16 |
+
base_retriever = qdrant.as_retriever(search_kwargs={"k": settings.RETRIEVAL_TOP_K})
|
| 17 |
+
|
| 18 |
+
if not settings.RERANKER_ENABLED:
|
| 19 |
+
return base_retriever
|
| 20 |
+
|
| 21 |
+
model = HuggingFaceCrossEncoder(model_name=settings.RERANKER_MODEL)
|
| 22 |
+
compressor = CrossEncoderReranker(model=model, top_n=settings.RERANKER_TOP_N)
|
| 23 |
return ContextualCompressionRetriever(base_compressor=compressor, base_retriever=base_retriever)
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def retrieve_documents(question: str):
|
| 27 |
+
retriever = get_retriever()
|
| 28 |
+
documents = []
|
| 29 |
+
seen = set()
|
| 30 |
+
for query in query_variants(question):
|
| 31 |
+
for document in retriever.invoke(query):
|
| 32 |
+
key = document.metadata.get("chunk_id") or document.page_content[:120]
|
| 33 |
+
if key in seen:
|
| 34 |
+
continue
|
| 35 |
+
seen.add(key)
|
| 36 |
+
documents.append(document)
|
| 37 |
+
logger.info(
|
| 38 |
+
"retrieval completed query_count=%s returned_chunks=%s reranker_enabled=%s",
|
| 39 |
+
len(query_variants(question)),
|
| 40 |
+
len(documents),
|
| 41 |
+
settings.RERANKER_ENABLED,
|
| 42 |
+
)
|
| 43 |
+
return documents[: settings.RETRIEVAL_TOP_K]
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def get_reranked_retriever():
|
| 47 |
+
return get_retriever()
|
app/graph/workflow.py
CHANGED
|
@@ -1,17 +1,19 @@
|
|
| 1 |
import json
|
| 2 |
-
from typing import List, TypedDict
|
| 3 |
from langchain_core.prompts import PromptTemplate
|
| 4 |
from langchain_core.documents import Document
|
| 5 |
from langchain_ollama import ChatOllama
|
| 6 |
from langgraph.graph import START, END, StateGraph
|
| 7 |
|
| 8 |
from app.core.config import settings
|
| 9 |
-
from app.engine.
|
|
|
|
| 10 |
|
| 11 |
class GraphState(TypedDict):
|
| 12 |
question: str
|
| 13 |
generation: str
|
| 14 |
documents: List[Document]
|
|
|
|
| 15 |
run_count: int
|
| 16 |
|
| 17 |
llm = ChatOllama(model=settings.OLLAMA_MODEL, temperature=0, base_url=settings.OLLAMA_BASE_URL)
|
|
@@ -21,9 +23,8 @@ def retrieve(state: GraphState):
|
|
| 21 |
print("--- NODE: RETRIEVE DOCS ---")
|
| 22 |
question = state["question"]
|
| 23 |
run_count = state.get("run_count", 0)
|
| 24 |
-
|
| 25 |
-
documents
|
| 26 |
-
return {"documents": documents, "question": question, "run_count": run_count}
|
| 27 |
|
| 28 |
def grade_documents(state: GraphState):
|
| 29 |
print("--- NODE: GRADE DOCUMENT RELEVANCE ---")
|
|
@@ -58,9 +59,9 @@ def generate(state: GraphState):
|
|
| 58 |
documents = state["documents"]
|
| 59 |
run_count = state.get("run_count", 0) + 1
|
| 60 |
|
| 61 |
-
context =
|
| 62 |
prompt = PromptTemplate(
|
| 63 |
-
template="""You are a Support Docs Copilot. Use the retrieved context to answer the question concisely. If
|
| 64 |
Question: {question}
|
| 65 |
Context: {context}
|
| 66 |
Answer:""",
|
|
@@ -68,7 +69,7 @@ def generate(state: GraphState):
|
|
| 68 |
)
|
| 69 |
rag_chain = prompt | llm
|
| 70 |
generation = rag_chain.invoke({"context": context, "question": question})
|
| 71 |
-
return {"generation": generation.content, "run_count": run_count}
|
| 72 |
|
| 73 |
def decide_to_generate(state: GraphState):
|
| 74 |
if not state["documents"]:
|
|
@@ -86,7 +87,7 @@ def check_hallucinations(state: GraphState):
|
|
| 86 |
print("--- ROUTE: MAX RETRIES REACHED ---")
|
| 87 |
return "end"
|
| 88 |
|
| 89 |
-
context =
|
| 90 |
prompt = PromptTemplate(
|
| 91 |
template="""You are evaluating whether a generated answer is fully grounded in the retrieved facts.
|
| 92 |
Facts: \n\n {context} \n\n
|
|
|
|
| 1 |
import json
|
| 2 |
+
from typing import List, Optional, TypedDict
|
| 3 |
from langchain_core.prompts import PromptTemplate
|
| 4 |
from langchain_core.documents import Document
|
| 5 |
from langchain_ollama import ChatOllama
|
| 6 |
from langgraph.graph import START, END, StateGraph
|
| 7 |
|
| 8 |
from app.core.config import settings
|
| 9 |
+
from app.engine.context_builder import build_context, source_citations
|
| 10 |
+
from app.engine.retriever import retrieve_documents
|
| 11 |
|
| 12 |
class GraphState(TypedDict):
|
| 13 |
question: str
|
| 14 |
generation: str
|
| 15 |
documents: List[Document]
|
| 16 |
+
sources: Optional[list[dict]]
|
| 17 |
run_count: int
|
| 18 |
|
| 19 |
llm = ChatOllama(model=settings.OLLAMA_MODEL, temperature=0, base_url=settings.OLLAMA_BASE_URL)
|
|
|
|
| 23 |
print("--- NODE: RETRIEVE DOCS ---")
|
| 24 |
question = state["question"]
|
| 25 |
run_count = state.get("run_count", 0)
|
| 26 |
+
documents = retrieve_documents(question)
|
| 27 |
+
return {"documents": documents, "sources": source_citations(documents), "question": question, "run_count": run_count}
|
|
|
|
| 28 |
|
| 29 |
def grade_documents(state: GraphState):
|
| 30 |
print("--- NODE: GRADE DOCUMENT RELEVANCE ---")
|
|
|
|
| 59 |
documents = state["documents"]
|
| 60 |
run_count = state.get("run_count", 0) + 1
|
| 61 |
|
| 62 |
+
context = build_context(documents)
|
| 63 |
prompt = PromptTemplate(
|
| 64 |
+
template="""You are a Support Docs Copilot. Use only the retrieved context to answer the question concisely. If the context does not contain the answer, say "I don't know".
|
| 65 |
Question: {question}
|
| 66 |
Context: {context}
|
| 67 |
Answer:""",
|
|
|
|
| 69 |
)
|
| 70 |
rag_chain = prompt | llm
|
| 71 |
generation = rag_chain.invoke({"context": context, "question": question})
|
| 72 |
+
return {"generation": generation.content, "sources": source_citations(documents), "run_count": run_count}
|
| 73 |
|
| 74 |
def decide_to_generate(state: GraphState):
|
| 75 |
if not state["documents"]:
|
|
|
|
| 87 |
print("--- ROUTE: MAX RETRIES REACHED ---")
|
| 88 |
return "end"
|
| 89 |
|
| 90 |
+
context = build_context(documents)
|
| 91 |
prompt = PromptTemplate(
|
| 92 |
template="""You are evaluating whether a generated answer is fully grounded in the retrieved facts.
|
| 93 |
Facts: \n\n {context} \n\n
|
app/main.py
CHANGED
|
@@ -10,6 +10,7 @@ from langchain_ollama import ChatOllama
|
|
| 10 |
from app.core.config import settings
|
| 11 |
from app.core.dependencies import check_ollama, check_qdrant
|
| 12 |
from app.core.logging import configure_logging, logger
|
|
|
|
| 13 |
from app.graph.workflow import compile_workflow
|
| 14 |
from app.guardrails.validators import DetectPromptInjection
|
| 15 |
|
|
@@ -21,9 +22,17 @@ input_guard = Guard().use(DetectPromptInjection, on_fail="exception")
|
|
| 21 |
class ChatRequest(BaseModel):
|
| 22 |
query: str
|
| 23 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
class ChatResponse(BaseModel):
|
| 25 |
query: str
|
| 26 |
answer: str
|
|
|
|
| 27 |
|
| 28 |
@app.get("/health")
|
| 29 |
async def health_endpoint():
|
|
@@ -42,6 +51,8 @@ async def ready_endpoint():
|
|
| 42 |
@app.post("/chat", response_model=ChatResponse)
|
| 43 |
async def chat_endpoint(request: ChatRequest):
|
| 44 |
started_at = time.perf_counter()
|
|
|
|
|
|
|
| 45 |
if settings.ENABLE_GUARDRAILS:
|
| 46 |
try:
|
| 47 |
input_guard.validate(request.query)
|
|
@@ -52,14 +63,17 @@ async def chat_endpoint(request: ChatRequest):
|
|
| 52 |
try:
|
| 53 |
final_state = rag_agent.invoke(initial_state)
|
| 54 |
answer = final_state.get("generation", "Unable to compile answer.")
|
|
|
|
| 55 |
except Exception as e:
|
| 56 |
raise HTTPException(status_code=500, detail=str(e))
|
| 57 |
|
| 58 |
logger.info("chat completed latency_ms=%s", round((time.perf_counter() - started_at) * 1000, 2))
|
| 59 |
-
return ChatResponse(query=request.query, answer=answer)
|
| 60 |
|
| 61 |
@app.post("/chat/stream")
|
| 62 |
async def chat_stream_endpoint(request: ChatRequest):
|
|
|
|
|
|
|
| 63 |
if settings.ENABLE_GUARDRAILS:
|
| 64 |
try:
|
| 65 |
input_guard.validate(request.query)
|
|
@@ -76,9 +90,9 @@ async def chat_stream_endpoint(request: ChatRequest):
|
|
| 76 |
yield "I am sorry, no reliable matching documentation was found."
|
| 77 |
return
|
| 78 |
|
| 79 |
-
context =
|
| 80 |
prompt = PromptTemplate(
|
| 81 |
-
template="""You are a Support Docs Copilot. Use the retrieved context to answer the question concisely. If you don't know the answer, say "I don't know".
|
| 82 |
Question: {question}
|
| 83 |
Context: {context} \n\nAnswer:""",
|
| 84 |
input_variables=["question", "context"],
|
|
@@ -90,6 +104,7 @@ async def chat_stream_endpoint(request: ChatRequest):
|
|
| 90 |
if chunk.content:
|
| 91 |
yield chunk.content
|
| 92 |
await asyncio.sleep(0.01)
|
|
|
|
| 93 |
logger.info("stream completed latency_ms=%s", round((time.perf_counter() - started_at) * 1000, 2))
|
| 94 |
|
| 95 |
return StreamingResponse(token_generator(), media_type="text/event-stream")
|
|
|
|
| 10 |
from app.core.config import settings
|
| 11 |
from app.core.dependencies import check_ollama, check_qdrant
|
| 12 |
from app.core.logging import configure_logging, logger
|
| 13 |
+
from app.engine.context_builder import build_context, format_sources
|
| 14 |
from app.graph.workflow import compile_workflow
|
| 15 |
from app.guardrails.validators import DetectPromptInjection
|
| 16 |
|
|
|
|
| 22 |
class ChatRequest(BaseModel):
|
| 23 |
query: str
|
| 24 |
|
| 25 |
+
class SourceCitation(BaseModel):
|
| 26 |
+
source: str
|
| 27 |
+
page: int | None = None
|
| 28 |
+
chunk_id: str | None = None
|
| 29 |
+
doc_id: str | None = None
|
| 30 |
+
snippet: str
|
| 31 |
+
|
| 32 |
class ChatResponse(BaseModel):
|
| 33 |
query: str
|
| 34 |
answer: str
|
| 35 |
+
sources: list[SourceCitation] = []
|
| 36 |
|
| 37 |
@app.get("/health")
|
| 38 |
async def health_endpoint():
|
|
|
|
| 51 |
@app.post("/chat", response_model=ChatResponse)
|
| 52 |
async def chat_endpoint(request: ChatRequest):
|
| 53 |
started_at = time.perf_counter()
|
| 54 |
+
if len(request.query) > settings.MAX_QUERY_LENGTH:
|
| 55 |
+
raise HTTPException(status_code=400, detail="Query is too long.")
|
| 56 |
if settings.ENABLE_GUARDRAILS:
|
| 57 |
try:
|
| 58 |
input_guard.validate(request.query)
|
|
|
|
| 63 |
try:
|
| 64 |
final_state = rag_agent.invoke(initial_state)
|
| 65 |
answer = final_state.get("generation", "Unable to compile answer.")
|
| 66 |
+
sources = final_state.get("sources", [])
|
| 67 |
except Exception as e:
|
| 68 |
raise HTTPException(status_code=500, detail=str(e))
|
| 69 |
|
| 70 |
logger.info("chat completed latency_ms=%s", round((time.perf_counter() - started_at) * 1000, 2))
|
| 71 |
+
return ChatResponse(query=request.query, answer=answer, sources=sources)
|
| 72 |
|
| 73 |
@app.post("/chat/stream")
|
| 74 |
async def chat_stream_endpoint(request: ChatRequest):
|
| 75 |
+
if len(request.query) > settings.MAX_QUERY_LENGTH:
|
| 76 |
+
raise HTTPException(status_code=400, detail="Query is too long.")
|
| 77 |
if settings.ENABLE_GUARDRAILS:
|
| 78 |
try:
|
| 79 |
input_guard.validate(request.query)
|
|
|
|
| 90 |
yield "I am sorry, no reliable matching documentation was found."
|
| 91 |
return
|
| 92 |
|
| 93 |
+
context = build_context(documents)
|
| 94 |
prompt = PromptTemplate(
|
| 95 |
+
template="""You are a Support Docs Copilot. Use only the retrieved context to answer the question concisely. If you don't know the answer, say "I don't know".
|
| 96 |
Question: {question}
|
| 97 |
Context: {context} \n\nAnswer:""",
|
| 98 |
input_variables=["question", "context"],
|
|
|
|
| 104 |
if chunk.content:
|
| 105 |
yield chunk.content
|
| 106 |
await asyncio.sleep(0.01)
|
| 107 |
+
yield format_sources(documents)
|
| 108 |
logger.info("stream completed latency_ms=%s", round((time.perf_counter() - started_at) * 1000, 2))
|
| 109 |
|
| 110 |
return StreamingResponse(token_generator(), media_type="text/event-stream")
|