feat(memory): 场景式关联召回——给记忆节点赋场景引用,场面重现即取回

背景(实测):带条件的记忆召不回来。生产库里明明有
「QQ回复禁用Markdown格式 --规定--> 纯文本不用Markdown」「老大 --偏好--> 同左」,
但输入「QQ回复格式」时命中 148 个实体、规则排第 32,注入只取前 5——规则根本没进去;
输入「在吗」这种零内容词的短消息,向量路反而灌进 17 个毫不相关的实体。

根因:词法/向量召回都建立在「字面或语义相似」上,而条件式记忆(在什么场合该怎么做)
约束的是**场面**不是话题。用户措辞不重合时它天然召不回;措辞太宽("QQ")时又被同形
命中淹没。另一处:自动注入只给实体名索引,而规则本体长在关系上(relation_type + object),
即使命中名字也拿不到「纯文本不用Markdown」这句正文。

改动:把「触发条件」升成一等索引维度。

- schema:新增 scenes(key) + scene_refs(scene_id, kind, ref_id, weight),
  kind ∈ relation|entity。刻意不建外键:节点可能先于引用被清理,
  悬空引用由读取侧 JOIN 过滤,级联删除会把清理变成跨表事务。
- 场景键是分层字符串(`/` 分隔,由宽到窄):chan:qq、chan:qq/peer:group_123、
  tool:qq_get_message。NormalizeSceneKey 归一(小写、空白/标点→_、按 `/` 分层),
  空白不算层级——否则「老大2026-09-04 12:27 QQ私聊图片」这种来源名会被拆成伪层级。
- 写入即挂场景:Triple 新增 Scene 字段,commit() 在同一事务里把「关系 + 两端实体」
  挂到场景上(同事务是必须的:关系进库但引用丢了 = 这条记忆永远无声地召不回来)。
- 召回:RecallByScene 前缀匹配(chan:qq 取回 chan:qq 及所有更窄场景;用 `/` 兜底
  防止 chan:qq 吞掉 chan:qq2),按 weight(=写入置信度)降序,返回**关系全文 + 原句**。
- 注入:BuildContextInScene 在词法/向量之外叠加场景路,FormatContext 把场景块排在
  最前(规则对行为的约束强于话题相关的实体名),上限 8 条 + 原句截断 60 字;
  场景实体不在【记忆索引】里重复占位。BuildContext(input) 保持原语义(无场景)。
- 当前场景推导:payload.scene 显式声明 > 通道(chan:qq)> 工具(tool:qq_get_message),
  并列命中不取交集。qq 通道本身 RecallPolicy=none(到达的是中断元文本),
  真正召回在 qq_get_message 工具上——现在那一步同时带上 chan:qq 与 tool:qq_get_message。
- 写入侧:memory_commit 新增 scene 参数(逐条 triples[].scene 优先,顶层 scene 作批次默认);
  docToTriples 按文档来源自动带 chan:<source>(QQ 归档的知识天然属于 QQ 场面)。
  不做自动猜测:猜错的场景会把无关记忆钉死,之后每次进入该场面都被注入。
- 存量引导:memgc -tag-scene <键> -entity-glob <GLOB>。用 GLOB 而非 LIKE——
  LIKE 对 ASCII 不区分大小写,`%QQ%` 会把对象带 /home/newqqagent 的路径类记忆
  (生产数据目录、email-mcp、dify-ops 路径…实测 7 条)一起卷进 QQ 场景。
- 清理对齐:PurgeNoise/PurgeOrphans 之后顺带删悬空场景引用,并提供
  PurgeStaleSceneRefs;memgc -scene-stats 看场景规模。

验证:go build/vet 干净,go test -count=1 ./... 全绿。
新增用例:场景键归一(含超长/分层/空白)、写入即挂场景(两端实体进、未标的实体不进)、
前缀语义(含 chan:qq2 反例)、weight 排序与 limit、GLOB 存量引导(dry-run 不写库)、
清理后无悬空引用、场景注入面(关系全文+原句+不在索引重复占位)、
agent 侧 sceneKeysFor 优先级(显式声明 > 通道 > 工具、数组形式、nil 安全)。

