mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
perf(rag): embed the query once and search indexes concurrently
OptimalService.search / query (via _aggregate_citations) ran each index's plugin.search() sequentially, and every plugin.search re-ran HyDE + embed — so an N-index query made N LLM+embed round-trips in series (~28s across all indexes, even though the SQL is fast). Embed the query ONCE (BaseIndexPlugin.compute_query_embedding) and run every index's vector search concurrently against that single embedding (search_with_embedding + asyncio.gather). The search/query signatures and return contract are unchanged; behavior is identical, just ~Nx fewer embed calls and parallel fetch. Adds a regression test asserting one embed + per-index fan-out.
This commit is contained in:
+65
-26
@@ -1034,6 +1034,30 @@ class OptimalService:
|
|||||||
# SEARCH OPERATIONS
|
# SEARCH OPERATIONS
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _collect_outcomes(
|
||||||
|
plugins: list[tuple[IndexType, BaseIndexPlugin]],
|
||||||
|
outcomes: list[Any],
|
||||||
|
) -> list[SearchResult]:
|
||||||
|
"""Flatten gathered per-index search outcomes; log and skip failures."""
|
||||||
|
results: list[SearchResult] = []
|
||||||
|
for (index_type, _), outcome in zip(plugins, outcomes, strict=True):
|
||||||
|
if isinstance(outcome, BaseException):
|
||||||
|
logger.warning(
|
||||||
|
"Search failed for index",
|
||||||
|
index_type=index_type.value,
|
||||||
|
error=str(outcome),
|
||||||
|
)
|
||||||
|
elif outcome.success:
|
||||||
|
results.extend(outcome.results)
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
"Search failed for index",
|
||||||
|
index_type=index_type.value,
|
||||||
|
error=outcome.error_message,
|
||||||
|
)
|
||||||
|
return results
|
||||||
|
|
||||||
async def search(
|
async def search(
|
||||||
self,
|
self,
|
||||||
query: str,
|
query: str,
|
||||||
@@ -1054,46 +1078,47 @@ class OptimalService:
|
|||||||
if not self._initialized:
|
if not self._initialized:
|
||||||
raise RuntimeError("OptimalService not initialized")
|
raise RuntimeError("OptimalService not initialized")
|
||||||
|
|
||||||
results: list[SearchResult] = []
|
|
||||||
index_types = (
|
index_types = (
|
||||||
context.index_types if context and context.index_types else list(IndexType)
|
context.index_types if context and context.index_types else list(IndexType)
|
||||||
)
|
)
|
||||||
|
plugins = [(it, self._plugins[it]) for it in index_types if it in self._plugins]
|
||||||
|
if not plugins:
|
||||||
|
return []
|
||||||
|
|
||||||
for index_type in index_types:
|
# Embed the query ONCE (HyDE + embed), then run every index's vector
|
||||||
plugin = self._plugins.get(index_type)
|
# search concurrently — instead of each index re-embedding in series.
|
||||||
if plugin:
|
try:
|
||||||
outcome = await plugin.search(query=query, top_k=top_k)
|
query_embedding = await plugins[0][1].compute_query_embedding(query)
|
||||||
if outcome.success:
|
except Exception as e:
|
||||||
results.extend(outcome.results)
|
logger.warning("Query embedding failed", error=str(e))
|
||||||
else:
|
return []
|
||||||
logger.warning(
|
|
||||||
"Search failed for index",
|
|
||||||
index_type=index_type.value,
|
|
||||||
error=outcome.error_message,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Sort by score descending
|
outcomes = await asyncio.gather(
|
||||||
|
*(
|
||||||
|
plugin.search_with_embedding(query_embedding, top_k=top_k)
|
||||||
|
for _, plugin in plugins
|
||||||
|
),
|
||||||
|
return_exceptions=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
results = self._collect_outcomes(plugins, outcomes)
|
||||||
results.sort(key=lambda r: r.score, reverse=True)
|
results.sort(key=lambda r: r.score, reverse=True)
|
||||||
return results[: top_k * len(index_types)]
|
return results[: top_k * len(index_types)]
|
||||||
|
|
||||||
async def _search_single_index(
|
async def _search_single_index(
|
||||||
self,
|
self,
|
||||||
index_type: IndexType,
|
index_type: IndexType,
|
||||||
query: str,
|
plugin: BaseIndexPlugin,
|
||||||
|
query_embedding: list[float],
|
||||||
top_k: int,
|
top_k: int,
|
||||||
buf: _QueryAggregationBuffer,
|
buf: _QueryAggregationBuffer,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Search one index and update aggregate buffers in place."""
|
"""Search one index with a pre-computed embedding; update buf in place."""
|
||||||
plugin = self._plugins.get(index_type)
|
if await plugin.count() == 0:
|
||||||
if not plugin:
|
|
||||||
return
|
|
||||||
|
|
||||||
count = await plugin.count()
|
|
||||||
if count == 0:
|
|
||||||
logger.debug("Skipping empty index", index_type=index_type.value)
|
logger.debug("Skipping empty index", index_type=index_type.value)
|
||||||
return
|
return
|
||||||
|
|
||||||
outcome = await plugin.search(query=query, top_k=top_k)
|
outcome = await plugin.search_with_embedding(query_embedding, top_k=top_k)
|
||||||
if outcome.success:
|
if outcome.success:
|
||||||
buf.stats[index_type.value] = len(outcome.results)
|
buf.stats[index_type.value] = len(outcome.results)
|
||||||
buf.citations.extend(outcome.results)
|
buf.citations.extend(outcome.results)
|
||||||
@@ -1109,11 +1134,25 @@ class OptimalService:
|
|||||||
async def _aggregate_citations(
|
async def _aggregate_citations(
|
||||||
self, index_types: list[IndexType], query: str, top_k: int
|
self, index_types: list[IndexType], query: str, top_k: int
|
||||||
) -> tuple[list[SearchResult], dict[str, int], dict[str, str]]:
|
) -> tuple[list[SearchResult], dict[str, int], dict[str, str]]:
|
||||||
"""Run search across the requested indexes and aggregate citations."""
|
"""Embed once, then search the requested indexes concurrently."""
|
||||||
buf = _QueryAggregationBuffer()
|
buf = _QueryAggregationBuffer()
|
||||||
|
plugins = [(it, self._plugins[it]) for it in index_types if it in self._plugins]
|
||||||
|
if not plugins:
|
||||||
|
return buf.citations, buf.stats, buf.errors
|
||||||
|
|
||||||
for index_type in index_types:
|
try:
|
||||||
await self._search_single_index(index_type, query, top_k, buf)
|
query_embedding = await plugins[0][1].compute_query_embedding(query)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("RAG query embedding failed", error=str(e))
|
||||||
|
return buf.citations, buf.stats, buf.errors
|
||||||
|
|
||||||
|
await asyncio.gather(
|
||||||
|
*(
|
||||||
|
self._search_single_index(it, plugin, query_embedding, top_k, buf)
|
||||||
|
for it, plugin in plugins
|
||||||
|
),
|
||||||
|
return_exceptions=True,
|
||||||
|
)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"RAG search complete",
|
"RAG search complete",
|
||||||
|
|||||||
@@ -854,34 +854,33 @@ class BaseIndexPlugin(ABC):
|
|||||||
break
|
break
|
||||||
return results
|
return results
|
||||||
|
|
||||||
async def search(
|
async def compute_query_embedding(self, query: str) -> list[float]:
|
||||||
|
"""Embed a query (incl. HyDE) once.
|
||||||
|
|
||||||
|
Lets the service compute one embedding and fan it out across every
|
||||||
|
index, instead of each index re-running HyDE + embed sequentially.
|
||||||
|
"""
|
||||||
|
return await self._compute_query_embedding(query)
|
||||||
|
|
||||||
|
async def search_with_embedding(
|
||||||
self,
|
self,
|
||||||
query: str,
|
query_embedding: list[float],
|
||||||
top_k: int = 5,
|
top_k: int = 5,
|
||||||
filters: dict[str, Any] | None = None,
|
filters: dict[str, Any] | None = None,
|
||||||
) -> SearchOutcome:
|
) -> SearchOutcome:
|
||||||
"""
|
"""Search using a pre-computed query embedding (skips HyDE/embed).
|
||||||
Search the index.
|
|
||||||
|
|
||||||
Args:
|
Splitting embed from fetch lets the service embed once and run every
|
||||||
query: Natural language search query
|
index's vector search concurrently.
|
||||||
top_k: Number of results to return
|
|
||||||
filters: Optional metadata filters
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
SearchOutcome with results and success status
|
|
||||||
"""
|
"""
|
||||||
import time
|
import time
|
||||||
|
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
query_embedding = await self._compute_query_embedding(query)
|
|
||||||
chunks = await self._fetch_citations(
|
chunks = await self._fetch_citations(
|
||||||
query_embedding, top_k, has_filters=bool(filters)
|
query_embedding, top_k, has_filters=bool(filters)
|
||||||
)
|
)
|
||||||
results = self._citations_to_results(chunks, top_k, filters)
|
results = self._citations_to_results(chunks, top_k, filters)
|
||||||
|
|
||||||
elapsed_ms = (time.time() - start_time) * 1000
|
elapsed_ms = (time.time() - start_time) * 1000
|
||||||
return SearchOutcome(
|
return SearchOutcome(
|
||||||
results=results,
|
results=results,
|
||||||
@@ -889,7 +888,6 @@ class BaseIndexPlugin(ABC):
|
|||||||
index_type=self.index_type,
|
index_type=self.index_type,
|
||||||
search_time_ms=elapsed_ms,
|
search_time_ms=elapsed_ms,
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
elapsed_ms = (time.time() - start_time) * 1000
|
elapsed_ms = (time.time() - start_time) * 1000
|
||||||
logger.warning(
|
logger.warning(
|
||||||
@@ -906,6 +904,37 @@ class BaseIndexPlugin(ABC):
|
|||||||
search_time_ms=elapsed_ms,
|
search_time_ms=elapsed_ms,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def search(
|
||||||
|
self,
|
||||||
|
query: str,
|
||||||
|
top_k: int = 5,
|
||||||
|
filters: dict[str, Any] | None = None,
|
||||||
|
) -> SearchOutcome:
|
||||||
|
"""Search the index: embed the query, then run the vector search."""
|
||||||
|
import time
|
||||||
|
|
||||||
|
start_time = time.time()
|
||||||
|
try:
|
||||||
|
query_embedding = await self._compute_query_embedding(query)
|
||||||
|
except Exception as e:
|
||||||
|
elapsed_ms = (time.time() - start_time) * 1000
|
||||||
|
logger.warning(
|
||||||
|
"Search failed",
|
||||||
|
index_type=self.index_type.value,
|
||||||
|
error=str(e),
|
||||||
|
search_time_ms=elapsed_ms,
|
||||||
|
)
|
||||||
|
return SearchOutcome(
|
||||||
|
results=[],
|
||||||
|
success=False,
|
||||||
|
error_message=str(e),
|
||||||
|
index_type=self.index_type,
|
||||||
|
search_time_ms=elapsed_ms,
|
||||||
|
)
|
||||||
|
return await self.search_with_embedding(
|
||||||
|
query_embedding, top_k=top_k, filters=filters
|
||||||
|
)
|
||||||
|
|
||||||
def _fallback_answer(self, _search_results: list[SearchResult]) -> str:
|
def _fallback_answer(self, _search_results: list[SearchResult]) -> str:
|
||||||
"""
|
"""
|
||||||
Return empty to let OptimalService continue searching other indexes.
|
Return empty to let OptimalService continue searching other indexes.
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"""OptimalService.search embeds the query once and fans out across indexes.
|
||||||
|
|
||||||
|
Guards the latency fix: previously each index re-ran HyDE + embed sequentially
|
||||||
|
(N LLM calls, serial), so an all-index search took ~28s. Now the query is
|
||||||
|
embedded once and every index's vector search runs concurrently with that single
|
||||||
|
embedding.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from roboco.models.optimal import IndexType, SearchOutcome
|
||||||
|
from roboco.services.optimal import OptimalService
|
||||||
|
|
||||||
|
|
||||||
|
def _fake_plugin(index_type: IndexType) -> MagicMock:
|
||||||
|
plugin = MagicMock()
|
||||||
|
plugin.compute_query_embedding = AsyncMock(return_value=[0.1, 0.2, 0.3])
|
||||||
|
plugin.search_with_embedding = AsyncMock(
|
||||||
|
return_value=SearchOutcome(results=[], success=True, index_type=index_type)
|
||||||
|
)
|
||||||
|
plugin.count = AsyncMock(return_value=1)
|
||||||
|
return plugin
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_search_embeds_once_and_fans_out(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
svc = OptimalService.__new__(OptimalService)
|
||||||
|
p_docs = _fake_plugin(IndexType.DOCUMENTATION)
|
||||||
|
p_journals = _fake_plugin(IndexType.JOURNALS)
|
||||||
|
monkeypatch.setattr(svc, "_initialized", True, raising=False)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
svc,
|
||||||
|
"_plugins",
|
||||||
|
{IndexType.DOCUMENTATION: p_docs, IndexType.JOURNALS: p_journals},
|
||||||
|
raising=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
await svc.search("anything")
|
||||||
|
|
||||||
|
# Embedded exactly once total (on the first plugin), reused across indexes —
|
||||||
|
# not once per index.
|
||||||
|
embed_calls = (
|
||||||
|
p_docs.compute_query_embedding.await_count
|
||||||
|
+ p_journals.compute_query_embedding.await_count
|
||||||
|
)
|
||||||
|
assert embed_calls == 1
|
||||||
|
# Every index ran a vector search with the pre-computed embedding.
|
||||||
|
p_docs.search_with_embedding.assert_awaited_once()
|
||||||
|
p_journals.search_with_embedding.assert_awaited_once()
|
||||||
Reference in New Issue
Block a user