kumarakkiy's picture
Discourse filter, exact-phrase search, ANN index (v2 dataset rev pinned)
75d531a verified
Raw
History Blame Contribute Delete
5.46 kB
"""Query side: text or Hindi-audio -> ranked moments.
text query -> bge-m3 embed -> LanceDB (dense-dominant hybrid) -> bge-reranker -> top-k
audio query -> ffmpeg -> faster-whisper(hi) -> Hindi text -> (same as above)
An English query matches Hindi passages natively in bge-m3's shared space, so there is no
query-time translation. The reranker reads (query, Hindi passage) pairs together and is
the main precision lever.
"""
from __future__ import annotations
import re
import tempfile
import threading
from pathlib import Path
from typing import List, Optional, Tuple
from app.config import Config, get_config
from app.models import SearchResult
from app.store import Store
# A query wrapped in quotes (straight or curly) means: exact phrase, FTS only.
_EXACT_RE = re.compile(r'^\s*["“](.+)["”]\s*$', re.S)
def exact_phrase(query: str) -> Optional[str]:
"""The phrase inside a fully-quoted query, or None for a semantic query."""
m = _EXACT_RE.match(query or "")
phrase = m.group(1).strip() if m else ""
return phrase or None
class Searcher:
def __init__(self, cfg: Optional[Config] = None):
self.cfg = cfg or get_config()
self.store = Store(self.cfg)
self._embedder = None
self._reranker = None
self._transcriber = None
# The server warms models on a background thread; without a lock a user
# request racing the warm-up would load bge-m3 twice.
self._load_lock = threading.Lock()
@property
def embedder(self):
if self._embedder is None:
with self._load_lock:
if self._embedder is None:
from app.embed import Embedder
self._embedder = Embedder(self.cfg)
return self._embedder
@property
def reranker(self):
if self._reranker is None:
from app.rerank import Reranker
self._reranker = Reranker(self.cfg)
return self._reranker
@property
def transcriber(self):
if self._transcriber is None:
from app.asr import Transcriber
self._transcriber = Transcriber(self.cfg)
return self._transcriber
# ---- text search ----------------------------------------------------
def search_text(
self,
query: str,
top_k: Optional[int] = None,
recording_id: Optional[str] = None,
) -> List[SearchResult]:
query = (query or "").strip()
if not query:
return []
top_k = top_k or self.cfg.search["top_k"]
where = None
if recording_id:
where = "recording_id = '{}'".format(recording_id.replace("'", "''"))
phrase = exact_phrase(query)
if phrase:
# Quoted query: exact FTS phrase match, no embedding involved. score=0
# signals the UI to show an "exact" badge instead of a percentage.
rows = self.store.search_fts(phrase, k=top_k, where=where, phrase=True)
for r in rows:
r["score"] = 0.0
return [self._to_result(r) for r in rows[:top_k]]
use_rerank = self.reranker.enabled
pool = max(self.cfg.reranker["candidates"], top_k) if use_rerank else top_k
qvec = self.embedder.embed_query(query)
rows = self.store.search(
qvec,
query_text=query,
k=pool,
hybrid=bool(self.cfg.search["hybrid"]),
vector_weight=float(self.cfg.search["vector_weight"]),
candidate_pool=max(pool, 80),
where=where,
)
if not rows:
return []
if use_rerank:
scores = self.reranker.scores(query, [r["hindi_text"] for r in rows])
for r, s in zip(rows, scores):
r["rerank_score"] = s
rows.sort(key=lambda r: r.get("rerank_score", 0.0), reverse=True)
rows = rows[:top_k]
return [self._to_result(r) for r in rows]
# ---- audio search ---------------------------------------------------
def search_audio(self, audio_path: str | Path, top_k: Optional[int] = None) -> Tuple[str, List[SearchResult]]:
"""Transcribe a Hindi clip, then search. Returns (recognized_text, results)."""
from app.audio_utils import normalize_to_wav
with tempfile.TemporaryDirectory() as tmp:
wav = Path(tmp) / "query.wav"
normalize_to_wav(audio_path, wav)
text = self.transcriber.transcribe_query(wav)
results = self.search_text(text, top_k=top_k) if text.strip() else []
return text, results
# ---- helpers --------------------------------------------------------
@staticmethod
def _to_result(r: dict) -> SearchResult:
# Prefer the reranker score, else the dense cosine (nice 0..1 for display);
# fused RRF score is only a last resort (tiny values, ranking-only).
score = r.get("rerank_score")
if score is None:
score = r.get("score", r.get("fused_score", 0.0))
return SearchResult(
recording_id=r["recording_id"],
source_file=r["source_file"],
start_ms=int(r["start_ms"]),
end_ms=int(r["end_ms"]),
hindi_text=r.get("hindi_text", ""),
english_gloss=r.get("english_gloss", ""),
score=float(score),
rerank_score=(float(r["rerank_score"]) if "rerank_score" in r else None),
)