JackSparrow89 commited on
Commit
1973e64
·
verified ·
1 Parent(s): 740cd7e

Delete searcher

Browse files
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]}")