fix(memory): 修图记忆召回的两处能力缺失(关系重复 + 原句不回显)

针对 memory_recall / 自动注入这条图记忆召回链路的实测复核:

- Recall(depth>1) 跨层不去重:每层都用已累积的 entityIDs 查邻接,
  上一层刚产出的关系会在下一层被反复查回并再次 append。实测
  小明→小红 在 depth=2 出现两次,memory_recall 的 10 条关系预算被
  同一句话刷屏、真正的新关系(小红→小刚)被截断。改为按 relation ID
  跨层去重(实体本就已去重)。
- memory_recall 从不回显 sentence_text:工具 schema 明写「填了才能日后
  从图谱回到原文」、Recall 也已 JOIN 出句子,但输出只给实体名与关系类型,
  该字段形同虚设。抽出 formatRecallRelations,对非空原句截断 60 字附在
  关系行后;超过 10 条仍截断并提示。

测试:TestRecallWithDepth 增补去重与精确条数断言;
新增 TestFormatRecallRelations_SurfacesSentence / _Truncates。
go build/vet 干净,go test -race ./internal/memory/ ./internal/agent/core/... 全绿。
This commit is contained in:
JianFeeeee
2026-09-15 06:48:36 +08:00
parent 6afe361804
commit 94995eaa64
5 changed files with 87 additions and 8 deletions

View File

@ -197,6 +197,28 @@ func (a *Agent) mediaContextForRelations(relations []memory.Relation) string {
return a.mediaContextForSentences(sentenceIDsFromRelations(relations))
}
// formatRecallRelations 渲染 memory_recall 的关系行,超 max 条截断。
//
// 带上原始句子(截断到 60 字三元组只是「A 关系 B」脱离原句往往看不出
// 语气、条件与指代——`sentence_text` 的存在意义就是「日后从图谱回到原文」,
// 而 Recall 已经把句子 JOIN 出来了。此前只回显实体名与关系类型,导致模型
// 填了 sentence_text 也永远拿不回来,这个能力形同虚设。
func formatRecallRelations(relations []memory.Relation, max int) []string {
var out []string
for i, r := range relations {
if max > 0 && i >= max {
out = append(out, "...更多关系被截断")
break
}
line := fmt.Sprintf("- %s →(%s)→ %s", r.SourceName, r.RelationType, r.TargetName)
if s := strings.TrimSpace(r.SentenceText); s != "" {
line += " 原句: \"" + truncateStr(s, 60) + "\""
}
out = append(out, line)
}
return out
}
// mediaContextForInjectedEntities 为自动注入路径产出媒体说明。
//
// Indexer.BuildContext 刻意不返回关系(只给实体索引以省 token

View File

@ -759,3 +759,39 @@ func TestMediaBlocksHeldByDocumentSurviveDeletion(t *testing.T) {
t.Fatal("删除后内容应已移除")
}
}
// TestFormatRecallRelations_SurfacesSentence 锁死「从图谱回到原文」:
// memory_recall 的关系行必须带上 sentence_text截断否则模型按工具
// schema 填了原始句子也永远取不回,该字段形同虚设。
func TestFormatRecallRelations_SurfacesSentence(t *testing.T) {
rels := []memory.Relation{
{SourceName: "张三", RelationType: "喜欢", TargetName: "咖啡", SentenceText: "张三说他每天早上一定要喝一杯手冲咖啡。"},
{SourceName: "张三", RelationType: "住在", TargetName: "北京"}, // 无原句:不应出现空的原句字段
}
lines := formatRecallRelations(rels, 10)
if len(lines) != 2 {
t.Fatalf("应渲染 2 行,实际 %d: %v", len(lines), lines)
}
if !strings.Contains(lines[0], "张三 →(喜欢)→ 咖啡") || !strings.Contains(lines[0], "原句:") {
t.Errorf("第一条应带原句,实际 %q", lines[0])
}
if strings.Contains(lines[1], "原句") {
t.Errorf("无 sentence_text 的关系不应出现原句字段,实际 %q", lines[1])
}
}
// TestFormatRecallRelations_Truncates 锁死关系条数上限:
// 超过 max 时截断并明确告知,避免刷屏。
func TestFormatRecallRelations_Truncates(t *testing.T) {
var rels []memory.Relation
for i := 0; i < 15; i++ {
rels = append(rels, memory.Relation{SourceName: "A", RelationType: "连", TargetName: "B"})
}
lines := formatRecallRelations(rels, 10)
if len(lines) != 11 {
t.Fatalf("10 条关系 + 1 条截断提示,实际 %d: %v", len(lines), lines)
}
if !strings.Contains(lines[10], "截断") {
t.Errorf("最后一行应为截断提示,实际 %q", lines[10])
}
}

View File

@ -174,13 +174,7 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall) string {
parts = append(parts, fmt.Sprintf("- %s (提及%d次, 类型:%s)", e.Name, e.MentionCount, e.Type))
}
parts = append(parts, fmt.Sprintf("找到 %d 条关系:", len(result.Relations)))
for i, r := range result.Relations {
if i >= 10 {
parts = append(parts, "...更多关系被截断")
break
}
parts = append(parts, fmt.Sprintf("- %s →(%s)→ %s", r.SourceName, r.RelationType, r.TargetName))
}
parts = append(parts, formatRecallRelations(result.Relations, 10)...)
// 命中的关系若挂着媒体块,把媒体说明附在结果末尾。
//
// 关系行只有实体名和关系类型,看不出"这条记忆当时还带了一张图"。

View File

@ -554,6 +554,14 @@ func (g *GraphDB) Recall(keywords []string, seedEntities []string, depth int, se
return result, nil
}
// seenRel 跨层去重。
//
// 每层都用**已累积的** entityIDs 查邻接关系,因此上一层刚产出、以及
// 两个已访问实体之间的关系会在下一层被重复查回并再次 append。
// 深度 2、稠密图上重复会淹没 memory_recall 的 10 条关系预算——
// 模型看到的是同一句话刷屏,真正的新关系被截断。
seenRel := make(map[int64]bool)
for depthLevel := 0; depthLevel < depth; depthLevel++ {
ids := make([]interface{}, 0, len(entityIDs))
for id := range entityIDs {
@ -600,7 +608,10 @@ func (g *GraphDB) Recall(keywords []string, seedEntities []string, depth int, se
relRows.Close()
return nil, err
}
result.Relations = append(result.Relations, rel)
if !seenRel[rel.ID] {
seenRel[rel.ID] = true
result.Relations = append(result.Relations, rel)
}
if !entityIDs[rel.SourceID] {
newIDs[rel.SourceID] = true

View File

@ -260,6 +260,22 @@ func TestRecallWithDepth(t *testing.T) {
if len(result.Relations) == 0 {
t.Error("expected relations with depth search")
}
// 逐层查邻接会把已访问实体之间的关系反复查回。不跨层去重时,
// 小明→小红 会在 depth=2 出现两次memory_recall 的 10 条关系预算
// 被同一句话刷屏、真正的新关系(小红→小刚)被截断。
seen := map[int64]int{}
for _, r := range result.Relations {
seen[r.ID]++
}
for id, n := range seen {
if n > 1 {
t.Errorf("关系 id=%d 在深度遍历中重复 %d 次", id, n)
}
}
if len(result.Relations) != 2 {
t.Errorf("depth=2 应得 2 条关系(小明→小红、小红→小刚),实际 %d", len(result.Relations))
}
}
func TestPurgeHard(t *testing.T) {