mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-10-15 03:52:25 +00:00
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:
@ -102,9 +102,27 @@ type InjectedContext struct {
|
||||
Relations []Relation `json:"relations"`
|
||||
Summary string `json:"summary"`
|
||||
TokenEstimate int `json:"token_estimate"`
|
||||
|
||||
// Scenes 是本轮识别出的当前场景;SceneRelations 是被钉在这些场景上的
|
||||
// 记忆(带 relation_type 与原句)。两者都进注入文本——场景记忆是
|
||||
// **带条件的规则**,只给实体名等于没召回。
|
||||
Scenes []string `json:"scenes,omitempty"`
|
||||
SceneRelations []Relation `json:"scene_relations,omitempty"`
|
||||
}
|
||||
|
||||
// BuildContext 不带场景的召回(保持既有行为:词法 + 实体名向量)。
|
||||
func (idx *Indexer) BuildContext(userInput string) *InjectedContext {
|
||||
return idx.BuildContextInScene(userInput, nil)
|
||||
}
|
||||
|
||||
// BuildContextInScene 在词法/向量召回之上叠加**场景召回**。
|
||||
//
|
||||
// 两条路正交且都要保留:
|
||||
// - 词法/向量:话题相关(「上次那个 bug 怎么修的」)
|
||||
// - 场景:条件相关(「在 QQ 上回消息」→ 不要 Markdown)
|
||||
//
|
||||
// 场景路不参与相似度打分、也不受关键词为空的影响:只要场面重现就该取回。
|
||||
func (idx *Indexer) BuildContextInScene(userInput string, scenes []string) *InjectedContext {
|
||||
if idx.db == nil {
|
||||
return &InjectedContext{Summary: ""}
|
||||
}
|
||||
@ -129,23 +147,41 @@ func (idx *Indexer) BuildContext(userInput string) *InjectedContext {
|
||||
|
||||
result, err := idx.db.Recall(allKeywords, nil, 2, "")
|
||||
if err != nil || result == nil {
|
||||
return &InjectedContext{Summary: ""}
|
||||
result = &RecallResult{}
|
||||
}
|
||||
|
||||
// 过滤已被工具调用显式召回的实体,避免重复注入
|
||||
idx.mu.RLock()
|
||||
filtered := result.Entities[:0]
|
||||
for _, e := range result.Entities {
|
||||
if !idx.recalled[e.Name] {
|
||||
filtered = append(filtered, e)
|
||||
// 3. 场景召回:当前场面钉住的记忆
|
||||
sceneRecall, err := idx.db.RecallByScene(scenes, maxSceneRecallRelations)
|
||||
if err != nil {
|
||||
sceneRecall = nil
|
||||
}
|
||||
sceneEntityNames := make(map[string]bool)
|
||||
if sceneRecall != nil {
|
||||
for _, e := range sceneRecall.Entities {
|
||||
sceneEntityNames[e.Name] = true
|
||||
}
|
||||
}
|
||||
|
||||
// 过滤已被工具调用显式召回的实体,避免重复注入;场景实体已在场景块
|
||||
// 里给过,也不在索引里再占位。
|
||||
idx.mu.RLock()
|
||||
filtered := make([]Entity, 0, len(result.Entities))
|
||||
for _, e := range result.Entities {
|
||||
if idx.recalled[e.Name] || sceneEntityNames[e.Name] {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, e)
|
||||
}
|
||||
idx.mu.RUnlock()
|
||||
|
||||
ctx := &InjectedContext{
|
||||
Entities: filtered,
|
||||
Relations: nil,
|
||||
}
|
||||
if sceneRecall != nil {
|
||||
ctx.Scenes = sceneRecall.Scenes
|
||||
ctx.SceneRelations = sceneRecall.Relations
|
||||
}
|
||||
|
||||
if len(filtered) > 0 {
|
||||
summary := buildIndexSummary(filtered)
|
||||
@ -194,8 +230,13 @@ func (idx *Indexer) BuildToolPrompt() string {
|
||||
将三元组写入图记忆。
|
||||
参数:
|
||||
- triples: [{"subject": "实体名", "relation": "关系类型", "object": "目标实体",
|
||||
"sentence_text": "原始句子(可选)", "media_digests": ["图片digest(可选)"]}]
|
||||
"sentence_text": "原始句子(可选)", "media_digests": ["图片digest(可选)"],
|
||||
"scene": "场景键(可选)"}]
|
||||
填了 media_digests,日后从这条记忆就能取回当时那张图/那段音频。
|
||||
填了 scene,这条记忆就挂在那个**场面**上:场面重现时(如又来一条 QQ 消息)
|
||||
不靠字面命中也会被召回。「在什么场合该怎么做」这类约定/规则都该填,
|
||||
例如回 QQ 消息的格式约定 → scene="chan:qq"。
|
||||
- scene: 本批次默认场景键(可选,逐条 triples 里的 scene 优先)。
|
||||
|
||||
### memory_introspect
|
||||
查看记忆统计信息。
|
||||
@ -210,11 +251,36 @@ func (idx *Indexer) BuildToolPrompt() string {
|
||||
}
|
||||
|
||||
func (idx *Indexer) FormatContext(ctx *InjectedContext) string {
|
||||
if ctx == nil || len(ctx.Entities) == 0 {
|
||||
if ctx == nil || (len(ctx.Entities) == 0 && len(ctx.SceneRelations) == 0) {
|
||||
return ""
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
|
||||
// 场景记忆排在前面:它们是**带条件的规则**(在什么场面下该怎么做),
|
||||
// 对行为的约束强于“话题相关的实体名”。也正因为带有触发条件,
|
||||
// 它们的正确性不依赖本轮措辞是否命中了字面。
|
||||
if len(ctx.SceneRelations) > 0 {
|
||||
b.WriteString(fmt.Sprintf("【场景记忆 %s】\n", strings.Join(ctx.Scenes, ", ")))
|
||||
for i, rel := range ctx.SceneRelations {
|
||||
if i >= maxSceneRecallRelations {
|
||||
break
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("- %s --%s--> %s", rel.SourceName, rel.RelationType, rel.TargetName))
|
||||
if rel.SentenceText != "" {
|
||||
b.WriteString("(")
|
||||
b.WriteString(truncateRunes(rel.SentenceText, sceneSentenceMaxRunes))
|
||||
b.WriteString(")")
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
b.WriteString("(以上是该场景下的既有约定,请照办)\n")
|
||||
}
|
||||
|
||||
if len(ctx.Entities) == 0 {
|
||||
return strings.TrimRight(b.String(), "\n")
|
||||
}
|
||||
|
||||
b.WriteString("【记忆索引】")
|
||||
|
||||
if ctx.Summary != "" {
|
||||
@ -241,6 +307,23 @@ func (idx *Indexer) FormatContext(ctx *InjectedContext) string {
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// maxSceneRecallRelations 是单次场景召回的关系上限。
|
||||
//
|
||||
// 场景是**每轮都要注入**的常驻内容:不封顶时一个宽场景(如 chan:qq)
|
||||
// 会把它下面所有关系都推进 prompt,把 token 预算吃光。
|
||||
const maxSceneRecallRelations = 8
|
||||
|
||||
// sceneSentenceMaxRunes 是场景关系后附原句的截断长度。
|
||||
const sceneSentenceMaxRunes = 60
|
||||
|
||||
func truncateRunes(s string, max int) string {
|
||||
r := []rune(s)
|
||||
if len(r) <= max {
|
||||
return s
|
||||
}
|
||||
return string(r[:max]) + "…"
|
||||
}
|
||||
|
||||
func (idx *Indexer) GetToolDefinitions() []map[string]interface{} {
|
||||
return []map[string]interface{}{
|
||||
{
|
||||
@ -291,6 +374,10 @@ func (idx *Indexer) GetToolDefinitions() []map[string]interface{} {
|
||||
"description": "可选:这条记忆关联的媒体 digest(对话或 memory_recall 的「关联媒体」里显示的十六进制串,短的即可)。填了以后从这条记忆能取回原图/音频。",
|
||||
"items": map[string]interface{}{"type": "string"},
|
||||
},
|
||||
"scene": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "可选:这条记忆所属的场景键(如 chan:qq、chan:qq/peer:group_123、tool:qq_get_message)。「在什么场合该怎么做」这类条件性约定/规则必须填:场面重现时它会被直接召回,与用户措辞无关。",
|
||||
},
|
||||
},
|
||||
"required": []string{"subject", "relation", "object"},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user