生产库实测(先 sqlite3 .backup 到 graph.db.bak-20260915-081043 再写):
把 22 条 QQ 相关关系标进 chan:qq(GLOB *QQ* 19 条 + *qq_* 3 条)。同一批输入前后对比:
- 「在吗」:改前注入 17 个无关实体;改后场景块直接给出「QQ回复禁用Markdown格式
  --规定--> 纯文本不用Markdown」等规则正文(零字面重合也能召回)。
- 「QQ回复格式」:改前规则排第 32 被截掉;改后排在场景块首位。
- 「帮我发个语音」:场景规则置顶,词法路的 qq通道语音输入 等仍在其后。
This commit is contained in:
JianFeeeee
2026-09-15 08:13:52 +08:00
parent b37141f3f5
commit d98bf512e1
17 changed files with 1071 additions and 37 deletions

View File

@ -10,11 +10,11 @@ import (
sdkmeta "gitcode.com/JianFeeeee/homeagent-sdk/meta"
)
func (a *Agent) buildMemoryContext(input string, maxTokens int) string {
func (a *Agent) buildMemoryContext(input string, maxTokens int, scenes []string) string {
if a.indexer == nil {
return ""
}
injected := a.indexer.BuildContext(input)
injected := a.indexer.BuildContextInScene(input, scenes)
s := a.indexer.FormatContext(injected)
// 图库召回命中的实体若关联着带媒体的句子,把媒体说明一并注入。
@ -48,8 +48,9 @@ func (a *Agent) buildMemoryContext(input string, maxTokens int) string {
// query 取**清洗后**的输入(通道 Cleaner 的输出),与裁剪侧同一套语义:
// 原始输入里的 ANSI/base64/JSON 包装会把相关性打分带偏。清洗为空时回退原文。
func (a *Agent) buildTaskMemoryContext(f *TaskFrame, input string, maxTokens int) string {
scenes := sceneKeysFor(evtOf(f), "")
if f == nil {
return a.recallText(input, "input", maxTokens)
return a.recallText(input, "input", maxTokens, scenes)
}
if !a.recallDeclared(f.Evt) {
return ""
@ -62,7 +63,15 @@ func (a *Agent) buildTaskMemoryContext(f *TaskFrame, input string, maxTokens int
if f.Evt != nil && f.Evt.Source != "" {
trigger = "input:" + f.Evt.Source
}
return a.recallText(query, trigger, maxTokens)
return a.recallText(query, trigger, maxTokens, scenes)
}
// evtOf 安全取出 TaskFrame 的事件(f 为 nil 时返回 nil)。
func evtOf(f *TaskFrame) *agentIO.InputEvent {
if f == nil {
return nil
}
return f.Evt
}
// recallTextFor 以 query 触发一次记忆召回,按当前预算截断,返回可注入的文本。
@ -70,27 +79,27 @@ func (a *Agent) buildTaskMemoryContext(f *TaskFrame, input string, maxTokens int
// 这是“召回”侧的单一入口:与 Prune 共用同一份**清洗后**的 query,
// 使“取进来”(召回)与“踢出去”(裁剪)落在同一个相关性过程上。
// trigger 仅用于日志溯源(如 "tool:qq_get_message")。
func (a *Agent) recallTextFor(query, trigger string) string {
func (a *Agent) recallTextFor(query, trigger string, scenes []string) string {
memTokens := 0 // 0 = 不截断
if a != nil && a.provider != nil {
memTokens = ComputeTokenBudget(a.provider, a.systemPrompt).MemoryTokens
}
return a.recallText(query, trigger, memTokens)
return a.recallText(query, trigger, memTokens, scenes)
}
// recallText 是召回侧的共同实现:query → 记忆索引文本(空串表示无)。
//
// 输入侧的 buildTaskMemoryContext 与工具侧的 recallTextFor 都收敛到这里,
// 使“同一份 query、同一次预算、同一条审计日志”只写一遍。
func (a *Agent) recallText(query, trigger string, maxTokens int) string {
func (a *Agent) recallText(query, trigger string, maxTokens int, scenes []string) string {
if a == nil || query == "" || a.indexer == nil {
return ""
}
text := a.buildMemoryContext(query, maxTokens)
text := a.buildMemoryContext(query, maxTokens, scenes)
if text == "" {
return ""
}
log.Printf("[agent] memory recall (%s): injected %d chars", trigger, len(text))
log.Printf("[agent] memory recall (%s): injected %d chars (scenes=%v)", trigger, len(text), scenes)
return text
}