Spaces:
Sleeping
Sleeping
File size: 11,810 Bytes
b3515de | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 | """
scripts/model.py
----------------
Retrieval-Augmented Generation pipeline supporting three retrieval strategies:
dense β sentence-transformers embeddings + FAISS (deep learning)
tfidf β TF-IDF bag-of-words + cosine similarity (classical ML)
random β random chunk selection (naive baseline)
Usage (from project root):
.venv/bin/python scripts/model.py catan 'What resources do you need to build a settlement?'
.venv/bin/python scripts/model.py monopoly 'How much money does each player start with?' --retriever tfidf
.venv/bin/python scripts/model.py catan --retriever random
Requires scripts/build_features.py to have been run first for the given game.
Requires OPENAI_API_KEY to be set in the environment.
"""
import json
import os
import sys
try:
from dotenv import load_dotenv
load_dotenv()
except ImportError:
pass
DEFAULT_K = 3
RERANK_CANDIDATES = 10
RERANK_TOP_N = 3
DEFAULT_RETRIEVER = "dense"
RETRIEVERS = ["dense", "tfidf", "random"]
OPENAI_MODEL = "gpt-4o-mini"
CROSS_ENCODER_MODEL = "cross-encoder/ms-marco-MiniLM-L-6-v2"
# ---------------------------------------------------------------------------
# Per-game singleton registry
# ---------------------------------------------------------------------------
_registry: dict[str, dict] = {} # game -> loaded artifacts
def _make_system_prompt(game: str) -> str:
game_name = game.title()
return (
f"You are an expert on the board game {game_name}. "
"Answer the user's question using ONLY the rulebook sections provided as context.\n\n"
"Rules:\n"
"1. Base your answer SOLELY on the provided context. Do not use outside knowledge.\n"
"2. If the answer can be inferred or directly found in the context, state it clearly "
"and cite the source section, e.g. \"According to [Almanac: ROBBER]...\".\n"
"3. Only say \"The rules don't specify this.\" if NO relevant information appears in ANY "
"of the retrieved sections. Do not abstain if the answer is present or can be reasonably "
"inferred from the context.\n"
"4. Do not invent or extrapolate rules beyond what the text says.\n"
"5. Be concise and precise."
)
def _load(game: str):
"""Lazy-load and cache all retrieval artifacts for the given game."""
if game in _registry:
return
try:
import faiss
from sentence_transformers import SentenceTransformer
from openai import OpenAI
except ImportError as e:
print(f"Missing dependency: {e}\nRun: .venv/bin/pip install -r requirements.txt", file=sys.stderr)
sys.exit(1)
chunks_path = f"models/{game}/chunks.json"
index_path = f"models/{game}/faiss.index"
if not os.path.exists(chunks_path) or not os.path.exists(index_path):
print(
f"Index files not found for '{game}'. "
f"Run: .venv/bin/python scripts/build_features.py {game}",
file=sys.stderr,
)
sys.exit(1)
if not os.environ.get("OPENAI_API_KEY"):
print("OPENAI_API_KEY is not set.", file=sys.stderr)
sys.exit(1)
print(f"Loading embedding model (all-MiniLM-L6-v2)...")
dense_model = SentenceTransformer("all-MiniLM-L6-v2")
print(f"Loading FAISS index for '{game}'...")
index = faiss.read_index(index_path)
with open(chunks_path) as f:
chunks = json.load(f)
entry: dict = {
"dense_model": dense_model,
"index": index,
"chunks": chunks,
"client": OpenAI(),
"system_prompt": _make_system_prompt(game),
}
# TF-IDF artifacts (classical ML) β optional, built by build_features.py
tfidf_matrix_path = f"models/{game}/tfidf_matrix.npz"
tfidf_vectorizer_path = f"models/{game}/tfidf_vectorizer.pkl"
if os.path.exists(tfidf_matrix_path) and os.path.exists(tfidf_vectorizer_path):
import pickle
import scipy.sparse
entry["tfidf_matrix"] = scipy.sparse.load_npz(tfidf_matrix_path)
with open(tfidf_vectorizer_path, "rb") as f:
entry["tfidf_vectorizer"] = pickle.load(f)
print(f"Loaded TF-IDF index for '{game}'.")
# Cross-encoder for reranking (loaded once, shared across games)
if "_cross_encoder" not in _registry:
from sentence_transformers import CrossEncoder
print(f"Loading cross-encoder model ({CROSS_ENCODER_MODEL})...")
_registry["_cross_encoder"] = CrossEncoder(CROSS_ENCODER_MODEL)
_registry[game] = entry
print(f"Ready β {len(chunks)} chunks loaded for '{game}'.\n")
# ---------------------------------------------------------------------------
# Retrieval β three strategies
# ---------------------------------------------------------------------------
def _dense_retrieve(query: str, game: str, k: int) -> list[dict]:
"""Deep learning: sentence-transformer embeddings + FAISS cosine search."""
import faiss
reg = _registry[game]
query_emb = reg["dense_model"].encode([query], convert_to_numpy=True)
faiss.normalize_L2(query_emb)
scores, indices = reg["index"].search(query_emb, k)
results = []
for score, idx in zip(scores[0], indices[0]):
if idx >= 0:
chunk = reg["chunks"][idx].copy()
chunk["retrieval_score"] = float(score)
chunk["chunk_idx"] = int(idx)
results.append(chunk)
return results
def _tfidf_retrieve(query: str, game: str, k: int) -> list[dict]:
"""Classical ML: TF-IDF bag-of-words + cosine similarity."""
from sklearn.metrics.pairwise import cosine_similarity
reg = _registry[game]
if "tfidf_vectorizer" not in reg:
print(
f"TF-IDF index not found for '{game}'. "
f"Re-run: .venv/bin/python scripts/build_features.py {game}",
file=sys.stderr,
)
return []
query_vec = reg["tfidf_vectorizer"].transform([query])
scores = cosine_similarity(query_vec, reg["tfidf_matrix"])[0]
top_idx = scores.argsort()[::-1][:k]
results = []
for idx in top_idx:
chunk = reg["chunks"][idx].copy()
chunk["retrieval_score"] = float(scores[idx])
chunk["chunk_idx"] = int(idx)
results.append(chunk)
return results
def _random_retrieve(query: str, game: str, k: int) -> list[dict]:
"""Naive baseline: randomly select k chunks (no query signal)."""
import random
reg = _registry[game]
chunks = reg["chunks"]
indices = random.sample(range(len(chunks)), min(k, len(chunks)))
results = []
for idx in indices:
chunk = chunks[idx].copy()
chunk["retrieval_score"] = 0.0
chunk["chunk_idx"] = int(idx)
results.append(chunk)
return results
def _rerank(query: str, candidates: list[dict], top_n: int = RERANK_TOP_N) -> list[dict]:
"""Re-score candidate chunks with a cross-encoder and return the top-n."""
cross_encoder = _registry["_cross_encoder"]
pairs = [[query, c["text"]] for c in candidates]
scores = cross_encoder.predict(pairs)
for chunk, score in zip(candidates, scores):
chunk["rerank_score"] = float(score)
ranked = sorted(candidates, key=lambda c: c["rerank_score"], reverse=True)
return ranked[:top_n]
def retrieve(
query: str,
game: str,
k: int = DEFAULT_K,
retriever: str = DEFAULT_RETRIEVER,
rerank: bool = False,
) -> list[dict]:
"""Return the top-k most relevant chunks using the specified retrieval strategy.
If rerank=True, first retrieve RERANK_CANDIDATES chunks, then re-score them
with a cross-encoder and return the top RERANK_TOP_N.
"""
_load(game)
fetch_k = RERANK_CANDIDATES if (rerank and retriever != "random") else k
if retriever == "tfidf":
results = _tfidf_retrieve(query, game, fetch_k)
elif retriever == "random":
results = _random_retrieve(query, game, k)
else:
results = _dense_retrieve(query, game, fetch_k)
if rerank and retriever != "random":
results = _rerank(query, results, top_n=RERANK_TOP_N)
return results
# ---------------------------------------------------------------------------
# Generation (shared across all retrievers)
# ---------------------------------------------------------------------------
def generate(query: str, context_chunks: list[dict], game: str) -> str:
"""Call OpenAI with retrieved context and return a grounded answer."""
_load(game)
reg = _registry[game]
context_str = "\n\n---\n\n".join(f"[{c['title']}]\n{c['text']}" for c in context_chunks)
user_message = f"Rulebook context:\n\n{context_str}\n\n---\n\nQuestion: {query}"
response = reg["client"].chat.completions.create(
model=OPENAI_MODEL,
messages=[
{"role": "system", "content": reg["system_prompt"]},
{"role": "user", "content": user_message},
],
temperature=0,
max_tokens=600,
)
return response.choices[0].message.content.strip()
# ---------------------------------------------------------------------------
# Full pipeline
# ---------------------------------------------------------------------------
def query_rag(
question: str,
game: str,
k: int = DEFAULT_K,
retriever: str = DEFAULT_RETRIEVER,
rerank: bool = False,
verbose: bool = False,
) -> dict:
"""
Full RAG pipeline: retrieve top-k chunks, then generate a grounded answer.
retriever options:
"dense" β sentence-transformer embeddings + FAISS (deep learning)
"tfidf" β TF-IDF + cosine similarity (classical ML)
"random" β random chunk selection (naive baseline)
If rerank=True, retrieves 10 candidates and re-scores with a cross-encoder
to select the top 3.
"""
retrieved = retrieve(question, game, k, retriever, rerank=rerank)
answer = generate(question, retrieved, game)
result = {
"question": question,
"answer": answer,
"retriever": retriever,
"retrieved": [
{"title": c["title"], "score": c["retrieval_score"], "chunk_idx": c["chunk_idx"]}
for c in retrieved
],
}
if verbose:
print(f"Retriever: {retriever}")
print(f"Question: {question}\n")
print("Retrieved chunks:")
for c in result["retrieved"]:
print(f" [{c['score']:.3f}] {c['title']}")
print(f"\nAnswer:\n{answer}")
print("\n" + "-" * 60)
return result
# ---------------------------------------------------------------------------
# CLI entry point
# ---------------------------------------------------------------------------
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description="Query the board game RAG pipeline.")
parser.add_argument("game", help="Game to query (e.g. catan, monopoly)")
parser.add_argument("question", nargs="?", default="", help="Question to ask")
parser.add_argument("--retriever", choices=RETRIEVERS, default=DEFAULT_RETRIEVER,
help="Retrieval strategy (default: dense)")
parser.add_argument("--rerank", action="store_true",
help="Re-rank candidates with cross-encoder (top 10 β top 3)")
args = parser.parse_args()
_defaults = {
"catan": "What resources do you need to build a settlement?",
"monopoly": "How much money does each player start with?",
}
question = args.question or _defaults.get(args.game, "")
query_rag(question, game=args.game, retriever=args.retriever, rerank=args.rerank, verbose=True)
|