RAG-study-assistant / answer.py
kevinkim728
feat: add example questions, tighten grounding instruction, disable markdown tables
60faf96
Raw
History Blame Contribute Delete
5.83 kB
from sentence_transformers import SentenceTransformer, CrossEncoder
from chromadb import PersistentClient
from dotenv import load_dotenv
from groq import Groq
from pydantic import BaseModel
from openai import OpenAI
load_dotenv(override=True)
class Chunk(BaseModel):
page_content: str
metadata: dict
embedder = SentenceTransformer("nomic-ai/nomic-embed-text-v1.5", trust_remote_code=True)
cross_encoder = CrossEncoder("BAAI/bge-reranker-large")
chroma = PersistentClient(path="./chroma_db")
collection = chroma.get_or_create_collection("transcripts")
# client = OpenAI()
# model = "gpt-4.1-mini"
client = Groq()
model = "openai/gpt-oss-120b"
def rewrite_query(query, history=[]):
"""
Calls the LLM to rewrite the query in a more clear and concise way
"""
clean_history = [{"role": m["role"], "content": m["content"]} for m in history]
response = client.chat.completions.create(
model=model,
messages=[
{"role": "system", "content": f"""You are a search query optimizer for a knowledge base of LLM engineering course transcripts.
Rewrite the user's question into a short, precise search query most likely to surface relevant content.
This is the conversation history so far: {clean_history}
Respond ONLY with the rewritten query, nothing else."""},
{"role": "user", "content": query}
]
)
return response.choices[0].message.content
def merge_chunks(chunks1, chunks2):
merged = chunks1[:]
existing = [chunk.page_content for chunk in chunks1]
for chunk in chunks2:
if chunk.page_content not in existing:
merged.append(chunk)
return merged
def rerank(query, chunks):
user_prompt = f"The user has asked the following question:\n\n{query}\n\nRank all chunks by relevance, most relevant first.\n\n"
for i, chunk in enumerate(chunks):
user_prompt += f"# CHUNK ID: {i + 1}:\n\n{chunk.page_content}\n\n"
user_prompt += "Reply with ONLY the chunk IDs as comma-separated integers, most relevant first. Example: 3,1,4,2,5..."
response = client.chat.completions.create(
model=model,
temperature=0,
messages=[
{"role": "system", "content": "You are a document re-ranker. Given a question and a list of chunks, return them ranked by relevance to the question, most relevant first. Return results as comma-separated integers only"},
{"role": "user", "content": user_prompt}
],
)
order_str = response.choices[0].message.content.strip()
order = [int(x.strip()) for x in order_str.split(',') if x.strip().isdigit()]
order = [i for i in order if 1 <= i <= len(chunks)] # Filter out-of-range IDs the LLM may hallucinate
print(f"Order returned by LLM: {order}")
return [chunks[i - 1] for i in order] # LLM returns 1-indexed IDs
def fetch_context_crossencoder(query, n_results=20, final_k=10):
"""
A fetch_context for a cross encoder technique
"""
query_embedding = embedder.encode(query).tolist()
results = collection.query(query_embeddings=[query_embedding], n_results=n_results)
chunks1 = [Chunk(page_content=doc, metadata=meta)
for doc, meta in zip(results["documents"][0], results["metadatas"][0])]
rewritten = rewrite_query(query)
rewritten_embedding = embedder.encode(rewritten).tolist()
results2 = collection.query(query_embeddings=[rewritten_embedding], n_results=n_results)
chunks2 = [Chunk(page_content=doc, metadata=meta) for doc, meta in zip(results2["documents"][0], results2["metadatas"][0])]
merged = merge_chunks(chunks1, chunks2)
pairs = [[query, chunk.page_content] for chunk in merged]
scores = cross_encoder.predict(pairs)
ranked = sorted(zip(scores, merged), key=lambda x: x[0], reverse=True)
return [chunk for _, chunk in ranked[:final_k]]
def fetch_context_hybrid(query, n_results=20, ce_k=20, final_k=15, history=[]):
query_embedding = embedder.encode(query).tolist()
results = collection.query(query_embeddings=[query_embedding], n_results=n_results)
chunks1 = [Chunk(page_content=doc, metadata=meta)
for doc, meta in zip(results["documents"][0], results["metadatas"][0])]
rewritten = rewrite_query(query, history)
rewritten_embedding = embedder.encode(rewritten).tolist()
results2 = collection.query(query_embeddings=[rewritten_embedding], n_results=n_results)
chunks2 = [Chunk(page_content=doc, metadata=meta)
for doc, meta in zip(results2["documents"][0], results2["metadatas"][0])]
merged = merge_chunks(chunks1, chunks2)
pairs = [[query, chunk.page_content] for chunk in merged]
scores = cross_encoder.predict(pairs)
ranked = sorted(zip(scores, merged), key=lambda x: x[0], reverse=True)
ce_top = [chunk for _, chunk in ranked[:ce_k]]
return rerank(query, ce_top)[:final_k]
def generate_answer(query, chunks, history=[]):
context = "\n\n".join(chunk.page_content for chunk in chunks)
clean_history = [{"role": m["role"], "content": m["content"]} for m in history]
response = client.chat.completions.create(
model=model,
messages=[
{"role": "system", "content": f"""You are a study assistant for an LLM engineering course.
Answer the question using ONLY the information in the context below. Do not use any knowledge outside of the provided context.
If the answer cannot be found in the context, respond only with: "I don't have information on that topic in the course material."
Do not use markdown tables in your response.
Context:
{context}"""},
] + clean_history + [
{"role": "user", "content": query}
]
)
return response.choices[0].message.content