[Bug] Memory index silently drops results: knowledge/ writes never mark the index dirty, and keyword-only hits are scaled below min_score
Environment
- Version: v2.1.9 (
2.1.9-9-g3bf04290) - OS: Windows 11
- Python: 3.12.13
- Install: source
- Model & channel: deepseek-flash, web
What happened?
Two independent paths in agent/memory silently return empty retrieval results — no exception, no log, the caller just gets []. Both are normal runtime states, not edge cases.
Problem 1 — knowledge/ writes (and any external write) never trigger an index sync
Writing a file into knowledge/*.md — or any write that bypasses the write/edit tools — does not mark the memory index dirty, so search() never re-syncs and the new content stays invisible until a process restart.
Evidence (a) — search() only syncs when _dirty is set (agent/memory/manager.py:124-126):
# Sync if needed
if self.config.sync_on_search and self._dirty:
await self.sync()
Evidence (b) — the dirty flag is set under a condition that only matches memory/ (agent/tools/write/write.py:97-99):
# Auto-sync to memory database if this is a memory file
if self.memory_manager and 'memory/' in path:
self.memory_manager.mark_dirty()
and the same in agent/tools/edit/edit.py:219-222:
# Notify memory manager if file is in memory directory
if self.memory_manager and "memory/" in path:
self.memory_manager.mark_dirty()
A knowledge/xxx.md path does not contain the substring memory/, so mark_dirty() is never called.
Evidence (c) — mark_dirty() has only 4 call sites in the whole repo:
agent/knowledge/service.py:126 manager.mark_dirty()
agent/tools/edit/edit.py:222 condition: "memory/" in path
agent/tools/write/write.py:99 condition: "memory/" in path
channel/web/web_channel.py:9231 mm.mark_dirty()
Any writer that does not go through these entry points (external scripts, other processes editing files directly) is exempt from index invalidation too.
Evidence (d) — _dirty is initialized to False (agent/memory/manager.py:79) and sync() has no mtime/hash-based detection of "the file changed since last sync" at the point of triggering. So files modified while the process was down are never re-indexed after a restart either.
Real-world confirmation — querying the live index DB (<workspace>/memory/long-term/index.db) of an actual running workspace:
files table:
memory | MEMORY.md indexed
memory | memory\2026-09-16.md indexed
memory | memory\2026-09-17.md <-- exists on disk, NOT in index
knowledge | knowledge\*.md indexed
SELECT COUNT(*) FROM chunks WHERE path LIKE '%2026-09-17%';
-> 0
That day's diary file exists on disk and has zero chunks in the index.
Trigger chain
write knowledge/xxx.md
-> 'memory/' in path == False
-> mark_dirty() not called
-> self._dirty stays False
-> `if sync_on_search and self._dirty` is False inside search()
-> sync() never runs -> new file never indexed
-> search returns nothing
Problem 2 — in keyword-only mode, every keyword-only hit is filtered out
When no embedding provider is available, memory degrades to keyword search only — a supported, automatically-entered state (manager.py:59-63 prints No embedding provider; memory will use keyword search only). In that mode, all hits are silently dropped.
Reproduced:
keyword raw score : [0.3]
score after fusion : [0.09]
manager.search returns: 0 results (min_score=0.1)
The same corpus with an embedding provider returns the hit fine (score=0.7900).
Evidence (a) — fusion weights an absent vector score as zero (manager.py:563-568):
combined_score = (
vector_weight * entry['vector_score'] + # 0.7 * 0.0
keyword_weight * entry['keyword_score'] # 0.3 * 0.3
)
# = 0.09
Evidence (b) — the keyword score has a floor of 0.3 (agent/memory/storage.py:1175-1180):
if rank is None:
return 0.0
# Add a floor of 0.3 so any FTS5 match always exceeds typical
# min_score thresholds (default 0.1). Small-corpus ranks close to
# 0 would otherwise produce score≈0 and be filtered out downstream.
return 0.3 + 0.69 * (abs(rank) / (1.0 + abs(rank))) # range [0.3, 0.99)
Evidence (c) — the filter (manager.py:172-174):
filtered = [r for r in merged if r.score >= min_score]
Evidence (d) — defaults (agent/memory/config.py:43,46-47): min_score = 0.1, vector_weight = 0.7, keyword_weight = 0.3.
Root cause — an implicit coupling whose inequality goes the wrong way:
keyword_weight(0.3) * bm25_floor(0.3) = 0.09 < min_score(0.1)
min_score is higher than the best score a keyword-only hit can achieve. So any chunk matched only by keywords is unconditionally filtered out — keyword-only retrieval is effectively dead.
Note the contradiction with the comment at storage.py:1177-1179: the floor was added precisely "so any FTS5 match always exceeds typical min_score thresholds (default 0.1)". That assumption held for the raw keyword score; once it is multiplied by keyword_weight = 0.3, 0.3 * 0.3 = 0.09 drops back below the threshold. The comment's guarantee silently stopped holding when weighted fusion was introduced.
These three constants live in config.py / storage.py / config.py, with no assertion or test tying them together — any future tweak re-triggers or reshapes the failure silently.
Reproduction
Zero-dependency script (standard library + project modules only, works in a temp dir, does not touch the real index):
import asyncio, tempfile
from pathlib import Path
from agent.memory.config import MemoryConfig
from agent.memory.manager import MemoryManager
async def main():
tmp = Path(tempfile.mkdtemp(prefix="repro_"))
cfg = MemoryConfig(workspace_root=str(tmp))
# ---- Problem 1: writing knowledge/ never marks the index dirty ----
mm = MemoryManager(config=cfg)
mm._init_workspace()
(tmp / "knowledge").mkdir(exist_ok=True)
(tmp / "memory").mkdir(exist_ok=True)
kf = tmp / "knowledge" / "note.md"
kf.write_text("# note\nGLACIERQUARTZ7731\n", encoding="utf-8")
path_rel = str(kf.relative_to(tmp)).replace("\\", "/")
if "memory/" in path_rel: # the real condition in write.py:98
mm.mark_dirty()
print("P1 hits:", len(await mm.search("GLACIERQUARTZ7731"))) # -> 0
# ---- Problem 2: keyword-only hits fall below min_score ----
kf2 = tmp / "knowledge" / "b3.md"
kf2.write_text("# b3\nSILVERLOTUS4410\n", encoding="utf-8")
await mm.sync() # force sync, isolating from P1
raw = mm.storage.search_keyword(query="SILVERLOTUS4410", user_id=None,
scopes=["shared"], limit=5)
fused = mm._merge_results([], raw, cfg.vector_weight, cfg.keyword_weight)
print("P2 raw:", [round(r.score, 4) for r in raw]) # -> [0.3]
print("P2 fused:", [round(r.score, 4) for r in fused]) # -> [0.09]
print("P2 hits:", len(await mm.search("SILVERLOTUS4410"))) # -> 0
asyncio.run(main())
Note: the snippet deliberately pre-creates a local knowledge/ directory, because otherwise sync() would hit the unrelated crash in #3175 and mask both results. The two problems also interact: verifying Problem 1 requires bypassing Problem 2 (e.g. by supplying an embedding provider), and verifying Problem 2 requires an explicit sync() to bypass Problem 1.
Suggested fixes
Problem 1
- Minimal: extend the condition in
write.py:98/edit.py:220from'memory/' in pathto coverknowledge/as well (or reuseMemoryConfig's directory prefixes). - More robust (preferred): stop relying on the in-memory flag — have
search()always attempt an incremental sync, using the existing mtime/hash comparison to skip unchanged files. - Fallback: initialize
_dirty = Trueat startup so changes made while the process was down are picked up. - Optional: a filesystem watcher (
watchdog) — best UX for hand-edited memory files, at higher complexity.
Problem 2
- Minimal and semantically correct — normalize by the weights that actually participated: divide by the sum of active weights instead of treating a missing channel as a zero score. Then a keyword-only hit scores
(0.3 * 0.3) / 0.3 = 0.3, correctly clearingmin_score, and matching the original intent of thestorage.pyfloor comment:
def _fused(v_score, k_score, vw, kw, has_v, has_k):
total = vw * has_v + kw * has_k
if total <= 0:
return 0.0
return (vw * v_score * has_v + kw * k_score * has_k) / total
- Alternatively, lower
min_scorebelowkeyword_weight * bm25_floor(e.g. 0.05) — but this only masks the problem and admits more noise; not recommended on its own. - Regardless of the approach, please make the implicit constraint explicit with a startup assertion or a dedicated
min_scorefor the keyword-only branch, so future tuning cannot silently reintroduce this.
Note on scope
I originally suspected knowledge/ was not indexed at all. That was wrong — querying the DB showed it is indexed (93/101 chunks). I am reporting the two problems above, both of which I reproduced deterministically. A separate crash found along the way was filed as #3175 since it has an independent root cause.
I'm happy to open a PR for either problem. For Problem 1 I would suggest the minimal fix plus the startup fallback; for Problem 2 the normalization approach. Please let me know your preference.
中文简述
agent/memory 有两条正常的运行时路径会静默返回空检索结果(无异常、无日志):
问题 1:knowledge/ 写入(及任何外部写入)不触发索引同步
write.py:98 / edit.py:220 的置脏条件写死为 'memory/' in path,而 knowledge/xxx.md 不含该子串 → 永不 mark_dirty() → search() 不触发 sync() → 新内容直到进程重启都对检索不可见。全仓库 mark_dirty() 仅 4 个调用点,任何绕过 write/edit 工具的外部写入同样不置脏;且 _dirty 初值为 False,停机期间改动的文件重启后也不会重新索引。真实佐证:磁盘上存在 memory/2026-09-17.md,索引里 0 个 chunk。
问题 2:纯关键词模式下,所有关键词命中被静默丢弃
融合公式把缺失的向量分当作 0:0.3 × 0.3 = 0.09,而 min_score = 0.1 —— 0.3 × keyword_weight 恒小于阈值。无 embedding provider(manager.py:59-63 明确支持的降级路径)时,仅关键词命中的结果全部被丢,纯关键词检索实际完全失效。storage.py:1177-1179 的注释说加 0.3 地板是为了"超过 min_score",但加权融合引入后该保证已失效且无人察觉。
三个常量分布在 config.py / storage.py / config.py,无任何断言保护这个不等式。
说明:我最初怀疑 knowledge/ 没被索引,实测证明该假设错误(93/101 chunks 来自 knowledge/)。以上两个问题均为我确定性复现的真缺陷。另有一个根因独立的崩溃已单独提为 #3175。
两份问题我都可以提 PR,请告知倾向的修复方案。
Source: zhayujie/CowAgent