Skip to content

Commit 01e1082

Browse files
authored
fix(recall): widen context tag candidate fetch (#215)
## Summary - widen semantic vector candidate fetches when recall has `context_profile.priority_tags`, so `context_tags` boosts can promote candidates outside a small requested limit - add lab harness support for per-query `context_tags` - add a small context-tags probe set for issue #201 Closes #201. ## Public/API Notes - `/recall` response shape is unchanged. - `context_tags` remains a soft boost; this only widens the pre-scoring vector candidate pool when a semantic query or embedding is present. - Lab test-set rows may now include `context_tags`. ## Validation - RED confirmed before implementation: - `tests/test_recall_overfetch.py::test_context_tags_widen_vector_fetch_before_scoring` failed with requested vector limit `20` instead of `50` - `tests/lab/test_lab_corpus.py::test_recall_serializes_params` failed with unexpected `context_tags` - `tests/lab/test_run_recall_test.py::test_run_single_query_forwards_row_context_tags` failed because row-level tags were ignored - `PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 .venv/bin/pytest tests/test_recall_overfetch.py tests/test_context_tag_separator.py -q` -> 17 passed - `PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 .venv/bin/pytest tests/lab/test_lab_corpus.py tests/lab/test_run_recall_test.py -q` -> 11 passed - `PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 .venv/bin/pytest tests/test_api_endpoints.py -q -k recall` -> 69 passed, 124 deselected - `make test` -> 631 passed, 1 skipped, 65 deselected - `make lint` -> passed - `git diff --check` -> passed - `python -m json.tool lab/test_sets/queries_context_tags_20260704.json` -> passed - `make bench-eval BENCH=locomo-mini` -> 84.26% overall accuracy (198/235), 367.5s, 69 cat-5 questions skipped by judge-off run; matches latest comparable local LoCoMo-mini artifact - `make bench-eval BENCH=locomo` -> 81.91% overall accuracy (1263/1542), 2072.6s, 444 cat-5 questions skipped by judge-off run; slightly above latest comparable local LoCoMo-full artifact (81.84%, 1262/1542) ## Live Lab A/B Ran two isolated local stacks from the same restored production snapshot, `prod-api-20260611-post-repair-001241`: - baseline: `origin/main` (`7636021`) at `http://localhost:8011` - candidate: this PR (`a29e9fb`) at `http://localhost:8012` - both reported `memory_count=9900`, `vector_count=9900`, `sync_status=synced` Context-tag probes (`lab/test_sets/queries_context_tags_20260704.json`, `limit=5`, `current_only=false`): - baseline: Recall@5/10/20 `0.000`, MRR `0.000`, NDCG@10 `0.000`, 5 misses - candidate: Recall@5/10/20 `1.000`, MRR `1.000`, NDCG@10 `1.000`, 0 misses Plain production set (`lab/test_sets/queries_prod_20260611.json`, `limit=20`, `current_only=true`): - baseline: Recall@10 `0.045`, MRR `0.024`, NDCG@10 `0.029`, latency avg `765ms` - candidate: Recall@10 `0.275`, MRR `0.220`, NDCG@10 `0.231`, latency avg `972ms` - observed no regression; latency increased by about `208ms` avg on this local restored stack The context-tag probe is the direct #201 validation signal. The plain-query production lift is observational and likely reflects existing derived `context_profile.priority_tags` now widening the candidate pool as intended. Ignored result files are under `lab/results/issue201/`, including `issue201_live_ab_summary.json`.
2 parents 7636021 + a29e9fb commit 01e1082

7 files changed

Lines changed: 174 additions & 2 deletions

File tree

automem/api/recall.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1968,7 +1968,8 @@ def _run_single_query(
19681968
per_query_limit,
19691969
min(per_query_limit * RECALL_VECTOR_OVERFETCH, RECALL_VECTOR_FETCH_CAP),
19701970
)
1971-
if tag_filters and (query_str or embedding_param):
1971+
priority_tags = (context_profile or {}).get("priority_tags") or set()
1972+
if (tag_filters or priority_tags) and (query_str or embedding_param):
19721973
vector_fetch_limit = max(
19731974
per_query_limit,
19741975
min(max(vector_fetch_limit, recall_max_limit), RECALL_VECTOR_FETCH_CAP),
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
{
2+
"metadata": {
3+
"created": "2026-07-04T00:00:00Z",
4+
"source": "issue-201-context-tags-probes",
5+
"query_count": 5,
6+
"notes": "Probe rows exercise per-query context_tags in the recall lab harness."
7+
},
8+
"queries": [
9+
{
10+
"query": "What issues do React SPA editors face with WP Fusion integrations regarding data loss?",
11+
"expected_ids": [
12+
"d4fc88f1-52d1-489b-b39c-72982e406910"
13+
],
14+
"category": "context_tags",
15+
"context_tags": [
16+
"wp-fusion"
17+
]
18+
},
19+
{
20+
"query": "What decisions were made regarding the AutoApp chat UI on April 21, 2026?",
21+
"expected_ids": [
22+
"d7f35a92-50b6-46cb-9f00-e5f0d4f94819"
23+
],
24+
"category": "context_tags",
25+
"context_tags": [
26+
"autoapp"
27+
]
28+
},
29+
{
30+
"query": "What upgrades were made to the AutoHub voice latency settings?",
31+
"expected_ids": [
32+
"de37ff1a-db7f-4310-98af-22c03b2622b8"
33+
],
34+
"category": "context_tags",
35+
"context_tags": [
36+
"autohub",
37+
"voice"
38+
]
39+
},
40+
{
41+
"query": "What issues were identified with mcp-wp taxonomy tools and their interaction with slugs?",
42+
"expected_ids": [
43+
"cd5115b8-e003-46c0-9178-b6b3c0d46117"
44+
],
45+
"category": "context_tags",
46+
"context_tags": [
47+
"mcp-wp",
48+
"wordpress"
49+
]
50+
},
51+
{
52+
"query": "What design constraints did Jack mention for the AutoMem server in June 2026?",
53+
"expected_ids": [
54+
"24997f3a-3bb4-406c-bed3-4172dfb1caa2"
55+
],
56+
"category": "context_tags",
57+
"context_tags": [
58+
"automem",
59+
"mcp-automem"
60+
]
61+
}
62+
]
63+
}

scripts/lab/lab_corpus.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ def recall(
2020
expand_relations: bool = False,
2121
current_only: bool = True,
2222
recency_bias: Optional[str] = None,
23+
context_tags: Optional[List[str]] = None,
2324
http_get=requests.get,
2425
) -> Dict[str, Any]:
2526
"""GET /recall with explicit recall parameters; returns parsed JSON."""
@@ -29,6 +30,8 @@ def recall(
2930
params["current_only"] = "true" if current_only else "false"
3031
if recency_bias is not None:
3132
params["recency_bias"] = recency_bias
33+
if context_tags:
34+
params["context_tags"] = context_tags
3235
resp = http_get(f"{api_url}/recall", params=params, headers=headers, timeout=30)
3336
resp.raise_for_status()
3437
return resp.json()

scripts/lab/run_recall_test.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -241,7 +241,9 @@ def run_single_query(
241241
expected_ids = query_data.get("expected_ids", [])
242242
category = query_data.get("category", "unknown")
243243
distractor_ids = distractor_ids or set()
244-
recall_params = recall_params or {}
244+
recall_params = dict(recall_params or {})
245+
if query_data.get("context_tags"):
246+
recall_params["context_tags"] = query_data["context_tags"]
245247

246248
start = time.perf_counter()
247249
try:

tests/lab/test_lab_corpus.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ def fake_get(url, params=None, headers=None, timeout=None):
3030
expand_relations=True,
3131
current_only=False,
3232
recency_bias="auto",
33+
context_tags=["automem", "mcp-automem"],
3334
http_get=fake_get,
3435
)
3536
assert captured["url"].endswith("/recall")
@@ -38,6 +39,7 @@ def fake_get(url, params=None, headers=None, timeout=None):
3839
assert captured["params"]["expand_relations"] == "true"
3940
assert captured["params"]["current_only"] == "false"
4041
assert captured["params"]["recency_bias"] == "auto"
42+
assert captured["params"]["context_tags"] == ["automem", "mcp-automem"]
4143

4244

4345
def test_recall_omits_recency_bias_when_none():

tests/lab/test_run_recall_test.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import json
22

3+
import pytest
34
import run_recall_test as rr
45

56

@@ -74,3 +75,34 @@ def test_save_results_keeps_query_distractor_rate(tmp_path):
7475
data = json.loads(output_path.read_text())
7576

7677
assert data["queries"][0]["distractor_rate_10"] == 0.5
78+
79+
80+
def test_run_single_query_forwards_row_context_tags(monkeypatch):
81+
captured = {}
82+
83+
def fake_recall(api_url, headers, query, **params):
84+
captured["api_url"] = api_url
85+
captured["headers"] = headers
86+
captured["query"] = query
87+
captured["params"] = params
88+
return {"results": [{"id": "target"}]}
89+
90+
monkeypatch.setattr(rr, "recall", fake_recall)
91+
monkeypatch.setattr(rr, "get_headers", lambda: {"Authorization": "Bearer test"})
92+
93+
result = rr.run_single_query(
94+
{
95+
"query": "What did the AutoMem server prefer?",
96+
"expected_ids": ["target"],
97+
"category": "context_tags",
98+
"context_tags": ["automem", "mcp-automem"],
99+
},
100+
"http://api",
101+
recall_params={"limit": 5, "context_tags": ["fallback"]},
102+
)
103+
104+
assert captured["api_url"] == "http://api"
105+
assert captured["query"] == "What did the AutoMem server prefer?"
106+
assert captured["params"]["limit"] == 5
107+
assert captured["params"]["context_tags"] == ["automem", "mcp-automem"]
108+
assert result.recall_5 == pytest.approx(1.0)

tests/test_recall_overfetch.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import app
1919
import automem.api.recall as recall_module
2020
import automem.search.runtime_recall_helpers as recall_helpers
21+
from automem.utils.scoring import _compute_metadata_score
2122
from tests.support.fake_graph import FakeGraph
2223

2324

@@ -138,6 +139,74 @@ def test_tag_scoped_vector_fetch_respects_cap(overfetch_env, monkeypatch):
138139
assert overfetch_env.last_limit == 20
139140

140141

142+
def test_context_tags_widen_vector_fetch_before_scoring(monkeypatch) -> None:
143+
monkeypatch.setattr(recall_module, "RECALL_VECTOR_OVERFETCH", 4)
144+
monkeypatch.setattr(recall_module, "RECALL_VECTOR_FETCH_CAP", 200)
145+
146+
requested_limits = []
147+
148+
def _result(memory_id, score, *, tags=None):
149+
return {
150+
"id": memory_id,
151+
"score": score,
152+
"match_score": score,
153+
"match_type": "vector",
154+
"source": "qdrant",
155+
"memory": {
156+
"id": memory_id,
157+
"content": f"unrelated content for {memory_id}",
158+
"tags": tags or [],
159+
"importance": 0.0,
160+
"confidence": 0.0,
161+
"type": "Context",
162+
"enriched": True,
163+
"timestamp": "2026-05-18T00:00:00+00:00",
164+
},
165+
"relations": [],
166+
}
167+
168+
vector_results = [_result(f"decoy-{i}", 0.95 - i * 0.001) for i in range(24)] + [
169+
_result("context-target", 0.01, tags=["target-tag"]),
170+
]
171+
172+
def _vector_search(_qdrant, _graph, _query, _embedding, limit, seen_ids, *_args):
173+
requested_limits.append(limit)
174+
matches = [dict(result) for result in vector_results[:limit]]
175+
for result in matches:
176+
seen_ids.add(result["id"])
177+
return matches
178+
179+
with app.app.test_request_context(
180+
"/recall?query=quarterly%20metrics&context_tags=target-tag&limit=5&current_only=false"
181+
):
182+
response = recall_module.handle_recall(
183+
get_memory_graph=lambda: None,
184+
get_qdrant_client=lambda: object(),
185+
normalize_tag_list=lambda value: value if isinstance(value, list) else [],
186+
normalize_timestamp=lambda value: value,
187+
parse_time_expression=lambda _value: (None, None),
188+
extract_keywords=lambda _query: ["quarterly", "metrics"],
189+
compute_metadata_score=_compute_metadata_score,
190+
result_passes_filters=lambda *_args, **_kwargs: True,
191+
graph_keyword_search=lambda *_args, **_kwargs: [],
192+
vector_search=_vector_search,
193+
vector_filter_only_tag_search=lambda *_args, **_kwargs: [],
194+
metadata_keyword_search=lambda *_args, **_kwargs: [],
195+
recall_max_limit=50,
196+
logger=SimpleNamespace(
197+
debug=lambda *_args, **_kwargs: None,
198+
info=lambda *_args, **_kwargs: None,
199+
exception=lambda *_args, **_kwargs: None,
200+
),
201+
)
202+
203+
data = response.get_json()
204+
ids = [result["id"] for result in data["results"]]
205+
assert requested_limits == [50]
206+
assert "context-target" in ids
207+
assert len(ids) <= 5
208+
209+
141210
def test_vector_overfetch_hydrates_relations_after_trim(overfetch_env, monkeypatch):
142211
relation_calls = []
143212

0 commit comments

Comments
 (0)