Vineetiitg commited on
Commit
aaa6ad5
·
1 Parent(s): 2d34555

feat: add cited answers and configurable retrieval

Browse files
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
- from langchain_community.embeddings import FastEmbedEmbeddings
2
- from langchain_qdrant import FastEmbedSparse, QdrantVectorStore, RetrievalMode
3
- from langchain_community.cross_encoders import HuggingFaceCrossEncoder
4
- from langchain.retrievers.document_compressors import CrossEncoderReranker
5
  from langchain.retrievers import ContextualCompressionRetriever
6
- from qdrant_client import QdrantClient
 
7
 
8
  from app.core.config import settings
 
 
9
 
10
- def get_reranked_retriever():
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.retriever import get_reranked_retriever
 
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
- retriever = get_reranked_retriever()
25
- documents = retriever.invoke(question)
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 = "\n\n".join(doc.page_content for doc in documents)
62
  prompt = PromptTemplate(
63
- 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".
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 = "\n\n".join(doc.page_content for doc in documents)
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 = "\n\n".join(doc.page_content for doc in documents)
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")