boardgame-rag-qa / boardgame_rag.py
Apug98's picture
Upload 3 files
a4ed3af verified
Raw
History Blame Contribute Delete
7.79 kB
from transformers import AutoTokenizer, AutoModelForCausalLM, pipeline
import torch
from pathlib import Path
from collections import Counter
import ast
import json
import shutil
import time
from textwrap import dedent
import numpy as np
import pandas as pd
from langchain_core.documents import Document
from langchain_text_splitters import RecursiveCharacterTextSplitter, MarkdownHeaderTextSplitter
from langchain_chroma import Chroma
from langchain_huggingface import HuggingFaceEmbeddings
class BoardGameRag():
def __init__(self, model_name:str, tokenizer_name: str | None, children:list[Document] | None=None, parents:list[Document] | None=None, max_new_tokens = 500, do_sample = False, persist_directory: str|None=None):
if tokenizer_name is None:
tokenizer_name = model_name
self.tokenizer = AutoTokenizer.from_pretrained(tokenizer_name)
self.model = AutoModelForCausalLM.from_pretrained(model_name, device_map = "auto", dtype = torch.bfloat16)
self.max_new_tokens = max_new_tokens
self.do_sample = do_sample
self.pipe = pipeline(
"text-generation",
model = self.model,
tokenizer = self.tokenizer,
torch_dtype = torch.bfloat16,
device_map = "auto",
max_new_tokens = self.max_new_tokens,
do_sample = self.do_sample
)
self.children = children
self.parents = parents
self.embedding_model = "Qwen/Qwen3-Embedding-4B"
self.retrieval_enabled = children is not None and parents is not None
self.persist_directory = persist_directory
if self.retrieval_enabled:
self.parent_lookup = self._build_parent_lookup()
self.embeddings = HuggingFaceEmbeddings(
model_name = self.embedding_model,
model_kwargs = {"device" : "cuda" if torch.cuda.is_available() else "cpu" },
encode_kwargs={"normalize_embeddings": True}
)
if self.persist_directory is not None and Path(self.persist_directory).exists():
self.vector_store = Chroma(
embedding_function = self.embeddings,
persist_directory = self.persist_directory
)
else:
self.vector_store = Chroma.from_documents(
documents = self.children,
embedding = self.embeddings,
ids = [child.metadata["chunk_id"] for child in self.children],
persist_directory = self.persist_directory
)
else:
self.parent_lookup = []
self.embeddings = None
self.vector_store = None
def _build_parent_lookup(self):
parent_lookup = {}
for parent in self.parents:
parent_id = (
parent.metadata["game"],
parent.metadata["section_id"],
parent.metadata["subsection_id"]
)
parent_lookup[parent_id] = parent
return parent_lookup
def _retrieve_child_chunks(self, question):
search_kwargs={"k": 5}
retriever = self.vector_store.as_retriever(search_type = "similarity", search_kwargs = search_kwargs)
retrieved_chunks = retriever.invoke(question)
return retrieved_chunks
def _expand_children_to_parents(self, child_chunks):
parent_documents = []
seen_parent_ids = set()
for child in child_chunks:
parent_id = (
child.metadata["game"],
child.metadata["section_id"],
child.metadata["subsection_id"]
)
if parent_id not in seen_parent_ids:
parent = self.parent_lookup.get(parent_id)
if parent is None:
raise KeyError(
f"No parent subsection found for {parent_id}"
)
parent_documents.append(parent)
seen_parent_ids.add(parent_id)
return parent_documents
def retrieve(self, question):
if not self.retrieval_enabled:
raise RuntimeError(
"This BoardGameRag instance was created without `documents`/`subsections`, "
"so it has no vector store to retrieve from. Either construct it with both, "
"or use generate_from_context() with pre-supplied contexts instead."
)
child_chunks = self._retrieve_child_chunks(question = question)
parent_documents = self._expand_children_to_parents(child_chunks)
return {
"child_chunks" : child_chunks,
"parent_documents" : parent_documents
}
def _format_parent_context(self, parent_documents: list[Document]):
formatted_parents = []
for parent in parent_documents:
metadata = parent.metadata
citation_label = f"{metadata['section_id']}_{metadata['subsection_id']}"
formatted_parents.append(
f"{citation_label}:\n"
f"{parent.page_content.strip()}"
)
return "\n\n".join(formatted_parents)
def _build_rag_prompt(self, question, context, prompt: str | None=None):
if prompt is None:
prompt = dedent(
f"""
You are a helpful assistant answering questions about how to play board games.
Answer the user's question thoroughly using only the provided context.
If the answer is not directly supported by the context you must say that you cannot answer the question with the information provided.
If the answer differs across game modes, variants, or optional rules described in the context, summarize the differences and specify which version each applies to.
Do not provide gameplay tips or strategies unless they are explicitly mentioned in the context.
""").strip()
return dedent(f"""
{prompt}
CONTEXT
{context}
QUESTION
{question}
ANSWER:
""").strip()
def generate_answer(self, question, prompt):
retrieval = self.retrieve(question)
context = self._format_parent_context(retrieval["parent_documents"])
prompt = self._build_rag_prompt(question, context, prompt)
messages = [
{
"role": "user",
"content": prompt,
}
]
prompt = self.tokenizer.apply_chat_template(
messages,
tokenize = False,
add_generation_prompt = True
)
output = self.pipe(prompt, return_full_text = False)
answer = output[0]['generated_text'].strip()
return {
"answer": answer,
"child_documents": retrieval["child_chunks"],
"parent_documents": retrieval["parent_documents"],
"context": context,
"prompt": prompt
}
def generate_from_context(self, question: str, contexts: list[str], prompt: str | None=None):
context = "\n\n".join(contexts)
formatted_prompt = self._build_rag_prompt(
question=question,
context=context,
prompt=prompt,
)
messages = [
{
"role" : "user",
"content" : formatted_prompt
}
]
prompt = self.tokenizer.apply_chat_template(
messages,
tokenize = False,
add_generation_prompt = True
)
output = self.pipe(prompt, return_full_text = False)
generated_text = output[0]["generated_text"]
return generated_text.strip()