Files
2026-07-13 13:35:10 +08:00

482 lines
15 KiB
Python

import json
import os
from dataclasses import asdict, dataclass
from datetime import datetime
from typing import Any, Dict, List, Optional
from openai import OpenAI
DOCUMENTS = [
"Ragas are melodic frameworks in Indian classical music.",
"There are many types of ragas, each with its own mood and time of day.",
"Ragas are used to evoke specific emotions in the listener.",
"The performance of a raga involves improvisation within a set structure.",
"Ragas can be performed on various instruments or sung vocally.",
]
@dataclass
class TraceEvent:
"""Single event in the RAG application trace"""
event_type: str
component: str
data: Dict[str, Any]
class BaseRetriever:
"""
Base class for retrievers.
Subclasses should implement the fit and get_top_k methods.
"""
def __init__(self):
self.documents = []
def fit(self, documents: List[str]):
"""Store the documents"""
self.documents = documents
def get_top_k(self, query: str, k: int = 3) -> List[tuple]:
"""Retrieve top-k most relevant documents for the query."""
raise NotImplementedError("Subclasses should implement this method.")
class SimpleKeywordRetriever(BaseRetriever):
"""Ultra-simple keyword matching retriever"""
def __init__(self):
super().__init__()
def _count_keyword_matches(self, query: str, document: str) -> int:
"""Count how many query words appear in the document"""
query_words = query.lower().split()
document_words = document.lower().split()
matches = 0
for word in query_words:
if word in document_words:
matches += 1
return matches
def get_top_k(self, query: str, k: int = 3) -> List[tuple]:
"""Get top k documents by keyword match count"""
scores = []
for i, doc in enumerate(self.documents):
match_count = self._count_keyword_matches(query, doc)
scores.append((i, match_count))
# Sort by match count (descending)
scores.sort(key=lambda x: x[1], reverse=True)
return scores[:k]
class ExampleRAG:
"""
Simple RAG system that:
1. accepts a llm client
2. uses simple keyword matching to retrieve relevant documents
3. uses the llm client to generate a response based on the retrieved documents when a query is made
"""
def __init__(
self,
llm_client,
retriever: Optional[BaseRetriever] = None,
system_prompt: Optional[str] = None,
logdir: str = "logs",
):
"""
Initialize RAG system
Args:
llm_client: LLM client with a generate() method
retriever: Document retriever (defaults to SimpleKeywordRetriever)
system_prompt: System prompt template for generation
logdir: Directory for trace log files
"""
self.llm_client = llm_client
self.retriever = retriever or SimpleKeywordRetriever()
self.system_prompt = (
system_prompt
or """Answer the following question based on the provided documents:
Question: {query}
Documents:
{context}
Answer:
"""
)
self.documents = []
self.is_fitted = False
self.traces = []
self.logdir = logdir
# Create log directory if it doesn't exist
os.makedirs(self.logdir, exist_ok=True)
# Initialize tracing
self.traces.append(
TraceEvent(
event_type="init",
component="rag_system",
data={
"retriever_type": type(self.retriever).__name__,
"system_prompt_length": len(self.system_prompt),
"logdir": self.logdir,
},
)
)
def add_documents(self, documents: List[str]):
"""Add documents to the knowledge base"""
self.traces.append(
TraceEvent(
event_type="document_operation",
component="rag_system",
data={
"operation": "add_documents",
"num_new_documents": len(documents),
"total_documents_before": len(self.documents),
"document_lengths": [len(doc) for doc in documents],
},
)
)
self.documents.extend(documents)
# Refit retriever with all documents
self.retriever.fit(self.documents)
self.is_fitted = True
self.traces.append(
TraceEvent(
event_type="document_operation",
component="retriever",
data={
"operation": "fit_completed",
"total_documents": len(self.documents),
"retriever_type": type(self.retriever).__name__,
},
)
)
def set_documents(self, documents: List[str]):
"""Set documents (replacing any existing ones)"""
old_doc_count = len(self.documents)
self.traces.append(
TraceEvent(
event_type="document_operation",
component="rag_system",
data={
"operation": "set_documents",
"num_new_documents": len(documents),
"old_document_count": old_doc_count,
"document_lengths": [len(doc) for doc in documents],
},
)
)
self.documents = documents
self.retriever.fit(self.documents)
self.is_fitted = True
self.traces.append(
TraceEvent(
event_type="document_operation",
component="retriever",
data={
"operation": "fit_completed",
"total_documents": len(self.documents),
"retriever_type": type(self.retriever).__name__,
},
)
)
def retrieve_documents(self, query: str, top_k: int = 3) -> List[Dict[str, Any]]:
"""
Retrieve top-k most relevant documents for the query
Args:
query: Search query
top_k: Number of documents to retrieve
Returns:
List of dictionaries containing document info
"""
if not self.is_fitted:
raise ValueError(
"No documents have been added. Call add_documents() or set_documents() first."
)
self.traces.append(
TraceEvent(
event_type="retrieval",
component="retriever",
data={
"operation": "retrieve_start",
"query": query,
"query_length": len(query),
"top_k": top_k,
"total_documents": len(self.documents),
},
)
)
top_docs = self.retriever.get_top_k(query, k=top_k)
retrieved_docs = []
for idx, score in top_docs:
if score > 0: # Only include documents with positive similarity scores
retrieved_docs.append(
{
"content": self.documents[idx],
"similarity_score": score,
"document_id": idx,
}
)
self.traces.append(
TraceEvent(
event_type="retrieval",
component="retriever",
data={
"operation": "retrieve_complete",
"num_retrieved": len(retrieved_docs),
"scores": [doc["similarity_score"] for doc in retrieved_docs],
"document_ids": [doc["document_id"] for doc in retrieved_docs],
},
)
)
return retrieved_docs
def generate_response(self, query: str, top_k: int = 3) -> str:
"""
Generate response to query using retrieved documents
Args:
query: User query
top_k: Number of documents to retrieve
Returns:
Generated response
"""
if not self.is_fitted:
raise ValueError(
"No documents have been added. Call add_documents() or set_documents() first."
)
# Retrieve relevant documents
retrieved_docs = self.retrieve_documents(query, top_k)
if not retrieved_docs:
return "I couldn't find any relevant documents to answer your question."
# Build context from retrieved documents
context_parts = []
for i, doc in enumerate(retrieved_docs, 1):
context_parts.append(f"Document {i}:\n{doc['content']}")
context = "\n\n".join(context_parts)
# Generate response using LLM client
prompt = self.system_prompt.format(query=query, context=context)
self.traces.append(
TraceEvent(
event_type="llm_call",
component="openai_api",
data={
"operation": "generate_response",
"model": "gpt-4o",
"query": query,
"prompt_length": len(prompt),
"context_length": len(context),
"num_context_docs": len(retrieved_docs),
},
)
)
try:
response = self.llm_client.chat.completions.create(
model="gpt-4o",
messages=[
{"role": "system", "content": self.system_prompt},
{"role": "user", "content": prompt},
],
)
response_text = response.choices[0].message.content.strip()
self.traces.append(
TraceEvent(
event_type="llm_response",
component="openai_api",
data={
"operation": "generate_response",
"response_length": len(response_text),
"usage": (
response.usage.model_dump() if response.usage else None
),
"model": "gpt-4o",
},
)
)
return response_text
except Exception as e:
self.traces.append(
TraceEvent(
event_type="error",
component="openai_api",
data={"operation": "generate_response", "error": str(e)},
)
)
return f"Error generating response: {str(e)}"
def query(
self, question: str, top_k: int = 3, run_id: Optional[str] = None
) -> Dict[str, Any]:
"""
Complete RAG pipeline: retrieve documents and generate response
Args:
question: User question
top_k: Number of documents to retrieve
run_id: Optional run ID for tracing (auto-generated if not provided)
Returns:
Dictionary containing response and retrieved documents
"""
# Generate run_id if not provided
if run_id is None:
run_id = f"{datetime.now().strftime('%Y%m%d_%H%M%S')}_{hash(question) % 10000:04d}"
# Reset traces for this query
self.traces = []
self.traces.append(
TraceEvent(
event_type="query_start",
component="rag_system",
data={
"run_id": run_id,
"question": question,
"question_length": len(question),
"top_k": top_k,
"total_documents": len(self.documents),
},
)
)
try:
retrieved_docs = self.retrieve_documents(question, top_k)
response = self.generate_response(question, top_k)
result = {"answer": response, "run_id": run_id}
self.traces.append(
TraceEvent(
event_type="query_complete",
component="rag_system",
data={
"run_id": run_id,
"success": True,
"response_length": len(response),
"num_retrieved": len(retrieved_docs),
},
)
)
logs_path = self.export_traces_to_log(run_id, question, result)
return {"answer": response, "run_id": run_id, "logs": logs_path}
except Exception as e:
self.traces.append(
TraceEvent(
event_type="error",
component="rag_system",
data={"run_id": run_id, "operation": "query", "error": str(e)},
)
)
# Return error result
logs_path = self.export_traces_to_log(run_id, question, None)
return {
"answer": f"Error processing query: {str(e)}",
"run_id": run_id,
"logs": logs_path,
}
def export_traces_to_log(
self,
run_id: str,
query: Optional[str] = None,
result: Optional[Dict[str, Any]] = None,
):
"""Export traces to a log file with run_id"""
timestamp = datetime.now().isoformat()
log_filename = (
f"rag_run_{run_id}_{timestamp.replace(':', '-').replace('.', '-')}.json"
)
log_filepath = os.path.join(self.logdir, log_filename)
log_data = {
"run_id": run_id,
"timestamp": timestamp,
"query": query,
"result": result,
"num_documents": len(self.documents),
"traces": [asdict(trace) for trace in self.traces],
}
with open(log_filepath, "w") as f:
json.dump(log_data, f, indent=2)
print(f"RAG traces exported to: {log_filepath}")
return log_filepath
def default_rag_client(llm_client, logdir: str = "logs") -> ExampleRAG:
"""
Create a default RAG client with OpenAI LLM and optional retriever.
Args:
retriever: Optional retriever instance (defaults to SimpleKeywordRetriever)
logdir: Directory for trace logs
Returns:
ExampleRAG instance
"""
retriever = SimpleKeywordRetriever()
client = ExampleRAG(llm_client=llm_client, retriever=retriever, logdir=logdir)
client.add_documents(DOCUMENTS) # Add default documents
return client
if __name__ == "__main__":
try:
api_key = os.environ["OPENAI_API_KEY"]
except KeyError:
print("Error: OPENAI_API_KEY environment variable is not set.")
print("Please set your OpenAI API key:")
print("export OPENAI_API_KEY='your_openai_api_key'")
exit(1)
# Initialize RAG system with tracing enabled
llm = OpenAI(api_key=api_key)
r = SimpleKeywordRetriever()
rag_client = ExampleRAG(llm_client=llm, retriever=r, logdir="logs")
# Add documents (this will be traced)
rag_client.add_documents(DOCUMENTS)
# Run query with tracing
query = "What is Ragas"
print(f"Query: {query}")
response = rag_client.query(query, top_k=3)
print("Response:", response["answer"])
print(f"Run ID: {response['logs']}")