omUniyal commited on
Commit
e7a3876
·
verified ·
1 Parent(s): 3a5ead7

Upload 40 files

Browse files
Files changed (41) hide show
  1. .env.example +18 -0
  2. .gitattributes +2 -0
  3. app.py +124 -0
  4. configs/config.yaml +27 -0
  5. data/raw/attention_is_all_you_need.pdf +3 -0
  6. data/raw/rag_for_knowledge_intesive_NLP_tasks.pdf +3 -0
  7. requirements.txt +0 -0
  8. src/__init__.py +0 -0
  9. src/__pycache__/__init__.cpython-314.pyc +0 -0
  10. src/evaluation/__init__.py +0 -0
  11. src/evaluation/__pycache__/__init__.cpython-314.pyc +0 -0
  12. src/evaluation/__pycache__/ragas_eval.cpython-314.pyc +0 -0
  13. src/evaluation/ragas_eval.py +281 -0
  14. src/generation/__init__.py +0 -0
  15. src/generation/__pycache__/__init__.cpython-314.pyc +0 -0
  16. src/generation/__pycache__/llm_client.cpython-314.pyc +0 -0
  17. src/generation/__pycache__/prompt_builder.cpython-314.pyc +0 -0
  18. src/generation/__pycache__/rag_chain.cpython-314.pyc +0 -0
  19. src/generation/llm_client.py +39 -0
  20. src/generation/prompt_builder.py +83 -0
  21. src/generation/rag_chain.py +61 -0
  22. src/ingestion/__init__.py +0 -0
  23. src/ingestion/__pycache__/__init__.cpython-314.pyc +0 -0
  24. src/ingestion/__pycache__/chunker.cpython-314.pyc +0 -0
  25. src/ingestion/__pycache__/pdf_loader.cpython-314.pyc +0 -0
  26. src/ingestion/chunker.py +77 -0
  27. src/ingestion/pdf_loader.py +59 -0
  28. src/retrieval/__init__.py +0 -0
  29. src/retrieval/__pycache__/__init__.cpython-314.pyc +0 -0
  30. src/retrieval/__pycache__/embedder.cpython-314.pyc +0 -0
  31. src/retrieval/__pycache__/retriever.cpython-314.pyc +0 -0
  32. src/retrieval/__pycache__/vector_store.cpython-314.pyc +0 -0
  33. src/retrieval/embedder.py +52 -0
  34. src/retrieval/retriever.py +39 -0
  35. src/retrieval/vector_store.py +95 -0
  36. src/utils/__init__.py +0 -0
  37. src/utils/__pycache__/__init__.cpython-314.pyc +0 -0
  38. src/utils/__pycache__/config.cpython-314.pyc +0 -0
  39. src/utils/__pycache__/logger.cpython-314.pyc +0 -0
  40. src/utils/config.py +60 -0
  41. src/utils/logger.py +31 -0
