Files
dotfiles/tests/ebook_search/test_rag_pipeline.py
T
Richie 0028237579 perf(ebook-search): run phrase detection in parallel with retrieval
Move protected phrase detection into the retrieval gather so it runs
concurrently with vector and BM25 candidates instead of sequentially
before them. Make the search API accept real bool form fields for
rerank/phrase_matching, gate phrase matching on both the request and
config kill switch, and reflow log f-strings for readability.
2026-07-24 11:38:51 -04:00

133 lines
6.2 KiB
Python

"""Tests for the ebook search RAG pipeline orchestration."""
from __future__ import annotations
import asyncio
from threading import Event
from typing import TYPE_CHECKING
from sqlalchemy.ext.asyncio import create_async_engine
from python.ebook_search.config import EbookSearchConfig, RerankConfig
from python.ebook_search.search import SearchResult, search_ebooks
if TYPE_CHECKING:
from pytest_mock import MockerFixture
async def test_search_ebooks_runs_vector_and_bm25_in_parallel(mocker: MockerFixture) -> None:
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
vector_started = Event()
bm25_started = Event()
received_engines: list[object] = []
async def fake_vector_candidates(received_engine, _client, query, _config):
"""Return vector candidates after confirming BM25 has started."""
received_engines.append(received_engine)
assert query == "what is parallel"
vector_started.set()
assert await asyncio.to_thread(bm25_started.wait, 2)
return [SearchResult(chunk_id=1, text="vector", source_title="Vector", vector_score=0.9)]
def fake_bm25_candidates(query, _config):
"""Return BM25 candidates after confirming vector search has started."""
assert query == "what is parallel"
bm25_started.set()
assert vector_started.wait(timeout=2)
return [SearchResult(chunk_id=2, text="bm25", source_title="BM25", bm25_score=2.0)]
mocker.patch("python.ebook_search.search.vector_candidates", side_effect=fake_vector_candidates)
mocker.patch("python.ebook_search.search.bm25_candidates", side_effect=fake_bm25_candidates)
config = EbookSearchConfig(rerank=RerankConfig(enabled=False))
response = await search_ebooks(
engine, mocker.Mock(), "what is parallel", config, rerank=False, phrase_matching=False
)
timings = {step.name: step for step in response.timings}
assert [result.chunk_id for result in response.results] == [1, 2]
assert timings["Embedding + vector search"].counts_toward_total is False
assert timings["BM25 search"].counts_toward_total is False
assert timings["Hybrid retrieval"].counts_toward_total is True
assert received_engines == [engine]
async def test_search_ebooks_runs_phrase_detection_in_parallel_with_retrieval(mocker: MockerFixture) -> None:
"""Phrase detection joins the retrieval gather instead of running before it."""
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
phrase_started = Event()
async def fake_vector_candidates(_engine, _client, _query, _config):
"""Return vector candidates only once phrase detection has started."""
assert await asyncio.to_thread(phrase_started.wait, 2)
return [SearchResult(chunk_id=1, text="vector", source_title="Vector", vector_score=0.9)]
async def fake_query_phrase_matches(_engine, _query, _config):
"""Record that phrase detection started and return no matches."""
phrase_started.set()
return []
mocker.patch("python.ebook_search.search.vector_candidates", side_effect=fake_vector_candidates)
mocker.patch("python.ebook_search.search.bm25_candidates", return_value=[])
mocker.patch("python.ebook_search.search.query_phrase_matches", side_effect=fake_query_phrase_matches)
config = EbookSearchConfig(rerank=RerankConfig(enabled=False))
response = await search_ebooks(
engine, mocker.Mock(), "what is parallel", config, rerank=False, phrase_matching=True
)
timings = {step.name: step for step in response.timings}
assert [result.chunk_id for result in response.results] == [1]
assert timings["Protected phrase detection"].counts_toward_total is False
assert timings["Hybrid retrieval"].counts_toward_total is True
async def test_search_ebooks_skips_phrase_matching_when_disabled(mocker: MockerFixture) -> None:
"""Phrase matching can be disabled for one search request."""
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
mocker.patch(
"python.ebook_search.search.vector_candidates",
return_value=[SearchResult(chunk_id=1, text="vector", source_title="Vector", vector_score=0.9)],
)
mocker.patch("python.ebook_search.search.bm25_candidates", return_value=[])
detect_mock = mocker.patch("python.ebook_search.search.query_phrase_matches")
boost_mock = mocker.patch("python.ebook_search.search.apply_phrase_mention_boosts")
config = EbookSearchConfig(rerank=RerankConfig(enabled=False))
response = await search_ebooks(
engine, mocker.Mock(), "what is parallel", config, rerank=False, phrase_matching=False
)
timing_names = {step.name for step in response.timings}
assert [result.chunk_id for result in response.results] == [1]
assert response.phrase_matches == ()
assert "Protected phrase detection skipped" in timing_names
assert "Phrase mention boost skipped" in timing_names
detect_mock.assert_not_called()
boost_mock.assert_not_called()
async def test_search_ebooks_ignores_phrase_matching_when_config_disabled(mocker: MockerFixture) -> None:
"""The config kill switch overrides a request that asks for phrase matching."""
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
mocker.patch(
"python.ebook_search.search.vector_candidates",
return_value=[SearchResult(chunk_id=1, text="vector", source_title="Vector", vector_score=0.9)],
)
mocker.patch("python.ebook_search.search.bm25_candidates", return_value=[])
detect_mock = mocker.patch("python.ebook_search.search.query_phrase_matches")
boost_mock = mocker.patch("python.ebook_search.search.apply_phrase_mention_boosts")
config = EbookSearchConfig(rerank=RerankConfig(enabled=False), phrase_matching_enabled=False)
response = await search_ebooks(
engine, mocker.Mock(), "what is parallel", config, rerank=False, phrase_matching=True
)
timing_names = {step.name for step in response.timings}
assert [result.chunk_id for result in response.results] == [1]
assert response.phrase_matches == ()
assert "Protected phrase detection skipped" in timing_names
assert "Phrase mention boost skipped" in timing_names
detect_mock.assert_not_called()
boost_mock.assert_not_called()