Jayan12321 commited on
Commit
e43c82d
·
verified ·
1 Parent(s): 3baa772

Upload folder using huggingface_hub

Browse files
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)