.env.example ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copy this file to .env and fill in your values
2
+ # .env is gitignored — never commit secrets
3
+
4
+ # For Groq API (free trial available)
5
+ GROQ_API_KEY=gsk_your_key_here
6
+
7
+ # Embedding model (runs locally, no token needed)
8
+ EMBEDDING_MODEL=sentence-transformers/all-MiniLM-L6-v2
9
+
10
+ # Vector store
11
+ CHROMA_PERSIST_DIR=./chroma_db
12
+ COLLECTION_NAME=rag_documents
13
+
14
+ # App settings
15
+ LOG_LEVEL=INFO
16
+ CHUNK_SIZE=500
17
+ CHUNK_OVERLAP=50
18
+ TOP_K_RESULTS=5
.gitattributes CHANGED
@@ -33,3 +33,5 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ data/raw/attention_is_all_you_need.pdf filter=lfs diff=lfs merge=lfs -text
37
+ data/raw/rag_for_knowledge_intesive_NLP_tasks.pdf filter=lfs diff=lfs merge=lfs -text
app.py ADDED
@@ -0,0 +1,124 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Gradio UI — RAG Document Q&A
3
+ Deployed on HuggingFace Spaces.
4
+
5
+ Pre-ingested documents:
6
+ - Attention Is All You Need (Vaswani et al., 2017)
7
+ - RAG for Knowledge-Intensive NLP Tasks (Lewis et al., 2020)
8
+ """
9
+
10
+ import os
11
+ from pathlib import Path
12
+ import gradio as gr
13
+ from src.generation.rag_chain import RAGChain
14
+ from src.retrieval.vector_store import VectorStore
15
+ from src.ingestion.pdf_loader import load_pdfs_from_dir
16
+ from src.ingestion.chunker import chunk_pages
17
+ from src.retrieval.embedder import Embedder
18
+ from src.utils.logger import logger
19
+
20
+
21
+ def ensure_ingested():
22
+ """
23
+ Auto-ingest PDFs on first startup if vector store is empty.
24
+ On HuggingFace Spaces, the chroma_db doesn't persist between restarts
25
+ so we re-ingest from data/raw/ every cold start.
26
+ """
27
+ store = VectorStore()
28
+ if store.collection.count() > 0:
29
+ logger.info(f"Vector store already has {store.collection.count()} chunks — skipping ingestion")
30
+ return
31
+
32
+ logger.info("Vector store empty — ingesting PDFs from data/raw/...")
33
+ pages = load_pdfs_from_dir("data/raw")
34
+ if not pages:
35
+ logger.error("No PDFs found in data/raw/ — app will not work correctly")
36
+ return
37
+
38
+ chunks = chunk_pages(pages)
39
+ embedder = Embedder()
40
+ texts = [c["text"] for c in chunks]
41
+ embeddings = embedder.embed_texts(texts)
42
+ store.add_chunks(chunks, embeddings)
43
+ logger.info(f"Ingestion complete — {len(chunks)} chunks stored")
44
+
45
+
46
+ # Run ingestion on startup
47
+ ensure_ingested()
48
+
49
+ # Initialise RAG chain
50
+ chain = RAGChain()
51
+
52
+
53
+ def answer_question(question: str) -> tuple[str, str]:
54
+ """Gradio callback: takes question, returns (answer, sources_markdown)."""
55
+ if not question.strip():
56
+ return "Please enter a question.", ""
57
+
58
+ result = chain.query(question)
59
+ answer = result["answer"]
60
+
61
+ sources_lines = ["**Sources retrieved:**\n"]
62
+ for i, s in enumerate(result["sources"], start=1):
63
+ sources_lines.append(
64
+ f"{i}. `{s['source']}` — Page {s['page']} (similarity: {s['score']})"
65
+ )
66
+ sources_md = "\n".join(sources_lines)
67
+ return answer, sources_md
68
+
69
+
70
+ # ------------------------------------------------------------
71
+ # Gradio UI
72
+ # ------------------------------------------------------------
73
+
74
+ with gr.Blocks(
75
+ title="RAG Document Q&A",
76
+ theme=gr.themes.Soft(),
77
+ ) as demo:
78
+ gr.Markdown("""
79
+ # RAG Document Q&A
80
+ Ask questions about the **Attention Is All You Need** and **RAG** papers.
81
+ Answers are grounded only in the retrieved document passages — no hallucination from training data.
82
+ """)
83
+
84
+ with gr.Row():
85
+ question_box = gr.Textbox(
86
+ label="Your question",
87
+ placeholder="e.g. What is the attention mechanism? How does RAG work?",
88
+ lines=2,
89
+ )
90
+
91
+ submit_btn = gr.Button("Ask", variant="primary", size="lg")
92
+
93
+ with gr.Row():
94
+ answer_box = gr.Textbox(
95
+ label="Answer",
96
+ lines=8,
97
+ interactive=False,
98
+ )
99
+ sources_box = gr.Markdown(label="Sources")
100
+
101
+ gr.Examples(
102
+ examples=[
103
+ "What is the attention mechanism in transformers?",
104
+ "What is multi-head attention?",
105
+ "How does RAG combine retrieval and generation?",
106
+ "What datasets were used to evaluate RAG?",
107
+ "What is the encoder-decoder architecture?",
108
+ ],
109
+ inputs=question_box,
110
+ )
111
+
112
+ submit_btn.click(
113
+ fn=answer_question,
114
+ inputs=[question_box],
115
+ outputs=[answer_box, sources_box],
116
+ )
117
+
118
+ gr.Markdown(
119
+ "_Built with sentence-transformers, ChromaDB, and Groq (Llama 3.1 8B). "
120
+ "[View source on GitHub](https://github.com/OmUniyal/rag-document-qa)_"
121
+ )
122
+
123
+ if __name__ == "__main__":
124
+ demo.launch()
configs/config.yaml ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Central config — all tuneable parameters live here
2
+ # Code reads this via src/utils/config.py — no magic strings in source files
3
+
4
+ ingestion:
5
+ chunk_size: 500
6
+ chunk_overlap: 50
7
+ supported_formats: [".pdf"]
8
+
9
+ retrieval:
10
+ embedding_model: "sentence-transformers/all-MiniLM-L6-v2"
11
+ top_k: 5
12
+ collection_name: "rag_documents"
13
+ chroma_persist_dir: "./chroma_db"
14
+
15
+ generation:
16
+ backend: "groq"
17
+ groq_model: "llama-3.1-8b-instant"
18
+ max_new_tokens: 512
19
+ temperature: 0.1 # Low temp = more factual, less creative
20
+
21
+ evaluation:
22
+ ragas_metrics: ["faithfulness", "answer_relevancy", "context_precision", "context_recall"]
23
+ faithfulness_threshold: 0.7
24
+
25
+ logging:
26
+ level: "INFO"
27
+ file: "logs/app.log"
data/raw/attention_is_all_you_need.pdf ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:bdfaa68d8984f0dc02beaca527b76f207d99b666d31d1da728ee0728182df697
3
+ size 2215244
data/raw/rag_for_knowledge_intesive_NLP_tasks.pdf ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:23e3249e9a1e75418d82efecab0ea8c4d033b89c93742f63208d47ce01f21233
3
+ size 885323
requirements.txt ADDED
Binary file (338 Bytes). View file
 
src/__init__.py ADDED
File without changes
src/__pycache__/__init__.cpython-314.pyc ADDED
Binary file (144 Bytes). View file
 
src/evaluation/__init__.py ADDED
File without changes
src/evaluation/__pycache__/__init__.cpython-314.pyc ADDED
Binary file (155 Bytes). View file
 
src/evaluation/__pycache__/ragas_eval.cpython-314.pyc ADDED
Binary file (11.1 kB). View file
 
src/evaluation/ragas_eval.py ADDED
@@ -0,0 +1,281 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ RAG Evaluation — lightweight implementation of RAGAS-style metrics.
3
+
4
+ Why not use the ragas library directly:
5
+ ragas has unstable langchain_community dependencies that break frequently.
6
+ This module implements the same four core metrics from first principles
7
+ using only sentence-transformers and the Groq LLM we already have.
8
+
9
+ Metrics implemented:
10
+ 1. Faithfulness — are answer claims supported by context?
11
+ 2. Answer Relevancy — does the answer address the question?
12
+ 3. Context Precision — are retrieved chunks relevant to the question?
13
+ 4. Context Recall — were all needed chunks retrieved?
14
+ """
15
+
16
+ import numpy as np
17
+ from sentence_transformers import SentenceTransformer
18
+ from src.utils.logger import logger
19
+
20
+ # Use the same embedding model as the retrieval pipeline
21
+ _embedder = None
22
+
23
+
24
+ def _get_embedder():
25
+ global _embedder
26
+ if _embedder is None:
27
+ _embedder = SentenceTransformer("sentence-transformers/all-MiniLM-L6-v2")
28
+ return _embedder
29
+
30
+
31
+ def _cosine_similarity(a, b):
32
+ a, b = np.array(a), np.array(b)
33
+ return float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b)))
34
+
35
+
36
+ # ------------------------------------------------------------
37
+ # Metric 1: Faithfulness
38
+ # "Is every claim in the answer supported by the retrieved context?"
39
+ # Score 0-1. Low score = hallucination.
40
+ # ------------------------------------------------------------
41
+
42
+ def faithfulness(answer: str, context_chunks: list[str], threshold: float = 0.5) -> dict:
43
+ """
44
+ Checks if each sentence in the answer has semantic support
45
+ in at least one retrieved chunk.
46
+
47
+ Args:
48
+ answer: The LLM-generated answer string.
49
+ context_chunks: List of retrieved chunk texts.
50
+ threshold: Minimum cosine similarity to consider a sentence supported.
51
+
52
+ Returns:
53
+ {
54
+ "score": float, # 0-1, fraction of sentences supported
55
+ "supported": int, # number of supported sentences
56
+ "total": int, # total sentences checked
57
+ "details": list[dict] # per-sentence breakdown
58
+ }
59
+
60
+ Note: Production RAGAS uses an LLM to decompose answers into atomic claims.
61
+ This implementation uses embedding similarity as a faster approximation.
62
+ """
63
+ model = _get_embedder()
64
+
65
+ sentences = [s.strip() for s in answer.split(".") if s.strip()]
66
+ if not sentences:
67
+ return {"score": 0.0, "supported": 0, "total": 0, "details": []}
68
+
69
+ chunk_embeddings = model.encode(context_chunks)
70
+ details = []
71
+ supported = 0
72
+
73
+ for sentence in sentences:
74
+ sentence_embedding = model.encode(sentence)
75
+ max_similarity = max(
76
+ _cosine_similarity(sentence_embedding, chunk_emb)
77
+ for chunk_emb in chunk_embeddings
78
+ )
79
+ is_supported = max_similarity >= threshold
80
+ if is_supported:
81
+ supported += 1
82
+ details.append({
83
+ "sentence": sentence,
84
+ "max_similarity": round(max_similarity, 4),
85
+ "supported": is_supported,
86
+ })
87
+
88
+ score = supported / len(sentences)
89
+ logger.debug(f"Faithfulness: {score:.2f} ({supported}/{len(sentences)} sentences supported)")
90
+ return {
91
+ "score": round(score, 4),
92
+ "supported": supported,
93
+ "total": len(sentences),
94
+ "details": details,
95
+ }
96
+
97
+
98
+ # ------------------------------------------------------------
99
+ # Metric 2: Answer Relevancy
100
+ # "Does the answer actually address the question asked?"
101
+ # Score 0-1. Low score = answer is off-topic or evasive.
102
+ # ------------------------------------------------------------
103
+
104
+ def answer_relevancy(question: str, answer: str) -> dict:
105
+ """
106
+ Measures semantic similarity between the question and answer.
107
+ High similarity = answer directly addresses the question.
108
+
109
+ Note: Production RAGAS generates multiple questions from the answer
110
+ and measures how well they reconstruct the original question.
111
+ This is a simpler direct similarity approximation.
112
+ """
113
+ model = _get_embedder()
114
+
115
+ question_embedding = model.encode(question)
116
+ answer_embedding = model.encode(answer)
117
+ score = _cosine_similarity(question_embedding, answer_embedding)
118
+
119
+ # Clamp to [0, 1] — cosine can be slightly negative
120
+ score = max(0.0, min(1.0, score))
121
+
122
+ logger.debug(f"Answer relevancy: {score:.2f}")
123
+ return {
124
+ "score": round(score, 4),
125
+ "question": question,
126
+ "answer_preview": answer[:100] + "..." if len(answer) > 100 else answer,
127
+ }
128
+
129
+
130
+ # ------------------------------------------------------------
131
+ # Metric 3: Context Precision
132
+ # "Are the retrieved chunks actually relevant to the question?"
133
+ # Score 0-1. Low score = too many irrelevant chunks retrieved.
134
+ # ------------------------------------------------------------
135
+
136
+ def context_precision(question: str, context_chunks: list[str], threshold: float = 0.4) -> dict:
137
+ """
138
+ Measures what fraction of retrieved chunks are relevant to the question.
139
+ Signal-to-noise ratio of retrieval.
140
+
141
+ Args:
142
+ question: The user's question.
143
+ context_chunks: Retrieved chunk texts.
144
+ threshold: Minimum similarity to consider a chunk relevant.
145
+ """
146
+ if not context_chunks:
147
+ return {"score": 0.0, "relevant": 0, "total": 0, "details": []}
148
+
149
+ model = _get_embedder()
150
+ question_embedding = model.encode(question)
151
+
152
+ details = []
153
+ relevant = 0
154
+
155
+ for i, chunk in enumerate(context_chunks):
156
+ chunk_embedding = model.encode(chunk)
157
+ similarity = _cosine_similarity(question_embedding, chunk_embedding)
158
+ is_relevant = similarity >= threshold
159
+ if is_relevant:
160
+ relevant += 1
161
+ details.append({
162
+ "chunk_index": i,
163
+ "similarity": round(similarity, 4),
164
+ "relevant": is_relevant,
165
+ "preview": chunk[:80] + "..." if len(chunk) > 80 else chunk,
166
+ })
167
+
168
+ score = relevant / len(context_chunks)
169
+ logger.debug(f"Context precision: {score:.2f} ({relevant}/{len(context_chunks)} chunks relevant)")
170
+ return {
171
+ "score": round(score, 4),
172
+ "relevant": relevant,
173
+ "total": len(context_chunks),
174
+ "details": details,
175
+ }
176
+
177
+
178
+ # ------------------------------------------------------------
179
+ # Metric 4: Context Recall
180
+ # "Did we retrieve all the chunks needed to answer fully?"
181
+ # Score 0-1. Low score = answer is incomplete due to missing context.
182
+ # ------------------------------------------------------------
183
+
184
+ def context_recall(answer: str, context_chunks: list[str], threshold: float = 0.5) -> dict:
185
+ """
186
+ Measures what fraction of the answer's content is attributable
187
+ to the retrieved context. Inverse of faithfulness framing —
188
+ here we measure coverage rather than support.
189
+
190
+ Note: Production RAGAS uses ground truth answers for recall.
191
+ Without ground truth, we approximate by checking how much of
192
+ the answer is semantically covered by the context.
193
+ """
194
+ model = _get_embedder()
195
+
196
+ sentences = [s.strip() for s in answer.split(".") if s.strip()]
197
+ if not sentences or not context_chunks:
198
+ return {"score": 0.0, "covered": 0, "total": 0}
199
+
200
+ chunk_embeddings = model.encode(context_chunks)
201
+ covered = 0
202
+
203
+ for sentence in sentences:
204
+ sentence_embedding = model.encode(sentence)
205
+ max_similarity = max(
206
+ _cosine_similarity(sentence_embedding, chunk_emb)
207
+ for chunk_emb in chunk_embeddings
208
+ )
209
+ if max_similarity >= threshold:
210
+ covered += 1
211
+
212
+ score = covered / len(sentences)
213
+ logger.debug(f"Context recall: {score:.2f} ({covered}/{len(sentences)} sentences covered)")
214
+ return {
215
+ "score": round(score, 4),
216
+ "covered": covered,
217
+ "total": len(sentences),
218
+ }
219
+
220
+
221
+ # ------------------------------------------------------------
222
+ # Combined evaluation — run all four metrics at once
223
+ # ------------------------------------------------------------
224
+
225
+ def evaluate(
226
+ question: str,
227
+ answer: str,
228
+ context_chunks: list[str],
229
+ faithfulness_threshold: float = 0.5,
230
+ precision_threshold: float = 0.4,
231
+ ) -> dict:
232
+ """
233
+ Run all four metrics and return a combined evaluation report.
234
+
235
+ Args:
236
+ question: The user's question.
237
+ answer: The LLM-generated answer.
238
+ context_chunks: List of retrieved chunk texts.
239
+
240
+ Returns:
241
+ Dict with all four metric results and an overall summary.
242
+ """
243
+ logger.info("Running RAG evaluation...")
244
+
245
+ faith = faithfulness(answer, context_chunks, faithfulness_threshold)
246
+ relevancy = answer_relevancy(question, answer)
247
+ precision = context_precision(question, context_chunks, precision_threshold)
248
+ recall = context_recall(answer, context_chunks, faithfulness_threshold)
249
+
250
+ # Overall score — average of four metrics
251
+ overall = np.mean([
252
+ faith["score"],
253
+ relevancy["score"],
254
+ precision["score"],
255
+ recall["score"],
256
+ ])
257
+
258
+ report = {
259
+ "overall": round(float(overall), 4),
260
+ "faithfulness": faith["score"],
261
+ "answer_relevancy": relevancy["score"],
262
+ "context_precision": precision["score"],
263
+ "context_recall": recall["score"],
264
+ "details": {
265
+ "faithfulness": faith,
266
+ "answer_relevancy": relevancy,
267
+ "context_precision": precision,
268
+ "context_recall": recall,
269
+ }
270
+ }
271
+
272
+ logger.info(
273
+ f"Evaluation complete — "
274
+ f"overall={report['overall']:.2f} | "
275
+ f"faithfulness={faith['score']:.2f} | "
276
+ f"relevancy={relevancy['score']:.2f} | "
277
+ f"precision={precision['score']:.2f} | "
278
+ f"recall={recall['score']:.2f}"
279
+ )
280
+
281
+ return report
src/generation/__init__.py ADDED
File without changes
src/generation/__pycache__/__init__.cpython-314.pyc ADDED
Binary file (155 Bytes). View file
 
