File size: 13,528 Bytes
7699638 1fb4407 7699638 0f25e7d 7699638 0f25e7d 7699638 1fb4407 7699638 dc3142c 7699638 dc3142c 7699638 dc3142c 9b44a6c dc3142c 9b44a6c 4adb461 9b44a6c 4adb461 9b44a6c 4adb461 9b44a6c 4adb461 9b44a6c 7699638 8095235 7699638 8095235 7699638 8095235 7699638 799cd4d 7699638 327c805 7699638 799cd4d 7699638 1fb4407 ab274be 7699638 1fb4407 7699638 1fb4407 7699638 1fb4407 7699638 | 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 327 328 329 330 | """
Basic RAG β Baseline Implementation
Vector similarity search β top-K chunks β LLM prompt
This is the BASELINE we're beating. No graph. No structure.
Just cosine similarity over document chunks.
"""
import os
import time
import logging
from pathlib import Path
from dataclasses import dataclass
import numpy as np
import faiss
from dotenv import load_dotenv
from ..llm.gemini_client import gemini_generate
# Load .env from project root
load_dotenv(Path(__file__).parent.parent.parent / ".env", override=True)
logger = logging.getLogger(__name__)
TOP_K = int((os.getenv("TOP_K_BASIC_RAG", "5") or "5").strip())
EMBEDDING_MODEL = (os.getenv("EMBEDDING_MODEL", "text-embedding-3-small") or "").strip()
EMBEDDING_DIM = 384 # sentence-transformers all-MiniLM-L6-v2
_sentence_transformer = None
USE_SENTENCE_TRANSFORMERS = (os.getenv("USE_SENTENCE_TRANSFORMERS", "true") or "true").lower().strip() == "true"
# Gemini calls go through shared gemini_client.gemini_generate()
def _get_sentence_transformer():
"""Lazily load sentence-transformer for local embeddings."""
global _sentence_transformer
if _sentence_transformer is None:
try:
from sentence_transformers import SentenceTransformer
_sentence_transformer = SentenceTransformer("all-MiniLM-L6-v2")
logger.info("β
Loaded sentence-transformers for local embeddings (384 dims)")
except ImportError:
logger.warning("sentence-transformers not installed. Install with: pip install sentence-transformers")
_sentence_transformer = False
return _sentence_transformer if _sentence_transformer else None
@dataclass
class RAGResult:
answer: str
retrieved_chunks: list[str]
prompt_tokens: int
completion_tokens: int
total_tokens: int
latency_ms: float
method: str = "basic_rag"
class BasicRAG:
"""
Standard RAG pipeline:
1. Embed query
2. Cosine similarity search over FAISS index
3. Concatenate top-K chunks
4. Send to LLM
This is the BASELINE. Expensive. Indiscriminate.
No structural understanding of relationships.
"""
def __init__(self):
self.index = faiss.IndexFlatIP(EMBEDDING_DIM) # Inner product = cosine similarity
self.chunks: list[str] = []
self.chunk_metadata: list[dict] = []
# Try to load pre-built FAISS index from disk
# Use absolute path: work up from this file β backend β graphrag-hackathon β data
faiss_path = Path(__file__).resolve().parent.parent.parent / "data" / "faiss_index.pkl"
logger.info(f"Attempting to load FAISS index from: {faiss_path}")
logger.info(f"Path exists: {faiss_path.exists()}")
if faiss_path.exists():
try:
import pickle
with open(faiss_path, "rb") as f:
data = pickle.load(f)
self.index = data["index"]
self.chunks = data["chunks"]
self.chunk_metadata = data.get("metadata", [])
logger.info(f"β
Loaded FAISS index from {faiss_path} ({len(self.chunks)} chunks, {self.index.ntotal} vectors)")
except Exception as e:
logger.error(f"β Failed to load FAISS index: {e}", exc_info=True)
# Fall through to build from sample docs
self._build_index_from_sample_docs()
else:
logger.warning(f"β οΈ FAISS index not found at {faiss_path}. Building from sample documents...")
self._build_index_from_sample_docs()
def _build_index_from_sample_docs(self):
"""Dynamically build FAISS index from sample documents with semantic chunking"""
sample_docs_dir = Path(__file__).resolve().parent.parent.parent / "data" / "sample_docs"
if not sample_docs_dir.exists():
logger.warning(f"Sample docs directory not found at {sample_docs_dir}")
return
# Load all .md and .txt files
doc_files = list(sample_docs_dir.glob("*.md")) + list(sample_docs_dir.glob("*.txt"))
if not doc_files:
logger.warning(f"No documents found in {sample_docs_dir}")
return
logger.info(f"Loading {len(doc_files)} documents from {sample_docs_dir}...")
for doc_file in doc_files:
try:
with open(doc_file, "r", encoding="utf-8", errors="ignore") as f:
content = f.read()
# Semantic chunking: split by sections and paragraphs
sections = content.split("##")
for section_idx, section in enumerate(sections):
# Split each section into paragraphs
paragraphs = section.split("\n\n")
# Group smaller paragraphs together, keep larger ones separate
current_chunk = ""
for para in paragraphs:
para = para.strip()
if not para:
continue
if len(current_chunk) + len(para) < 1000: # Combine paragraphs up to 1000 chars
if current_chunk:
current_chunk += "\n\n"
current_chunk += para
else:
if current_chunk:
self.chunks.append(current_chunk)
self.chunk_metadata.append({
"source": doc_file.name,
"section": section_idx,
})
current_chunk = para
if current_chunk:
self.chunks.append(current_chunk)
self.chunk_metadata.append({
"source": doc_file.name,
"section": section_idx,
})
logger.info(f" β
Loaded {doc_file.name} ({len(content)} chars β {len([c for c in self.chunks if c.split('###')[0].startswith(doc_file.name.split('.')[0][:20])])} semantic chunks)")
except Exception as e:
logger.warning(f"Failed to load {doc_file.name}: {e}")
# Embed all chunks
if self.chunks:
try:
logger.info(f"Embedding {len(self.chunks)} chunks...")
embeddings = self._embed_batch(self.chunks)
vectors = np.array(embeddings, dtype="float32")
faiss.normalize_L2(vectors)
self.index.add(vectors)
logger.info(f"β
Built FAISS index with {self.index.ntotal} vectors from {len(self.chunks)} chunks")
except Exception as e:
logger.error(f"Failed to embed chunks: {e}")
self.chunks = [] # Clear on failure
else:
logger.warning("No chunks extracted from documents")
def add_documents(self, chunks: list[str], metadata: list[dict] = None):
"""Add document chunks to the FAISS index."""
if not chunks:
return
logger.info(f"Embedding {len(chunks)} chunks for FAISS index...")
embeddings = self._embed_batch(chunks)
vectors = np.array(embeddings, dtype="float32")
# Normalize for cosine similarity
faiss.normalize_L2(vectors)
self.index.add(vectors)
self.chunks.extend(chunks)
self.chunk_metadata.extend(metadata or [{} for _ in chunks])
logger.info(f"FAISS index now has {self.index.ntotal} vectors")
def query(self, question: str, top_k: int = TOP_K) -> RAGResult:
"""
Full RAG pipeline for a single question.
Returns answer + full token accounting.
"""
t0 = time.time()
# 1. Embed the query
try:
query_embedding = self._embed(question)
if not query_embedding or len(query_embedding) == 0:
raise ValueError("Empty embedding returned")
query_vec = np.array([query_embedding], dtype="float32")
faiss.normalize_L2(query_vec)
except Exception as e:
logger.error(f"Embedding failed: {e}. Using fallback retrieval.")
query_vec = None
# 2. Retrieve top-K similar chunks
retrieved = []
if query_vec is not None and self.index.ntotal > 0:
try:
distances, indices = self.index.search(query_vec, min(top_k, self.index.ntotal))
retrieved = [self.chunks[i] for i in indices[0] if i < len(self.chunks)]
logger.info(f"β
Retrieved {len(retrieved)} chunks from FAISS (ntotal: {self.index.ntotal})")
except Exception as e:
logger.error(f"FAISS search failed: {e}. Falling back to first chunks.")
retrieved = self.chunks[:top_k]
elif self.chunks:
# Fallback: return first K chunks if embedding/search fails
logger.warning(f"β οΈ Embedding or index unavailable. Using first {top_k} chunks as fallback.")
retrieved = self.chunks[:top_k]
else:
retrieved = ["[No documents available. Please ingest documents first.]"]
# 3. Build context (this is where tokens pile up)
context = "\n\n---\n\n".join(retrieved)
# 4. Build prompt
system_prompt = (
"You are an expert assistant. Use the provided context when available. "
"If context is insufficient, leverage your knowledge to provide a helpful answer. "
"Always prioritize context when it's available."
)
user_prompt = f"""Context from documents:
{context}
Question: {question}
Provide a comprehensive answer using the context if available, or general knowledge:"""
# 5. Call Gemini via shared client (accurate token counts)
result = gemini_generate(
system_prompt=system_prompt,
user_prompt=user_prompt,
temperature=0.1,
max_tokens=1000,
)
latency_ms = (time.time() - t0) * 1000
return RAGResult(
answer=result["answer"],
retrieved_chunks=retrieved,
prompt_tokens=result["prompt_tokens"],
completion_tokens=result["completion_tokens"],
total_tokens=result["total_tokens"],
latency_ms=latency_ms,
method="basic_rag",
)
def _embed(self, text: str) -> list[float]:
"""Embed a single text string using OpenAI, then sentence-transformers, then zeros."""
# Try OpenAI first
try:
from openai import OpenAI
openai_client = OpenAI()
response = openai_client.embeddings.create(
model=EMBEDDING_MODEL,
input=text[:8191],
)
return response.data[0].embedding
except Exception as e:
logger.debug(f"OpenAI embedding failed: {e}")
# Try sentence-transformers
if USE_SENTENCE_TRANSFORMERS:
try:
model = _get_sentence_transformer()
if model:
embedding = model.encode(text[:8191])
return embedding.tolist()
except Exception as e:
logger.debug(f"Sentence-transformers embedding failed: {e}")
# Fallback to zeros
logger.warning("Falling back to zero embeddings")
return [0.0] * 384
def _embed_batch(self, texts: list[str]) -> list[list[float]]:
"""Embed multiple texts efficiently using OpenAI, then sentence-transformers, then zeros."""
# Try OpenAI first (batch of 100)
try:
from openai import OpenAI
openai_client = OpenAI()
all_embeddings = []
batch_size = 100
for i in range(0, len(texts), batch_size):
batch = texts[i:i + batch_size]
response = openai_client.embeddings.create(
model=EMBEDDING_MODEL,
input=[t[:8191] for t in batch],
)
all_embeddings.extend([item.embedding for item in response.data])
return all_embeddings
except Exception as e:
logger.debug(f"OpenAI batch embedding failed: {e}")
# Try sentence-transformers (local, no API call)
if USE_SENTENCE_TRANSFORMERS:
try:
model = _get_sentence_transformer()
if model:
embeddings = model.encode([t[:8191] for t in texts])
return embeddings.tolist()
except Exception as e:
logger.debug(f"Sentence-transformers batch embedding failed: {e}")
# Fallback to zeros
logger.warning(f"Falling back to zero embeddings for {len(texts)} texts")
return [[0.0] * 384 for _ in texts]
def count_tokens(self, text: str) -> int:
from ..llm.gemini_client import count_tokens_gemini
return count_tokens_gemini(text)
def get_index_stats(self) -> dict:
return {
"total_chunks": len(self.chunks),
"index_size": self.index.ntotal,
"embedding_dim": EMBEDDING_DIM,
}
|