diff --git a/haiku_rag_slim/haiku/rag/client/search.py b/haiku_rag_slim/haiku/rag/client/search.py index 6b029b4f..3be0d96a 100644 --- a/haiku_rag_slim/haiku/rag/client/search.py +++ b/haiku_rag_slim/haiku/rag/client/search.py @@ -39,7 +39,7 @@ async def search( if limit is None: limit = client._config.search.limit - resolved = search_type or ("hybrid" if isinstance(query, str) else "vector") + resolved = _resolved_search_type(query, search_type) # One database embeds inside the repository, which returns early for a filter # that matches nothing, so a text query that finds no documents never embeds. query_vector = ( @@ -86,13 +86,14 @@ async def search_sources( if not names: return [] selected = await client.clients_for(names) - client._require_one_embedder(selected) + resolved = _resolved_search_type(query, search_type) + if resolved != "fts": + client._require_one_embedder(selected) # One over-fetch decision, one query vector, and one reranker, for the whole # set. The databases in a selection share an embedder, so the vector is the # same wherever it is computed. fetch_limit = _fetch_limit(client, query, limit) - resolved = search_type or ("hybrid" if isinstance(query, str) else "vector") query_vector = await _embed_query(selected[0], query, resolved) per_source = await asyncio.gather( *( @@ -198,6 +199,19 @@ def _fetch_limit( return limit * _RERANK_OVERFETCH if client.reranker else limit +def _resolved_search_type( + query: "str | bytes | PILImage.Image", search_type: SearchType | None +) -> SearchType: + """The search actually run for this query. + + An image query has no text to match against, so it is vector-only whatever + the caller asked for; a text query defaults to hybrid. + """ + if not isinstance(query, str): + return "vector" + return search_type or "hybrid" + + async def _embed_query( client: "HaikuRAG", query: "str | bytes | PILImage.Image", search_type: SearchType ) -> list[float] | None: @@ -205,7 +219,8 @@ async def _embed_query( Computed by the caller so that searching several databases embeds once: the databases in a selection share an embedder, and embedding per database costs - a round trip each on a remote endpoint. + a round trip each on a remote endpoint. `search_type` is the resolved one, so + only a text query ever reaches this as full-text. """ if search_type == "fts": return None diff --git a/tests/test_multi_db.py b/tests/test_multi_db.py index 9af39685..da2e3c7f 100644 --- a/tests/test_multi_db.py +++ b/tests/test_multi_db.py @@ -384,6 +384,20 @@ class TestOneEmbedderAcrossTheSet: assert await rag.search("one", sources=["alpha"]) is not None assert await rag.count_documents(filter=None) is not None + @pytest.mark.asyncio + async def test_full_text_search_needs_no_agreement(self, tmp_path): + """Full-text search embeds nothing, so which model wrote each database + does not come into it.""" + config = _config(tmp_path, ["alpha", "beta"]) + await _seed(config, "alpha", ["alpha one"]) + await _seed(config, "beta", ["beta one"]) + await _restore_embedder(config, "beta", model_name="some-other-model") + + async with HaikuRAG(config=config, read_only=True) as rag: + results = await rag.search("one", search_type="fts") + + assert {r.source for r in results} == {"alpha", "beta"} + @pytest.mark.asyncio async def test_agreeing_databases_search_together(self, tmp_path, query_embedding): """The databases agree with each other; that they were written by a diff --git a/tests/test_search_phases.py b/tests/test_search_phases.py index 63cf32d8..896a2c9a 100644 --- a/tests/test_search_phases.py +++ b/tests/test_search_phases.py @@ -58,3 +58,31 @@ async def test_image_fetch_never_builds_the_reranker( # No over-fetch: nothing will re-rank these. assert seen["limit"] == 5 + + +@pytest.mark.asyncio +async def test_an_image_query_ignores_a_full_text_search_type( + temp_db_path, monkeypatch +): + """An image has no text to match, so it searches by vector whatever the + caller asked for.""" + async with HaikuRAG(temp_db_path, create=True) as rag: + seen = {} + + async def fake_search(query, limit, search_type, filter, query_vector): + seen.update({"search_type": search_type, "query_vector": query_vector}) + return [] + + async def fake_embed_image(self, image): + return [0.1] * 8 + + monkeypatch.setattr(rag.chunk_repository, "search", fake_search) + monkeypatch.setattr(type(rag.embedder), "embed_image", fake_embed_image) + monkeypatch.setattr( + type(rag.embedder), "supports_images", property(lambda self: True) + ) + + await rag.search(b"image-bytes", search_type="fts") + + assert seen["search_type"] == "vector" + assert seen["query_vector"] == [0.1] * 8