src/generation/__pycache__/llm_client.cpython-314.pyc ADDED
Binary file (2.54 kB). View file
 
src/generation/__pycache__/prompt_builder.cpython-314.pyc ADDED
Binary file (4 kB). View file
 
src/generation/__pycache__/rag_chain.cpython-314.pyc ADDED
Binary file (2.82 kB). View file
 
src/generation/llm_client.py ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ LLM backend — Groq API.
3
+ Fast, free tier, reliable. Uses llama-3.1-8b-instant.
4
+
5
+ Why Groq over HuggingFace inference API:
6
+ - More stable free tier
7
+ - Lower latency
8
+ - No provider routing issues
9
+ """
10
+
11
+ import os
12
+ from groq import Groq
13
+ from src.utils.config import config
14
+ from src.utils.logger import logger
15
+
16
+
17
+ class LLMClient:
18
+
19
+ def __init__(self):
20
+ api_key = os.getenv("GROQ_API_KEY")
21
+ if not api_key:
22
+ raise ValueError("GROQ_API_KEY not set in .env")
23
+ self.client = Groq(api_key=api_key)
24
+ self.model = config.groq_model
25
+ logger.info(f"LLM client ready: Groq ({self.model})")
26
+
27
+ def generate(self, prompt: str) -> str:
28
+ """Send prompt, get response string back."""
29
+ try:
30
+ completion = self.client.chat.completions.create(
31
+ model=self.model,
32
+ messages=[{"role": "user", "content": prompt}],
33
+ max_tokens=config.max_new_tokens,
34
+ temperature=config.temperature,
35
+ )
36
+ return completion.choices[0].message.content.strip()
37
+ except Exception as e:
38
+ logger.error(f"Groq error: {e}")
39
+ raise
src/generation/prompt_builder.py ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Constructs the prompt sent to the LLM.
3
+
4
+ This is where grounding happens — the system prompt explicitly instructs the
5
+ model to only use the provided context. Understanding this module deeply is
6
+ what separates RAG engineers from people who just call LangChain.
7
+
8
+ Key concepts:
9
+ - System prompt: Sets behaviour for the entire conversation.
10
+ - Context block: The retrieved chunks, formatted clearly with source info.
11
+ - User question: The original query.
12
+
13
+ Interview question: "How do you prevent hallucination in RAG?"
14
+ Answer lives here: the system prompt + the fallback behaviour when context is weak.
15
+ """
16
+
17
+
18
+ SYSTEM_PROMPT = """You are a precise document assistant. Your job is to answer questions
19
+ using ONLY the context passages provided below.
20
+
21
+ Rules you must follow:
22
+ 1. If the answer is clearly in the context, answer directly and cite the source.
23
+ 2. If the context is partially relevant, use what is there and acknowledge limits.
24
+ 3. If the context contains no relevant information, say exactly:
25
+ "I could not find relevant information in the provided documents."
26
+ Do NOT guess, infer beyond the text, or use prior knowledge.
27
+ 4. Always mention which document and page your answer comes from.
28
+ """
29
+
30
+
31
+ def build_prompt(query: str, chunks: list[dict]) -> str:
32
+ """
33
+ Assemble the full prompt from retrieved chunks + user query.
34
+
35
+ Args:
36
+ query: The user's question.
37
+ chunks: Retrieved chunks from Retriever.retrieve()
38
+
39
+ Returns:
40
+ A single formatted string ready to send to the LLM.
41
+ """
42
+ if not chunks:
43
+ context_block = "No relevant context was retrieved."
44
+ else:
45
+ context_parts = []
46
+ for i, chunk in enumerate(chunks, start=1):
47
+ context_parts.append(
48
+ f"[Source {i}: {chunk['source']}, Page {chunk['page']} | Similarity: {chunk['score']}]\n"
49
+ f"{chunk['text']}"
50
+ )
51
+ context_block = "\n\n---\n\n".join(context_parts)
52
+
53
+ prompt = (
54
+ f"{SYSTEM_PROMPT}\n\n"
55
+ f"=== CONTEXT ===\n{context_block}\n\n"
56
+ f"=== QUESTION ===\n{query}\n\n"
57
+ f"=== ANSWER ==="
58
+ )
59
+ return prompt
60
+
61
+
62
+ def build_messages(query: str, chunks: list[dict]) -> list[dict]:
63
+ """
64
+ Build chat-style messages (system/user) for models that use message format.
65
+ Used with HuggingFace chat-template models.
66
+ """
67
+ context_block = _format_context(chunks)
68
+ return [
69
+ {"role": "system", "content": SYSTEM_PROMPT},
70
+ {
71
+ "role": "user",
72
+ "content": f"Context:\n{context_block}\n\nQuestion: {query}",
73
+ },
74
+ ]
75
+
76
+
77
+ def _format_context(chunks: list[dict]) -> str:
78
+ if not chunks:
79
+ return "No relevant context was retrieved."
80
+ parts = []
81
+ for i, chunk in enumerate(chunks, start=1):
82
+ parts.append(f"[{chunk['source']} | p{chunk['page']}]\n{chunk['text']}")
83
+ return "\n\n".join(parts)
src/generation/rag_chain.py ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ The complete RAG pipeline in one place.
3
+
4
+ RAGChain.query() is the single entrypoint for the app and API.
5
+ It orchestrates: retrieve → build prompt → generate → return with sources.
6
+
7
+ This is the class you demo in interviews.
8
+ """
9
+
10
+ from src.retrieval.retriever import Retriever
11
+ from src.generation.prompt_builder import build_prompt
12
+ from src.generation.llm_client import LLMClient
13
+ from src.utils.config import config
14
+ from src.utils.logger import logger
15
+
16
+
17
+ class RAGChain:
18
+
19
+ def __init__(self):
20
+ self.retriever = Retriever()
21
+ self.llm = LLMClient()
22
+
23
+ def query(self, question: str, top_k: int = None) -> dict:
24
+ """
25
+ Full RAG pipeline: question in, answer + sources out.
26
+
27
+ Returns:
28
+ {
29
+ "question": str,
30
+ "answer": str,
31
+ "sources": [{"source": str, "page": int, "score": float}],
32
+ "chunks_used": int,
33
+ }
34
+ """
35
+ logger.info(f"Query received: '{question}'")
36
+
37
+ # Step 1: Retrieve relevant chunks
38
+ chunks = self.retriever.retrieve(question, top_k=top_k or config.top_k)
39
+
40
+ # Step 2: Build the grounded prompt
41
+ prompt = build_prompt(question, chunks)
42
+
43
+ # Step 3: Generate answer
44
+ logger.info("Sending to LLM...")
45
+ answer = self.llm.generate(prompt)
46
+
47
+ # Step 4: Package sources for attribution
48
+ sources = [
49
+ {"source": c["source"], "page": c["page"], "score": c["score"]}
50
+ for c in chunks
51
+ ]
52
+
53
+ result = {
54
+ "question": question,
55
+ "answer": answer,
56
+ "sources": sources,
57
+ "chunks_used": len(chunks),
58
+ }
59
+
60
+ logger.info(f"Answer generated. Sources: {[s['source'] for s in sources]}")
61
+ return result
src/ingestion/__init__.py ADDED
File without changes
src/ingestion/__pycache__/__init__.cpython-314.pyc ADDED
Binary file (154 Bytes). View file
 
src/ingestion/__pycache__/chunker.cpython-314.pyc ADDED
Binary file (3.42 kB). View file
 
src/ingestion/__pycache__/pdf_loader.cpython-314.pyc ADDED
Binary file (3.34 kB). View file
 
src/ingestion/chunker.py ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Phase 2: split page text into overlapping chunks.
3
+
4
+ Key design questions this module answers (study these for interviews):
5
+ - Why chunk at all? LLMs have token limits; we can't feed a 100-page PDF.
6
+ - Why overlap? A sentence split across chunk boundaries would be missed
7
+ by retrieval. Overlap ensures boundary context appears in at least one chunk.
8
+ - Why not just use token count? Characters are simpler; tokens vary by model.
9
+ In production you'd switch to tiktoken-based splitting for precision.
10
+ """
11
+
12
+ from src.utils.config import config
13
+ from src.utils.logger import logger
14
+
15
+
16
+ def chunk_text(text: str, chunk_size: int = None, overlap: int = None) -> list[str]:
17
+ """
18
+ Split a single string into overlapping chunks by character count.
19
+
20
+ Args:
21
+ text: Raw text from one PDF page.
22
+ chunk_size: Max characters per chunk. Defaults to config value.
23
+ overlap: Characters shared between consecutive chunks.
24
+
25
+ Returns:
26
+ List of chunk strings.
27
+
28
+ Interview note: This is a naive fixed-size chunker.
29
+ A smarter approach splits at sentence boundaries (semantic chunking).
30
+ We build this first to understand WHY the smarter approach matters.
31
+ """
32
+ chunk_size = chunk_size or config.chunk_size
33
+ overlap = overlap or config.chunk_overlap
34
+
35
+ if not text.strip():
36
+ return []
37
+
38
+ chunks = []
39
+ start = 0
40
+
41
+ while start < len(text):
42
+ end = start + chunk_size
43
+ chunk = text[start:end].strip()
44
+ if chunk:
45
+ chunks.append(chunk)
46
+ # Move start forward by (chunk_size - overlap) to create the overlap window
47
+ start += chunk_size - overlap
48
+
49
+ return chunks
50
+
51
+
52
+ def chunk_pages(pages: list[dict]) -> list[dict]:
53
+ """
54
+ Chunk all pages from a document, preserving metadata.
55
+
56
+ Returns:
57
+ List of chunk dicts:
58
+ [{"chunk_id": str, "text": str, "source": str, "page": int}, ...]
59
+
60
+ The chunk_id is critical for deduplication and tracing answers back to source.
61
+ """
62
+ all_chunks = []
63
+ chunk_index = 0
64
+
65
+ for page in pages:
66
+ page_chunks = chunk_text(page["text"])
67
+ for chunk in page_chunks:
68
+ all_chunks.append({
69
+ "chunk_id": f"{page['source']}_p{page['page']}_c{chunk_index}",
70
+ "text": chunk,
71
+ "source": page["source"],
72
+ "page": page["page"],
73
+ })
74
+ chunk_index += 1
75
+
76
+ logger.info(f"Created {len(all_chunks)} chunks from {len(pages)} pages")
77
+ return all_chunks
src/ingestion/pdf_loader.py ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ PDF loader using pdfplumber — pure Python, no native DLLs.
3
+ Extracts text page by page, preserving page number metadata
4
+ for source attribution in answers.
5
+ """
6
+
7
+ from pathlib import Path
8
+ import pdfplumber
9
+ from src.utils.logger import logger
10
+
11
+
12
+ def load_pdf(file_path: str) -> list[dict]:
13
+ """
14
+ Extract text from each page of a PDF.
15
+
16
+ Returns:
17
+ List of dicts: [{"page": int, "text": str, "source": str}, ...]
18
+ """
19
+ path = Path(file_path)
20
+ if not path.exists():
21
+ raise FileNotFoundError(f"PDF not found: {path}")
22
+ if path.suffix.lower() != ".pdf":
23
+ raise ValueError(f"Expected a .pdf file, got: {path.suffix}")
24
+
25
+ logger.info(f"Loading PDF: {path.name}")
26
+ pages = []
27
+
28
+ with pdfplumber.open(str(path)) as pdf:
29
+ for page_num, page in enumerate(pdf.pages, start=1):
30
+ text = page.extract_text()
31
+ if not text or not text.strip():
32
+ logger.debug(f" Page {page_num}: empty, skipping")
33
+ continue
34
+ pages.append({
35
+ "page": page_num,
36
+ "text": text.strip(),
37
+ "source": path.name,
38
+ })
39
+
40
+ logger.info(f" Extracted {len(pages)} pages from {path.name}")
41
+ return pages
42
+
43
+
44
+ def load_pdfs_from_dir(dir_path: str) -> list[dict]:
45
+ """Load all PDFs from a directory."""
46
+ dir_path = Path(dir_path)
47
+ all_pages = []
48
+ pdf_files = list(dir_path.glob("*.pdf"))
49
+
50
+ if not pdf_files:
51
+ logger.warning(f"No PDFs found in {dir_path}")
52
+ return []
53
+
54
+ for pdf_file in pdf_files:
55
+ pages = load_pdf(pdf_file)
56
+ all_pages.extend(pages)
57
+
58
+ logger.info(f"Total pages loaded: {len(all_pages)} from {len(pdf_files)} file(s)")
59
+ return all_pages
src/retrieval/__init__.py ADDED
File without changes
src/retrieval/__pycache__/__init__.cpython-314.pyc ADDED
Binary file (154 Bytes). View file
 
src/retrieval/__pycache__/embedder.cpython-314.pyc ADDED
Binary file (3.69 kB). View file
 
src/retrieval/__pycache__/retriever.cpython-314.pyc ADDED
Binary file (2.59 kB). View file
 
src/retrieval/__pycache__/vector_store.cpython-314.pyc ADDED
Binary file (5.85 kB). View file
 
src/retrieval/embedder.py ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Converts text (chunks or queries) into dense vector embeddings.
3
+
4
+ Why sentence-transformers and not the OpenAI embeddings API?
5
+ - Runs fully locally — no cost, no rate limits, no data leaving your machine.
6
+ - You understand what the model IS: a fine-tuned BERT encoder.
7
+ - In interviews: "I used sentence-transformers because I wanted full control
8
+ over the embedding layer and to avoid vendor lock-in."
9
+
10
+ Model choice:
11
+ - all-MiniLM-L6-v2: fast, small (80MB), good general quality. Good for dev.
12
+ - bge-large-en-v1.5: slower, larger, better retrieval quality. Good for prod.
13
+ """
14
+
15
+ from sentence_transformers import SentenceTransformer
16
+ from src.utils.config import config
17
+ from src.utils.logger import logger
18
+
19
+
20
+ class Embedder:
21
+ """Wraps a sentence-transformer model with a consistent interface."""
22
+
23
+ def __init__(self, model_name: str = None):
24
+ model_name = model_name or config.embedding_model
25
+ logger.info(f"Loading embedding model: {model_name}")
26
+ self.model = SentenceTransformer(model_name)
27
+ self.model_name = model_name
28
+ self.embedding_dim = self.model.get_embedding_dimension()
29
+ logger.info(f" Embedding dimension: {self.embedding_dim}")
30
+
31
+ def embed_texts(self, texts: list[str]) -> list[list[float]]:
32
+ """
33
+ Embed a batch of strings.
34
+
35
+ Returns list of float vectors (one per input string).
36
+ Batching is important: embedding 1000 texts one-by-one is ~10x slower
37
+ than batching them together.
38
+ """
39
+ if not texts:
40
+ return []
41
+ logger.debug(f"Embedding {len(texts)} texts...")
42
+ embeddings = self.model.encode(texts, batch_size=32, show_progress_bar=False)
43
+ return embeddings.tolist()
44
+
45
+ def embed_query(self, query: str) -> list[float]:
46
+ """
47
+ Embed a single query string.
48
+
49
+ Kept separate from embed_texts because some models use different
50
+ pooling for queries vs documents (asymmetric embedding models).
51
+ """
52
+ return self.model.encode(query).tolist()
src/retrieval/retriever.py ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Ties embedder + vector store into a single retrieve() call.
3
+
4
+ This is the interface the rest of the app uses.
5
+ Neither app.py nor rag_chain.py should talk to Embedder or VectorStore directly.
6
+ """
7
+
8
+ from src.retrieval.embedder import Embedder
9
+ from src.retrieval.vector_store import VectorStore
10
+ from src.utils.config import config
11
+ from src.utils.logger import logger
12
+
13
+
14
+ class Retriever:
15
+
16
+ def __init__(self):
17
+ self.embedder = Embedder()
18
+ self.store = VectorStore()
19
+
20
+ def retrieve(self, query: str, top_k: int = None) -> list[dict]:
21
+ """
22
+ Given a natural language query, return the top-k relevant chunks.
23
+
24
+ Steps:
25
+ 1. Embed the query using the same model used for documents.
26
+ (Critical: query and document embeddings MUST use the same model.)
27
+ 2. Search the vector store for nearest neighbours.
28
+ 3. Return ranked chunks with source metadata.
29
+ """
30
+ top_k = top_k or config.top_k
31
+ logger.info(f"Retrieving top-{top_k} chunks for: '{query[:60]}...'")
32
+
33
+ query_embedding = self.embedder.embed_query(query)
34
+ chunks = self.store.query(query_embedding, top_k=top_k)
35
+
36
+ for i, chunk in enumerate(chunks):
37
+ logger.debug(f" [{i+1}] score={chunk['score']} | {chunk['source']} p{chunk['page']}")
38
+
39
+ return chunks
src/retrieval/vector_store.py ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ ChromaDB vector store wrapper.
3
+
4
+ ChromaDB stores: the raw text, the embedding vector, and metadata (source, page).
5
+ On disk so the vector store survives between runs — you don't re-embed on every restart.
6
+
7
+ Interview concept to own:
8
+ Chroma uses HNSW (Hierarchical Navigable Small World) indexing under the hood.
9
+ HNSW is an approximate nearest neighbour algorithm — it trades a tiny bit of
10
+ recall for massive speed gains at scale. At 10M vectors, brute-force cosine
11
+ search is unusable; HNSW is O(log n).
12
+ """
13
+
14
+ import chromadb
15
+ from chromadb.config import Settings
16
+ from src.utils.config import config
17
+ from src.utils.logger import logger
18
+
19
+
20
+ class VectorStore:
21
+
22
+ def __init__(self, collection_name: str = None, persist_dir: str = None):
23
+ collection_name = collection_name or config.collection_name
24
+ persist_dir = persist_dir or config.chroma_persist_dir
25
+
26
+ self.client = chromadb.PersistentClient(
27
+ path=persist_dir,
28
+ settings=Settings(anonymized_telemetry=False),
29
+ )
30
+ self.collection = self.client.get_or_create_collection(
31
+ name=collection_name,
32
+ metadata={"hnsw:space": "cosine"}, # Use cosine distance, not L2
33
+ )
34
+ logger.info(f"Vector store ready: '{collection_name}' at {persist_dir}")
35
+ logger.info(f" Current document count: {self.collection.count()}")
36
+
37
+ def add_chunks(self, chunks: list[dict], embeddings: list[list[float]]) -> None:
38
+ """
39
+ Store chunks + their embeddings in Chroma.
40
+
41
+ Args:
42
+ chunks: Output from chunker.chunk_pages()
43
+ embeddings: Parallel list of embedding vectors from Embedder.embed_texts()
44
+ """
45
+ if not chunks:
46
+ logger.warning("No chunks to add.")
47
+ return
48
+
49
+ self.collection.add(
50
+ ids=[c["chunk_id"] for c in chunks],
51
+ embeddings=embeddings,
52
+ documents=[c["text"] for c in chunks],
53
+ metadatas=[{"source": c["source"], "page": c["page"]} for c in chunks],
54
+ )
55
+ logger.info(f"Added {len(chunks)} chunks. Total in store: {self.collection.count()}")
56
+
57
+ def query(self, query_embedding: list[float], top_k: int = None) -> list[dict]:
58
+ """
59
+ Find the top-k most similar chunks to a query embedding.
60
+
61
+ Returns:
62
+ List of dicts: [{"text": str, "source": str, "page": int, "score": float}]
63
+ Ordered by similarity (most similar first).
64
+ """
65
+ top_k = top_k or config.top_k
66
+ results = self.collection.query(
67
+ query_embeddings=[query_embedding],
68
+ n_results=top_k,
69
+ include=["documents", "metadatas", "distances"],
70
+ )
71
+
72
+ chunks = []
73
+ for doc, meta, dist in zip(
74
+ results["documents"][0],
75
+ results["metadatas"][0],
76
+ results["distances"][0],
77
+ ):
78
+ chunks.append({
79
+ "text": doc,
80
+ "source": meta["source"],
81
+ "page": meta["page"],
82
+ "score": round(1 - dist, 4), # Convert cosine distance → similarity
83
+ })
84
+
85
+ return chunks
86
+
87
+ def reset(self) -> None:
88
+ """Delete and recreate the collection. Use during development to re-ingest."""
89
+ name = self.collection.name
90
+ self.client.delete_collection(name)
91
+ self.collection = self.client.get_or_create_collection(
92
+ name=name,
93
+ metadata={"hnsw:space": "cosine"},
94
+ )
95
+ logger.warning(f"Collection '{name}' has been reset.")
src/utils/__init__.py ADDED
File without changes
src/utils/__pycache__/__init__.cpython-314.pyc ADDED
Binary file (150 Bytes). View file
 
