Memory recall dedup keeps whichever duplicate a thread finished first, not the best score
Nobody has claimed this yet.
- Dominant language
- Python
- Stars
- 58.8k
- Forks
- 8.5k
- Avg merge
- 1d 15h
- Merged PRs (30d)
- 109
Description
Description
RecallFlow.synthesize_results deduplicates by keeping the first occurrence of each record.id, but the findings it iterates arrive in thread-completion order. When the same record is reachable from more than one (sub-query, scope) search task, which of its scores survives is decided by thread timing, so the same query can return the same record at a different rank on consecutive runs.
lib/crewai/src/crewai/memory/recall_flow.py, synthesize_results:
seen_ids: set[str] = set()
matches: list[MemoryMatch] = []
for finding in self.state.chunk_findings:
...
for item in results:
...
if isinstance(record, MemoryRecord) and record.id not in seen_ids:
seen_ids.add(record.id) # <-- first wins
composite, reasons = compute_composite_score(record, float(score), self._config)
matches.append(MemoryMatch(record=record, score=composite, match_reasons=reasons))
matches.sort(key=lambda m: m.score, reverse=True)
final_results = matches[: self.state.limit]
And _do_search, which produces chunk_findings:
with ThreadPoolExecutor(max_workers=min(len(tasks), 4)) as pool:
futures = {pool.submit(...): (emb, sc) for emb, sc in tasks}
for future in as_completed(futures): # <-- completion order, not task order
...
findings.append({"scope": scope, "results": results, "top_score": top_composite})
Why duplicates are guaranteed, not hypothetical
Two independent reasons, both structural:
-
Scope prefix matching overlaps by design.
lib/crewai/src/crewai/memory/storage/lancedb_storage.py:if scope_prefix is not None and scope_prefix.strip("/"): prefix = scope_prefix.rstrip("/") like_val = prefix + "%" query = query.where(f"scope LIKE '{like_val}'")A record stored at
/a/bis returned for scope/aand for scope/a/b.filter_and_chunkroutinely selects both (selected_scopes = candidates[:20]). -
Each sub-query embedding scores the same record differently.
analyze_query_stepproduces up to 3 sub-query embeddings, and_do_searchruns the fullembeddings × scopescross product — up to 60 tasks against overlapping scopes.
So the same record.id legitimately arrives from several tasks carrying different semantic scores, and compute_composite_score is monotonic in semantic_score:
composite = (
config.semantic_weight * semantic_score
+ config.recency_weight * decay
+ config.importance_weight * record.importance
)
Keeping the first-arriving one therefore does not merely reorder — it can keep a strictly worse score and under-rank the record, or push it out of matches[: self.state.limit] entirely. The duplicate also consumed nothing visible, but the lower score it left behind is what the caller ranks on.
Reproduction
Standalone, no storage backend needed — it models _do_search's dispatch plus the first-wins dedup, and only the sleep order differs between the two runs:
"""Models _do_search's dispatch plus synthesize_results' first-wins dedup.
One record 'R' lives at /a/b, so lancedb's `scope LIKE 'prefix%'` returns it for
scope /a AND for scope /a/b, and each sub-query embedding scores it differently.
Only the per-task delays differ between the two runs below.
"""
from concurrent.futures import ThreadPoolExecutor, as_completed
import time
def run(tasks):
"""tasks: list of (scope, semantic_score, delay_seconds)."""
def one(scope, score, delay):
time.sleep(delay)
return {"scope": scope, "results": [("R", score)]}
findings = []
with ThreadPoolExecutor(max_workers=2) as pool:
futures = [pool.submit(one, *t) for t in tasks]
for future in as_completed(futures):
findings.append(future.result())
seen, out = set(), []
for finding in findings:
for rid, score in finding["results"]:
if rid not in seen: # first wins -- the bug
seen.add(rid)
out.append((rid, score, finding["scope"]))
return out
print("record 'R': scope /a scores 0.9, scope /a/b scores 0.4")
print(" /a finishes first: ", run([("/a", 0.9, 0.00), ("/a/b", 0.4, 0.05)]))
print(" /a/b finishes first:", run([("/a", 0.9, 0.05), ("/a/b", 0.4, 0.00)]))
Measured output:
record 'R': scope /a scores 0.9, scope /a/b scores 0.4
/a finishes first: [('R', 0.9, '/a')]
/a/b finishes first: [('R', 0.4, '/a/b')]
Same inputs, different surviving score, purely from which thread finished first.
Impact on callers
UnifiedMemory consumes flow.state.final_results directly (lib/crewai/src/crewai/memory/unified_memory.py), then calls touch_records on it and emits MemoryQueryCompletedEvent, so the nondeterminism reaches user-visible recall results, last_accessed bookkeeping, and telemetry.
There is a second, smaller ordering dependence: matches.sort(key=lambda m: m.score, reverse=True) is stable, so records with equal composite scores keep their arrival order — also as_completed order.
Suggested fix
Keep the best score per record.id rather than the first, and give the sort a deterministic tiebreak on record.id. Best-wins is both order-independent and the score ranking should be using anyway.
Out of scope
self.state.confidence = max((f["top_score"] for f in findings), default=0.0) in _do_search is already order-independent (max over the whole list) and needs no change.
Environment
- crewAI
main - Python 3.12, Windows 11
I have a fix plus regression tests ready and will open a PR referencing this issue.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start in lib/crewai/src/crewai/memory/recall_flow.py, reading synthesize_results and _do_search, then inspect the overlapping scope behavior in lib/crewai/src/crewai/memory/storage/lancedb_storage.py. Review the regression tests mentioned in the issue and verify that duplicate records retain the best score and that equal-score results have deterministic ordering. Check lib/crewai/src/crewai/memory/unified_memory.py to understand the caller-visible result flow.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- python
- Domain
- ai, backend
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 35/100