persona-rag-dmitry / answer.py
Dmitry Kisselev
Initial commit
415d78f
Raw
History Blame Contribute Delete
9.09 kB
"""
RAG Answer Module for Persona
Retrieval pipeline with sub-query generation, semantic search, and reranking
"""
from pathlib import Path
from langchain_openai import ChatOpenAI
from langchain_chroma import Chroma
from langchain_huggingface import HuggingFaceEmbeddings
from langchain_core.messages import SystemMessage, HumanMessage, convert_to_messages
from langchain_core.documents import Document
from langchain_core.output_parsers import CommaSeparatedListOutputParser
from langchain_core.prompts import ChatPromptTemplate
from sentence_transformers import CrossEncoder
from rank_bm25 import BM25Okapi
import numpy as np
from dotenv import load_dotenv
load_dotenv(override=True)
# Configuration
DATA_DIR = Path(__file__).parent / "data"
VECTOR_DB = str(DATA_DIR / "vector_db")
EMBEDDING_MODEL = "thenlper/gte-small"
LLM_MODEL = "gpt-4o-mini"
PERSONA_NAME = "Dmitry Kisselev"
# Retrieval parameters
RETRIEVAL_K = 20 # Retrieve candidates for reranking
FINAL_K = 5 # Return top K after reranking
USE_QUERY_EXPANSION = False # Disabled: hurt accuracy, completeness, MRR
USE_HYBRID_SEARCH = False # Disabled: hurt accuracy, completeness, MRR
# System prompt for persona
SYSTEM_PROMPT = """You are {PERSONA_NAME}, answering questions about yourself.
Respond naturally in first person as if you're talking about your own life, career, and experiences.
Use the context provided to answer accurately. If you don't know something, say so honestly.
Context (with metadata):
{context}
"""
# Initialize components
embeddings = HuggingFaceEmbeddings(model_name=EMBEDDING_MODEL)
vectorstore = None
retriever = None
llm = ChatOpenAI(temperature=0, model_name=LLM_MODEL)
# Initialize reranker
_reranker = None
def get_reranker():
"""Lazy load cross-encoder reranker"""
global _reranker
if _reranker is None:
_reranker = CrossEncoder('cross-encoder/ms-marco-MiniLM-L-6-v2')
return _reranker
# Initialize BM25 for hybrid search
_bm25 = None
_bm25_docs = None
def get_bm25():
"""Initialize BM25 index from all documents in vector store"""
global _bm25, _bm25_docs
if _bm25 is None:
# Get all documents from vector store
collection = vectorstore._collection
all_data = collection.get(include=["documents", "metadatas"])
# Create Document objects
_bm25_docs = [
Document(page_content=doc, metadata=meta)
for doc, meta in zip(all_data['documents'], all_data['metadatas'])
]
# Tokenize documents
tokenized_docs = [doc.page_content.lower().split() for doc in _bm25_docs]
_bm25 = BM25Okapi(tokenized_docs)
return _bm25, _bm25_docs
def initialize_retriever():
"""Initialize vector store and retriever"""
global vectorstore, retriever
if vectorstore is None:
vectorstore = Chroma(persist_directory=VECTOR_DB, embedding_function=embeddings)
retriever = vectorstore.as_retriever(search_kwargs={"k": RETRIEVAL_K})
return retriever
# Sub-query generation
output_parser = CommaSeparatedListOutputParser()
template = """
You are a helpful assistant. Given a user question, generate 1 to 3
sub-queries that are optimized for a vector database search.
The sub-queries should cover the different parts of the user's question.
Question: {question}
Format your response as a comma-separated list.
"""
query_gen_prompt = ChatPromptTemplate.from_template(template)
query_gen_chain = query_gen_prompt | llm | output_parser
def expand_query(question: str) -> list[str]:
"""
Query Expansion: Generate 2-3 variations of the query to improve retrieval coverage.
"""
expansion_prompt = f"""Given this question, generate 2 alternative phrasings that would help find relevant information.
Keep the variations concise and focused on the same topic.
Original question: {question}
Provide ONLY 2 alternative phrasings, one per line, without numbering or extra text:"""
try:
response = llm.invoke([HumanMessage(content=expansion_prompt)])
variations = [line.strip() for line in response.content.strip().split('\n') if line.strip()]
# Return original + variations (limit to 3 total)
return [question] + variations[:2]
except Exception as e:
print(f"Query expansion failed: {e}")
return [question]
def fetch_context(question: str) -> list[Document]:
"""
Retrieve and rerank relevant context documents.
Uses: (Query Expansion) + Sub-query generation + Semantic search + (Hybrid Search) + Reranking.
"""
retriever = initialize_retriever()
# Query expansion
if USE_QUERY_EXPANSION:
expanded_queries = expand_query(question)
base_question = expanded_queries[0]
else:
base_question = question
# Generate sub-queries
try:
sub_queries = query_gen_chain.invoke({"question": base_question})
all_queries = [base_question] + sub_queries
except Exception as e:
print(f"Sub-query generation failed: {e}. Using original question.")
all_queries = [base_question]
# Add expanded queries if enabled
if USE_QUERY_EXPANSION:
all_queries.extend(expanded_queries[1:]) # Add variations
# Initialize BM25 if hybrid search is enabled
bm25 = None
bm25_docs = None
if USE_HYBRID_SEARCH:
try:
bm25, bm25_docs = get_bm25()
except Exception as e:
print(f"Failed to initialize BM25: {e}")
# Retrieve documents for all queries
all_docs = []
seen_ids = set()
for q in all_queries:
# Semantic search
try:
docs = retriever.invoke(q)
for doc in docs:
doc_id = f"{doc.metadata.get('source', '')}:{hash(doc.page_content)}"
if doc_id not in seen_ids:
seen_ids.add(doc_id)
all_docs.append(doc)
except Exception as e:
print(f"Semantic retrieval failed for query '{q}': {e}")
# BM25 search (if enabled)
if USE_HYBRID_SEARCH and bm25 and bm25_docs:
try:
tokenized_query = q.lower().split()
bm25_scores = bm25.get_scores(tokenized_query)
top_bm25_indices = np.argsort(bm25_scores)[::-1][:RETRIEVAL_K]
bm25_results = [bm25_docs[i] for i in top_bm25_indices]
for doc in bm25_results:
doc_id = f"{doc.metadata.get('source', '')}:{hash(doc.page_content)}"
if doc_id not in seen_ids:
seen_ids.add(doc_id)
all_docs.append(doc)
except Exception as e:
print(f"BM25 retrieval failed for query '{q}': {e}")
if not all_docs:
print("No documents retrieved.")
return []
# Rerank with cross-encoder
try:
reranker = get_reranker()
pairs = [[question, doc.page_content] for doc in all_docs]
scores = reranker.predict(pairs)
doc_scores = list(zip(all_docs, scores))
doc_scores.sort(key=lambda x: x[1], reverse=True)
top_docs = [doc for doc, score in doc_scores[:FINAL_K]]
return top_docs
except Exception as e:
print(f"Reranking failed: {e}. Returning top documents without reranking.")
return all_docs[:FINAL_K]
def format_doc_with_metadata(doc: Document, idx: int) -> str:
"""Format document with metadata for context"""
meta = doc.metadata
formatted = f"--- Document {idx+1} ---\n"
# Add metadata
if 'source' in meta:
formatted += f"Source: {meta['source']}\n"
if 'data_type' in meta:
formatted += f"Type: {meta['data_type']}\n"
if 'time_period' in meta:
formatted += f"Time Period: {meta['time_period']}\n"
if 'item_count' in meta:
formatted += f"Items: {meta['item_count']}\n"
# Add content
formatted += f"\nContent:\n{doc.page_content}\n"
return formatted
def answer_question(question: str, history: list[dict] = []) -> tuple[str, list[Document]]:
""" Answer the given question using RAG."""
# Fetch relevant context
docs = fetch_context(question)
# Format context with metadata
context = "\n\n".join(format_doc_with_metadata(doc, i) for i, doc in enumerate(docs))
# Build messages
system_prompt = SYSTEM_PROMPT.format(context=context, PERSONA_NAME=PERSONA_NAME)
messages = [SystemMessage(content=system_prompt)]
messages.extend(convert_to_messages(history))
messages.append(HumanMessage(content=question[:5000]))
# Get response
response = llm.invoke(messages)
return response.content, docs
if __name__ == "__main__":
# Test the module
print("Testing RAG answer module...")
test_question = "What is your current role?"
answer, docs = answer_question(test_question)
print(f"\nQuestion: {test_question}")
print(f"\nAnswer: {answer}")
print(f"\nRetrieved {len(docs)} documents")