News-Search-Engine / tests /test_engine.py
1xMezoo's picture
Deploy News Search Engine to Hugging Face Spaces
39ff835
Raw
History Blame Contribute Delete
9.46 kB
"""Tests for the search engine pipeline.
Run with: pytest
"""
from pathlib import Path
import pytest
from news_search import Document, SearchEngine, build_index, load_corpus
from news_search import ranking
from news_search.index import InvertedIndex
SAMPLE = Path(__file__).resolve().parents[1] / "data" / "sample_news.jsonl"
def test_corpus_loads():
docs = load_corpus(SAMPLE)
assert len(docs) > 100
# The overwhelming majority of records have indexable text.
assert sum(1 for d in docs if d.text) > 0.9 * len(docs)
assert docs[0].id == 0
def test_index_stats(engine):
assert engine.index.num_docs > 100
assert engine.index.vocabulary_size > 500
# IDF precomputed for every term
assert all(idf >= 0 for idf in engine.index.idf.values())
def test_basic_search_returns_ranked_results(engine):
res = engine.search("health", method="tfidf", top_k=10)
assert res.total_hits > 0
scores = [r["score"] for r in res.results]
assert scores == sorted(scores, reverse=True) # descending
ranks = [r["rank"] for r in res.results]
assert ranks == list(range(1, len(ranks) + 1))
def test_or_semantics_not_and(engine):
"""A multi-term query should still return docs even if no single doc has all
terms (the original AND-only behaviour returned nothing here)."""
res = engine.search("health technology economy", method="tfidf", top_k=10)
assert res.total_hits > 0
def test_empty_and_nonsense_queries(engine):
assert engine.search("").total_hits == 0
assert engine.search("zzzqqqxyzzy").total_hits == 0
def test_category_filter(engine):
# 'health' is a real content word (not a stopword), so this actually exercises
# the filter — the old test used the stopword 'the' and passed vacuously.
res = engine.search("health", method="bm25", top_k=30, category="POLITICS")
assert all(r["category"] == "POLITICS" for r in res.results)
def _make_engine_with_categories() -> SearchEngine:
"""Synthetic corpus: 8 POLITICS + 3 SPORTS docs all containing 'reform'."""
docs = []
for i in range(8):
docs.append(Document(id=i, text="reform policy senate vote",
headline="h", short_description="reform policy senate vote",
category="POLITICS", date="", link="https://example.com"))
for i in range(8, 11):
docs.append(Document(id=i, text="reform team game season",
headline="h", short_description="reform team game season",
category="SPORTS", date="", link="https://example.com"))
return SearchEngine(build_index(docs, verbose=False))
def test_category_filter_restricts_before_truncation():
"""Regression for the bug where the category filter ran *after* top_k
truncation, silently dropping relevant in-category docs ranked below top_k."""
eng = _make_engine_with_categories()
full = eng.search("reform", method="bm25", top_k=100, category="POLITICS")
assert full.total_hits == 8
assert all(r["category"] == "POLITICS" for r in full.results)
limited = eng.search("reform", method="bm25", top_k=3, category="POLITICS")
assert len(limited.results) == 3 # page filled from within the category
assert limited.total_hits == 8 # honest total, not capped at top_k
assert all(r["category"] == "POLITICS" for r in limited.results)
def test_category_with_no_docs_returns_empty(engine):
res = engine.search("health", category="NO_SUCH_CATEGORY_XYZ")
assert res.total_hits == 0
assert res.results == []
def test_total_hits_is_true_count_not_capped(engine):
# Use the most frequent indexed term so we know many docs match.
term = max(engine.index.postings, key=lambda t: len(engine.index.postings[t]))
page = engine.search(term, method="bm25", top_k=5)
full = engine.search(term, method="bm25", top_k=10_000)
assert page.total_hits == full.total_hits # total independent of page size
assert page.total_hits > 5 # more matches than one page
assert len(page.results) == 5 # page filled to top_k
assert len(full.results) == full.total_hits # everything returned when top_k huge
def test_ranking_and_mode_requires_all_terms(engine):
by_df = sorted(engine.index.postings, key=lambda t: len(engine.index.postings[t]),
reverse=True)
t1, t2 = by_df[0], by_df[3]
or_hits = ranking.bm25([t1, t2], engine.index, top_k=None, mode="or")
and_hits = ranking.bm25([t1, t2], engine.index, top_k=None, mode="and")
assert len(and_hits) <= len(or_hits)
for doc_id, _ in and_hits:
fwd = engine.index.forward[doc_id]
assert t1 in fwd and t2 in fwd # 'and' docs contain every term
def test_ranking_restrict_to_limits_candidates(engine):
term = max(engine.index.postings, key=lambda t: len(engine.index.postings[t]))
allowed = set(list(engine.index.meta)[:3])
res = ranking.bm25([term], engine.index, top_k=None, mode="or", restrict_to=allowed)
assert {doc_id for doc_id, _ in res}.issubset(allowed)
def test_bert_method_without_dense_falls_back_gracefully(engine):
# engine.dense is None -> semantic methods fall back to lexical, no error
res = engine.search("health", method="bert", top_k=5)
assert res.method == "bert"
assert isinstance(res.results, list)
def test_hybrid_method_without_dense_falls_back_gracefully(engine):
res = engine.search("health", method="hybrid", top_k=5)
assert res.method == "hybrid"
assert isinstance(res.results, list)
def test_prf_relevance_feedback_uses_marked_docs(engine):
from news_search import expansion
rel = list(engine.index.meta)[:2]
fb_terms = expansion.prf_terms(["news"], engine.index, relevant_ids=rel)
# every feedback term must come from the documents the user marked relevant
union = set()
for doc_id in rel:
union |= set(engine.index.forward[doc_id])
assert fb_terms # the marked docs contribute some terms
assert all(t in union for t in fb_terms)
def test_prf_search_accepts_relevant_ids(engine):
rel = list(engine.index.meta)[:3]
res = engine.search("health", method="prf", top_k=10, relevant_ids=rel)
assert res.method == "prf"
assert isinstance(res.expansion_terms, list)
# --- dense / semantic retrieval (needs sentence-transformers) --------------- #
_SEMANTIC_DOCS = [
Document(id=0, text="the president won the national election", headline="Election",
short_description="", category="POLITICS", date="", link="https://example.com"),
Document(id=1, text="the striker scored a goal in the football match", headline="Football",
short_description="", category="SPORTS", date="", link="https://example.com"),
Document(id=2, text="a new vaccine lowers the risk of disease", headline="Vaccine",
short_description="", category="HEALTH", date="", link="https://example.com"),
]
def test_dense_retriever_matches_by_meaning():
pytest.importorskip("sentence_transformers")
from news_search.dense import DenseRetriever
dr = DenseRetriever().fit(_SEMANTIC_DOCS, verbose=False)
top = dr.search("soccer match result", top_k=1) # no shared keywords with the doc
assert top and top[0][0] == 1 # the football doc wins on meaning
def test_engine_bert_and_hybrid_with_dense():
pytest.importorskip("sentence_transformers")
from news_search.dense import DenseRetriever
idx = build_index(_SEMANTIC_DOCS, verbose=False)
eng = SearchEngine(idx, dense=DenseRetriever().fit(_SEMANTIC_DOCS, verbose=False))
res = eng.search("soccer match result", method="bert", top_k=1)
assert res.method == "bert"
assert res.results and res.results[0]["id"] == 1
res_h = eng.search("soccer match result", method="hybrid", top_k=3)
assert res_h.method == "hybrid"
assert len(res_h.results) >= 1
def test_prf_adds_expansion_terms(engine):
res = engine.search("health", method="prf", top_k=10)
# PRF should surface additional terms from the top documents
assert isinstance(res.expansion_terms, list)
assert all(t not in res.query.split() for t in res.expansion_terms)
def test_bm25_is_default_and_ranks(engine):
res = engine.search("health") # default method
assert res.method == "bm25"
assert res.total_hits > 0
scores = [r["score"] for r in res.results]
assert scores == sorted(scores, reverse=True)
def test_bm25_avoids_trivial_short_doc_bias(engine):
"""BM25 should not let a 1-2 token headline dominate a multi-term query the
way normalised-TF TF-IDF does."""
assert engine.index.avg_doc_len > 0
res = engine.search("climate change", method="bm25", top_k=5)
top_lengths = [engine.index.doc_len[r["id"]] for r in res.results]
# at least one of the top results is a real (non-trivial) document
assert max(top_lengths) >= 8
def test_unknown_method_raises(engine):
with pytest.raises(ValueError):
engine.search("health", method="not-a-method")
def test_index_persistence_roundtrip(engine, tmp_path):
p = tmp_path / "idx.pkl"
engine.index.save(p)
reloaded = InvertedIndex.load(p)
assert reloaded.num_docs == engine.index.num_docs
assert reloaded.vocabulary_size == engine.index.vocabulary_size
res = SearchEngine(reloaded).search("health", top_k=5)
assert res.total_hits > 0