From 3df5b55e99d73a3402b8e721c331642e1c897899 Mon Sep 17 00:00:00 2001 From: SiyaoZheng Date: Wed, 15 Jul 2026 23:22:01 +0800 Subject: [PATCH] fix(knowledge): keep rerank passages nonempty --- src/everos/service/knowledge.py | 28 +++- .../test_service/test_knowledge_search.py | 125 ++++++++++++++++++ 2 files changed, 147 insertions(+), 6 deletions(-) diff --git a/src/everos/service/knowledge.py b/src/everos/service/knowledge.py index 396c410d..d840b26b 100644 --- a/src/everos/service/knowledge.py +++ b/src/everos/service/knowledge.py @@ -1138,11 +1138,11 @@ async def _base_retrieve( async def _enrich_with_content( candidates: list[Candidate], ) -> list[Candidate]: - """Batch-fetch SQLite content and attach to candidate metadata. + """Batch-fetch authoritative content and build safe rerank passages. - Acts as a no-reranker path: returns candidates in their original - order with ``content`` added to metadata for downstream hit - conversion. + ``content`` remains the exact SQLite value for downstream API responses. + ``rerank_content`` falls back through topic summary/name/id so providers + never receive an empty document. """ if not candidates: return candidates @@ -1151,10 +1151,26 @@ async def _enrich_with_content( topics = await knowledge_topic_sqlite_repo.get_topics_by_ids(topic_ids) content_map = {t.node_id: t.content for t in topics} + def _first_nonblank(*values: object) -> str: + for value in values: + text = str(value or "").strip() + if text: + return text + return "Knowledge topic" + return [ c.model_copy( update={ - "metadata": {**c.metadata, "content": content_map.get(c.id, "")}, + "metadata": { + **c.metadata, + "content": str(content_map.get(c.id, "") or ""), + "rerank_content": _first_nonblank( + content_map.get(c.id, ""), + c.metadata.get("summary"), + c.metadata.get("topic_name"), + c.id, + ), + }, } ) for c in candidates @@ -1250,7 +1266,7 @@ async def _retrieve(q: str, k: int) -> list[Candidate]: recaller, where, method=method, query=q, vector=vector, limit=k ) - raw_rerank = build_rerank_fn(reranker, text_field="content") + raw_rerank = build_rerank_fn(reranker, text_field="rerank_content") async def _rerank_with_enrich( q: str, candidates: Sequence[Candidate] diff --git a/tests/unit/test_service/test_knowledge_search.py b/tests/unit/test_service/test_knowledge_search.py index 8cc6daa4..432e9488 100644 --- a/tests/unit/test_service/test_knowledge_search.py +++ b/tests/unit/test_service/test_knowledge_search.py @@ -15,6 +15,7 @@ import pytest from everalgo.types import Candidate +from everos.component.rerank.protocol import RerankResult from everos.component.utils.datetime import get_utc_now from everos.core.errors import ConfigurationError from everos.infra.persistence.sqlite.tables.knowledge import ( @@ -23,6 +24,8 @@ from everos.service.knowledge import ( DocumentContext, SearchKnowledgeResult, + _enrich_with_content, + _run_category_pipeline, compile_knowledge_where, search_knowledge, ) @@ -169,6 +172,128 @@ def test_accepts_valid_ids_with_at_plus(self) -> None: assert "proj_1" in result +class TestKnowledgeRerankContent: + async def test_empty_sqlite_content_uses_summary_only_for_reranking(self) -> None: + candidate = _candidate( + node_id="n_empty_content", + summary="Non-empty topic summary for reranking.", + content="", + ) + topic = MagicMock(node_id="n_empty_content", content="") + + with patch( + f"{_MOD}.knowledge_topic_sqlite_repo.get_topics_by_ids", + new=AsyncMock(return_value=[topic]), + ): + enriched = await _enrich_with_content([candidate]) + + assert enriched[0].metadata["content"] == "" + assert enriched[0].metadata["rerank_content"] == ( + "Non-empty topic summary for reranking." + ) + + async def test_blank_content_and_summary_fall_through_to_topic_name(self) -> None: + candidate = _candidate( + node_id="n_blank_summary", + summary=" \t ", + topic_name="Useful Topic Name", + ) + topic = MagicMock(node_id="n_blank_summary", content=" \n ") + + with patch( + f"{_MOD}.knowledge_topic_sqlite_repo.get_topics_by_ids", + new=AsyncMock(return_value=[topic]), + ): + enriched = await _enrich_with_content([candidate]) + + assert enriched[0].metadata["content"] == " \n " + assert enriched[0].metadata["rerank_content"] == "Useful Topic Name" + + async def test_missing_row_and_blank_metadata_fall_back_to_candidate_id( + self, + ) -> None: + candidate = _candidate( + node_id="n_stable_fallback", + summary=" ", + topic_name="\n", + ) + + with patch( + f"{_MOD}.knowledge_topic_sqlite_repo.get_topics_by_ids", + new=AsyncMock(return_value=[]), + ): + enriched = await _enrich_with_content([candidate]) + + assert enriched[0].metadata["content"] == "" + assert enriched[0].metadata["rerank_content"] == "n_stable_fallback" + + async def test_all_blank_fields_use_nonempty_terminal_fallback(self) -> None: + candidate = _candidate(node_id="", summary=" ", topic_name="\n") + + with patch( + f"{_MOD}.knowledge_topic_sqlite_repo.get_topics_by_ids", + new=AsyncMock(return_value=[]), + ): + enriched = await _enrich_with_content([candidate]) + + assert enriched[0].metadata["rerank_content"] == "Knowledge topic" + + async def test_nonempty_sqlite_content_remains_authoritative(self) -> None: + candidate = _candidate(node_id="n_full_content", summary="Fallback summary") + topic = MagicMock( + node_id="n_full_content", + content="\n Authoritative body. \n", + ) + + with patch( + f"{_MOD}.knowledge_topic_sqlite_repo.get_topics_by_ids", + new=AsyncMock(return_value=[topic]), + ): + enriched = await _enrich_with_content([candidate]) + + assert enriched[0].metadata["content"] == "\n Authoritative body. \n" + assert enriched[0].metadata["rerank_content"] == "Authoritative body." + + async def test_category_pipeline_sends_nonempty_rerank_passage(self) -> None: + candidate = _candidate( + node_id="n_provider_seam", + summary="Provider-safe topic summary.", + content="", + ) + topic = MagicMock(node_id="n_provider_seam", content="") + recaller = AsyncMock() + recaller.sparse_recall = AsyncMock(return_value=[candidate]) + reranker = AsyncMock() + reranker.rerank = AsyncMock(return_value=[RerankResult(0, 0.9)]) + config = _mock_settings().knowledge.search + + async def _run_facade(query, *, base_retrieve, rerank_fn, **_kwargs): + recalled = await base_retrieve(query, 10) + return await rerank_fn(query, recalled) + + with ( + patch(f"{_MOD}._build_recaller", return_value=recaller), + patch( + f"{_MOD}.knowledge_topic_sqlite_repo.get_topics_by_ids", + new=AsyncMock(return_value=[topic]), + ), + patch("everalgo.rank.acategory_retrieve", new=_run_facade), + ): + await _run_category_pipeline( + "query", + "app_id = 'default' AND project_id = 'default'", + method="keyword", + vector=[], + reranker=reranker, + config=config, + top_k=5, + ) + + documents = reranker.rerank.await_args.args[1] + assert documents == ["Provider-safe topic summary."] + assert all(document.strip() for document in documents) + + # ── search_knowledge ─────────────────────────────────────────────────────────