Spaces:
Running
Running
Upload folder using huggingface_hub
Browse files- src/rag_engine/__init__.py +41 -0
- src/rag_engine/__pycache__/__init__.cpython-314.pyc +0 -0
- src/rag_engine/__pycache__/bm25_search.cpython-314.pyc +0 -0
- src/rag_engine/__pycache__/chunking.cpython-314.pyc +0 -0
- src/rag_engine/__pycache__/documents.cpython-314.pyc +0 -0
- src/rag_engine/__pycache__/embeddings.cpython-314.pyc +0 -0
- src/rag_engine/__pycache__/eval.cpython-314.pyc +0 -0
- src/rag_engine/__pycache__/generator.cpython-314.pyc +0 -0
- src/rag_engine/__pycache__/hybrid_search.cpython-314.pyc +0 -0
- src/rag_engine/__pycache__/pipeline.cpython-314.pyc +0 -0
- src/rag_engine/__pycache__/vector_store.cpython-314.pyc +0 -0
- src/rag_engine/bm25_search.py +133 -0
- src/rag_engine/chunking.py +117 -0
- src/rag_engine/documents.py +86 -0
- src/rag_engine/embeddings.py +81 -0
- src/rag_engine/eval.py +65 -0
- src/rag_engine/generator.py +114 -0
- src/rag_engine/hybrid_search.py +100 -0
- src/rag_engine/pipeline.py +154 -0
- src/rag_engine/vector_store.py +57 -0
src/rag_engine/__init__.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""RAG Engine: A clear, educational Retrieval-Augmented Generation system.
|
| 2 |
+
|
| 3 |
+
Designed to illustrate how a production-grade RAG pipeline works:
|
| 4 |
+
- Ingestion & Text Extraction (PDF / Text)
|
| 5 |
+
- Semantic Text Chunking
|
| 6 |
+
- Dense Vector Embeddings (with offline Hash fallback)
|
| 7 |
+
- Sparse BM25 Keyword Search
|
| 8 |
+
- Hybrid Retrieval with Reciprocal Rank Fusion (RRF)
|
| 9 |
+
- Augmented Generation with Source Citations
|
| 10 |
+
- Faithfulness and Relevancy Evaluation
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from rag_engine.documents import Document, load_text_file, load_pdf_file
|
| 14 |
+
from rag_engine.chunking import TextChunk, chunk_document, chunk_documents
|
| 15 |
+
from rag_engine.embeddings import HashEmbeddings, get_embedding_model
|
| 16 |
+
from rag_engine.vector_store import VectorStore
|
| 17 |
+
from rag_engine.bm25_search import BM25SearchIndex
|
| 18 |
+
from rag_engine.hybrid_search import HybridRetriever, reciprocal_rank_fusion
|
| 19 |
+
from rag_engine.generator import generate_answer
|
| 20 |
+
from rag_engine.eval import evaluate_faithfulness, evaluate_relevancy, evaluate_response
|
| 21 |
+
from rag_engine.pipeline import RAGPipeline
|
| 22 |
+
|
| 23 |
+
__all__ = [
|
| 24 |
+
"Document",
|
| 25 |
+
"load_text_file",
|
| 26 |
+
"load_pdf_file",
|
| 27 |
+
"TextChunk",
|
| 28 |
+
"chunk_document",
|
| 29 |
+
"chunk_documents",
|
| 30 |
+
"HashEmbeddings",
|
| 31 |
+
"get_embedding_model",
|
| 32 |
+
"VectorStore",
|
| 33 |
+
"BM25SearchIndex",
|
| 34 |
+
"HybridRetriever",
|
| 35 |
+
"reciprocal_rank_fusion",
|
| 36 |
+
"generate_answer",
|
| 37 |
+
"evaluate_faithfulness",
|
| 38 |
+
"evaluate_relevancy",
|
| 39 |
+
"evaluate_response",
|
| 40 |
+
"RAGPipeline",
|
| 41 |
+
]
|
src/rag_engine/__pycache__/__init__.cpython-314.pyc
ADDED
|
Binary file (1.51 kB). View file
|
|
|
src/rag_engine/__pycache__/bm25_search.cpython-314.pyc
ADDED
|
Binary file (6.63 kB). View file
|
|
|
src/rag_engine/__pycache__/chunking.cpython-314.pyc
ADDED
|
Binary file (4.78 kB). View file
|
|
|
src/rag_engine/__pycache__/documents.cpython-314.pyc
ADDED
|
Binary file (5.78 kB). View file
|
|
|
src/rag_engine/__pycache__/embeddings.cpython-314.pyc
ADDED
|
Binary file (5.47 kB). View file
|
|
|
src/rag_engine/__pycache__/eval.cpython-314.pyc
ADDED
|
Binary file (3 kB). View file
|
|
|
src/rag_engine/__pycache__/generator.cpython-314.pyc
ADDED
|
Binary file (4.59 kB). View file
|
|
|
src/rag_engine/__pycache__/hybrid_search.cpython-314.pyc
ADDED
|
Binary file (4.32 kB). View file
|
|
|
src/rag_engine/__pycache__/pipeline.cpython-314.pyc
ADDED
|
Binary file (7.81 kB). View file
|
|
|
src/rag_engine/__pycache__/vector_store.cpython-314.pyc
ADDED
|
Binary file (3.26 kB). View file
|
|
|
src/rag_engine/bm25_search.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Sparse keyword search using BM25 (Best Matching 25).
|
| 2 |
+
|
| 3 |
+
Implemented cleanly in pure Python with optional rank_bm25 fallback:
|
| 4 |
+
Zero external dependencies required!
|
| 5 |
+
|
| 6 |
+
Why BM25?
|
| 7 |
+
While vector embeddings capture semantic meaning (synonyms, conceptual similarity),
|
| 8 |
+
BM25 captures exact lexical matches (rare keywords, acronyms, specific numbers).
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
import math
|
| 12 |
+
import re
|
| 13 |
+
from collections import Counter
|
| 14 |
+
|
| 15 |
+
TOKEN_RE = re.compile(r"[A-Za-z0-9_\-]+")
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def tokenize(text):
|
| 19 |
+
"""Clean and tokenize text into lowercase words/terms."""
|
| 20 |
+
if not text:
|
| 21 |
+
return []
|
| 22 |
+
return [t.lower() for t in TOKEN_RE.findall(text)]
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class SimpleBM25:
|
| 26 |
+
"""Pure Python implementation of Okapi BM25.
|
| 27 |
+
|
| 28 |
+
Formula:
|
| 29 |
+
IDF(q) = ln((N - n(q) + 0.5) / (n(q) + 0.5) + 1)
|
| 30 |
+
Score(D, Q) = sum( IDF(q) * (f(q, D) * (k1 + 1)) / (f(q, D) + k1 * (1 - b + b * (|D| / avgdl))) )
|
| 31 |
+
"""
|
| 32 |
+
|
| 33 |
+
def __init__(self, corpus, k1=1.5, b=0.75):
|
| 34 |
+
self.k1 = k1
|
| 35 |
+
self.b = b
|
| 36 |
+
self.corpus_size = len(corpus)
|
| 37 |
+
self.doc_lengths = [len(doc) for doc in corpus]
|
| 38 |
+
self.avg_doc_len = sum(self.doc_lengths) / max(1, self.corpus_size)
|
| 39 |
+
|
| 40 |
+
# Document frequencies for each term
|
| 41 |
+
self.doc_freqs = Counter()
|
| 42 |
+
self.doc_term_counts = []
|
| 43 |
+
|
| 44 |
+
for doc in corpus:
|
| 45 |
+
counts = Counter(doc)
|
| 46 |
+
self.doc_term_counts.append(counts)
|
| 47 |
+
for term in counts.keys():
|
| 48 |
+
self.doc_freqs[term] += 1
|
| 49 |
+
|
| 50 |
+
def get_scores(self, query_tokens):
|
| 51 |
+
"""Calculate BM25 relevance score for each document in the corpus."""
|
| 52 |
+
scores = [0.0] * self.corpus_size
|
| 53 |
+
for token in query_tokens:
|
| 54 |
+
if token not in self.doc_freqs:
|
| 55 |
+
continue
|
| 56 |
+
|
| 57 |
+
# Inverse Document Frequency (IDF)
|
| 58 |
+
df = self.doc_freqs[token]
|
| 59 |
+
idf = math.log((self.corpus_size - df + 0.5) / (df + 0.5) + 1.0)
|
| 60 |
+
|
| 61 |
+
# Term frequency & length normalization for each doc
|
| 62 |
+
for doc_idx, term_counts in enumerate(self.doc_term_counts):
|
| 63 |
+
tf = term_counts.get(token, 0)
|
| 64 |
+
if tf == 0:
|
| 65 |
+
continue
|
| 66 |
+
doc_len = self.doc_lengths[doc_idx]
|
| 67 |
+
numerator = tf * (self.k1 + 1.0)
|
| 68 |
+
denominator = tf + self.k1 * (1.0 - self.b + self.b * (doc_len / max(1.0, self.avg_doc_len)))
|
| 69 |
+
scores[doc_idx] += idf * (numerator / denominator)
|
| 70 |
+
|
| 71 |
+
return scores
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
class BM25SearchIndex:
|
| 75 |
+
"""Keyword search index over chunks."""
|
| 76 |
+
|
| 77 |
+
def __init__(self):
|
| 78 |
+
self.chunks = []
|
| 79 |
+
self.corpus_tokens = []
|
| 80 |
+
self.bm25 = None
|
| 81 |
+
|
| 82 |
+
def build(self, chunks):
|
| 83 |
+
"""Index a list of TextChunk objects."""
|
| 84 |
+
if not chunks:
|
| 85 |
+
self.chunks = []
|
| 86 |
+
self.corpus_tokens = []
|
| 87 |
+
self.bm25 = None
|
| 88 |
+
return
|
| 89 |
+
|
| 90 |
+
self.chunks = chunks
|
| 91 |
+
self.corpus_tokens = []
|
| 92 |
+
for chunk in chunks:
|
| 93 |
+
# Include text and document title for matching
|
| 94 |
+
combined_text = f"{chunk.title} {chunk.text}"
|
| 95 |
+
self.corpus_tokens.append(tokenize(combined_text))
|
| 96 |
+
|
| 97 |
+
try:
|
| 98 |
+
from rank_bm25 import BM25Okapi
|
| 99 |
+
self.bm25 = BM25Okapi(self.corpus_tokens)
|
| 100 |
+
except ImportError:
|
| 101 |
+
# Use pure Python BM25 fallback
|
| 102 |
+
self.bm25 = SimpleBM25(self.corpus_tokens)
|
| 103 |
+
|
| 104 |
+
def search(self, query, top_k=5):
|
| 105 |
+
"""Find the top_k best matching chunks by BM25 keyword relevance.
|
| 106 |
+
|
| 107 |
+
Returns:
|
| 108 |
+
List of dicts: [{"chunk": TextChunk, "score": float, "source": "bm25"}]
|
| 109 |
+
"""
|
| 110 |
+
if not self.bm25 or not self.chunks:
|
| 111 |
+
return []
|
| 112 |
+
|
| 113 |
+
query_tokens = tokenize(query)
|
| 114 |
+
if not query_tokens:
|
| 115 |
+
return []
|
| 116 |
+
|
| 117 |
+
scores = self.bm25.get_scores(query_tokens)
|
| 118 |
+
scored = list(enumerate(scores))
|
| 119 |
+
scored.sort(key=lambda x: x[1], reverse=True)
|
| 120 |
+
|
| 121 |
+
results = []
|
| 122 |
+
for idx, score in scored[:top_k]:
|
| 123 |
+
if score <= 0:
|
| 124 |
+
continue
|
| 125 |
+
results.append({
|
| 126 |
+
"chunk": self.chunks[idx],
|
| 127 |
+
"score": round(float(score), 4),
|
| 128 |
+
"source": "bm25",
|
| 129 |
+
})
|
| 130 |
+
return results
|
| 131 |
+
|
| 132 |
+
def __len__(self):
|
| 133 |
+
return len(self.chunks)
|
src/rag_engine/chunking.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Text chunking strategies for the RAG engine.
|
| 2 |
+
|
| 3 |
+
Splitting text into smaller chunks is essential in RAG because:
|
| 4 |
+
1. Embedding models have context length limits (typically 256-512 tokens).
|
| 5 |
+
2. Small chunks provide high-precision retrieval matching.
|
| 6 |
+
3. Overlap between chunks ensures ideas aren't cut in half at boundaries.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import re
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class TextChunk:
|
| 13 |
+
"""Represents a chunk of text derived from a source document."""
|
| 14 |
+
|
| 15 |
+
def __init__(self, chunk_id, doc_id, text, title=None, chunk_index=0, parent_text=None):
|
| 16 |
+
self.chunk_id = chunk_id
|
| 17 |
+
self.doc_id = doc_id
|
| 18 |
+
self.text = text.strip()
|
| 19 |
+
self.title = title or ""
|
| 20 |
+
self.chunk_index = chunk_index
|
| 21 |
+
# Parent text is the broader surrounding context (optional parent-child retrieval)
|
| 22 |
+
self.parent_text = parent_text or self.text
|
| 23 |
+
|
| 24 |
+
def to_dict(self):
|
| 25 |
+
return {
|
| 26 |
+
"chunk_id": self.chunk_id,
|
| 27 |
+
"doc_id": self.doc_id,
|
| 28 |
+
"text": self.text,
|
| 29 |
+
"title": self.title,
|
| 30 |
+
"chunk_index": self.chunk_index,
|
| 31 |
+
"parent_text": self.parent_text,
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
@classmethod
|
| 35 |
+
def from_dict(cls, data):
|
| 36 |
+
return cls(
|
| 37 |
+
chunk_id=data.get("chunk_id"),
|
| 38 |
+
doc_id=data.get("doc_id"),
|
| 39 |
+
text=data.get("text", ""),
|
| 40 |
+
title=data.get("title", ""),
|
| 41 |
+
chunk_index=data.get("chunk_index", 0),
|
| 42 |
+
parent_text=data.get("parent_text"),
|
| 43 |
+
)
|
| 44 |
+
|
| 45 |
+
def __repr__(self):
|
| 46 |
+
return f"<TextChunk id={self.chunk_id} doc={self.doc_id} len={len(self.text)}>"
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def split_text_into_chunks(text, chunk_size=400, chunk_overlap=50):
|
| 50 |
+
"""Split a string of text into overlapping word chunks.
|
| 51 |
+
|
| 52 |
+
Args:
|
| 53 |
+
text: The string content to split.
|
| 54 |
+
chunk_size: Target number of words per chunk.
|
| 55 |
+
chunk_overlap: Number of overlapping words between consecutive chunks.
|
| 56 |
+
|
| 57 |
+
Returns:
|
| 58 |
+
List of string chunks.
|
| 59 |
+
"""
|
| 60 |
+
text = (text or "").strip()
|
| 61 |
+
if not text:
|
| 62 |
+
return []
|
| 63 |
+
|
| 64 |
+
words = text.split()
|
| 65 |
+
if len(words) <= chunk_size:
|
| 66 |
+
return [text]
|
| 67 |
+
|
| 68 |
+
chunks = []
|
| 69 |
+
step = max(1, chunk_size - chunk_overlap)
|
| 70 |
+
for start in range(0, len(words), step):
|
| 71 |
+
end = start + chunk_size
|
| 72 |
+
chunk_words = words[start:end]
|
| 73 |
+
chunks.append(" ".join(chunk_words))
|
| 74 |
+
if end >= len(words):
|
| 75 |
+
break
|
| 76 |
+
|
| 77 |
+
return chunks
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def chunk_document(document, chunk_size=400, chunk_overlap=50):
|
| 81 |
+
"""Chunk a Document into TextChunk objects.
|
| 82 |
+
|
| 83 |
+
Args:
|
| 84 |
+
document: A Document instance.
|
| 85 |
+
chunk_size: Words per chunk.
|
| 86 |
+
chunk_overlap: Overlap between consecutive chunks.
|
| 87 |
+
|
| 88 |
+
Returns:
|
| 89 |
+
List of TextChunk instances.
|
| 90 |
+
"""
|
| 91 |
+
text_chunks = split_text_into_chunks(
|
| 92 |
+
document.text,
|
| 93 |
+
chunk_size=chunk_size,
|
| 94 |
+
chunk_overlap=chunk_overlap,
|
| 95 |
+
)
|
| 96 |
+
|
| 97 |
+
chunks = []
|
| 98 |
+
for idx, piece in enumerate(text_chunks):
|
| 99 |
+
cid = f"{document.doc_id}_chunk_{idx}"
|
| 100 |
+
chunk = TextChunk(
|
| 101 |
+
chunk_id=cid,
|
| 102 |
+
doc_id=document.doc_id,
|
| 103 |
+
text=piece,
|
| 104 |
+
title=document.title,
|
| 105 |
+
chunk_index=idx,
|
| 106 |
+
parent_text=document.text, # Keep full document text as parent context
|
| 107 |
+
)
|
| 108 |
+
chunks.append(chunk)
|
| 109 |
+
return chunks
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def chunk_documents(documents, chunk_size=400, chunk_overlap=50):
|
| 113 |
+
"""Chunk multiple Document objects."""
|
| 114 |
+
all_chunks = []
|
| 115 |
+
for doc in documents:
|
| 116 |
+
all_chunks.extend(chunk_document(doc, chunk_size, chunk_overlap))
|
| 117 |
+
return all_chunks
|
src/rag_engine/documents.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Document representations and file loaders for the RAG engine.
|
| 2 |
+
|
| 3 |
+
Zero external complexity: clean, readable objects representing documents.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import json
|
| 7 |
+
import hashlib
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class Document:
|
| 12 |
+
"""Represents a source document in the RAG system."""
|
| 13 |
+
|
| 14 |
+
def __init__(self, text, doc_id=None, title=None, source="upload", metadata=None):
|
| 15 |
+
self.text = text.strip() if text else ""
|
| 16 |
+
self.doc_id = doc_id or hashlib.md5(self.text[:100].encode("utf-8")).hexdigest()[:12]
|
| 17 |
+
self.title = title or f"Document-{self.doc_id[:6]}"
|
| 18 |
+
self.source = source
|
| 19 |
+
self.metadata = metadata or {}
|
| 20 |
+
|
| 21 |
+
def to_dict(self):
|
| 22 |
+
return {
|
| 23 |
+
"doc_id": self.doc_id,
|
| 24 |
+
"title": self.title,
|
| 25 |
+
"text": self.text,
|
| 26 |
+
"source": self.source,
|
| 27 |
+
"metadata": self.metadata,
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
@classmethod
|
| 31 |
+
def from_dict(cls, data):
|
| 32 |
+
return cls(
|
| 33 |
+
text=data.get("text", ""),
|
| 34 |
+
doc_id=data.get("doc_id"),
|
| 35 |
+
title=data.get("title"),
|
| 36 |
+
source=data.get("source", "upload"),
|
| 37 |
+
metadata=data.get("metadata", {}),
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
def __repr__(self):
|
| 41 |
+
return f"<Document id={self.doc_id} title='{self.title}'>"
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def load_text_file(filepath):
|
| 45 |
+
"""Load a plain text or markdown file into a Document."""
|
| 46 |
+
path = Path(filepath)
|
| 47 |
+
text = path.read_text(encoding="utf-8").strip()
|
| 48 |
+
title = path.stem.replace("_", " ").replace("-", " ").title()
|
| 49 |
+
return Document(text=text, doc_id=path.stem, title=title, source=path.name)
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def load_pdf_file(filepath):
|
| 53 |
+
"""Extract text from a PDF file into a Document."""
|
| 54 |
+
path = Path(filepath)
|
| 55 |
+
try:
|
| 56 |
+
from pypdf import PdfReader
|
| 57 |
+
reader = PdfReader(str(path))
|
| 58 |
+
pages = [page.extract_text() or "" for page in reader.pages]
|
| 59 |
+
text = "\n\n".join(pages).strip()
|
| 60 |
+
except Exception:
|
| 61 |
+
text = f"Failed to extract text from {path.name}"
|
| 62 |
+
title = path.stem.replace("_", " ").replace("-", " ").title()
|
| 63 |
+
return Document(text=text, doc_id=path.stem, title=title, source=path.name)
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def save_documents_jsonl(documents, filepath):
|
| 67 |
+
"""Save a list of Document objects to a JSONL file."""
|
| 68 |
+
path = Path(filepath)
|
| 69 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 70 |
+
with open(path, "w", encoding="utf-8") as f:
|
| 71 |
+
for doc in documents:
|
| 72 |
+
f.write(json.dumps(doc.to_dict()) + "\n")
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def load_documents_jsonl(filepath):
|
| 76 |
+
"""Load Document objects from a JSONL file."""
|
| 77 |
+
path = Path(filepath)
|
| 78 |
+
if not path.exists():
|
| 79 |
+
return []
|
| 80 |
+
docs = []
|
| 81 |
+
with open(path, "r", encoding="utf-8") as f:
|
| 82 |
+
for line in f:
|
| 83 |
+
line = line.strip()
|
| 84 |
+
if line:
|
| 85 |
+
docs.append(Document.from_dict(json.loads(line)))
|
| 86 |
+
return docs
|
src/rag_engine/embeddings.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Embedding functions for converting text to semantic vector representations.
|
| 2 |
+
|
| 3 |
+
Embeddings map human text into high-dimensional vectors such that texts with
|
| 4 |
+
similar meanings have high cosine similarity (close to 1.0).
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import hashlib
|
| 8 |
+
import math
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class HashEmbeddings:
|
| 12 |
+
"""A deterministic, zero-dependency embedding generator.
|
| 13 |
+
|
| 14 |
+
Uses SHA-256 token hashing into 384 buckets.
|
| 15 |
+
Great for instant offline demos, testing, and machines without GPUs or PyTorch.
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
def __init__(self, dim=384):
|
| 19 |
+
self.dim = dim
|
| 20 |
+
|
| 21 |
+
def embed_text(self, text):
|
| 22 |
+
"""Convert a single string into a normalized float vector."""
|
| 23 |
+
vector = [0.0] * self.dim
|
| 24 |
+
if not text:
|
| 25 |
+
return vector
|
| 26 |
+
|
| 27 |
+
tokens = text.lower().split()
|
| 28 |
+
for idx, token in enumerate(tokens):
|
| 29 |
+
digest = hashlib.sha256(token.encode("utf-8")).digest()
|
| 30 |
+
bucket = int.from_bytes(digest[:4], "little") % self.dim
|
| 31 |
+
# Weigh by position and presence
|
| 32 |
+
vector[bucket] += 1.0 + (idx % 5) * 0.05
|
| 33 |
+
|
| 34 |
+
# L2 normalize
|
| 35 |
+
squared_sum = sum(v * v for v in vector)
|
| 36 |
+
norm = math.sqrt(squared_sum) or 1.0
|
| 37 |
+
return [v / norm for v in vector]
|
| 38 |
+
|
| 39 |
+
def embed_documents(self, texts):
|
| 40 |
+
"""Batch embed a list of texts."""
|
| 41 |
+
return [self.embed_text(t) for t in texts]
|
| 42 |
+
|
| 43 |
+
def embed_query(self, query):
|
| 44 |
+
"""Embed a search query."""
|
| 45 |
+
return self.embed_text(query)
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def cosine_similarity(vec_a, vec_b):
|
| 49 |
+
"""Compute cosine similarity between two unit-normalized vectors."""
|
| 50 |
+
if not vec_a or not vec_b or len(vec_a) != len(vec_b):
|
| 51 |
+
return 0.0
|
| 52 |
+
dot_product = sum(a * b for a, b in zip(vec_a, vec_b))
|
| 53 |
+
return float(dot_product)
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def get_embedding_model(settings=None):
|
| 57 |
+
"""Factory to get the configured embedding model."""
|
| 58 |
+
if settings is None or getattr(settings, "use_hash_embeddings", True):
|
| 59 |
+
return HashEmbeddings()
|
| 60 |
+
|
| 61 |
+
# If requested and available, use SentenceTransformers
|
| 62 |
+
try:
|
| 63 |
+
from sentence_transformers import SentenceTransformer
|
| 64 |
+
|
| 65 |
+
class STEmbeddings:
|
| 66 |
+
def __init__(self, model_name):
|
| 67 |
+
self.model = SentenceTransformer(model_name)
|
| 68 |
+
|
| 69 |
+
def embed_text(self, text):
|
| 70 |
+
return self.model.encode(text, normalize_embeddings=True).tolist()
|
| 71 |
+
|
| 72 |
+
def embed_documents(self, texts):
|
| 73 |
+
return self.model.encode(texts, normalize_embeddings=True).tolist()
|
| 74 |
+
|
| 75 |
+
def embed_query(self, query):
|
| 76 |
+
return self.embed_text(query)
|
| 77 |
+
|
| 78 |
+
return STEmbeddings(settings.embedding_model)
|
| 79 |
+
except Exception:
|
| 80 |
+
# Fall back to HashEmbeddings if sentence-transformers is missing
|
| 81 |
+
return HashEmbeddings()
|
src/rag_engine/eval.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Lightweight RAG evaluation metrics: Faithfulness and Answer Relevancy.
|
| 2 |
+
|
| 3 |
+
Evaluation answers the core question: 'Is my RAG system working well?'
|
| 4 |
+
- Faithfulness: Does the answer stick to the facts present in the retrieved context?
|
| 5 |
+
- Answer Relevancy: Does the answer actually address what the user asked?
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import re
|
| 9 |
+
|
| 10 |
+
WORD_RE = re.compile(r"[a-z0-9]{3,}")
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def _extract_keywords(text):
|
| 14 |
+
"""Extract lowercase keywords of 3+ letters."""
|
| 15 |
+
if not text:
|
| 16 |
+
return set()
|
| 17 |
+
return set(WORD_RE.findall(text.lower()))
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def evaluate_faithfulness(answer, retrieved_items):
|
| 21 |
+
"""Compute lexical faithfulness of an answer against retrieved context.
|
| 22 |
+
|
| 23 |
+
Returns a score between 0.0 and 1.0 representing what percentage of
|
| 24 |
+
key terms in the answer are supported by the retrieved context.
|
| 25 |
+
"""
|
| 26 |
+
if not answer or not retrieved_items:
|
| 27 |
+
return 0.0
|
| 28 |
+
|
| 29 |
+
context_text = " ".join(item["chunk"].text for item in retrieved_items)
|
| 30 |
+
answer_words = _extract_keywords(answer)
|
| 31 |
+
context_words = _extract_keywords(context_text)
|
| 32 |
+
|
| 33 |
+
if not answer_words:
|
| 34 |
+
return 0.0
|
| 35 |
+
|
| 36 |
+
overlap = answer_words.intersection(context_words)
|
| 37 |
+
score = len(overlap) / len(answer_words)
|
| 38 |
+
return round(min(1.0, score), 3)
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def evaluate_relevancy(question, answer):
|
| 42 |
+
"""Compute lexical relevancy between question and answer."""
|
| 43 |
+
if not question or not answer:
|
| 44 |
+
return 0.0
|
| 45 |
+
|
| 46 |
+
q_words = _extract_keywords(question)
|
| 47 |
+
a_words = _extract_keywords(answer)
|
| 48 |
+
|
| 49 |
+
if not q_words or not a_words:
|
| 50 |
+
return 0.0
|
| 51 |
+
|
| 52 |
+
overlap = q_words.intersection(a_words)
|
| 53 |
+
score = len(overlap) / len(q_words)
|
| 54 |
+
return round(min(1.0, score), 3)
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def evaluate_response(question, answer, retrieved_items):
|
| 58 |
+
"""Evaluate a complete RAG query result."""
|
| 59 |
+
faithfulness = evaluate_faithfulness(answer, retrieved_items)
|
| 60 |
+
relevancy = evaluate_relevancy(question, answer)
|
| 61 |
+
return {
|
| 62 |
+
"faithfulness": faithfulness,
|
| 63 |
+
"answer_relevancy": relevancy,
|
| 64 |
+
"overall_score": round((faithfulness + relevancy) / 2.0, 3),
|
| 65 |
+
}
|
src/rag_engine/generator.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Answer generator for RAG: Combines retrieved context with the user question.
|
| 2 |
+
|
| 3 |
+
Augments the prompt sent to the LLM with relevant retrieved chunks.
|
| 4 |
+
Provides an extractive fallback mode if no API key is set so the entire pipeline
|
| 5 |
+
runs 100% offline out-of-the-box.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from config.prompts import INSUFFICIENT_EVIDENCE, SYSTEM_PROMPT
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def format_context(retrieved_items):
|
| 12 |
+
"""Format retrieved chunks into a clean, numbered context block for the prompt."""
|
| 13 |
+
if not retrieved_items:
|
| 14 |
+
return "No relevant context found."
|
| 15 |
+
|
| 16 |
+
blocks = []
|
| 17 |
+
for idx, item in enumerate(retrieved_items, start=1):
|
| 18 |
+
chunk = item["chunk"]
|
| 19 |
+
title = chunk.title or f"Document {chunk.doc_id}"
|
| 20 |
+
blocks.append(f"[Source {idx}: {title}]\n{chunk.text}")
|
| 21 |
+
return "\n\n".join(blocks)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def extractive_fallback(question, retrieved_items):
|
| 25 |
+
"""Offline extractive summary when no LLM API key is configured."""
|
| 26 |
+
if not retrieved_items:
|
| 27 |
+
return INSUFFICIENT_EVIDENCE
|
| 28 |
+
|
| 29 |
+
lines = [
|
| 30 |
+
f"Answer synthesized from indexed documents for: '{question}'",
|
| 31 |
+
"",
|
| 32 |
+
"Key relevant excerpts from your knowledge base:",
|
| 33 |
+
]
|
| 34 |
+
for idx, item in enumerate(retrieved_items[:3], start=1):
|
| 35 |
+
chunk = item["chunk"]
|
| 36 |
+
snippet = chunk.text.strip().replace("\n", " ")
|
| 37 |
+
if len(snippet) > 280:
|
| 38 |
+
snippet = snippet[:280] + "..."
|
| 39 |
+
lines.append(f"{idx}. [Source: {chunk.title}] \"{snippet}\"")
|
| 40 |
+
|
| 41 |
+
lines.append("")
|
| 42 |
+
lines.append(
|
| 43 |
+
"*Note: Set USER_LLM_API_KEY in your .env file to enable full generative AI answers with OpenAI/Claude.*"
|
| 44 |
+
)
|
| 45 |
+
return "\n".join(lines)
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def generate_answer(question, retrieved_items, settings):
|
| 49 |
+
"""Generate an answer using OpenAI LLM or graceful offline fallback."""
|
| 50 |
+
if not retrieved_items:
|
| 51 |
+
return {
|
| 52 |
+
"answer": INSUFFICIENT_EVIDENCE,
|
| 53 |
+
"sources": [],
|
| 54 |
+
"used_llm": False,
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
# Extract unique source titles
|
| 58 |
+
sources = []
|
| 59 |
+
seen_titles = set()
|
| 60 |
+
for item in retrieved_items:
|
| 61 |
+
title = item["chunk"].title
|
| 62 |
+
if title and title not in seen_titles:
|
| 63 |
+
seen_titles.add(title)
|
| 64 |
+
sources.append(title)
|
| 65 |
+
|
| 66 |
+
# Check if LLM API key is present
|
| 67 |
+
if not settings.has_llm_key():
|
| 68 |
+
return {
|
| 69 |
+
"answer": extractive_fallback(question, retrieved_items),
|
| 70 |
+
"sources": sources,
|
| 71 |
+
"used_llm": False,
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
# Generate with LLM (OpenAI via LangChain)
|
| 75 |
+
try:
|
| 76 |
+
from langchain_openai import ChatOpenAI
|
| 77 |
+
from langchain_core.prompts import ChatPromptTemplate
|
| 78 |
+
from langchain_core.output_parsers import StrOutputParser
|
| 79 |
+
|
| 80 |
+
context = format_context(retrieved_items)
|
| 81 |
+
prompt = ChatPromptTemplate.from_messages([
|
| 82 |
+
("system", SYSTEM_PROMPT),
|
| 83 |
+
("human", "{question}"),
|
| 84 |
+
])
|
| 85 |
+
|
| 86 |
+
llm_kwargs = {
|
| 87 |
+
"api_key": settings.user_llm_api_key,
|
| 88 |
+
"model": settings.user_llm_model,
|
| 89 |
+
"temperature": settings.llm_temperature,
|
| 90 |
+
"max_tokens": settings.llm_max_tokens,
|
| 91 |
+
}
|
| 92 |
+
if settings.user_llm_base_url:
|
| 93 |
+
llm_kwargs["base_url"] = settings.user_llm_base_url
|
| 94 |
+
|
| 95 |
+
llm = ChatOpenAI(**llm_kwargs)
|
| 96 |
+
chain = prompt | llm | StrOutputParser()
|
| 97 |
+
|
| 98 |
+
raw_answer = chain.invoke({
|
| 99 |
+
"context": context,
|
| 100 |
+
"question": question,
|
| 101 |
+
})
|
| 102 |
+
|
| 103 |
+
return {
|
| 104 |
+
"answer": raw_answer.strip(),
|
| 105 |
+
"sources": sources,
|
| 106 |
+
"used_llm": True,
|
| 107 |
+
}
|
| 108 |
+
except Exception as exc:
|
| 109 |
+
# If LLM API call fails (e.g. invalid key or network error), fallback cleanly
|
| 110 |
+
return {
|
| 111 |
+
"answer": f"{extractive_fallback(question, retrieved_items)}\n\n*(LLM generation failed: {str(exc)})*",
|
| 112 |
+
"sources": sources,
|
| 113 |
+
"used_llm": False,
|
| 114 |
+
}
|
src/rag_engine/hybrid_search.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Hybrid search combining Dense Vector Search and BM25 Keyword Search.
|
| 2 |
+
|
| 3 |
+
Uses Reciprocal Rank Fusion (RRF) to merge the ranked result lists from both
|
| 4 |
+
retrieval methods into a single, high-quality candidate list.
|
| 5 |
+
|
| 6 |
+
Why Hybrid Search?
|
| 7 |
+
- Dense vectors understand semantic similarity (e.g. 'space telescope' -> 'astronomy').
|
| 8 |
+
- BM25 understands exact identifiers and keywords (e.g. 'NIRCam', 'L2', '15 millikelvin').
|
| 9 |
+
- Together, they significantly outperform either method on its own.
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from collections import defaultdict
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def reciprocal_rank_fusion(dense_results, bm25_results, dense_weight=0.6, bm25_weight=0.4, k=60):
|
| 16 |
+
"""Merge two ranked lists using Reciprocal Rank Fusion (RRF).
|
| 17 |
+
|
| 18 |
+
Formula for each document:
|
| 19 |
+
Score = sum( weight / (k + rank) )
|
| 20 |
+
|
| 21 |
+
Args:
|
| 22 |
+
dense_results: List of dicts from vector search [{"chunk": ..., "score": ...}].
|
| 23 |
+
bm25_results: List of dicts from BM25 search.
|
| 24 |
+
dense_weight: Relative importance of vector search (default: 0.6).
|
| 25 |
+
bm25_weight: Relative importance of keyword search (default: 0.4).
|
| 26 |
+
k: Smoothing constant to dampen extreme high ranks (standard is 60).
|
| 27 |
+
|
| 28 |
+
Returns:
|
| 29 |
+
List of fused dicts sorted by combined RRF score.
|
| 30 |
+
"""
|
| 31 |
+
rrf_scores = defaultdict(float)
|
| 32 |
+
chunks_by_id = {}
|
| 33 |
+
dense_ranks = {}
|
| 34 |
+
bm25_ranks = {}
|
| 35 |
+
|
| 36 |
+
# Accumulate dense ranks
|
| 37 |
+
for rank, item in enumerate(dense_results, start=1):
|
| 38 |
+
chunk = item["chunk"]
|
| 39 |
+
cid = chunk.chunk_id
|
| 40 |
+
chunks_by_id[cid] = chunk
|
| 41 |
+
dense_ranks[cid] = rank
|
| 42 |
+
rrf_scores[cid] += dense_weight * (1.0 / (k + rank))
|
| 43 |
+
|
| 44 |
+
# Accumulate BM25 ranks
|
| 45 |
+
for rank, item in enumerate(bm25_results, start=1):
|
| 46 |
+
chunk = item["chunk"]
|
| 47 |
+
cid = chunk.chunk_id
|
| 48 |
+
chunks_by_id[cid] = chunk
|
| 49 |
+
bm25_ranks[cid] = rank
|
| 50 |
+
rrf_scores[cid] += bm25_weight * (1.0 / (k + rank))
|
| 51 |
+
|
| 52 |
+
# Sort descending by fused score
|
| 53 |
+
sorted_items = sorted(rrf_scores.items(), key=lambda x: x[1], reverse=True)
|
| 54 |
+
|
| 55 |
+
fused_results = []
|
| 56 |
+
for cid, combined_score in sorted_items:
|
| 57 |
+
fused_results.append({
|
| 58 |
+
"chunk": chunks_by_id[cid],
|
| 59 |
+
"score": round(combined_score, 5),
|
| 60 |
+
"dense_rank": dense_ranks.get(cid),
|
| 61 |
+
"bm25_rank": bm25_ranks.get(cid),
|
| 62 |
+
"source": "hybrid",
|
| 63 |
+
})
|
| 64 |
+
|
| 65 |
+
return fused_results
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
class HybridRetriever:
|
| 69 |
+
"""Orchestrates dense vector search and BM25 search."""
|
| 70 |
+
|
| 71 |
+
def __init__(self, vector_store, bm25_index, dense_weight=0.6, bm25_weight=0.4):
|
| 72 |
+
self.vector_store = vector_store
|
| 73 |
+
self.bm25_index = bm25_index
|
| 74 |
+
self.dense_weight = dense_weight
|
| 75 |
+
self.bm25_weight = bm25_weight
|
| 76 |
+
|
| 77 |
+
def retrieve(self, query, top_k=4, dense_k=10, bm25_k=10):
|
| 78 |
+
"""Retrieve the best matching chunks using hybrid search.
|
| 79 |
+
|
| 80 |
+
Returns:
|
| 81 |
+
Dict containing:
|
| 82 |
+
- 'results': top_k fused results
|
| 83 |
+
- 'dense_results': raw vector results
|
| 84 |
+
- 'bm25_results': raw BM25 results
|
| 85 |
+
"""
|
| 86 |
+
dense_hits = self.vector_store.search(query, top_k=dense_k)
|
| 87 |
+
bm25_hits = self.bm25_index.search(query, top_k=bm25_k)
|
| 88 |
+
|
| 89 |
+
fused = reciprocal_rank_fusion(
|
| 90 |
+
dense_results=dense_hits,
|
| 91 |
+
bm25_results=bm25_hits,
|
| 92 |
+
dense_weight=self.dense_weight,
|
| 93 |
+
bm25_weight=self.bm25_weight,
|
| 94 |
+
)
|
| 95 |
+
|
| 96 |
+
return {
|
| 97 |
+
"results": fused[:top_k],
|
| 98 |
+
"dense_results": dense_hits,
|
| 99 |
+
"bm25_results": bm25_hits,
|
| 100 |
+
}
|
src/rag_engine/pipeline.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Complete end-to-end RAG Pipeline orchestrator.
|
| 2 |
+
|
| 3 |
+
Coordinates all 5 core stages of Retrieval-Augmented Generation:
|
| 4 |
+
1. Ingestion: Load raw text, PDFs, or demo articles into Document objects.
|
| 5 |
+
2. Chunking: Split documents into smaller, overlapping TextChunk passages.
|
| 6 |
+
3. Indexing: Index chunks into both Dense Vector Store and BM25 Keyword Search.
|
| 7 |
+
4. Hybrid Retrieval: Retrieve and rank chunks using Reciprocal Rank Fusion (RRF).
|
| 8 |
+
5. Generation: Augment prompt with retrieved chunks and generate answer with LLM.
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
import time
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
|
| 14 |
+
from config.settings import get_settings
|
| 15 |
+
from rag_engine.documents import Document, load_text_file, load_pdf_file, save_documents_jsonl, load_documents_jsonl
|
| 16 |
+
from rag_engine.chunking import chunk_documents
|
| 17 |
+
from rag_engine.vector_store import VectorStore
|
| 18 |
+
from rag_engine.bm25_search import BM25SearchIndex
|
| 19 |
+
from rag_engine.hybrid_search import HybridRetriever
|
| 20 |
+
from rag_engine.generator import generate_answer
|
| 21 |
+
from rag_engine.eval import evaluate_response
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class RAGPipeline:
|
| 25 |
+
"""The central RAG engine coordinating ingestion, search, and generation."""
|
| 26 |
+
|
| 27 |
+
def __init__(self, settings=None):
|
| 28 |
+
self.settings = settings or get_settings()
|
| 29 |
+
self.documents = []
|
| 30 |
+
self.chunks = []
|
| 31 |
+
self.vector_store = VectorStore()
|
| 32 |
+
self.bm25_index = BM25SearchIndex()
|
| 33 |
+
self.retriever = HybridRetriever(
|
| 34 |
+
vector_store=self.vector_store,
|
| 35 |
+
bm25_index=self.bm25_index,
|
| 36 |
+
dense_weight=self.settings.dense_weight,
|
| 37 |
+
bm25_weight=self.settings.bm25_weight,
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
def add_document(self, text, title=None, doc_id=None, source="upload"):
|
| 41 |
+
"""Add a single raw text document and index it."""
|
| 42 |
+
doc = Document(text=text, title=title, doc_id=doc_id, source=source)
|
| 43 |
+
self.documents.append(doc)
|
| 44 |
+
new_chunks = chunk_documents(
|
| 45 |
+
[doc],
|
| 46 |
+
chunk_size=self.settings.chunk_size,
|
| 47 |
+
chunk_overlap=self.settings.chunk_overlap,
|
| 48 |
+
)
|
| 49 |
+
self.chunks.extend(new_chunks)
|
| 50 |
+
self.vector_store.add_chunks(new_chunks)
|
| 51 |
+
self.bm25_index.build(self.chunks)
|
| 52 |
+
save_documents_jsonl(self.documents, self.settings.documents_file)
|
| 53 |
+
return doc
|
| 54 |
+
|
| 55 |
+
def add_pdf(self, filepath):
|
| 56 |
+
"""Extract text from a PDF file and index it."""
|
| 57 |
+
doc = load_pdf_file(filepath)
|
| 58 |
+
return self.add_document(text=doc.text, title=doc.title, doc_id=doc.doc_id, source=doc.source)
|
| 59 |
+
|
| 60 |
+
def load_demo_corpus(self):
|
| 61 |
+
"""Load bundled educational demo articles."""
|
| 62 |
+
demo_dir = Path(__file__).resolve().parents[2] / "tests" / "fixtures" / "demo_corpus"
|
| 63 |
+
if not demo_dir.exists():
|
| 64 |
+
return []
|
| 65 |
+
|
| 66 |
+
loaded = []
|
| 67 |
+
for txt_file in sorted(demo_dir.glob("*.txt")):
|
| 68 |
+
doc = load_text_file(txt_file)
|
| 69 |
+
self.documents.append(doc)
|
| 70 |
+
loaded.append(doc)
|
| 71 |
+
|
| 72 |
+
if loaded:
|
| 73 |
+
self.chunks = chunk_documents(
|
| 74 |
+
self.documents,
|
| 75 |
+
chunk_size=self.settings.chunk_size,
|
| 76 |
+
chunk_overlap=self.settings.chunk_overlap,
|
| 77 |
+
)
|
| 78 |
+
self.vector_store.clear()
|
| 79 |
+
self.vector_store.add_chunks(self.chunks)
|
| 80 |
+
self.bm25_index.build(self.chunks)
|
| 81 |
+
save_documents_jsonl(self.documents, self.settings.documents_file)
|
| 82 |
+
|
| 83 |
+
return loaded
|
| 84 |
+
|
| 85 |
+
def query(self, question, top_k=None):
|
| 86 |
+
"""Execute the end-to-end RAG workflow for a user question.
|
| 87 |
+
|
| 88 |
+
Steps:
|
| 89 |
+
1. Query dense vector store and BM25 keyword index.
|
| 90 |
+
2. Merge results using Reciprocal Rank Fusion.
|
| 91 |
+
3. Format retrieved context into LLM prompt.
|
| 92 |
+
4. Synthesize final answer with source citations.
|
| 93 |
+
5. Evaluate faithfulness and relevancy.
|
| 94 |
+
|
| 95 |
+
Returns:
|
| 96 |
+
Dict containing:
|
| 97 |
+
- 'question': input question
|
| 98 |
+
- 'answer': generated answer
|
| 99 |
+
- 'sources': cited document titles
|
| 100 |
+
- 'retrieved_chunks': top ranked chunks
|
| 101 |
+
- 'dense_results': vector search results
|
| 102 |
+
- 'bm25_results': keyword search results
|
| 103 |
+
- 'evaluation': faithfulness & relevancy metrics
|
| 104 |
+
- 'latency_ms': elapsed execution time
|
| 105 |
+
"""
|
| 106 |
+
start_time = time.perf_counter()
|
| 107 |
+
top_k = top_k or self.settings.final_top_k
|
| 108 |
+
|
| 109 |
+
# Step 1: Hybrid Retrieval
|
| 110 |
+
retrieval_output = self.retriever.retrieve(
|
| 111 |
+
query=question,
|
| 112 |
+
top_k=top_k,
|
| 113 |
+
dense_k=self.settings.dense_top_k,
|
| 114 |
+
bm25_k=self.settings.bm25_top_k,
|
| 115 |
+
)
|
| 116 |
+
|
| 117 |
+
retrieved_chunks = retrieval_output["results"]
|
| 118 |
+
|
| 119 |
+
# Step 2: Generation
|
| 120 |
+
gen_output = generate_answer(
|
| 121 |
+
question=question,
|
| 122 |
+
retrieved_items=retrieved_chunks,
|
| 123 |
+
settings=self.settings,
|
| 124 |
+
)
|
| 125 |
+
|
| 126 |
+
# Step 3: Evaluation
|
| 127 |
+
evaluation = evaluate_response(
|
| 128 |
+
question=question,
|
| 129 |
+
answer=gen_output["answer"],
|
| 130 |
+
retrieved_items=retrieved_chunks,
|
| 131 |
+
)
|
| 132 |
+
|
| 133 |
+
elapsed_ms = round((time.perf_counter() - start_time) * 1000, 1)
|
| 134 |
+
|
| 135 |
+
return {
|
| 136 |
+
"question": question,
|
| 137 |
+
"answer": gen_output["answer"],
|
| 138 |
+
"sources": gen_output["sources"],
|
| 139 |
+
"used_llm": gen_output["used_llm"],
|
| 140 |
+
"retrieved_chunks": retrieved_chunks,
|
| 141 |
+
"dense_results": retrieval_output["dense_results"],
|
| 142 |
+
"bm25_results": retrieval_output["bm25_results"],
|
| 143 |
+
"evaluation": evaluation,
|
| 144 |
+
"latency_ms": elapsed_ms,
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
def get_stats(self):
|
| 148 |
+
"""Return summary statistics of the indexed knowledge base."""
|
| 149 |
+
return {
|
| 150 |
+
"document_count": len(self.documents),
|
| 151 |
+
"chunk_count": len(self.chunks),
|
| 152 |
+
"vector_store_size": len(self.vector_store),
|
| 153 |
+
"bm25_index_size": len(self.bm25_index),
|
| 154 |
+
}
|
src/rag_engine/vector_store.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Vector Store implementation for indexing and semantic similarity search.
|
| 2 |
+
|
| 3 |
+
A vector database stores chunks alongside their embedding vectors and finds
|
| 4 |
+
the nearest neighbors (most relevant chunks) using cosine similarity.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from rag_engine.embeddings import cosine_similarity, get_embedding_model
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class VectorStore:
|
| 11 |
+
"""Simple in-memory vector store with cosine similarity ranking."""
|
| 12 |
+
|
| 13 |
+
def __init__(self, embedding_model=None):
|
| 14 |
+
self.embedding_model = embedding_model or get_embedding_model()
|
| 15 |
+
self.chunks = []
|
| 16 |
+
self.vectors = []
|
| 17 |
+
|
| 18 |
+
def add_chunks(self, chunks):
|
| 19 |
+
"""Add TextChunk objects and calculate their embeddings."""
|
| 20 |
+
if not chunks:
|
| 21 |
+
return
|
| 22 |
+
new_texts = [c.text for c in chunks]
|
| 23 |
+
new_vectors = self.embedding_model.embed_documents(new_texts)
|
| 24 |
+
self.chunks.extend(chunks)
|
| 25 |
+
self.vectors.extend(new_vectors)
|
| 26 |
+
|
| 27 |
+
def search(self, query, top_k=5):
|
| 28 |
+
"""Find the top_k most semantically similar chunks for a query.
|
| 29 |
+
|
| 30 |
+
Returns:
|
| 31 |
+
List of dicts: [{"chunk": TextChunk, "score": float, "source": "dense"}]
|
| 32 |
+
"""
|
| 33 |
+
if not self.chunks:
|
| 34 |
+
return []
|
| 35 |
+
|
| 36 |
+
query_vector = self.embedding_model.embed_query(query)
|
| 37 |
+
scored_results = []
|
| 38 |
+
|
| 39 |
+
for idx, (chunk, doc_vector) in enumerate(zip(self.chunks, self.vectors)):
|
| 40 |
+
similarity = cosine_similarity(query_vector, doc_vector)
|
| 41 |
+
scored_results.append({
|
| 42 |
+
"chunk": chunk,
|
| 43 |
+
"score": round(similarity, 4),
|
| 44 |
+
"source": "dense",
|
| 45 |
+
})
|
| 46 |
+
|
| 47 |
+
# Sort descending by similarity score
|
| 48 |
+
scored_results.sort(key=lambda x: x["score"], reverse=True)
|
| 49 |
+
return scored_results[:top_k]
|
| 50 |
+
|
| 51 |
+
def clear(self):
|
| 52 |
+
"""Empty the store."""
|
| 53 |
+
self.chunks = []
|
| 54 |
+
self.vectors = []
|
| 55 |
+
|
| 56 |
+
def __len__(self):
|
| 57 |
+
return len(self.chunks)
|