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.
This commit is contained in:
Richie committed 2026-07-24 11:38:51 -04:00
1 parent 8f1a69529c
commit 0028237579
5 files changed
+113 -37

No files matched your search

+34 -19
View File
@@ -84,10 +84,11 @@ class SearchResponse:
@dataclass(frozen=True)
class RetrievalResponse:
"""Parallel retrieval output for vector and BM25 candidates."""
"""Parallel retrieval output for vector, BM25, and protected phrase candidates."""
vector_results: list[SearchResult]
lexical_results: list[SearchResult]
phrase_matches: list[HydratedPhraseMatch]
timings: tuple[RuntimeStep, ...]
@@ -97,28 +98,26 @@ async def search_ebooks(
query: str,
config: EbookSearchConfig,
*,
rerank: bool = False,
phrase_matching: bool | None = None,
rerank: bool,
phrase_matching: bool,
) -> SearchResponse:
"""Run hybrid vector/BM25 search and optional reranking."""
"""Run hybrid vector/BM25 search and optional reranking.
Phrase matching only runs when both the request asks for it and
``config.phrase_matching_enabled`` allows it.
"""
if not query.strip():
logger.info("ebook_search_empty_query")
return SearchResponse(query=query, results=[], rank_label="Hybrid")
phrase_matching_enabled = config.phrase_matching_enabled if phrase_matching is None else phrase_matching
logger.info(f"ebook_search_start query_length={len(query)} {rerank=} {phrase_matching_enabled=}")
phrase_matching = phrase_matching and config.phrase_matching_enabled
logger.info(f"ebook_search_start query_length={len(query)} {rerank=} {phrase_matching=}")
timings: list[RuntimeStep] = []
if phrase_matching_enabled:
phrase_matches, timing = await async_timed_result(
"Protected phrase detection", query_phrase_matches(engine, query, config)
)
else:
phrase_matches, timing = timed_result("Protected phrase detection skipped", skip_phrase_matches)
timings.append(timing)
retrieval, timing = await async_timed_result(
"Hybrid retrieval",
parallel_retrieval(engine, client, query, config),
parallel_retrieval(engine, client, query, config, phrase_matching=phrase_matching),
)
phrase_matches = retrieval.phrase_matches
timings.extend(retrieval.timings)
timings.append(timing)
fused, timing = timed_result(
@@ -129,7 +128,7 @@ async def search_ebooks(
rank_constant=config.rrf_rank_constant,
)
timings.append(timing)
if phrase_matching_enabled:
if phrase_matching:
fused, timing = await async_timed_result(
"Phrase mention boost",
apply_phrase_mention_boosts(engine, fused, phrase_matches, config.phrase_hit_boost),
@@ -145,7 +144,7 @@ async def search_ebooks(
response = replace(response, timings=tuple(timings), phrase_matches=tuple(phrase_matches))
logger.info(
f"ebook_search_complete vector_candidates={len(retrieval.vector_results)} "
f"lexical_candidates={len(retrieval.lexical_results)} fused_candidates={len(fused)} {phrase_matching_enabled=} "
f"lexical_candidates={len(retrieval.lexical_results)} fused_candidates={len(fused)} {phrase_matching=} "
f"phrase_matches={len(phrase_matches)} returned={len(response.results)} {response.rank_label=} "
f"{response.total_runtime_ms=:.1f}"
)
@@ -236,27 +235,43 @@ async def parallel_retrieval(
client: httpx.AsyncClient,
query: str,
config: EbookSearchConfig,
*,
phrase_matching: bool,
) -> RetrievalResponse:
"""Run vector and BM25 candidate retrieval concurrently with separate database sessions.
"""Run vector, BM25, and protected phrase retrieval concurrently with separate database sessions.
BM25 scoring is pure CPU work over the cached corpus, so it runs in a worker thread
instead of on the event loop.
instead of on the event loop. Protected phrase detection only depends on the query, so
it joins the gather as a third task when phrase matching is enabled.
"""
phrase_task = (
asyncio.create_task(
async_timed_result("Protected phrase detection", query_phrase_matches(engine, query, config))
)
if phrase_matching
else None
)
(vector_results, vector_timing), (lexical_results, lexical_timing) = await asyncio.gather(
async_timed_result("Embedding + vector search", vector_candidates(engine, client, query, config)),
async_timed_result("BM25 search", asyncio.to_thread(bm25_candidates, query, config)),
)
if phrase_task is not None:
phrase_matches, phrase_timing = await phrase_task
else:
phrase_matches, phrase_timing = timed_result("Protected phrase detection skipped", skip_phrase_matches)
logger.info(
f"ebook_parallel_retrieval_complete vector_candidates={len(vector_results)} "
f"lexical_candidates={len(lexical_results)}"
f"lexical_candidates={len(lexical_results)} phrase_matches={len(phrase_matches)}"
)
return RetrievalResponse(
vector_results=vector_results,
lexical_results=lexical_results,
phrase_matches=phrase_matches,
timings=(
replace(vector_timing, counts_toward_total=False),
replace(lexical_timing, counts_toward_total=False),
replace(phrase_timing, counts_toward_total=False),
),
)