src/utils/__pycache__/config.cpython-314.pyc ADDED
Binary file (3.29 kB). View file
 
src/utils/__pycache__/logger.cpython-314.pyc ADDED
Binary file (1.04 kB). View file
 
src/utils/config.py ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Loads config.yaml and .env into a single typed config object.
3
+ Everything in the codebase imports from here — no hardcoded values elsewhere.
4
+ """
5
+
6
+ import os
7
+ from pathlib import Path
8
+ import yaml
9
+ from dotenv import load_dotenv
10
+
11
+ load_dotenv()
12
+
13
+ _CONFIG_PATH = Path(__file__).parent.parent.parent / "configs" / "config.yaml"
14
+
15
+
16
+ def _load_yaml() -> dict:
17
+ with open(_CONFIG_PATH, "r") as f:
18
+ return yaml.safe_load(f)
19
+
20
+
21
+ class Config:
22
+ """Single source of truth for all runtime settings."""
23
+
24
+ def __init__(self):
25
+ cfg = _load_yaml()
26
+
27
+ # Ingestion
28
+ self.chunk_size: int = cfg["ingestion"]["chunk_size"]
29
+ self.chunk_overlap: int = cfg["ingestion"]["chunk_overlap"]
30
+
31
+ # Retrieval
32
+ self.embedding_model: str = cfg["retrieval"]["embedding_model"]
33
+ self.top_k: int = cfg["retrieval"]["top_k"]
34
+ self.collection_name: str = cfg["retrieval"]["collection_name"]
35
+ self.chroma_persist_dir: str = cfg["retrieval"]["chroma_persist_dir"]
36
+
37
+ # Generation
38
+ self.llm_backend: str = cfg["generation"]["backend"]
39
+ self.groq_model: str = cfg["generation"]["groq_model"]
40
+ self.max_new_tokens: int = cfg["generation"]["max_new_tokens"]
41
+ self.temperature: float = cfg["generation"]["temperature"]
42
+ self.groq_model: str = cfg["generation"]["groq_model"]
43
+
44
+ # Secrets from .env (never from yaml)
45
+ self.hf_api_token: str = os.getenv("HF_API_TOKEN", "")
46
+ self.ollama_base_url: str = os.getenv("OLLAMA_BASE_URL", "http://localhost:11434")
47
+
48
+ # Evaluation
49
+ self.faithfulness_threshold: float = cfg["evaluation"]["faithfulness_threshold"]
50
+
51
+ def __repr__(self):
52
+ return (
53
+ f"Config(backend={self.llm_backend}, "
54
+ f"embedding={self.embedding_model}, "
55
+ f"chunk_size={self.chunk_size})"
56
+ )
57
+
58
+
59
+ # Module-level singleton — import this everywhere
60
+ config = Config()
src/utils/logger.py ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Centralised logger using loguru.
3
+ Import `logger` from here everywhere — don't use print() in production code.
4
+ """
5
+
6
+ import sys
7
+ from loguru import logger
8
+ from pathlib import Path
9
+
10
+ LOG_DIR = Path("logs")
11
+ LOG_DIR.mkdir(exist_ok=True)
12
+
13
+ # Remove default handler, set our format
14
+ logger.remove()
15
+
16
+ logger.add(
17
+ sys.stdout,
18
+ format="<green>{time:HH:mm:ss}</green> | <level>{level: <8}</level> | <cyan>{name}</cyan>:<cyan>{line}</cyan> - {message}",
19
+ level="INFO",
20
+ colorize=True,
21
+ )
22
+
23
+ logger.add(
24
+ LOG_DIR / "app.log",
25
+ format="{time:YYYY-MM-DD HH:mm:ss} | {level} | {name}:{line} - {message}",
26
+ level="DEBUG",
27
+ rotation="10 MB",
28
+ retention="7 days",
29
+ )
30
+
31
+ __all__ = ["logger"]