fix(search): explain empty results in metadata (#647)
Some checks failed
CI / Python tests / Unit Tests - py3.13 (push) Has been cancelled
CI / Python tests / Unit Tests - py3.14 (push) Has been cancelled
CI and Release / Docker / Build and test / amd64 (push) Has been cancelled
CI and Release / Docker / Build and test / arm64 (push) Has been cancelled
CI / Python packages / Build and verify distributions (push) Has been cancelled
CI / Python quality / GitHub Actions (push) Has been cancelled
CI / Python quality / Pre-commit (push) Has been cancelled
CI / Python tests / Unit Tests - py3.11 (push) Has been cancelled
CI / Python tests / Unit Tests - py3.12 (push) Has been cancelled
CI / Windows / CLI smoke - py3.11 (push) Has been cancelled
Deploy / Documentation / Build documentation (push) Has been cancelled
Security / CodeQL / Analyze javascript-typescript (push) Has been cancelled
Security / CodeQL / Analyze python (push) Has been cancelled
Deploy / Documentation / deploy (push) Has been cancelled
CI and Release / Docker / Publish multi-platform tags (push) Has been cancelled

* fix(search): explain empty search results

* fix(codex): ignore empty search notices

* fix(integrations): skip empty search results in auto recall

* fix(search): explain empty results in metadata
This commit is contained in:
fly liu 2026-10-23 15:53:44 +08:00 • committed by GitHub
parent a597d1a355
commit c773fa7b77
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 309 additions and 0 deletions

View file

@ -276,6 +276,23 @@ connects to. If a result is worth pursuing, use `read path=...` to open the sour
followed by outlinks and inlinks.
- `response.metadata`: structured programmatic results containing `results`, `link_expansion`, and `counts`.
When the default `search` Job returns no results, `response.answer` remains `""` and `success` remains `true`.
The `results` list is empty and `counts.returned` is `0`. An optional `response.metadata.empty_result` explains why:
| `reason` | Meaning |
|----------------|-----------------------------------------------------------------------------|
| `no_results` | No candidates matched or survived `min_score`. |
| `all_returned` | All eligible chunks were already returned within the same `tool_context_id`. |
The field also contains a human-readable `message`. Programmatic consumers should use `reason` to distinguish the
outcomes. Normal hits omit `empty_result` and keep their existing answer format.
To show the explanation through the running service's CLI client, enable the existing metadata display option:
```bash
reme search query="recent discussions about indexing" show_metadata=true
```
Typical text structure:
```text

View file

@ -253,6 +253,23 @@ Memory Search 的“渐进式”不是一次把全库内容塞进结果,而是
- `response.answer`:给人看的文本,每个命中块包含路径、行号、分数和 chunk 内容,后面跟 outlinks / inlinks。
- `response.metadata`:给程序看的结构化结果,包括 `results`、`link_expansion`、`counts`。
默认 `search` Job 没有结果可返回时,`response.answer` 保持 `""`,`success` 保持 `true`。
此时 `results` 为空列表,`counts.returned` 为 `0`,可选字段 `response.metadata.empty_result` 说明原因:
| `reason` | 含义 |
|----------------|-----------------------------------------------------------------|
| `no_results` | 没有匹配结果,或所有候选都被 `min_score` 过滤。 |
| `all_returned` | 符合分数要求的内容在同一 `tool_context_id` 中都已返回过。 |
该字段还包含便于阅读的 `message`。程序调用方应通过 `reason` 区分两种情况。
正常命中不包含 `empty_result`,正文格式保持原样。
通过 CLI 客户端调用已启动的服务时,可以开启已有的元数据显示选项查看原因:
```bash
reme search query="最近关于索引的讨论" show_metadata=true
```
典型文本结构:
```text

View file

@ -5,6 +5,7 @@ import datetime
import os
from typing import Final
from ._source_format import ALL_RETURNED_MESSAGE, NO_RESULTS_MESSAGE
from ._tool_context import _ToolContextMixin
from ..base_step import BaseStep
from ..file_io import extract_daily_date
@ -195,6 +196,7 @@ class SearchStep(_ToolContextMixin, BaseStep):
async def execute(self):
assert self.context is not None
self.context.response.metadata.pop("empty_result", None)
raw_query = self.context.get("query")
query: str = "" if raw_query is None else str(raw_query).strip()
limit: int = int(self.context.get("limit") or _default_limit())
@ -341,6 +343,7 @@ class SearchStep(_ToolContextMixin, BaseStep):
)
fused = [c for c in fused if c.score >= min_score]
pre_dedup_count = len(fused)
dedup: dict | None = None
if tool_context_id:
fused, dedup = self._dedupe_tool_context(fused, tool_context_id, limit)
@ -361,6 +364,11 @@ class SearchStep(_ToolContextMixin, BaseStep):
answer_lines.extend(render_expansion_lines(link_expansion.get(c.path, {})))
self.context.response.answer = "\n".join(answer_lines)
if not fused:
self.context.response.metadata["empty_result"] = {
"reason": "all_returned" if pre_dedup_count else "no_results",
"message": ALL_RETURNED_MESSAGE if pre_dedup_count else NO_RESULTS_MESSAGE,
}
self.context.response.metadata["results"] = [
c.model_dump(exclude_none=True, exclude={"embedding"}) for c in fused
]

