Spaces:
Sleeping
Sleeping
Upload 40 files
Browse files- .env.example +18 -0
- .gitattributes +2 -0
- app.py +124 -0
- configs/config.yaml +27 -0
- data/raw/attention_is_all_you_need.pdf +3 -0
- data/raw/rag_for_knowledge_intesive_NLP_tasks.pdf +3 -0
- requirements.txt +0 -0
- src/__init__.py +0 -0
- src/__pycache__/__init__.cpython-314.pyc +0 -0
- src/evaluation/__init__.py +0 -0
- src/evaluation/__pycache__/__init__.cpython-314.pyc +0 -0
- src/evaluation/__pycache__/ragas_eval.cpython-314.pyc +0 -0
- src/evaluation/ragas_eval.py +281 -0
- src/generation/__init__.py +0 -0
- src/generation/__pycache__/__init__.cpython-314.pyc +0 -0
- src/generation/__pycache__/llm_client.cpython-314.pyc +0 -0
- src/generation/__pycache__/prompt_builder.cpython-314.pyc +0 -0
- src/generation/__pycache__/rag_chain.cpython-314.pyc +0 -0
- src/generation/llm_client.py +39 -0
- src/generation/prompt_builder.py +83 -0
- src/generation/rag_chain.py +61 -0
- src/ingestion/__init__.py +0 -0
- src/ingestion/__pycache__/__init__.cpython-314.pyc +0 -0
- src/ingestion/__pycache__/chunker.cpython-314.pyc +0 -0
- src/ingestion/__pycache__/pdf_loader.cpython-314.pyc +0 -0
- src/ingestion/chunker.py +77 -0
- src/ingestion/pdf_loader.py +59 -0
- src/retrieval/__init__.py +0 -0
- src/retrieval/__pycache__/__init__.cpython-314.pyc +0 -0
- src/retrieval/__pycache__/embedder.cpython-314.pyc +0 -0
- src/retrieval/__pycache__/retriever.cpython-314.pyc +0 -0
- src/retrieval/__pycache__/vector_store.cpython-314.pyc +0 -0
- src/retrieval/embedder.py +52 -0
- src/retrieval/retriever.py +39 -0
- src/retrieval/vector_store.py +95 -0
- src/utils/__init__.py +0 -0
- src/utils/__pycache__/__init__.cpython-314.pyc +0 -0
- src/utils/__pycache__/config.cpython-314.pyc +0 -0
- src/utils/__pycache__/logger.cpython-314.pyc +0 -0
- src/utils/config.py +60 -0
- 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"]
|