ShutterSearch / search.py
SwikarG's picture
Merge github code to hugging face (#1)
5f39285
Raw
History Blame Contribute Delete
4.28 kB
"""Semantic search with keyword boosting over structured captions."""
from __future__ import annotations
import json
import re
import numpy as np
from sentence_transformers import SentenceTransformer
from caption_store import all_entries
_embed_model: SentenceTransformer | None = None
_MODEL_NAME = "BAAI/bge-base-en-v1.5" # stronger than MiniLM
MIN_RELEVANCE = 0.6
TOP_K = 20
# Weight given to keyword boost relative to semantic score (0–1 additive)
KEYWORD_BOOST = 0.25
def _get_embed_model() -> SentenceTransformer:
global _embed_model
if _embed_model is None:
_embed_model = SentenceTransformer(_MODEL_NAME)
return _embed_model
def _query_tokens(query: str) -> list[str]:
"""Lowercase words from query, 3+ chars."""
return [w for w in re.findall(r"\b\w+\b", query.lower()) if len(w) >= 3]
def _keyword_score(query_tokens: list[str], search_text: str, raw_caption: str) -> float:
"""
Boost score if query tokens appear in high-signal fields (attire, tags, summary).
Returns a value in [0, 1].
"""
if not query_tokens:
return 0.0
# Tokenize the target text strictly by word boundaries to avoid partial substring hits
target_words = set(re.findall(r"\b\w+\b", search_text.lower()))
# Calculate regular hits
hits = sum(1 for token in query_tokens if token in target_words)
# Extra weight for specific high-signal fields (attire, keywords, tags)
high_signal_words = set()
try:
meta = json.loads(raw_caption)
# Gather arrays that explicitly hold high-intent search terms
for field in [meta.get("search_tags", []), meta.get("archive_keywords", []), meta.get("subjects", {}).get("attire", [])]:
if isinstance(field, list):
for item in field:
high_signal_words.update(re.findall(r"\b\w+\b", str(item).lower()))
elif isinstance(field, str):
high_signal_words.update(re.findall(r"\b\w+\b", field.lower()))
except (json.JSONDecodeError, TypeError):
pass
high_signal_hits = sum(1 for token in query_tokens if token in high_signal_words)
# Instead of penalizing long queries, reward ANY valid keyword match heavily
if hits == 0:
return 0.0
base_score = hits / len(query_tokens)
boost_bonus = 0.5 if high_signal_hits > 0 else 0.0
return min(base_score + boost_bonus, 1.0)
def search(query: str, collection: str | None = None) -> list[dict]:
"""
Hybrid search: semantic similarity + keyword boost on structured fields.
Returns up to TOP_K results with score >= MIN_RELEVANCE.
"""
all_indexed = all_entries()
if not all_indexed:
return []
# Filter by collection if specified
if collection and collection != "All":
entries = {
p: data for p, data in all_indexed.items()
if data.get("collection") == collection
}
else:
entries = all_indexed
if not entries:
return []
model = _get_embed_model()
query_tokens = _query_tokens(query)
paths = list(entries.keys())
search_texts = [entries[p].get("search_text") or entries[p]["caption"] for p in paths]
raw_captions = [entries[p]["caption"] for p in paths]
# Query side gets an explicit instruction prefix
query_text = f"Represent this sentence for searching relevant passages: {query}"
query_vec = model.encode([query_text], normalize_embeddings=True)
# Document side stays normal
caption_vecs = model.encode(search_texts, normalize_embeddings=True)
semantic_scores = (caption_vecs @ query_vec.T).flatten()
final_scores = []
for i, (sem, st, rc) in enumerate(zip(semantic_scores, search_texts, raw_captions)):
kw = _keyword_score(query_tokens, st, rc)
final_scores.append(float(sem) + KEYWORD_BOOST * kw)
ranked = sorted(
zip(paths, search_texts, final_scores),
key=lambda x: x[2],
reverse=True,
)
return [
{"path": p, "caption": c, "score": round(s, 4)}
for p, c, s in ranked[:TOP_K]
if s >= MIN_RELEVANCE
]