Spaces:
Running
Running
| """ | |
| Flat RAG: standard vector similarity retrieval. | |
| Embed documents → store in FAISS → embed query → find top-k nearest neighbors. | |
| This is what most RAG tutorials teach. It works well for single-hop questions | |
| ("What is the capital of France?") but fails on multi-hop questions that require | |
| bridging across documents ("Who was the president when the person who wrote | |
| Hamlet's most famous soliloquy was born?"). | |
| """ | |
| import os | |
| import json | |
| import numpy as np | |
| import faiss | |
| from typing import List, Dict, Tuple | |
| from openai import OpenAI | |
| from dataclasses import dataclass | |
| class RetrievedChunk: | |
| text: str | |
| score: float | |
| doc_id: str | |
| source: str = "flat_rag" | |
| class FlatRAG: | |
| """ | |
| Standard dense retrieval: | |
| 1. Chunk documents | |
| 2. Embed with text-embedding-3-small | |
| 3. Store in FAISS flat index (exact L2 search) | |
| 4. At query time: embed question, retrieve top-k by cosine similarity | |
| 5. Concatenate retrieved chunks → LLM context → answer | |
| """ | |
| EMBED_DIM = 1536 # text-embedding-3-small output dimension | |
| EMBED_MODEL = "text-embedding-3-small" | |
| GEN_MODEL = "gpt-4o-mini" | |
| def __init__(self, openai_api_key: str, chunk_size: int = 200, chunk_overlap: int = 50): | |
| self.client = OpenAI(api_key=openai_api_key) | |
| self.chunk_size = chunk_size | |
| self.chunk_overlap = chunk_overlap | |
| self.index = faiss.IndexFlatIP(self.EMBED_DIM) # Inner product (cosine after normalize) | |
| self.chunks: List[Dict] = [] | |
| def _embed(self, texts: List[str]) -> np.ndarray: | |
| """Embed a list of texts using OpenAI text-embedding-3-small.""" | |
| if not texts: | |
| return np.array([]) | |
| response = self.client.embeddings.create(model=self.EMBED_MODEL, input=texts) | |
| embeddings = np.array([e.embedding for e in response.data], dtype=np.float32) | |
| # Normalize for cosine similarity via inner product | |
| norms = np.linalg.norm(embeddings, axis=1, keepdims=True) | |
| return embeddings / (norms + 1e-10) | |
| def _chunk_text(self, text: str, doc_id: str) -> List[Dict]: | |
| """Simple word-boundary chunking with overlap.""" | |
| words = text.split() | |
| chunks = [] | |
| i = 0 | |
| chunk_idx = 0 | |
| while i < len(words): | |
| chunk_words = words[i:i + self.chunk_size] | |
| chunks.append({ | |
| "text": " ".join(chunk_words), | |
| "doc_id": doc_id, | |
| "chunk_idx": chunk_idx, | |
| }) | |
| i += self.chunk_size - self.chunk_overlap | |
| chunk_idx += 1 | |
| return chunks | |
| def add_documents(self, documents: List[Dict]) -> None: | |
| """ | |
| Add documents to the index. | |
| Each document: {"id": str, "title": str, "text": str} | |
| """ | |
| new_chunks = [] | |
| for doc in documents: | |
| doc_chunks = self._chunk_text(doc["text"], doc["id"]) | |
| for chunk in doc_chunks: | |
| chunk["title"] = doc.get("title", doc["id"]) | |
| new_chunks.extend(doc_chunks) | |
| if not new_chunks: | |
| return | |
| texts = [c["text"] for c in new_chunks] | |
| embeddings = self._embed(texts) | |
| self.index.add(embeddings) | |
| self.chunks.extend(new_chunks) | |
| print(f"[FlatRAG] Indexed {len(new_chunks)} chunks from {len(documents)} documents") | |
| def retrieve(self, query: str, top_k: int = 5) -> List[RetrievedChunk]: | |
| """Embed query and retrieve top-k chunks by cosine similarity.""" | |
| if self.index.ntotal == 0: | |
| return [] | |
| query_emb = self._embed([query]) | |
| scores, indices = self.index.search(query_emb, min(top_k, self.index.ntotal)) | |
| results = [] | |
| for score, idx in zip(scores[0], indices[0]): | |
| if idx >= 0: | |
| chunk = self.chunks[idx] | |
| results.append(RetrievedChunk( | |
| text=chunk["text"], | |
| score=float(score), | |
| doc_id=chunk["doc_id"], | |
| source="flat_rag", | |
| )) | |
| return results | |
| def answer( | |
| self, question: str, top_k: int = 5 | |
| ) -> Tuple[str, List[RetrievedChunk], str]: | |
| """Retrieve context and generate an answer.""" | |
| retrieved = self.retrieve(question, top_k=top_k) | |
| context = "\n\n---\n\n".join( | |
| [f"[Doc: {r.doc_id}] {r.text}" for r in retrieved] | |
| ) | |
| prompt = f"""Answer the question using ONLY the provided context. If the answer cannot be determined from the context, say "I cannot determine this from the provided context." | |
| Context: | |
| {context} | |
| Question: {question} | |
| Answer:""" | |
| response = self.client.chat.completions.create( | |
| model=self.GEN_MODEL, | |
| messages=[ | |
| {"role": "system", "content": "You are a precise question-answering system. Answer only from the provided context. Be concise."}, | |
| {"role": "user", "content": prompt}, | |
| ], | |
| temperature=0, | |
| max_tokens=200, | |
| ) | |
| answer = response.choices[0].message.content.strip() | |
| return answer, retrieved, context | |
| def clear(self): | |
| self.index = faiss.IndexFlatIP(self.EMBED_DIM) | |
| self.chunks = [] | |