JackSparrow89 commited on
Commit
76d5a51
Β·
verified Β·
1 Parent(s): 425841e

Upload 21 files

Browse files
.dockerignore ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .git
2
+ .gitignore
3
+ .venv
4
+ __pycache__/
5
+ *.pyc
6
+ *.pyo
7
+ *.pyd
8
+ *.log
9
+ .pytest_cache/
10
+ .mypy_cache/
11
+ .ruff_cache/
12
+ .idea/
13
+ .vscode/
14
+ data/
15
+ results/
.gitignore ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Virtual environment
2
+ .venv/
3
+
4
+ # Vector index and database (large binary files)
5
+ data/
6
+ results/
7
+
8
+ # Logs
9
+ *.log
10
+
11
+ # Downloaded ML models (auto-downloaded at runtime)
12
+ models/
13
+ .cache/
14
+ sentence_transformers/
15
+
16
+ # Python cache
17
+ ___pycache__/
18
+ *.pyc
19
+ *.pyo
20
+ *.pyd
21
+ .env
22
+ venv/
23
+ env/
24
+ .venv/
25
+ # Model cache
26
+ .cache/
27
+
28
+ # OS files
29
+ .DS_Store
30
+ Thumbs.db
31
+
32
+ # IDE
33
+ .vscode/
34
+ .idea/
Dockerfile ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.10
2
+
3
+ ENV PYTHONDONTWRITEBYTECODE=1 \
4
+ PYTHONUNBUFFERED=1 \
5
+ PIP_NO_CACHE_DIR=1 \
6
+ NLTK_DATA=/usr/local/share/nltk_data
7
+
8
+ WORKDIR /app
9
+
10
+ COPY requirements.txt .
11
+
12
+ RUN pip install --upgrade pip && \
13
+ pip install -r requirements.txt && \
14
+ python -c "import nltk; nltk.download('wordnet', download_dir='/usr/local/share/nltk_data'); nltk.download('omw-1.4', download_dir='/usr/local/share/nltk_data')"
15
+
16
+ COPY . .
17
+
18
+ EXPOSE 7860
19
+
20
+ CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
check_nfcorpus.py ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import sys
3
+ import os
4
+
5
+ sys.path.append(os.path.abspath("."))
6
+ # Load results
7
+ with open('results/eval_nfcorpus.json') as f:
8
+ data = json.load(f)
9
+
10
+ # Load qrels
11
+ from evaluation.dataset_loader import DatasetLoader
12
+
13
+ loader = DatasetLoader('data/nfcorpus')
14
+ qrels = loader.load_qrels()
15
+
16
+ # πŸ” Debug prints
17
+ print("Sample RESULT query_id:", list(data.keys())[0])
18
+
19
+ first_qid = list(qrels.keys())[0]
20
+ print("Sample QREL query_id:", first_qid)
21
+
22
+ print("Sample QREL doc_id:", list(qrels[first_qid].keys())[0])
23
+
24
+ print("Total QREL queries:", len(qrels))
25
+ print("Total RESULT queries:", len(data))
26
+
27
+ # πŸ”₯ Check overlap
28
+ common = set(data.keys()) & set(qrels.keys())
29
+ print("Common query IDs:", len(common))
config.yaml ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Directories to index
2
+ watch_paths:
3
+ - ./data/scifact
4
+ - ./data/nfcorpus #modify this
5
+
6
+ # File extensions to include
7
+ include_extensions:
8
+ - ".pdf"
9
+ - ".docx"
10
+ - ".txt"
11
+ - ".md"
12
+ - ".pptx"
13
+ - ".xlsx"
14
+ - ".py"
15
+ - ".js"
16
+ - ".ipynb"
17
+
18
+
19
+ # --- Add these ---
20
+ top_k: 5 # final results returned to user
21
+ candidate_k: 20 # candidates fetched before reranking
22
+
23
+ query_expansion: true # WordNet synonym expansion
24
+ max_synonyms: 5 # max synonyms to append
25
+
26
+ reranking_enabled: true # cross-encoder reranking
27
+ reranker_model: "cross-encoder/ms-marco-MiniLM-L-6-v2"
28
+
29
+ # Directories to skip
30
+ skip_directories:
31
+ - ".git"
32
+ - "node_modules"
33
+ - "__pycache__"
34
+ - ".venv"
35
+
36
+ # Where to store index data
37
+ data_dir: "./data"
38
+
39
+ embedding_model: "all-MiniLM-L6-v2"
40
+ # embedding_model: BAAI/bge-small-en-v1.5
41
+
42
+ debounce_seconds: 5
docker-compose.yml ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ services:
2
+ semantic-search:
3
+ build:
4
+ context: .
5
+ dockerfile: Dockerfile
6
+ container_name: semantic-search
7
+ ports:
8
+ - "7860:7860"
9
+ environment:
10
+ NLTK_DATA: /usr/local/share/nltk_data
11
+ volumes:
12
+ - ./config.yaml:/app/config.yaml:ro
13
+ - ./data:/app/data
14
+ - ./results:/app/results
15
+ - ./documents:/documents
16
+ restart: unless-stopped
main.py ADDED
@@ -0,0 +1,269 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # main.py
2
+
3
+ import json
4
+ import os
5
+ import time
6
+ import yaml
7
+ from fastapi import FastAPI, Request, Form
8
+ from fastapi.responses import HTMLResponse
9
+ from fastapi.staticfiles import StaticFiles
10
+ from fastapi.templating import Jinja2Templates
11
+
12
+ from searcher.search_engine import SearchEngine
13
+ from evaluation.dataset_loader import DatasetLoader
14
+
15
+ app = FastAPI(title="Semantic Search Engine")
16
+
17
+ app.mount("/static", StaticFiles(directory="static"), name="static")
18
+ templates = Jinja2Templates(directory="templates")
19
+
20
+ # ── load search engine once at startup ──────────────────────────────────────
21
+ def get_engine():
22
+ from searcher.search_engine import SearchEngine
23
+ return SearchEngine("config.yaml")
24
+
25
+ engine = get_engine()
26
+
27
+
28
+ # ── load dataset queries at startup ─────────────────────────────────────────
29
+ # These are the actual queries from SciFact and NFCorpus
30
+ # We use them to show "which dataset queries matched your search"
31
+
32
+ def load_dataset_queries() -> dict:
33
+ """
34
+ Load all queries from SciFact and NFCorpus at startup.
35
+
36
+ Returns:
37
+ dict β€” {
38
+ "scifact": {query_id: query_text, ...},
39
+ "nfcorpus": {query_id: query_text, ...},
40
+ }
41
+ """
42
+ all_queries = {}
43
+
44
+ datasets = {
45
+ "scifact": "data/scifact",
46
+ "nfcorpus": "data/nfcorpus",
47
+ }
48
+
49
+ for name, path in datasets.items():
50
+ if os.path.exists(path):
51
+ try:
52
+ loader = DatasetLoader(path)
53
+ all_queries[name] = loader.load_queries()
54
+ print(f"[Startup] Loaded {len(all_queries[name])} queries from {name}")
55
+ except Exception as e:
56
+ print(f"[Startup] Could not load {name} queries: {e}")
57
+ all_queries[name] = {}
58
+ else:
59
+ print(f"[Startup] Dataset path not found: {path}")
60
+ all_queries[name] = {}
61
+
62
+ return all_queries
63
+
64
+
65
+ # load once at startup β€” available globally
66
+ DATASET_QUERIES = load_dataset_queries()
67
+
68
+
69
+ # ── helpers ──────────────────────────────────────────────────────────────────
70
+
71
+ def load_eval_results() -> dict:
72
+ path = "results/eval_all.json"
73
+ if os.path.exists(path):
74
+ with open(path, "r") as f:
75
+ return json.load(f)
76
+ return {}
77
+
78
+
79
+ def extract_doc_id(filepath: str) -> str:
80
+ if "://" in filepath:
81
+ return filepath.split("://", 1)[1]
82
+ return filepath
83
+
84
+
85
+ def get_dataset_from_filepath(filepath: str) -> str:
86
+ if "scifact://" in filepath: return "scifact"
87
+ if "nfcorpus://" in filepath: return "nfcorpus"
88
+ return "filesystem"
89
+
90
+
91
+ def get_file_icon(filepath: str) -> str:
92
+ if "scifact://" in filepath: return "πŸ”¬"
93
+ if "nfcorpus://" in filepath: return "πŸ₯"
94
+ ext = filepath.lower().split(".")[-1] if "." in filepath else ""
95
+ icons = {
96
+ "pdf": "πŸ“„", "docx": "πŸ“", "txt": "πŸ“ƒ",
97
+ "pptx": "πŸ“Š", "xlsx": "πŸ“‹", "py": "🐍",
98
+ }
99
+ return icons.get(ext, "πŸ“„")
100
+
101
+
102
+ def find_matching_dataset_queries(
103
+ user_query: str,
104
+ top_results: list,
105
+ ) -> list:
106
+ """
107
+ Find which dataset queries are semantically related to what the user typed.
108
+
109
+ Strategy β€” two passes:
110
+ 1. Exact / substring match β€” query text contains user words
111
+ 2. Doc-based match β€” if a result doc came from dataset X,
112
+ show the queries that reference that doc
113
+ from the qrels (loaded separately)
114
+
115
+ We use simple word overlap here (no extra model call needed).
116
+
117
+ Returns:
118
+ list of dicts β€” [
119
+ {
120
+ "query_id": "1234",
121
+ "query_text": "Does vitamin D cause cancer?",
122
+ "dataset": "scifact",
123
+ "match_type": "text" or "doc"
124
+ },
125
+ ...
126
+ ]
127
+ """
128
+ matched = []
129
+ seen_ids = set()
130
+
131
+ # words from user query β€” lowercase, skip short words
132
+ user_words = set(
133
+ w.lower() for w in user_query.split()
134
+ if len(w) > 3
135
+ )
136
+
137
+ # Pass 1 β€” text overlap match
138
+ # check every dataset query for word overlap with user query
139
+ for dataset_name, queries in DATASET_QUERIES.items():
140
+ for qid, qtext in queries.items():
141
+ q_words = set(w.lower() for w in qtext.split() if len(w) > 3)
142
+ overlap = user_words & q_words
143
+
144
+ # need at least 1 word overlap
145
+ if overlap and qid not in seen_ids:
146
+ matched.append({
147
+ "query_id": qid,
148
+ "query_text": qtext,
149
+ "dataset": dataset_name,
150
+ "match_type": "text",
151
+ "overlap": len(overlap),
152
+ })
153
+ seen_ids.add(qid)
154
+
155
+ # sort by overlap count β€” most overlapping queries first
156
+ matched.sort(key=lambda x: x["overlap"], reverse=True)
157
+
158
+ # return top 8 matched queries max
159
+ return matched[:8]
160
+
161
+
162
+ # ── routes ───────────────────────────────────────────────────────────────────
163
+
164
+ @app.get("/", response_class=HTMLResponse)
165
+ async def home(request: Request):
166
+ return templates.TemplateResponse("index.html", {
167
+ "request": request,
168
+ "scifact_count": len(DATASET_QUERIES.get("scifact", {})),
169
+ "nfcorpus_count": len(DATASET_QUERIES.get("nfcorpus", {})),
170
+ })
171
+
172
+
173
+ @app.post("/search", response_class=HTMLResponse)
174
+ async def search(
175
+ request: Request,
176
+ query: str = Form(...),
177
+ top_k: int = Form(10),
178
+ mode: str = Form("full"),
179
+ ):
180
+ if not query.strip():
181
+ return templates.TemplateResponse("index.html", {
182
+ "request": request,
183
+ "error": "Please enter a search query.",
184
+ })
185
+
186
+ t0 = time.time()
187
+ output = engine.search(query.strip(), top_k=top_k)
188
+ elapsed = round(time.time() - t0, 3)
189
+
190
+ # format search results
191
+ results = []
192
+ for r in output.get("results", []):
193
+ filepath = r.get("filepath", "")
194
+ doc_id = extract_doc_id(filepath)
195
+ score = r.get("rerank_score", r.get("rrf_score", r.get("dense_score", 0)))
196
+ snippet = r.get("chunk_text", r.get("text", "No preview available."))
197
+
198
+ if len(snippet) > 200:
199
+ snippet = snippet[:200].rsplit(" ", 1)[0] + "..."
200
+
201
+ dataset = get_dataset_from_filepath(filepath)
202
+
203
+ results.append({
204
+ "doc_id": doc_id,
205
+ "filepath": filepath,
206
+ "score": round(float(score), 4),
207
+ "snippet": snippet,
208
+ "icon": get_file_icon(filepath),
209
+ "dataset": dataset,
210
+ })
211
+
212
+ # find matching dataset queries
213
+ matched_queries = find_matching_dataset_queries(query.strip(), results)
214
+
215
+ # group matched queries by dataset for display
216
+ matched_scifact = [q for q in matched_queries if q["dataset"] == "scifact"]
217
+ matched_nfcorpus = [q for q in matched_queries if q["dataset"] == "nfcorpus"]
218
+
219
+ return templates.TemplateResponse("results.html", {
220
+ "request": request,
221
+ "query": query,
222
+ "results": results,
223
+ "total": len(results),
224
+ "elapsed": elapsed,
225
+ "mode": mode,
226
+ "top_k": top_k,
227
+ "matched_scifact": matched_scifact,
228
+ "matched_nfcorpus": matched_nfcorpus,
229
+ "total_matched": len(matched_queries),
230
+ })
231
+
232
+
233
+ @app.get("/dashboard", response_class=HTMLResponse)
234
+ async def dashboard(request: Request):
235
+ eval_data = load_eval_results()
236
+
237
+ datasets = []
238
+ for dataset_name, mode_results in eval_data.items():
239
+ full = mode_results.get("full", {})
240
+ datasets.append({
241
+ "name": dataset_name,
242
+ "ndcg": full.get("NDCG@10", 0.0),
243
+ "mrr": full.get("MRR", 0.0),
244
+ "map": full.get("MAP@100", 0.0),
245
+ "recall": full.get("Recall@100", 0.0),
246
+ "precision": full.get("P@10", 0.0),
247
+ "queries": full.get("num_queries", 0),
248
+ "modes": mode_results,
249
+ })
250
+
251
+ return templates.TemplateResponse("dashboard.html", {
252
+ "request": request,
253
+ "datasets": datasets,
254
+ })
255
+
256
+
257
+ @app.get("/health")
258
+ async def health():
259
+ return {"status": "ok"}
260
+
261
+
262
+ if __name__ == "__main__":
263
+ import uvicorn
264
+ uvicorn.run("main:app", host="0.0.0.0", port=7860, reload=True)
265
+
266
+
267
+
268
+
269
+ # uvicorn main:app --reload --host 0.0.0.0 --port 8000
requirements.txt ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ annotated-doc==0.0.4
2
+ anyio==4.12.1
3
+ certifi==2026.2.25
4
+ click==8.3.1
5
+ colorama==0.4.6
6
+ et_xmlfile==2.0.0
7
+ faiss-cpu==1.13.2
8
+ filelock==3.25.2
9
+ fsspec==2026.2.0
10
+ h11==0.16.0
11
+ hf-xet==1.4.2
12
+ httpcore==1.0.9
13
+ httpx==0.28.1
14
+ huggingface_hub==1.7.1
15
+ idna==3.11
16
+ Jinja2==3.1.6
17
+ joblib==1.5.3
18
+ lxml==6.0.2
19
+ markdown-it-py==4.0.0
20
+ MarkupSafe==3.0.3
21
+ mdurl==0.1.2
22
+ mpmath==1.3.0
23
+ networkx==3.6.1
24
+ numpy==2.4.3
25
+ openpyxl==3.1.5
26
+ packaging==26.0
27
+ pillow==12.1.1
28
+ Pygments==2.19.2
29
+ PyMuPDF==1.27.2
30
+ python-docx==1.2.0
31
+ python-pptx==1.0.2
32
+ PyYAML==6.0.3
33
+ regex==2026.2.28
34
+ rich==14.3.3
35
+ safetensors==0.7.0
36
+ scikit-learn==1.8.0
37
+ scipy==1.17.1
38
+ sentence-transformers==5.3.0
39
+ setuptools==82.0.1
40
+ shellingham==1.5.4
41
+ sympy==1.14.0
42
+ threadpoolctl==3.6.0
43
+ tokenizers==0.22.2
44
+ torch==2.10.0
45
+ tqdm==4.67.3
46
+ transformers==5.3.0
47
+ typer==0.24.1
48
+ typing_extensions==4.15.0
49
+ watchdog==6.0.0
50
+ xlsxwriter==3.2.9
51
+ # add these
52
+ nltk # WordNet query expansion
53
+ rank-bm25 # optional, but our SparseRetriever is self-contained
54
+ fastapi # phase 4 API
55
+ uvicorn # phase 4 API server
56
+ typer # phase 4 CLI
57
+ jinja2
58
+ python-multipart
searcher/__init__.py ADDED
File without changes
searcher/dense_retriever.py ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # searcher/dense_retriever.py
2
+
3
+ import numpy as np
4
+ import faiss
5
+ import sqlite3
6
+ import yaml
7
+ from indexer.embedder import Embedder
8
+
9
+
10
+ class DenseRetriever:
11
+ """
12
+ Embeds the (expanded) query and searches the FAISS index for
13
+ nearest-neighbor chunks by semantic similarity.
14
+
15
+ This is the core semantic search β€” finds chunks that MEAN the same
16
+ thing as the query, even if they share no keywords.
17
+ """
18
+
19
+ def __init__(self, config_path="config.yaml"):
20
+ with open(config_path) as f:
21
+ config = yaml.safe_load(f)
22
+
23
+ self.data_dir = config["data_dir"]
24
+ self.faiss_path = f"{self.data_dir}/index.faiss"
25
+ self.db_path = f"{self.data_dir}/metadata.db"
26
+ self.top_k = config.get("top_k", 20) # fetch more than needed; reranker will trim
27
+
28
+ self.embedder = Embedder(config_path)
29
+ self.index = faiss.read_index(self.faiss_path)
30
+
31
+ def retrieve(self, query: str, top_k: int = None) -> list[dict]:
32
+ """
33
+ Embed the query and search FAISS for the closest chunk vectors.
34
+
35
+ Args:
36
+ query (str) β€” expanded query string
37
+ top_k (int) β€” number of results
38
+
39
+ Returns:
40
+ list[dict] each containing:
41
+ chunk_id, chunk_text, filepath, chunk_index,
42
+ dense_score (float, lower = more similar in L2 space)
43
+ """
44
+ k = top_k or self.top_k
45
+
46
+ query_vec = self.embedder.embed_single(query)
47
+ query_vec = np.array([query_vec], dtype="float32")
48
+
49
+ distances, ids = self.index.search(query_vec, k)
50
+
51
+ results = []
52
+ conn = sqlite3.connect(self.db_path)
53
+ cursor = conn.cursor()
54
+
55
+ for dist, chunk_id in zip(distances[0], ids[0]):
56
+ if chunk_id == -1:
57
+ continue
58
+ row = cursor.execute(
59
+ "SELECT chunk_text, filepath, chunk_index FROM chunks WHERE id = ?",
60
+ (int(chunk_id),)
61
+ ).fetchone()
62
+ if row:
63
+ results.append({
64
+ "chunk_id": int(chunk_id),
65
+ "chunk_text": row[0],
66
+ "filepath": row[1],
67
+ "chunk_index": row[2],
68
+ "dense_score": float(dist),
69
+ })
70
+
71
+ conn.close()
72
+ return results
73
+
74
+
75
+ if __name__ == "__main__":
76
+ dr = DenseRetriever()
77
+ results = dr.retrieve("quarterly budget report", top_k=5)
78
+ for r in results:
79
+ print(f"[{r['dense_score']:.4f}] {r['filepath']} β†’ {r['chunk_text'][:80]}")
searcher/facet_filter.py ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # searcher/facet_filter.py
2
+
3
+ import os
4
+ from datetime import datetime
5
+
6
+
7
+ class FacetFilter:
8
+ """
9
+ Post-retrieval filtering by structured metadata.
10
+ Applied AFTER fusion and reranking to restrict results
11
+ without hurting semantic quality.
12
+
13
+ Supported facets:
14
+ file_type β€” e.g. [".pdf", ".docx"]
15
+ date_after β€” only files modified after this date
16
+ date_before β€” only files modified before this date
17
+ min_size β€” minimum file size in bytes
18
+ max_size β€” maximum file size in bytes
19
+ directory β€” only files inside this directory path
20
+ """
21
+
22
+ def filter(
23
+ self,
24
+ results: list[dict],
25
+ file_type: list[str] = None,
26
+ date_after: datetime = None,
27
+ date_before: datetime = None,
28
+ min_size: int = None,
29
+ max_size: int = None,
30
+ directory: str = None,
31
+ ) -> list[dict]:
32
+ """
33
+ Apply facet filters to a list of search results.
34
+
35
+ Args:
36
+ results β€” list of chunk dicts (must include 'filepath')
37
+ file_type β€” list of extensions e.g. [".pdf", ".txt"]
38
+ date_after β€” datetime object; exclude files older than this
39
+ date_before β€” datetime object; exclude files newer than this
40
+ min_size β€” minimum file size in bytes
41
+ max_size β€” maximum file size in bytes
42
+ directory β€” restrict to files under this directory
43
+
44
+ Returns:
45
+ list[dict] β€” filtered results (same structure, subset)
46
+ """
47
+ filtered = []
48
+
49
+ for result in results:
50
+ fp = result["filepath"]
51
+
52
+ # File type filter
53
+ if file_type:
54
+ ext = os.path.splitext(fp)[1].lower()
55
+ if ext not in [e.lower() for e in file_type]:
56
+ continue
57
+
58
+ # Directory scope filter
59
+ if directory:
60
+ if not fp.startswith(os.path.abspath(directory)):
61
+ continue
62
+
63
+ # File system checks (only if file still exists)
64
+ if os.path.exists(fp):
65
+ stat = os.stat(fp)
66
+
67
+ # Date filters
68
+ mtime = datetime.fromtimestamp(stat.st_mtime)
69
+ if date_after and mtime < date_after:
70
+ continue
71
+ if date_before and mtime > date_before:
72
+ continue
73
+
74
+ # Size filters
75
+ size = stat.st_size
76
+ if min_size and size < min_size:
77
+ continue
78
+ if max_size and size > max_size:
79
+ continue
80
+
81
+ filtered.append(result)
82
+
83
+ return filtered
searcher/fusion_ranker.py ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # searcher/fusion_ranker.py
2
+
3
+
4
+ class FusionRanker:
5
+ """
6
+ Reciprocal Rank Fusion (RRF) merges dense and sparse result lists
7
+ into a single ranked list without needing to normalise scores.
8
+
9
+ Why RRF instead of score averaging?
10
+ - Dense scores (L2 distance) and BM25 scores live on different scales
11
+ - Normalising them requires knowing min/max, which varies per query
12
+ - RRF uses only the RANK of each result, making it scale-invariant
13
+
14
+ RRF formula:
15
+ score(d) = Ξ£ 1 / (k + rank(d, list_i))
16
+ where k=60 is a constant that dampens the effect of very high ranks
17
+ """
18
+
19
+ def __init__(self, k: int = 60):
20
+ self.k = k
21
+
22
+ def fuse(
23
+ self,
24
+ dense_results: list[dict],
25
+ sparse_results: list[dict],
26
+ top_k: int = 10,
27
+ ) -> list[dict]:
28
+ """
29
+ Fuse two ranked result lists using Reciprocal Rank Fusion.
30
+
31
+ Args:
32
+ dense_results β€” from DenseRetriever, ordered by dense_score ascending
33
+ (lower L2 distance = better)
34
+ sparse_results β€” from SparseRetriever, ordered by sparse_score descending
35
+ top_k β€” number of results to return after fusion
36
+
37
+ Returns:
38
+ list[dict] β€” merged results sorted by rrf_score descending,
39
+ each dict includes all original fields + rrf_score
40
+ """
41
+ rrf_scores = {}
42
+ chunk_data = {}
43
+
44
+ # Dense list: lower distance = better rank = higher RRF contribution
45
+ for rank, result in enumerate(dense_results):
46
+ cid = result["chunk_id"]
47
+ rrf_scores[cid] = rrf_scores.get(cid, 0) + 1 / (self.k + rank + 1)
48
+ chunk_data[cid] = result
49
+
50
+ # Sparse list: higher BM25 score = better rank = higher RRF contribution
51
+ for rank, result in enumerate(sparse_results):
52
+ cid = result["chunk_id"]
53
+ rrf_scores[cid] = rrf_scores.get(cid, 0) + 1 / (self.k + rank + 1)
54
+ if cid not in chunk_data:
55
+ chunk_data[cid] = result
56
+
57
+ sorted_ids = sorted(rrf_scores, key=lambda x: rrf_scores[x], reverse=True)[:top_k]
58
+
59
+ fused = []
60
+ for cid in sorted_ids:
61
+ entry = dict(chunk_data[cid])
62
+ entry["rrf_score"] = rrf_scores[cid]
63
+ fused.append(entry)
64
+
65
+ return fused
66
+
67
+
68
+ if __name__ == "__main__":
69
+ dense = [
70
+ {"chunk_id": 1, "chunk_text": "budget summary Q3", "filepath": "/a.pdf", "chunk_index": 0, "dense_score": 0.12},
71
+ {"chunk_id": 2, "chunk_text": "revenue report", "filepath": "/b.pdf", "chunk_index": 0, "dense_score": 0.25},
72
+ ]
73
+ sparse = [
74
+ {"chunk_id": 2, "chunk_text": "revenue report", "filepath": "/b.pdf", "chunk_index": 0, "sparse_score": 8.1},
75
+ {"chunk_id": 3, "chunk_text": "financial overview", "filepath": "/c.pdf", "chunk_index": 0, "sparse_score": 5.2},
76
+ ]
77
+ ranker = FusionRanker()
78
+ results = ranker.fuse(dense, sparse, top_k=5)
79
+ for r in results:
80
+ print(f"[RRF {r['rrf_score']:.5f}] {r['filepath']} β†’ {r['chunk_text'][:60]}")
searcher/highlighter.py ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # searcher/highlighter.py
2
+
3
+
4
+ class Highlighter:
5
+ """
6
+ Extracts the most relevant passage from a chunk and highlights
7
+ query-matching terms for display in search results.
8
+
9
+ Why highlight?
10
+ - The full chunk may be 500 words; the user needs a ~2-sentence preview
11
+ - Highlighting query terms helps users quickly judge relevance
12
+ - This is purely display logic β€” does not affect ranking
13
+ """
14
+
15
+ def __init__(self, preview_words: int = 30):
16
+ self.preview_words = preview_words
17
+
18
+ def extract_preview(self, chunk_text: str, query: str) -> str:
19
+ """
20
+ Find the sentence in chunk_text most relevant to the query
21
+ and return a short preview around it.
22
+
23
+ Strategy: find the window of preview_words words that contains
24
+ the most query term matches.
25
+
26
+ Args:
27
+ chunk_text β€” full text of the chunk
28
+ query β€” original user query
29
+
30
+ Returns:
31
+ str β€” the best preview snippet
32
+ """
33
+ words = chunk_text.split()
34
+ if len(words) <= self.preview_words:
35
+ return chunk_text
36
+
37
+ query_terms = set(query.lower().split())
38
+ best_score = -1
39
+ best_start = 0
40
+
41
+ for i in range(len(words) - self.preview_words + 1):
42
+ window = words[i: i + self.preview_words]
43
+ score = sum(1 for w in window if w.lower().strip(".,;:") in query_terms)
44
+ if score > best_score:
45
+ best_score = score
46
+ best_start = i
47
+
48
+ snippet = " ".join(words[best_start: best_start + self.preview_words])
49
+
50
+ # Add ellipsis if truncated
51
+ if best_start > 0:
52
+ snippet = "..." + snippet
53
+ if best_start + self.preview_words < len(words):
54
+ snippet = snippet + "..."
55
+
56
+ return snippet
57
+
58
+ def highlight_html(self, text: str, query: str) -> str:
59
+ """
60
+ Wrap query-matching words in <mark> tags for HTML display.
61
+
62
+ Args:
63
+ text β€” preview snippet
64
+ query β€” original user query
65
+
66
+ Returns:
67
+ str β€” HTML string with <mark> tags around matching words
68
+ """
69
+ query_terms = set(query.lower().split())
70
+ highlighted_words = []
71
+
72
+ for word in text.split():
73
+ clean = word.lower().strip(".,;:!?")
74
+ if clean in query_terms:
75
+ highlighted_words.append(f"<mark>{word}</mark>")
76
+ else:
77
+ highlighted_words.append(word)
78
+
79
+ return " ".join(highlighted_words)
80
+
81
+ def annotate(self, results: list[dict], query: str) -> list[dict]:
82
+ """
83
+ Add preview and highlighted_preview to each result dict.
84
+
85
+ Args:
86
+ results β€” list of chunk dicts
87
+ query β€” original user query
88
+
89
+ Returns:
90
+ list[dict] β€” same results with 'preview' and 'preview_html' added
91
+ """
92
+ for result in results:
93
+ preview = self.extract_preview(result["chunk_text"], query)
94
+ result["preview"] = preview
95
+ result["preview_html"] = self.highlight_html(preview, query)
96
+ return results
searcher/query_understanding.py ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # searcher/query_understanding.py
2
+
3
+ import yaml
4
+ from nltk.corpus import wordnet
5
+ import nltk
6
+
7
+ nltk.download("wordnet", quiet=True)
8
+ nltk.download("omw-1.4", quiet=True)
9
+
10
+
11
+ class QueryUnderstanding:
12
+ """
13
+ Expands and rewrites the raw user query before retrieval.
14
+
15
+ Two strategies:
16
+ 1. WordNet synonym expansion β€” "apples" β†’ ["apple", "malus", "orchard", "fruit"]
17
+ 2. (Optional) T5 query rewriting β€” rephrase for better embedding alignment
18
+
19
+ Why expand?
20
+ - A search for "apples" should also find "fruit", "orchard", "nutrition"
21
+ - Embedding models are good but can miss synonyms in different domains
22
+ - Expansion bridges the vocabulary gap before we even hit FAISS
23
+ """
24
+
25
+ def __init__(self, config_path="config.yaml"):
26
+ with open(config_path) as f:
27
+ config = yaml.safe_load(f)
28
+ self.max_synonyms = config.get("max_synonyms", 5)
29
+ self.expansion_enabled = config.get("query_expansion", True)
30
+
31
+ def expand(self, query: str) -> str:
32
+ """
33
+ Expand the query using WordNet synonyms.
34
+
35
+ Args:
36
+ query (str) β€” raw user query
37
+
38
+ Returns:
39
+ str β€” expanded query string (original + synonyms appended)
40
+
41
+ Example:
42
+ "apple nutrition" β†’ "apple nutrition fruit malus orchard dietary"
43
+ """
44
+ if not self.expansion_enabled:
45
+ return query
46
+
47
+ words = query.lower().split()
48
+ synonyms = set()
49
+
50
+ for word in words:
51
+ for syn in wordnet.synsets(word):
52
+ for lemma in syn.lemmas():
53
+ cleaned = lemma.name().replace("_", " ")
54
+ if cleaned.lower() != word:
55
+ synonyms.add(cleaned)
56
+ if len(synonyms) >= self.max_synonyms:
57
+ break
58
+
59
+ expanded = query
60
+ if synonyms:
61
+ expanded = query + " " + " ".join(list(synonyms)[:self.max_synonyms])
62
+
63
+ return expanded
64
+
65
+ def rewrite(self, query: str) -> str:
66
+ """
67
+ Lightweight query rewriting β€” normalise and clean the query.
68
+ (Plug in a T5/FLAN-T5 model here for more powerful rewriting.)
69
+
70
+ Args:
71
+ query (str) β€” raw user query
72
+
73
+ Returns:
74
+ str β€” cleaned query
75
+ """
76
+ # Basic normalisation: strip extra spaces, lowercase
77
+ query = " ".join(query.strip().split())
78
+ return query
79
+
80
+ def process(self, query: str) -> dict:
81
+ """
82
+ Full query understanding pipeline.
83
+
84
+ Returns:
85
+ dict with:
86
+ original β€” the raw input
87
+ rewritten β€” cleaned version
88
+ expanded β€” synonym-expanded version for dense retrieval
89
+ """
90
+ rewritten = self.rewrite(query)
91
+ expanded = self.expand(rewritten)
92
+ return {
93
+ "original": query,
94
+ "rewritten": rewritten,
95
+ "expanded": expanded,
96
+ }
97
+
98
+
99
+ if __name__ == "__main__":
100
+ qu = QueryUnderstanding()
101
+ result = qu.process("quarterly budget report")
102
+ print(f"Original : {result['original']}")
103
+ print(f"Rewritten: {result['rewritten']}")
104
+ print(f"Expanded : {result['expanded']}")
searcher/reranker.py ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # searcher/reranker.py
2
+
3
+ import yaml
4
+ from sentence_transformers import CrossEncoder
5
+
6
+
7
+ class Reranker:
8
+ """
9
+ Cross-encoder reranking for precision improvement on top-k candidates.
10
+
11
+ Why rerank after fusion?
12
+ - Bi-encoders (used in DenseRetriever) embed query and doc INDEPENDENTLY
13
+ β€” fast but they can't model their interaction
14
+ - Cross-encoders process (query, document) TOGETHER β€” much more accurate
15
+ but too slow to run over the whole corpus
16
+ - Solution: use bi-encoder to shortlist ~20 candidates, then cross-encoder
17
+ to precisely rank them β†’ speed of FAISS + accuracy of cross-encoder
18
+
19
+ Model: ms-marco-MiniLM-L-6-v2 (fast, strong on passage relevance)
20
+ """
21
+
22
+ def __init__(self, config_path="config.yaml"):
23
+ with open(config_path) as f:
24
+ config = yaml.safe_load(f)
25
+
26
+ model_name = config.get("reranker_model", "cross-encoder/ms-marco-MiniLM-L-6-v2")
27
+ self.enabled = config.get("reranking_enabled", True)
28
+ if self.enabled:
29
+ print(f"Loading reranker model '{model_name}'...")
30
+ self.model = CrossEncoder(model_name)
31
+ print("Reranker loaded.")
32
+
33
+ def rerank(self, query: str, candidates: list[dict], top_k: int = 5) -> list[dict]:
34
+ """
35
+ Rerank candidates using cross-encoder relevance scores.
36
+
37
+ Args:
38
+ query β€” original (not expanded) user query
39
+ candidates β€” list of chunk dicts from FusionRanker
40
+ top_k β€” number of final results to return
41
+
42
+ Returns:
43
+ list[dict] β€” reranked results, each with rerank_score added
44
+ """
45
+ if not self.enabled or not candidates:
46
+ return candidates[:top_k]
47
+
48
+ pairs = [(query, c["chunk_text"]) for c in candidates]
49
+ scores = self.model.predict(pairs)
50
+
51
+ for i, score in enumerate(scores):
52
+ candidates[i]["rerank_score"] = float(score)
53
+
54
+ reranked = sorted(candidates, key=lambda x: x["rerank_score"], reverse=True)
55
+ return reranked[:top_k]
56
+
57
+
58
+ if __name__ == "__main__":
59
+ reranker = Reranker()
60
+ candidates = [
61
+ {"chunk_id": 1, "chunk_text": "Q3 budget shows 15% revenue increase", "filepath": "/a.pdf", "chunk_index": 0, "rrf_score": 0.03},
62
+ {"chunk_id": 2, "chunk_text": "The weather today is sunny and warm", "filepath": "/b.txt", "chunk_index": 0, "rrf_score": 0.02},
63
+ ]
64
+ results = reranker.rerank("quarterly revenue", candidates, top_k=2)
65
+ for r in results:
66
+ print(f"[rerank {r['rerank_score']:.4f}] {r['chunk_text']}")
searcher/search_engine.py ADDED
@@ -0,0 +1,163 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # searcher/search_engine.py
2
+
3
+ import yaml
4
+ from searcher.query_understanding import QueryUnderstanding
5
+ from searcher.dense_retriever import DenseRetriever
6
+ from searcher.sparse_retriever import SparseRetriever
7
+ from searcher.fusion_ranker import FusionRanker
8
+ from searcher.reranker import Reranker
9
+ from searcher.facet_filter import FacetFilter
10
+ from searcher.highlighter import Highlighter
11
+
12
+
13
+ class SearchEngine:
14
+ """
15
+ Orchestrates the full search pipeline end-to-end:
16
+
17
+ raw query
18
+ β†’ QueryUnderstanding (expand + rewrite)
19
+ β†’ DenseRetriever (semantic FAISS search)
20
+ β†’ SparseRetriever (BM25 lexical search)
21
+ β†’ FusionRanker (RRF merge)
22
+ β†’ Reranker (cross-encoder precision)
23
+ β†’ FacetFilter (type / date / size / directory)
24
+ β†’ Highlighter (preview + HTML highlights)
25
+ β†’ final results
26
+ """
27
+
28
+ def __init__(self, config_path="config.yaml"):
29
+ self.config_path = config_path
30
+ with open(config_path) as f:
31
+ self.config = yaml.safe_load(f)
32
+
33
+ self.query_understanding = QueryUnderstanding(config_path)
34
+ self.dense_retriever = DenseRetriever(config_path)
35
+ self.sparse_retriever = SparseRetriever(config_path)
36
+ self.fusion_ranker = FusionRanker(k=60)
37
+ self.reranker = Reranker(config_path)
38
+ self.facet_filter = FacetFilter()
39
+ self.highlighter = Highlighter(preview_words=30)
40
+
41
+ self.candidate_k = self.config.get("candidate_k", 20)
42
+ self.final_k = self.config.get("top_k", 5)
43
+
44
+ def search(
45
+ self,
46
+ query: str,
47
+ top_k: int = None,
48
+ file_type: list[str] = None,
49
+ date_after=None,
50
+ date_before=None,
51
+ min_size: int = None,
52
+ max_size: int = None,
53
+ directory: str = None,
54
+ ) -> dict:
55
+ """
56
+ Run the full search pipeline.
57
+
58
+ Args:
59
+ query β€” natural language user query
60
+ top_k β€” number of final results (overrides config)
61
+ file_type β€” e.g. [".pdf", ".docx"]
62
+ date_after β€” datetime; exclude older files
63
+ date_before β€” datetime; exclude newer files
64
+ min_size β€” min file size in bytes
65
+ max_size β€” max file size in bytes
66
+ directory β€” restrict to this directory
67
+
68
+ Returns:
69
+ dict:
70
+ query_info β€” dict from QueryUnderstanding
71
+ results β€” list of final result dicts, each with:
72
+ filepath, chunk_text, chunk_index,
73
+ preview, preview_html,
74
+ dense_score (if present),
75
+ sparse_score (if present),
76
+ rrf_score, rerank_score
77
+ """
78
+ k = top_k or self.final_k
79
+
80
+ # Step 1 β€” query understanding
81
+ query_info = self.query_understanding.process(query)
82
+
83
+ query_info.setdefault("original", query)
84
+ query_info.setdefault("expanded", query)
85
+ query_info.setdefault("rewritten", query)
86
+
87
+ # Step 2 β€” dense retrieval (uses expanded query for better semantic reach)
88
+ dense_results = self.dense_retriever.retrieve(
89
+ query_info["expanded"], top_k=self.candidate_k
90
+ )
91
+
92
+ # Step 3 β€” sparse retrieval (uses rewritten query; expansion hurts BM25)
93
+ sparse_results = self.sparse_retriever.retrieve(
94
+ query_info["rewritten"], top_k=self.candidate_k
95
+ )
96
+
97
+ # Step 4 β€” RRF fusion
98
+ fused = self.fusion_ranker.fuse(dense_results, sparse_results, top_k=self.candidate_k)
99
+
100
+ # Step 5 β€” cross-encoder reranking
101
+ reranked = self.reranker.rerank(query_info["original"], fused, top_k=k * 2)
102
+
103
+ # Step 6 β€” facet filtering
104
+ filtered = self.facet_filter.filter(
105
+ reranked,
106
+ file_type=file_type,
107
+ date_after=date_after,
108
+ date_before=date_before,
109
+ min_size=min_size,
110
+ max_size=max_size,
111
+ directory=directory,
112
+ )
113
+
114
+ # Trim to top_k after filtering
115
+ final = filtered[:k]
116
+
117
+ # Step 7 β€” highlight previews
118
+ final = self.highlighter.annotate(final, query_info["original"])
119
+ for r in final:
120
+ if "preview" not in r or not r["preview"]:
121
+ r["preview"] = r.get("chunk_text", "")[:200]
122
+
123
+ return {
124
+ "query_info": query_info,
125
+ "results": final or [],
126
+ }
127
+
128
+ if __name__ == "__main__":
129
+ engine = SearchEngine()
130
+
131
+ while True:
132
+ query = input("\nπŸ” Enter your search query (or type 'exit'): ")
133
+
134
+ if query.lower() == "exit":
135
+ print("Exiting search engine...")
136
+ break
137
+
138
+ output = engine.search(query, top_k=3)
139
+
140
+ print(f"\nQuery : {output['query_info']['original']}")
141
+ print(f"Expanded : {output['query_info']['expanded']}")
142
+ print(f"Results : {len(output['results'])}\n")
143
+
144
+ for i, r in enumerate(output["results"], 1):
145
+ print(f"--- Result {i} ---")
146
+ print(f"File : {r['filepath']}")
147
+ print(f"Preview : {r['preview']}")
148
+
149
+ # Handle safe printing of scores
150
+ rrf = r.get('rrf_score')
151
+ rerank = r.get('rerank_score')
152
+
153
+ if rrf is not None:
154
+ print(f"RRF : {rrf:.5f}")
155
+ else:
156
+ print("RRF : n/a")
157
+
158
+ if rerank is not None:
159
+ print(f"Rerank : {rerank:.4f}")
160
+ else:
161
+ print("Rerank : n/a")
162
+
163
+ print()
searcher/sparse_retriever.py ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # searcher/sparse_retriever.py
2
+
3
+ import sqlite3
4
+ import math
5
+ import yaml
6
+ from collections import defaultdict
7
+
8
+
9
+ class SparseRetriever:
10
+ """
11
+ BM25 (Okapi BM25) lexical retrieval over the SQLite chunk store.
12
+
13
+ Why BM25 alongside semantic search?
14
+ - Dense retrieval can miss exact keyword matches (product codes, names, IDs)
15
+ - BM25 is great for rare/specific terms that embeddings smooth over
16
+ - Hybrid = best of both worlds
17
+
18
+ BM25 formula:
19
+ score(q, d) = Ξ£ IDF(t) Γ— (tf Γ— (k1+1)) / (tf + k1 Γ— (1 - b + b Γ— dl/avgdl))
20
+ """
21
+
22
+ def __init__(self, config_path="config.yaml"):
23
+ with open(config_path) as f:
24
+ config = yaml.safe_load(f)
25
+
26
+ self.db_path = f"{config['data_dir']}/metadata.db"
27
+ self.k1 = 1.5 # term frequency saturation
28
+ self.b = 0.75 # length normalisation
29
+
30
+ # Build in-memory BM25 index from SQLite on startup
31
+ self._corpus = [] # list of (chunk_id, token_list)
32
+ self._avgdl = 0.0
33
+ self._df = defaultdict(int) # term β†’ doc frequency
34
+ self._build_index()
35
+
36
+ def _build_index(self):
37
+ """Load all chunks from SQLite and compute BM25 statistics."""
38
+ conn = sqlite3.connect(self.db_path)
39
+ rows = conn.execute("SELECT id, chunk_text FROM chunks").fetchall()
40
+ conn.close()
41
+
42
+ total_len = 0
43
+ for chunk_id, text in rows:
44
+ tokens = text.lower().split()
45
+ self._corpus.append((chunk_id, tokens))
46
+ total_len += len(tokens)
47
+ for token in set(tokens):
48
+ self._df[token] += 1
49
+
50
+ self._avgdl = total_len / len(rows) if rows else 1.0
51
+ self._N = len(rows)
52
+
53
+ def _idf(self, term: str) -> float:
54
+ """Inverse document frequency for a term."""
55
+ df = self._df.get(term, 0)
56
+ return math.log((self._N - df + 0.5) / (df + 0.5) + 1)
57
+
58
+ def retrieve(self, query: str, top_k: int = 20) -> list[dict]:
59
+ """
60
+ Run BM25 retrieval over the corpus.
61
+
62
+ Args:
63
+ query (str) β€” raw or rewritten query (NOT expanded β€” BM25 is lexical)
64
+ top_k (int) β€” number of results to return
65
+
66
+ Returns:
67
+ list[dict] with chunk_id and sparse_score, sorted descending
68
+ """
69
+ query_terms = query.lower().split()
70
+ scores = {}
71
+
72
+ for chunk_id, tokens in self._corpus:
73
+ dl = len(tokens)
74
+ score = 0.0
75
+ tf_map = defaultdict(int)
76
+ for t in tokens:
77
+ tf_map[t] += 1
78
+
79
+ for term in query_terms:
80
+ if term not in tf_map:
81
+ continue
82
+ tf = tf_map[term]
83
+ idf = self._idf(term)
84
+ numerator = tf * (self.k1 + 1)
85
+ denominator = tf + self.k1 * (1 - self.b + self.b * dl / self._avgdl)
86
+ score += idf * numerator / denominator
87
+
88
+ if score > 0:
89
+ scores[chunk_id] = score
90
+
91
+ sorted_results = sorted(scores.items(), key=lambda x: x[1], reverse=True)[:top_k]
92
+
93
+ # Fetch text for top results
94
+ conn = sqlite3.connect(self.db_path)
95
+ results = []
96
+ for chunk_id, score in sorted_results:
97
+ row = conn.execute(
98
+ "SELECT chunk_text, filepath, chunk_index FROM chunks WHERE id = ?",
99
+ (chunk_id,)
100
+ ).fetchone()
101
+ if row:
102
+ results.append({
103
+ "chunk_id": chunk_id,
104
+ "chunk_text": row[0],
105
+ "filepath": row[1],
106
+ "chunk_index": row[2],
107
+ "sparse_score": score,
108
+ })
109
+ conn.close()
110
+ return results
111
+
112
+
113
+ if __name__ == "__main__":
114
+ sr = SparseRetriever()
115
+ results = sr.retrieve("quarterly budget", top_k=5)
116
+ for r in results:
117
+ print(f"[{r['sparse_score']:.4f}] {r['filepath']} β†’ {r['chunk_text'][:80]}")
static/style.css ADDED
@@ -0,0 +1,405 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* static/style.css */
2
+
3
+ * { margin:0; padding:0; box-sizing:border-box; }
4
+
5
+ body {
6
+ font-family: "Segoe UI", Arial, sans-serif;
7
+ background: #f4f6f9;
8
+ color: #1a1a2e;
9
+ min-height: 100vh;
10
+ }
11
+
12
+ /* NAV */
13
+ nav {
14
+ background: #1a3a5c;
15
+ padding: 0.8rem 2rem;
16
+ display: flex;
17
+ align-items: center;
18
+ gap: 2rem;
19
+ }
20
+ nav .brand {
21
+ color: #fff;
22
+ font-size: 1.05rem;
23
+ font-weight: 700;
24
+ text-decoration: none;
25
+ }
26
+ nav a {
27
+ color: #a8c8e8;
28
+ text-decoration: none;
29
+ font-size: 0.9rem;
30
+ }
31
+ nav a:hover { color: #fff; }
32
+
33
+ /* CONTAINER */
34
+ .container {
35
+ max-width: 1100px;
36
+ margin: 0 auto;
37
+ padding: 2rem 1.5rem;
38
+ }
39
+
40
+ /* HERO */
41
+ .hero {
42
+ text-align: center;
43
+ padding: 3rem 1rem 1.5rem;
44
+ }
45
+ .hero h1 {
46
+ font-size: 2rem;
47
+ color: #1a3a5c;
48
+ margin-bottom: 0.4rem;
49
+ }
50
+ .hero p {
51
+ color: #777;
52
+ font-size: 0.98rem;
53
+ margin-bottom: 1.8rem;
54
+ }
55
+
56
+ /* STATS PILLS */
57
+ .stats {
58
+ display: flex;
59
+ justify-content: center;
60
+ gap: 1rem;
61
+ margin-bottom: 2rem;
62
+ flex-wrap: wrap;
63
+ }
64
+ .stat-pill {
65
+ background: #e8f0fa;
66
+ color: #1a3a5c;
67
+ padding: 0.35rem 1rem;
68
+ border-radius: 20px;
69
+ font-size: 0.85rem;
70
+ font-weight: 600;
71
+ }
72
+
73
+ /* SEARCH BOX */
74
+ .search-card {
75
+ background: #fff;
76
+ border-radius: 10px;
77
+ box-shadow: 0 2px 16px rgba(0,0,0,0.08);
78
+ padding: 1.6rem;
79
+ max-width: 720px;
80
+ margin: 0 auto 1.5rem;
81
+ }
82
+ .search-row {
83
+ display: flex;
84
+ gap: 0.6rem;
85
+ margin-bottom: 0.85rem;
86
+ }
87
+ .search-input {
88
+ flex: 1;
89
+ padding: 0.72rem 1rem;
90
+ border: 1.5px solid #d0dbe8;
91
+ border-radius: 7px;
92
+ font-size: 1rem;
93
+ outline: none;
94
+ transition: border 0.2s;
95
+ }
96
+ .search-input:focus { border-color: #1a3a5c; }
97
+ .btn-search {
98
+ padding: 0.72rem 1.6rem;
99
+ background: #1a3a5c;
100
+ color: #fff;
101
+ border: none;
102
+ border-radius: 7px;
103
+ font-size: 0.95rem;
104
+ font-weight: 700;
105
+ cursor: pointer;
106
+ transition: background 0.2s;
107
+ }
108
+ .btn-search:hover { background: #0f2a44; }
109
+ .filters {
110
+ display: flex;
111
+ gap: 1.2rem;
112
+ align-items: center;
113
+ flex-wrap: wrap;
114
+ }
115
+ .filters label { font-size: 0.85rem; color: #666; }
116
+ .filters select {
117
+ padding: 0.32rem 0.7rem;
118
+ border: 1.5px solid #d0dbe8;
119
+ border-radius: 5px;
120
+ font-size: 0.85rem;
121
+ background: #fff;
122
+ cursor: pointer;
123
+ }
124
+
125
+ /* SAMPLE QUERIES */
126
+ .sample-queries {
127
+ text-align: center;
128
+ margin-top: 0.8rem;
129
+ }
130
+ .sample-queries p {
131
+ font-size: 0.82rem;
132
+ color: #999;
133
+ margin-bottom: 0.5rem;
134
+ }
135
+ .sample-btn {
136
+ background: #e8f0fa;
137
+ border: none;
138
+ border-radius: 14px;
139
+ padding: 0.28rem 0.85rem;
140
+ font-size: 0.8rem;
141
+ color: #1a3a5c;
142
+ cursor: pointer;
143
+ margin: 0.2rem;
144
+ transition: background 0.2s;
145
+ }
146
+ .sample-btn:hover { background: #ccdcf0; }
147
+
148
+ /* ERROR */
149
+ .error-box {
150
+ background: #fff0f0;
151
+ border: 1px solid #f0c0c0;
152
+ border-radius: 7px;
153
+ padding: 0.7rem 1rem;
154
+ color: #a00;
155
+ font-size: 0.9rem;
156
+ margin-bottom: 1rem;
157
+ max-width: 720px;
158
+ margin-left: auto;
159
+ margin-right: auto;
160
+ }
161
+
162
+ /* RESULTS PAGE LAYOUT */
163
+ .results-layout {
164
+ display: grid;
165
+ grid-template-columns: 1fr 320px;
166
+ gap: 1.5rem;
167
+ align-items: start;
168
+ margin-top: 1.5rem;
169
+ }
170
+
171
+ /* RESULTS HEADER */
172
+ .results-header {
173
+ margin-bottom: 1rem;
174
+ padding-bottom: 0.6rem;
175
+ border-bottom: 2px solid #e0e8f0;
176
+ }
177
+ .results-header h2 {
178
+ font-size: 1.15rem;
179
+ color: #1a3a5c;
180
+ }
181
+ .results-meta {
182
+ font-size: 0.83rem;
183
+ color: #999;
184
+ margin-top: 0.25rem;
185
+ }
186
+ .expanded-tag {
187
+ background: #f0f4f8;
188
+ border-radius: 6px;
189
+ padding: 0.45rem 0.85rem;
190
+ font-size: 0.83rem;
191
+ color: #555;
192
+ margin-bottom: 0.85rem;
193
+ }
194
+
195
+ /* RESULT CARD */
196
+ .result-card {
197
+ background: #fff;
198
+ border-radius: 8px;
199
+ padding: 1rem 1.2rem;
200
+ margin-bottom: 0.8rem;
201
+ box-shadow: 0 1px 6px rgba(0,0,0,0.06);
202
+ border-left: 3px solid #1a3a5c;
203
+ transition: box-shadow 0.15s;
204
+ }
205
+ .result-card:hover { box-shadow: 0 3px 14px rgba(0,0,0,0.1); }
206
+ .result-top {
207
+ display: flex;
208
+ justify-content: space-between;
209
+ align-items: flex-start;
210
+ margin-bottom: 0.4rem;
211
+ }
212
+ .result-title {
213
+ font-size: 0.95rem;
214
+ font-weight: 700;
215
+ color: #1a3a5c;
216
+ word-break: break-all;
217
+ }
218
+ .result-score {
219
+ font-size: 0.8rem;
220
+ background: #e8f0fa;
221
+ color: #1a3a5c;
222
+ padding: 0.18rem 0.55rem;
223
+ border-radius: 12px;
224
+ font-weight: 700;
225
+ white-space: nowrap;
226
+ margin-left: 0.8rem;
227
+ }
228
+ .result-snippet {
229
+ font-size: 0.88rem;
230
+ color: #444;
231
+ line-height: 1.55;
232
+ }
233
+ .result-footer {
234
+ margin-top: 0.5rem;
235
+ }
236
+ .badge {
237
+ font-size: 0.74rem;
238
+ padding: 0.15rem 0.55rem;
239
+ border-radius: 10px;
240
+ font-weight: 600;
241
+ }
242
+ .badge-scifact { background: #e0f0e8; color: #1a5c2e; }
243
+ .badge-nfcorpus { background: #fce8e8; color: #7a1a1a; }
244
+ .badge-file { background: #e8eaf0; color: #3a3a6c; }
245
+
246
+ .no-results {
247
+ text-align: center;
248
+ padding: 3rem;
249
+ color: #999;
250
+ }
251
+
252
+ /* QUERY PANELS (right side) */
253
+ .query-panel {
254
+ background: #fff;
255
+ border-radius: 8px;
256
+ box-shadow: 0 1px 6px rgba(0,0,0,0.06);
257
+ overflow: hidden;
258
+ margin-bottom: 1rem;
259
+ }
260
+ .panel-header {
261
+ display: flex;
262
+ justify-content: space-between;
263
+ align-items: center;
264
+ padding: 0.6rem 1rem;
265
+ font-size: 0.88rem;
266
+ font-weight: 700;
267
+ }
268
+ .panel-header.scifact { background: #e0f0e8; color: #1a5c2e; }
269
+ .panel-header.nfcorpus { background: #fce8e8; color: #7a1a1a; }
270
+ .panel-count {
271
+ background: rgba(0,0,0,0.1);
272
+ border-radius: 10px;
273
+ padding: 0.1rem 0.5rem;
274
+ font-size: 0.75rem;
275
+ }
276
+ .query-item {
277
+ padding: 0.55rem 1rem;
278
+ border-bottom: 1px solid #f0f4f8;
279
+ }
280
+ .query-item:last-child { border-bottom: none; }
281
+ .query-id { font-size: 0.72rem; color: #bbb; margin-bottom: 0.15rem; }
282
+ .query-text { font-size: 0.85rem; color: #333; line-height: 1.45; }
283
+ .panel-empty {
284
+ padding: 0.75rem 1rem;
285
+ font-size: 0.83rem;
286
+ color: #bbb;
287
+ font-style: italic;
288
+ }
289
+ .panel-info {
290
+ background: #f7fafd;
291
+ border-radius: 7px;
292
+ padding: 0.7rem 0.9rem;
293
+ font-size: 0.78rem;
294
+ color: #888;
295
+ line-height: 1.55;
296
+ }
297
+
298
+ /* DASHBOARD */
299
+ .dash-title {
300
+ font-size: 1.4rem;
301
+ color: #1a3a5c;
302
+ font-weight: 700;
303
+ margin-bottom: 0.3rem;
304
+ }
305
+ .dash-sub {
306
+ color: #999;
307
+ font-size: 0.88rem;
308
+ margin-bottom: 1.5rem;
309
+ }
310
+ .dash-grid {
311
+ display: grid;
312
+ grid-template-columns: 1fr 1fr;
313
+ gap: 1.2rem;
314
+ margin-bottom: 2rem;
315
+ }
316
+ .metric-card {
317
+ background: #fff;
318
+ border-radius: 8px;
319
+ padding: 1.2rem 1.4rem;
320
+ box-shadow: 0 1px 6px rgba(0,0,0,0.06);
321
+ }
322
+ .metric-card h3 {
323
+ font-size: 1rem;
324
+ color: #1a3a5c;
325
+ margin-bottom: 1rem;
326
+ padding-bottom: 0.5rem;
327
+ border-bottom: 1.5px solid #e0e8f0;
328
+ }
329
+ .metric-row {
330
+ display: flex;
331
+ align-items: center;
332
+ margin-bottom: 0.65rem;
333
+ }
334
+ .metric-label {
335
+ width: 95px;
336
+ font-size: 0.85rem;
337
+ color: #666;
338
+ }
339
+ .bar-wrap {
340
+ flex: 1;
341
+ background: #e8f0fa;
342
+ border-radius: 4px;
343
+ height: 8px;
344
+ overflow: hidden;
345
+ margin: 0 0.7rem;
346
+ }
347
+ .bar {
348
+ height: 100%;
349
+ border-radius: 4px;
350
+ background: #1a3a5c;
351
+ }
352
+ .bar.green { background: #2d7a4f; }
353
+ .bar.amber { background: #c07020; }
354
+ .metric-val {
355
+ width: 52px;
356
+ text-align: right;
357
+ font-size: 0.9rem;
358
+ font-weight: 700;
359
+ color: #1a3a5c;
360
+ }
361
+ .section-label {
362
+ font-size: 1.05rem;
363
+ font-weight: 700;
364
+ color: #1a3a5c;
365
+ margin: 1.5rem 0 0.7rem;
366
+ }
367
+ .table-card {
368
+ background: #fff;
369
+ border-radius: 8px;
370
+ box-shadow: 0 1px 6px rgba(0,0,0,0.06);
371
+ overflow: hidden;
372
+ margin-bottom: 1.5rem;
373
+ }
374
+ .table-card table {
375
+ width: 100%;
376
+ border-collapse: collapse;
377
+ font-size: 0.88rem;
378
+ }
379
+ .table-card th {
380
+ background: #1a3a5c;
381
+ color: #fff;
382
+ padding: 0.6rem 1rem;
383
+ text-align: left;
384
+ font-weight: 600;
385
+ }
386
+ .table-card td {
387
+ padding: 0.55rem 1rem;
388
+ border-bottom: 1px solid #eef2f7;
389
+ color: #333;
390
+ }
391
+ .table-card tr:last-child td { border-bottom: none; }
392
+ .table-card tr:nth-child(even) td { background: #f7fafd; }
393
+ .table-card tr.best td {
394
+ font-weight: 700;
395
+ color: #1a3a5c;
396
+ background: #eaf4ff;
397
+ }
398
+
399
+ /* RESPONSIVE */
400
+ @media (max-width: 700px) {
401
+ .results-layout { grid-template-columns: 1fr; }
402
+ .dash-grid { grid-template-columns: 1fr; }
403
+ .search-row { flex-direction: column; }
404
+ .hero h1 { font-size: 1.5rem; }
405
+ }
templates/dashboard.html ADDED
@@ -0,0 +1,128 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Evaluation Dashboard</title>
7
+ <link rel="stylesheet" href="/static/style.css">
8
+ </head>
9
+ <body>
10
+
11
+ <nav>
12
+ <a href="/" class="brand">πŸ” Semantic Search</a>
13
+ <a href="/">Search</a>
14
+ <a href="/dashboard">Dashboard</a>
15
+ </nav>
16
+
17
+ <div class="container">
18
+
19
+ <div style="margin-top:1.5rem;">
20
+ <div class="dash-title">Evaluation Dashboard</div>
21
+ <div class="dash-sub">
22
+ BEIR Benchmark β€” Full pipeline (Dense + BM25 + RRF + Cross-Encoder)
23
+ </div>
24
+ </div>
25
+
26
+ {% if datasets %}
27
+
28
+ <!-- metric cards -->
29
+ <div class="dash-grid">
30
+ {% for d in datasets %}
31
+ <div class="metric-card">
32
+ <h3>
33
+ {% if d.name == "scifact" %}πŸ”¬{% else %}πŸ₯{% endif %}
34
+ {{ d.name | title }}
35
+ <span style="font-size:0.76rem;color:#999;font-weight:400;">
36
+ β€” {{ d.queries }} queries
37
+ </span>
38
+ </h3>
39
+
40
+ <div class="metric-row">
41
+ <span class="metric-label">NDCG@10</span>
42
+ <div class="bar-wrap">
43
+ <div class="bar green" style="width:{{ (d.ndcg * 100) | round(1) }}%"></div>
44
+ </div>
45
+ <span class="metric-val">{{ "%.4f" | format(d.ndcg) }}</span>
46
+ </div>
47
+
48
+ <div class="metric-row">
49
+ <span class="metric-label">MRR</span>
50
+ <div class="bar-wrap">
51
+ <div class="bar" style="width:{{ (d.mrr * 100) | round(1) }}%"></div>
52
+ </div>
53
+ <span class="metric-val">{{ "%.4f" | format(d.mrr) }}</span>
54
+ </div>
55
+
56
+ <div class="metric-row">
57
+ <span class="metric-label">MAP@100</span>
58
+ <div class="bar-wrap">
59
+ <div class="bar amber" style="width:{{ (d.map * 100) | round(1) }}%"></div>
60
+ </div>
61
+ <span class="metric-val">{{ "%.4f" | format(d.map) }}</span>
62
+ </div>
63
+
64
+ <div class="metric-row">
65
+ <span class="metric-label">Recall@100</span>
66
+ <div class="bar-wrap">
67
+ <div class="bar" style="width:{{ (d.recall * 100) | round(1) }}%"></div>
68
+ </div>
69
+ <span class="metric-val">{{ "%.4f" | format(d.recall) }}</span>
70
+ </div>
71
+
72
+ <div class="metric-row">
73
+ <span class="metric-label">P@10</span>
74
+ <div class="bar-wrap">
75
+ <div class="bar amber" style="width:{{ (d.precision * 100) | round(1) }}%"></div>
76
+ </div>
77
+ <span class="metric-val">{{ "%.4f" | format(d.precision) }}</span>
78
+ </div>
79
+ </div>
80
+ {% endfor %}
81
+ </div>
82
+
83
+ <!-- ablation tables -->
84
+ {% for d in datasets %}
85
+ <div class="section-label">
86
+ Ablation Table β€” {{ d.name | title }}
87
+ </div>
88
+ <div class="table-card">
89
+ <table>
90
+ <thead>
91
+ <tr>
92
+ <th>Mode</th>
93
+ <th>NDCG@10</th>
94
+ <th>MAP@100</th>
95
+ <th>MRR</th>
96
+ <th>Recall@100</th>
97
+ <th>P@10</th>
98
+ </tr>
99
+ </thead>
100
+ <tbody>
101
+ {% for mode_name, m in d.modes.items() %}
102
+ <tr {% if mode_name == "full" %}class="best"{% endif %}>
103
+ <td>{{ mode_name }}</td>
104
+ <td>{{ "%.4f" | format(m.get("NDCG@10", 0)) }}</td>
105
+ <td>{{ "%.4f" | format(m.get("MAP@100", 0)) }}</td>
106
+ <td>{{ "%.4f" | format(m.get("MRR", 0)) }}</td>
107
+ <td>{{ "%.4f" | format(m.get("Recall@100", 0)) }}</td>
108
+ <td>{{ "%.4f" | format(m.get("P@10", 0)) }}</td>
109
+ </tr>
110
+ {% endfor %}
111
+ </tbody>
112
+ </table>
113
+ </div>
114
+ {% endfor %}
115
+
116
+ {% else %}
117
+ <div class="no-results">
118
+ <p>No evaluation results found.</p>
119
+ <p style="margin-top:0.5rem;font-size:0.85rem;">
120
+ Run:
121
+ <code>python -m evaluation.run_eval --datasets scifact nfcorpus --mode all</code>
122
+ </p>
123
+ </div>
124
+ {% endif %}
125
+
126
+ </div>
127
+ </body>
128
+ </html>
templates/index.html ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Semantic Search Engine</title>
7
+ <link rel="stylesheet" href="/static/style.css">
8
+ </head>
9
+ <body>
10
+
11
+ <nav>
12
+ <a href="/" class="brand">πŸ” Semantic Search</a>
13
+ <a href="/">Search</a>
14
+ <a href="/dashboard">Dashboard</a>
15
+ </nav>
16
+
17
+ <div class="container">
18
+ <div class="hero">
19
+ <h1>Semantic File Search Engine</h1>
20
+ <p>AI-powered search across SciFact and NFCorpus using Dense + BM25 + RRF + Cross-Encoder</p>
21
+
22
+ <div class="stats">
23
+ <span class="stat-pill">πŸ”¬ SciFact β€” {{ scifact_count }} queries</span>
24
+ <span class="stat-pill">πŸ₯ NFCorpus β€” {{ nfcorpus_count }} queries</span>
25
+ </div>
26
+ </div>
27
+
28
+ {% if error %}
29
+ <div class="error-box">{{ error }}</div>
30
+ {% endif %}
31
+
32
+ <div class="search-card">
33
+ <form action="/search" method="post">
34
+ <div class="search-row">
35
+ <input
36
+ class="search-input"
37
+ type="text"
38
+ name="query"
39
+ placeholder="e.g. vitamin D deficiency cancer risk"
40
+ autofocus
41
+ required
42
+ />
43
+ <button class="btn-search" type="submit">Search</button>
44
+ </div>
45
+ <div class="filters">
46
+ <label>Top results:</label>
47
+ <select name="top_k">
48
+ <option value="5">5</option>
49
+ <option value="10" selected>10</option>
50
+ <option value="20">20</option>
51
+ </select>
52
+ </div>
53
+ </form>
54
+ </div>
55
+
56
+ <div class="sample-queries">
57
+ <p>Try a sample query:</p>
58
+ {% for q in [
59
+ "vitamin D cancer",
60
+ "BRCA1 breast cancer",
61
+ "smoking lung disease",
62
+ "mRNA vaccine immune",
63
+ "diabetes insulin resistance",
64
+ "protein folding structure"
65
+ ] %}
66
+ <button class="sample-btn"
67
+ onclick="document.querySelector('.search-input').value='{{ q }}'">
68
+ {{ q }}
69
+ </button>
70
+ {% endfor %}
71
+ </div>
72
+ </div>
73
+
74
+ </body>
75
+ </html>
templates/results.html ADDED
@@ -0,0 +1,153 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Results β€” {{ query }}</title>
7
+ <link rel="stylesheet" href="/static/style.css">
8
+ </head>
9
+ <body>
10
+
11
+ <nav>
12
+ <a href="/" class="brand">πŸ” Semantic Search</a>
13
+ <a href="/">Search</a>
14
+ <a href="/dashboard">Dashboard</a>
15
+ </nav>
16
+
17
+ <div class="container">
18
+
19
+ <!-- search bar stays on results page -->
20
+ <div class="search-card" style="margin-top:1.5rem; max-width:100%;">
21
+ <form action="/search" method="post">
22
+ <div class="search-row">
23
+ <input
24
+ class="search-input"
25
+ type="text"
26
+ name="query"
27
+ value="{{ query }}"
28
+ required
29
+ />
30
+ <button class="btn-search" type="submit">Search</button>
31
+ </div>
32
+ <div class="filters">
33
+ <label>Top results:</label>
34
+ <select name="top_k">
35
+ <option value="5" {% if top_k==5 %}selected{% endif %}>5</option>
36
+ <option value="10" {% if top_k==10 %}selected{% endif %}>10</option>
37
+ <option value="20" {% if top_k==20 %}selected{% endif %}>20</option>
38
+ </select>
39
+ </div>
40
+ </form>
41
+ </div>
42
+
43
+ <div class="results-layout">
44
+
45
+ <!-- LEFT β€” ranked results -->
46
+ <div>
47
+ <div class="results-header">
48
+ <h2>Results for "{{ query }}"</h2>
49
+ <div class="results-meta">
50
+ {{ total }} result{{ "s" if total != 1 else "" }}
51
+ &nbsp;Β·&nbsp; {{ elapsed }}s
52
+ &nbsp;Β·&nbsp; Full pipeline
53
+ </div>
54
+ </div>
55
+
56
+ {% if expanded_query and expanded_query != query %}
57
+ <div class="expanded-tag">
58
+ <strong>Expanded:</strong> {{ expanded_query }}
59
+ </div>
60
+ {% endif %}
61
+
62
+ {% if results %}
63
+ {% for r in results %}
64
+ <div class="result-card">
65
+ <div class="result-top">
66
+ <div class="result-title">
67
+ {{ r.icon }}&nbsp; {{ r.doc_id }}
68
+ </div>
69
+ <div class="result-score">{{ r.score }}</div>
70
+ </div>
71
+
72
+ <div class="result-snippet">{{ r.snippet }}</div>
73
+
74
+ <div class="result-footer">
75
+ {% if r.dataset == "scifact" %}
76
+ <span class="badge badge-scifact">πŸ”¬ SciFact</span>
77
+ {% elif r.dataset == "nfcorpus" %}
78
+ <span class="badge badge-nfcorpus">πŸ₯ NFCorpus</span>
79
+ {% else %}
80
+ <span class="badge badge-file">πŸ“ File</span>
81
+ {% endif %}
82
+ </div>
83
+ </div>
84
+ {% endfor %}
85
+
86
+ {% else %}
87
+ <div class="no-results">
88
+ <p>No results found for "<strong>{{ query }}</strong>"</p>
89
+ <p style="margin-top:0.5rem; font-size:0.85rem; color:#bbb;">
90
+ Try shorter or different keywords.
91
+ </p>
92
+ </div>
93
+ {% endif %}
94
+ </div>
95
+
96
+ <!-- RIGHT β€” matched dataset queries -->
97
+ <div>
98
+
99
+ <!-- SciFact panel -->
100
+ <div class="query-panel">
101
+ <div class="panel-header scifact">
102
+ πŸ”¬ SciFact Queries
103
+ <span class="panel-count">{{ scifact_matches | length }}</span>
104
+ </div>
105
+ {% if scifact_matches %}
106
+ {% for q in scifact_matches %}
107
+ <div class="query-item">
108
+ <div class="query-id">Query #{{ q.query_id }}</div>
109
+ <div class="query-text">{{ q.query_text }}</div>
110
+ </div>
111
+ {% endfor %}
112
+ {% else %}
113
+ <div class="panel-empty">
114
+ No SciFact queries matched "{{ query }}"
115
+ </div>
116
+ {% endif %}
117
+ </div>
118
+
119
+ <!-- NFCorpus panel -->
120
+ <div class="query-panel">
121
+ <div class="panel-header nfcorpus">
122
+ πŸ₯ NFCorpus Queries
123
+ <span class="panel-count">{{ nfcorpus_matches | length }}</span>
124
+ </div>
125
+ {% if nfcorpus_matches %}
126
+ {% for q in nfcorpus_matches %}
127
+ <div class="query-item">
128
+ <div class="query-id">Query #{{ q.query_id }}</div>
129
+ <div class="query-text">{{ q.query_text }}</div>
130
+ </div>
131
+ {% endfor %}
132
+ {% else %}
133
+ <div class="panel-empty">
134
+ No NFCorpus queries matched "{{ query }}"
135
+ </div>
136
+ {% endif %}
137
+ </div>
138
+
139
+ <!-- info box -->
140
+ <div class="panel-info">
141
+ These are actual evaluation queries from the BEIR benchmark
142
+ that share keywords with your search.
143
+ <br><br>
144
+ <strong>300</strong> SciFact queries &nbsp;Β·&nbsp;
145
+ <strong>323</strong> NFCorpus queries loaded.
146
+ </div>
147
+
148
+ </div>
149
+ </div>
150
+ </div>
151
+
152
+ </body>
153
+ </html>