Spaces:
Sleeping
Sleeping
Upload 21 files
Browse files- .dockerignore +15 -0
- .gitignore +34 -0
- Dockerfile +20 -0
- check_nfcorpus.py +29 -0
- config.yaml +42 -0
- docker-compose.yml +16 -0
- main.py +269 -0
- requirements.txt +58 -0
- searcher/__init__.py +0 -0
- searcher/dense_retriever.py +79 -0
- searcher/facet_filter.py +83 -0
- searcher/fusion_ranker.py +80 -0
- searcher/highlighter.py +96 -0
- searcher/query_understanding.py +104 -0
- searcher/reranker.py +66 -0
- searcher/search_engine.py +163 -0
- searcher/sparse_retriever.py +117 -0
- static/style.css +405 -0
- templates/dashboard.html +128 -0
- templates/index.html +75 -0
- templates/results.html +153 -0
.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 |
+
Β· {{ elapsed }}s
|
| 52 |
+
Β· 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 }} {{ 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 Β·
|
| 145 |
+
<strong>323</strong> NFCorpus queries loaded.
|
| 146 |
+
</div>
|
| 147 |
+
|
| 148 |
+
</div>
|
| 149 |
+
</div>
|
| 150 |
+
</div>
|
| 151 |
+
|
| 152 |
+
</body>
|
| 153 |
+
</html>
|