Enabley RAG
Deploy Enabley Smart Search โ€” semantic RAG for Hebrew training content
8205dab
Raw History Blame Contribute Delete
6.17 kB
"""
rag.py โ€” Core RAG engine: chunk โ†’ embed โ†’ index โ†’ search.
Works with Hebrew, Russian, and English out of the box.
"""
import json
import os
import pickle
import numpy as np
import faiss
from sentence_transformers import SentenceTransformer
# โ”€โ”€ Config โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
MODEL_NAME = "intfloat/multilingual-e5-base" # great Hebrew support
INDEX_DIR = "index"
CHUNK_SIZE = 400 # characters per chunk
CHUNK_OVERLAP = 80 # overlap between chunks
class TextChunker:
"""Split long texts into overlapping chunks with metadata."""
def __init__(self, chunk_size: int = CHUNK_SIZE, overlap: int = CHUNK_OVERLAP):
self.chunk_size = chunk_size
self.overlap = overlap
def chunk_text(self, text: str, metadata: dict = None) -> list[dict]:
"""Split text into overlapping chunks, preserving sentence boundaries."""
if metadata is None:
metadata = {}
# Split by paragraphs first, then by sentences
paragraphs = [p.strip() for p in text.split("\n") if p.strip()]
chunks = []
current_chunk = ""
for para in paragraphs:
if len(current_chunk) + len(para) + 1 <= self.chunk_size:
current_chunk += ("\n" + para if current_chunk else para)
else:
if current_chunk:
chunks.append({
"text": current_chunk,
**metadata,
})
# Start new chunk with overlap from previous
if self.overlap > 0 and current_chunk:
overlap_text = current_chunk[-self.overlap:]
current_chunk = overlap_text + "\n" + para
else:
current_chunk = para
if current_chunk:
chunks.append({"text": current_chunk, **metadata})
return chunks
def chunk_documents(self, documents: list[dict]) -> list[dict]:
"""Chunk a list of extracted documents."""
all_chunks = []
for doc in documents:
text = doc["text"]
metadata = {k: v for k, v in doc.items() if k != "text"}
chunks = self.chunk_text(text, metadata)
all_chunks.extend(chunks)
return all_chunks
class RAGIndex:
"""FAISS-based vector index with semantic search."""
def __init__(self, model_name: str = MODEL_NAME):
print(f"๐Ÿ”„ Loading embedding model: {model_name}")
self.model = SentenceTransformer(model_name)
self.dimension = self.model.get_sentence_embedding_dimension()
self.index = None
self.chunks = []
def _embed(self, texts: list[str]) -> np.ndarray:
"""Embed texts with E5 prefix for better retrieval."""
# multilingual-e5 uses "query: " and "passage: " prefixes
return self.model.encode(texts, normalize_embeddings=True, show_progress_bar=True)
def build(self, chunks: list[dict]):
"""Build FAISS index from text chunks."""
self.chunks = chunks
texts = [f"passage: {c['text']}" for c in chunks]
print(f"๐Ÿงฎ Embedding {len(texts)} chunks...")
embeddings = self._embed(texts)
# Use IndexFlatIP for cosine similarity (embeddings are normalized)
self.index = faiss.IndexFlatIP(self.dimension)
self.index.add(embeddings.astype("float32"))
print(f"โœ… Index built: {self.index.ntotal} vectors, dim={self.dimension}")
def search(self, query: str, top_k: int = 5) -> list[dict]:
"""Semantic search: returns top_k most relevant chunks."""
query_vec = self._embed([f"query: {query}"])
scores, indices = self.index.search(query_vec.astype("float32"), top_k)
results = []
for score, idx in zip(scores[0], indices[0]):
if idx == -1:
continue
chunk = self.chunks[idx].copy()
chunk["score"] = float(score)
results.append(chunk)
return results
def save(self, index_dir: str = INDEX_DIR):
"""Save index and chunks to disk."""
os.makedirs(index_dir, exist_ok=True)
faiss.write_index(self.index, os.path.join(index_dir, "faiss.index"))
with open(os.path.join(index_dir, "chunks.pkl"), "wb") as f:
pickle.dump(self.chunks, f)
print(f"๐Ÿ’พ Index saved to {index_dir}/")
def load(self, index_dir: str = INDEX_DIR):
"""Load index and chunks from disk."""
self.index = faiss.read_index(os.path.join(index_dir, "faiss.index"))
with open(os.path.join(index_dir, "chunks.pkl"), "rb") as f:
self.chunks = pickle.load(f)
print(f"๐Ÿ“‚ Index loaded: {self.index.ntotal} vectors")
# โ”€โ”€ CLI โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
if __name__ == "__main__":
import sys
if len(sys.argv) > 1 and sys.argv[1] == "build":
# Build index from extracted texts
with open("data/extracted_texts.json", "r", encoding="utf-8") as f:
documents = json.load(f)
chunker = TextChunker()
chunks = chunker.chunk_documents(documents)
print(f"๐Ÿ“ {len(chunks)} chunks created")
rag = RAGIndex()
rag.build(chunks)
rag.save()
elif len(sys.argv) > 1 and sys.argv[1] == "search":
query = " ".join(sys.argv[2:])
rag = RAGIndex()
rag.load()
results = rag.search(query)
print(f"\n๐Ÿ” Query: {query}\n{'โ”€' * 60}")
for i, r in enumerate(results, 1):
src = r.get("source", "?")
score = r["score"]
text = r["text"][:200]
print(f"\n [{i}] (score: {score:.3f}) [{src}]")
print(f" {text}...")
else:
print("Usage:")
print(" python rag.py build # Build index from data/extracted_texts.json")
print(" python rag.py search <query> # Search the index")