Spaces:
Sleeping
Sleeping
Delete searcher
Browse files- searcher/__init__.py +0 -0
- searcher/dense_retriever.py +0 -79
- searcher/facet_filter.py +0 -83
- searcher/fusion_ranker.py +0 -80
- searcher/highlighter.py +0 -96
- searcher/query_understanding.py +0 -104
- searcher/reranker.py +0 -66
- searcher/search_engine.py +0 -163
- searcher/sparse_retriever.py +0 -117
searcher/__init__.py
DELETED
|
File without changes
|
searcher/dense_retriever.py
DELETED
|
@@ -1,79 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,83 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,80 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,96 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,104 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,66 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,163 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,117 +0,0 @@
|
|
| 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]}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|