fleet-memory/hindsight-api-slim/tests/test_graph_entity_fanout_cap.py
Nicolò Boschi 57f154454d
fix(recall): cap entity fanout in graph expansion (#911)
* fix(recall): cap entity fanout in graph expansion to prevent slow queries

On large banks, the entity co-occurrence self-join in _expand_combined()
produces massive intermediate row counts when seeds reference high-fanout
entities (e.g. an entity with 25K+ mentions). This causes recall latency
to degrade significantly.

Changes:
- Replace unbounded entity self-join with LATERAL per-entity cap
  (graph_per_entity_limit, default 200), reducing intermediate rows
  from potentially millions to at most num_entities * 200
- Add ORDER BY unit_id DESC in LATERAL subquery for deterministic
  recency-biased sampling (rides the PK index, no extra sort)
- Add timeout fallback (graph_expansion_timeout, default 10s) that
  drops entity expansion and falls back to semantic+causal only
- Add composite index (entity_id, unit_id) on unit_entities for
  index-only scans in the LATERAL subquery
- Merge 3 unmerged migration heads into one
- Fix recall_perf.py dotenv override issue

Unlike the approach in #895, this does NOT filter out hub entities
entirely — all entities are kept but capped equally, preserving
retrieval quality for queries about frequently-mentioned entities.

Benchmarked on a 67K-unit bank (top entity = 25K mentions):
- retrieval_graph: 0.337s → 0.055s (84% faster)
- end-to-end recall: 0.912s → 0.519s (43% faster)

* fix(tests): fix broken test_combined_scoring and test_reranking_proof_count

- test_combined_scoring: replace MagicMock(spec=RetrievalResult) with real
  dataclass instances — MagicMock attributes returned nested mocks that
  failed on >= comparisons with int
- test_reranking_proof_count: remove deleted `embedding` param from
  RetrievalResult constructor, use None for occurred_start/end to get
  neutral recency (datetime.now gave recency=1.0 which boosted scores)

* refactor: rename config to link_expansion_ prefix, fix observation fanout

- Rename GRAPH_PER_ENTITY_LIMIT → LINK_EXPANSION_PER_ENTITY_LIMIT and
  GRAPH_EXPANSION_TIMEOUT → LINK_EXPANSION_TIMEOUT to follow the
  convention that these are specific to the link_expansion graph retriever
- Apply the same LATERAL per-entity cap to _expand_observations(), which
  had the same unbounded self-join through unit_entities

* style: fix formatting in config.py
2026-04-07 18:59:50 +02:00

211 lines
7.6 KiB
Python

"""
Tests for LATERAL entity fanout cap in graph expansion.
Verifies that the per-entity LIMIT in _expand_combined prevents high-fanout
entities from exploding the self-join, while still returning entity-based
graph results.
"""
import asyncio
from datetime import datetime, timezone
import pytest
@pytest.mark.asyncio
async def test_high_fanout_entity_returns_results(memory, request_context):
"""
A high-fanout entity (appearing in many facts) should still produce
graph retrieval results — the LATERAL cap limits rows per entity but
does not drop the entity entirely.
"""
bank_id = f"test_fanout_cap_{datetime.now(timezone.utc).timestamp()}"
try:
# Create many facts sharing one common entity ("Acme Corp") plus
# a few with a unique entity so we can query for the unique one
# and verify graph expansion finds siblings via "Acme Corp".
contents = [
# Target: unique entity "Zara" shares "Acme Corp" with the rest
{
"content": "Zara joined Acme Corp as a senior engineer last month",
"context": "hr update",
"entities": [{"text": "Zara"}, {"text": "Acme Corp"}],
},
]
# Add many facts that all share "Acme Corp" — creates a high-fanout entity
for i in range(60):
contents.append(
{
"content": f"Employee {i} completed onboarding at Acme Corp in department {i % 5}",
"context": "hr update",
"entities": [{"text": f"Employee {i}"}, {"text": "Acme Corp"}],
}
)
await memory.retain_batch_async(
bank_id=bank_id,
contents=contents,
request_context=request_context,
)
from hindsight_api.engine.memory_engine import Budget
# Query for "Zara" — semantic search finds Zara's fact as a seed,
# then graph expansion should find other Acme Corp facts via the
# shared entity, even though "Acme Corp" has 60+ mentions.
result = await memory.recall_async(
bank_id=bank_id,
query="Zara",
budget=Budget.HIGH,
max_tokens=4096,
enable_trace=True,
request_context=request_context,
_quiet=True,
)
assert result.results is not None
assert len(result.results) > 0
# Verify graph retrieval ran and found results
retrieval_results = result.trace.get("retrieval_results", [])
graph_results = [r for r in retrieval_results if r.get("method_name") == "graph"]
assert len(graph_results) > 0, "Graph retrieval should have run"
# At least one graph result should contain Acme Corp content
# (found via shared entity, not just semantic similarity)
all_texts = [r.text for r in result.results]
acme_found = any("Acme Corp" in t for t in all_texts)
assert acme_found, "Should find Acme Corp facts via entity graph expansion"
finally:
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_entity_expansion_timeout_fallback(memory, request_context):
"""
When graph_expansion_timeout is set very low, entity expansion should
time out gracefully and fall back to semantic+causal links only,
rather than failing the entire recall.
"""
bank_id = f"test_timeout_fallback_{datetime.now(timezone.utc).timestamp()}"
try:
await memory.retain_batch_async(
bank_id=bank_id,
contents=[
{
"content": "Alice works on the backend API at TechCorp",
"context": "team info",
"entities": [{"text": "Alice"}, {"text": "TechCorp"}],
},
{
"content": "Bob maintains the frontend at TechCorp",
"context": "team info",
"entities": [{"text": "Bob"}, {"text": "TechCorp"}],
},
],
request_context=request_context,
)
from hindsight_api.config import _get_raw_config
from hindsight_api.engine.memory_engine import Budget
config = _get_raw_config()
original_timeout = config.link_expansion_timeout
try:
# Set an impossibly low timeout to force the fallback path
config.link_expansion_timeout = 0.0001
result = await memory.recall_async(
bank_id=bank_id,
query="Alice",
budget=Budget.MID,
max_tokens=2048,
enable_trace=True,
request_context=request_context,
_quiet=True,
)
# Recall should succeed even when entity expansion times out
assert result.results is not None
assert len(result.results) > 0
# Alice should still be found via semantic search
result_texts = [r.text for r in result.results]
alice_found = any("Alice" in t for t in result_texts)
assert alice_found, "Should find Alice via semantic search despite graph timeout"
finally:
config.link_expansion_timeout = original_timeout
finally:
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_per_entity_limit_caps_expansion(memory, request_context):
"""
With graph_per_entity_limit set to a small value, entity expansion should
still work but return fewer results from high-fanout entities.
"""
bank_id = f"test_per_entity_limit_{datetime.now(timezone.utc).timestamp()}"
try:
# Create facts with a shared entity
contents = [
{
"content": "Lead engineer Dana oversees the Widgets project at MegaCorp",
"context": "project info",
"entities": [{"text": "Dana"}, {"text": "MegaCorp"}],
},
]
for i in range(30):
contents.append(
{
"content": f"MegaCorp hired contractor {i} for the Q4 push",
"context": "hiring info",
"entities": [{"text": f"Contractor {i}"}, {"text": "MegaCorp"}],
}
)
await memory.retain_batch_async(
bank_id=bank_id,
contents=contents,
request_context=request_context,
)
from hindsight_api.config import _get_raw_config
from hindsight_api.engine.memory_engine import Budget
config = _get_raw_config()
original_limit = config.link_expansion_per_entity_limit
try:
# Set a very small per-entity limit
config.link_expansion_per_entity_limit = 5
result = await memory.recall_async(
bank_id=bank_id,
query="Dana",
budget=Budget.HIGH,
max_tokens=4096,
enable_trace=True,
request_context=request_context,
_quiet=True,
)
# Recall should succeed with the cap
assert result.results is not None
assert len(result.results) > 0
# Graph retrieval should have run
retrieval_results = result.trace.get("retrieval_results", [])
graph_results = [r for r in retrieval_results if r.get("method_name") == "graph"]
assert len(graph_results) > 0, "Graph retrieval should have run"
finally:
config.link_expansion_per_entity_limit = original_limit
finally:
await memory.delete_bank(bank_id, request_context=request_context)