View file

@ -0,0 +1,267 @@
"""Empty search metadata explains missing results without changing answer compatibility."""
# pylint: disable=protected-access
import asyncio
import json
from unittest.mock import AsyncMock, Mock
import pytest
from reme.components import ApplicationContext
from reme.components.client.http_client import HttpClient
from reme.components.file_store import BaseFileStore
from reme.components.runtime_context import RuntimeContext
from reme.components.service.cli_service import CliService
from reme.schema import FileChunk
from reme.steps.index import SearchStep
from reme.steps.index._source_format import ALL_RETURNED_MESSAGE, NO_RESULTS_MESSAGE
def _chunk(chunk_id="match", score=2.0):
"""Create a scored hit with an embedding that must not appear in results."""
return FileChunk(
id=chunk_id,
path=f"daily/{chunk_id}.md",
text=f"memory {chunk_id}",
start_line=1,
end_line=2,
embedding=[0.1, 0.2],
scores={"score": score},
)
def _store(chunks=(), mode="keyword"):
"""Mock retrieval boundaries without starting indexes or model providers."""
store = Mock(spec=BaseFileStore)
store.vector_search = AsyncMock(return_value=list(chunks) if mode in {"vector", "hybrid"} else [])
store.keyword_search = AsyncMock(return_value=list(chunks) if mode in {"keyword", "hybrid"} else [])
return store
def _step(tmp_path, store, *, app_context=None, vector_weight=0.0):
"""Use an isolated application, sharing it explicitly for repeated calls."""
return SearchStep(
app_context=app_context if app_context is not None else ApplicationContext(workspace_dir=str(tmp_path)),
file_store=store,
vector_weight=vector_weight,
expand_links=False,
)
@pytest.mark.parametrize(
"mode,weight,score,min_score",
[
("keyword", 0.7, None, 0.0),
("keyword", 0.0, 0.1, 1.0),
("vector", 1.0, 0.1, 1.0),
("hybrid", 0.7, 0.1, 1.0),
],
ids=["no-matches", "keyword-below-threshold", "vector-below-threshold", "hybrid-below-threshold"],
)
@pytest.mark.parametrize("tool_context_id", ["", "ctx-1"], ids=["without-context", "with-context"])
def test_search_without_eligible_results_explains_empty_metadata(
tmp_path,
mode,
weight,
score,
min_score,
tool_context_id,
):
"""No matches and score-filtered hits share the same empty-result behavior."""
chunks = [] if score is None else [_chunk(score=score)]
response = asyncio.run(
_step(tmp_path, _store(chunks, mode), vector_weight=weight)(
RuntimeContext(
query="missing" if score is None else "memory",
min_score=min_score,
tool_context_id=tool_context_id,
),
),
)
assert response.success
assert response.answer == ""
assert response.metadata["empty_result"] == {"reason": "no_results", "message": NO_RESULTS_MESSAGE}
assert response.metadata["results"] == []
assert response.metadata["link_expansion"] == {}
assert response.metadata["counts"] == {
"vector": int(score is not None and mode in {"vector", "hybrid"}),
"keyword": int(score is not None and mode in {"keyword", "hybrid"}),
"returned": 0,
"hybrid": score is not None and mode == "hybrid",
}
if tool_context_id:
assert response.metadata["dedup"]["skipped_seen"] == 0
else:
assert "dedup" not in response.metadata
def test_search_all_seen_explains_empty_metadata_across_step_instances(tmp_path):
"""Fresh Step instances share previously returned content through the application."""
async def run():
app_context = ApplicationContext(workspace_dir=str(tmp_path))
store = _store([_chunk()])
first = await _step(tmp_path, store, app_context=app_context)(
RuntimeContext(query="memory", tool_context_id="ctx-1"),
)
second = await _step(tmp_path, store, app_context=app_context)(
RuntimeContext(query="memory", tool_context_id="ctx-1"),
)
assert first.metadata["counts"]["returned"] == 1
assert "empty_result" not in first.metadata
assert second.success
assert second.answer == ""
assert second.metadata["empty_result"] == {"reason": "all_returned", "message": ALL_RETURNED_MESSAGE}
assert second.metadata["results"] == []
assert second.metadata["link_expansion"] == {}
assert second.metadata["counts"] == {"vector": 0, "keyword": 1, "returned": 0, "hybrid": False}
assert second.metadata["dedup"]["seen_before"] == 1
assert second.metadata["dedup"]["skipped_seen"] == 1
asyncio.run(run())
@pytest.mark.parametrize("min_score", [0.0, 3.0], ids=["no-matches", "below-threshold"])
def test_search_history_does_not_turn_missing_results_into_all_returned(tmp_path, min_score):
"""Past hits do not explain a new query with no eligible candidates."""
async def run():
app_context = ApplicationContext(workspace_dir=str(tmp_path))
store = _store([_chunk()])
first = await _step(tmp_path, store, app_context=app_context)(
RuntimeContext(query="memory", tool_context_id="ctx-1"),
)
assert first.metadata["counts"]["returned"] == 1
if not min_score:
store.keyword_search.return_value = []
response = await _step(tmp_path, store, app_context=app_context)(
RuntimeContext(query="another query", min_score=min_score, tool_context_id="ctx-1"),
)
assert response.success
assert response.answer == ""
assert response.metadata["empty_result"] == {"reason": "no_results", "message": NO_RESULTS_MESSAGE}
assert response.metadata["results"] == []
assert response.metadata["counts"]["returned"] == 0
assert response.metadata["dedup"]["seen_before"] == 1
assert response.metadata["dedup"]["skipped_seen"] == 0
asyncio.run(run())
@pytest.mark.parametrize("tool_context_id", ["", "ctx-1"], ids=["without-context", "with-context"])
def test_search_hit_keeps_answer_and_structured_results(tmp_path, tool_context_id):
"""A normal hit keeps its exact rendering and excludes the embedding from metadata."""
chunk = _chunk()
response = asyncio.run(
_step(tmp_path, _store([chunk]))(RuntimeContext(query="memory", tool_context_id=tool_context_id)),
)
assert response.success
assert "empty_result" not in response.metadata
assert response.answer == "========== daily/match.md:1-2 [score=2.0000] ==========\nmemory match"
assert response.metadata["results"] == [chunk.model_dump(exclude_none=True, exclude={"embedding"})]
assert response.metadata["link_expansion"] == {}
assert response.metadata["counts"] == {"vector": 0, "keyword": 1, "returned": 1, "hybrid": False}
def test_search_partial_dedup_and_context_isolation_keep_hit_answers(tmp_path):
"""Unseen content still renders normally and another context can retrieve seen content."""
async def run():
app_context = ApplicationContext(workspace_dir=str(tmp_path))
store = _store([_chunk()])
await _step(tmp_path, store, app_context=app_context)(
RuntimeContext(query="memory", tool_context_id="ctx-1"),
)
store.keyword_search.return_value = [_chunk(), _chunk("new")]
response = await _step(tmp_path, store, app_context=app_context)(
RuntimeContext(query="memory", tool_context_id="ctx-1"),
)
assert response.success
assert "empty_result" not in response.metadata
assert response.answer == "========== daily/new.md:1-2 [score=2.0000] ==========\nmemory new"
assert [result["id"] for result in response.metadata["results"]] == ["new"]
assert response.metadata["dedup"]["skipped_seen"] == 1
other = await _step(tmp_path, store, app_context=app_context)(
RuntimeContext(query="memory", tool_context_id="ctx-2"),
)
assert [result["id"] for result in other.metadata["results"]] == ["match", "new"]
assert "memory match" in other.answer
assert "memory new" in other.answer
assert other.metadata["dedup"]["skipped_seen"] == 0
assert "empty_result" not in other.metadata
asyncio.run(run())
@pytest.mark.parametrize("next_query", ["memory", ""], ids=["normal-hit", "invalid-query"])
def test_search_reused_response_does_not_keep_empty_reason(tmp_path, next_query):
"""A later hit or error must not inherit an earlier empty search explanation."""
async def run():
store = _store()
step = _step(tmp_path, store)
context = RuntimeContext(query="missing")
context.response.metadata["other_step"] = "preserved"
first = await step(context)
assert first.answer == ""
assert first.metadata["empty_result"]["reason"] == "no_results"
store.keyword_search.return_value = [_chunk()]
context.update({"query": next_query})
response = await step(context)
assert "empty_result" not in response.metadata
assert response.metadata["other_step"] == "preserved"
if next_query:
assert response.success
assert "memory match" in response.answer
assert response.metadata["counts"]["returned"] == 1
else:
assert not response.success
assert response.answer == "Error: query cannot be empty"
asyncio.run(run())
@pytest.mark.parametrize("all_returned", [False, True], ids=["no-results", "all-returned"])
@pytest.mark.parametrize("show_metadata", [False, True], ids=["default-display", "show-metadata"])
def test_empty_search_explanation_uses_existing_cli_metadata_display(tmp_path, capsys, all_returned, show_metadata):
"""Both CLI paths expose reasons only when metadata display is requested."""
async def search():
store = _store([_chunk()] if all_returned else [])
step = _step(tmp_path, store)
if all_returned:
await step(RuntimeContext(query="memory", tool_context_id="ctx-1"))
return await step(RuntimeContext(query="memory", tool_context_id="ctx-1"))
response = asyncio.run(search())
expected = {
"reason": "all_returned" if all_returned else "no_results",
"message": ALL_RETURNED_MESSAGE if all_returned else NO_RESULTS_MESSAGE,
}
assert response.answer == ""
assert response.success
assert response.metadata["empty_result"] == expected
http_text = HttpClient(show_metadata=show_metadata)._format_for_display(response.model_dump_json())
app = Mock(start=AsyncMock(), run_job=AsyncMock(return_value=response), close=AsyncMock())
CliService(job="search", job_args={"query": "memory"}, show_metadata=show_metadata).start_service(app)
output = capsys.readouterr()
assert output.err == ""
app.run_job.assert_awaited_once_with("search", query="memory")
app.close.assert_awaited_once()
if show_metadata:
assert json.loads(http_text.removeprefix("\n✅ "))["empty_result"] == expected
assert json.loads(output.out)["empty_result"] == expected
else:
assert http_text == "\n✅"
assert output.out == "\n"