Files
HomeAgent/internal/memory/noise.go
JianFeeeee d98bf512e1 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通道语音输入 等仍在其后。
2026-09-15 08:13:52 +08:00

264 lines
9.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package memory
import (
"sort"
"strings"
)
// ──────────────────────────────────────────────
// 图记忆「噪音实体」判定与清理
//
// 为什么需要这一层doc→graph 蒸馏在 1cb3e87 从「CutExact 滑窗词链」换成
// NLP 依存提取器后,落库闸门只剩 validEntityName长度 250、含字母/汉字)。
// validEntityName 挡的是「不像名字的字符串」,不挡「像名字的常用词」——
// 于是「结果 / 什么 / 哪个 / 待命 / 报告」这类词被反复写成实体,
// mention_count 冲到几百,度数却只有 1~2它们占着热实体位、挤满召回预算
// 却不带任何结构。CutExact去停用词 + validEntityName + 去重)本可以做这层
// 过滤,但它当年只挂在 doc→graph 上,换提取器时被整条摘掉,如今只剩测试调用。
//
// 这里把判定收敛到一处,供三个地方共用:
// - 自动填充路docToTriples冷文档归档、extractKeyTriples对话蒸馏
// - 历史数据清理GraphDB.PurgeNoise老库里的存量垃圾
// ──────────────────────────────────────────────
// 模板摘要的判定片段summarizeEntries 产出形如
// 「来自 N 个来源的 M 条对话 (src…) 涉及: kw…」。这类串只是「哪些词出现过」
// 的回声,没有独立信息量,写进图库会把「文档 --主题--> …」变成同构垃圾边。
// 判定与 agent/core.isTemplateSummary 保持一致。
const (
templateSummaryPrefix = "来自 "
templateSummaryMarker = "条对话"
)
// archivedContextSource 是归档上下文文档写死的 source 标记。
// 它是内部状态而非概念,不应以「来源」实体形式存在于图库中。
const archivedContextSource = "context_archived"
// IsNoiseEntity 判定一个实体名是否属于「不该进图记忆的噪音」。
//
// 判定刻意保守,只覆盖三类**客观**噪音,不做「这个词有没有信息量」的价值判断:
// 1. 停用词表命中的常用词(代词/助词/连词/介词/泛化名词/英文虚词)
// 2. 归档上下文内部标记context_archived
// 3. summarizeEntries 的模板摘要串
//
// 开放类词(「报告 / 对话 / 处理」这类真名词真动词)不在此列:它们在别的
// 语境下可能是有意义的实体,挡不挡属于领域决策,不该由这个函数替使用者拍板。
func IsNoiseEntity(name string) bool {
n := strings.TrimSpace(name)
if n == "" {
return true
}
if stopWords[n] {
return true
}
if n == archivedContextSource {
return true
}
return IsTemplateSummaryName(n)
}
// IsTemplateSummaryName 判断实体名是否为模板摘要串(供调用方单独使用)。
func IsTemplateSummaryName(name string) bool {
n := strings.TrimSpace(name)
return strings.HasPrefix(n, templateSummaryPrefix) && strings.Contains(n, templateSummaryMarker)
}
// FilterNoiseTriples 丢弃任一端为噪音实体的三元组,保持原顺序。
//
// 为什么在自动填充入口过滤、而不是在 Commit 里过滤Commit 同时是
// memory_commit 工具(模型显式写入)的落库口。模型主动写「结果 --是--> X」
// 是它的自由(也可能它当时真的想记),而自动填充是无人在环的批量产出,
// 必须自己保证质量——闸门开在产生噪声的那一端。
func FilterNoiseTriples(triples []Triple) []Triple {
if len(triples) == 0 {
return triples
}
out := make([]Triple, 0, len(triples))
for _, t := range triples {
if IsNoiseEntity(t.Subject) || IsNoiseEntity(t.Object) {
continue
}
out = append(out, t)
}
return out
}
// NoiseEntities 列出库中现存的噪音实体,按 mention_count 降序。
func (g *GraphDB) NoiseEntities() ([]Entity, error) {
g.mu.RLock()
defer g.mu.RUnlock()
return g.noiseEntitiesLocked()
}
func (g *GraphDB) noiseEntitiesLocked() ([]Entity, error) {
rows, err := g.db.Query(
`SELECT id, name, type, mention_count, created_at, updated_at FROM entities`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Entity
for rows.Next() {
var e Entity
if err := rows.Scan(&e.ID, &e.Name, &e.Type, &e.MentionCount, &e.CreatedAt, &e.UpdatedAt); err != nil {
return nil, err
}
if IsNoiseEntity(e.Name) {
out = append(out, e)
}
}
if err := rows.Err(); err != nil {
return nil, err
}
sort.Slice(out, func(i, j int) bool {
if out[i].MentionCount != out[j].MentionCount {
return out[i].MentionCount > out[j].MentionCount
}
return out[i].Name < out[j].Name
})
return out, nil
}
// PurgeNoise 清理库中已存在的噪音实体及其关系,返回删除的实体数与关系数。
//
// dryRun 为 true 时只统计、不写库——清理生产库前先看清楚要动什么。
// 关系按「任一端是噪音实体」删除;删完实体后顺带清理失去引用的孤儿句子
// (复用 cleanupOrphanedSentencesLocked不在这里重写一遍判定
func (g *GraphDB) PurgeNoise(dryRun bool) (int, int, error) {
g.mu.Lock()
defer g.mu.Unlock()
junk, err := g.noiseEntitiesLocked()
if err != nil {
return 0, 0, err
}
if len(junk) == 0 {
return 0, 0, nil
}
ids := make([]interface{}, 0, len(junk))
for _, e := range junk {
ids = append(ids, e.ID)
}
ph := placeholders(len(junk))
args := append(append([]interface{}{}, ids...), ids...)
// 关系数按 DISTINCT id 统计:两端都是噪音的关系不能被算两次。
var relCount int
if err := g.db.QueryRow(
`SELECT COUNT(DISTINCT id) FROM relations
WHERE source_id IN (`+ph+`) OR target_id IN (`+ph+`)`,
args...,
).Scan(&relCount); err != nil {
return 0, 0, err
}
if dryRun {
return len(junk), relCount, nil
}
tx, err := g.db.Begin()
if err != nil {
return 0, 0, err
}
defer tx.Rollback()
if _, err := tx.Exec(
`DELETE FROM relations WHERE source_id IN (`+ph+`) OR target_id IN (`+ph+`)`,
args...); err != nil {
return 0, 0, err
}
if _, err := tx.Exec(
`DELETE FROM entities WHERE id IN (`+ph+`)`, ids...); err != nil {
return 0, 0, err
}
if err := tx.Commit(); err != nil {
return 0, 0, err
}
if _, err := g.cleanupOrphanedSentencesLocked(); err != nil {
return len(junk), relCount, err
}
// 节点没了,场景引用必须跟着对齐:残留引用会让场景看着大、召回却是空的。
if _, err := g.purgeStaleSceneRefsLocked(); err != nil {
return len(junk), relCount, err
}
return len(junk), relCount, nil
}
// OrphanEntities 列出「没有任何关系的孤立实体」,按 mention_count 降序。
//
// 孤立实体在图里只剩一个名字:关系召回永远够不到它,唯一的副作用是
// 混进实体名向量索引、被自动注入当成相关实体。它们出现的典型路径是
// 噪音实体被清理后留下的另一端(边没了,节点还在),或提取器把关系写重了。
func (g *GraphDB) OrphanEntities() ([]Entity, error) {
g.mu.RLock()
defer g.mu.RUnlock()
return g.orphanEntitiesLocked()
}
func (g *GraphDB) orphanEntitiesLocked() ([]Entity, error) {
rows, err := g.db.Query(
`SELECT id, name, type, mention_count, created_at, updated_at FROM entities e
WHERE NOT EXISTS (SELECT 1 FROM relations r WHERE r.source_id = e.id OR r.target_id = e.id)
-- 与媒体块有边的实体不算孤立:那是 sentence/document --contains--> block
-- 体系的一部分,删了会让块边悬空。
AND NOT EXISTS (SELECT 1 FROM memory_block_edges b
WHERE (b.source_kind = 'entity' AND b.source_id = CAST(e.id AS TEXT))
OR (b.target_kind = 'entity' AND b.target_id = CAST(e.id AS TEXT)))`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Entity
for rows.Next() {
var e Entity
if err := rows.Scan(&e.ID, &e.Name, &e.Type, &e.MentionCount, &e.CreatedAt, &e.UpdatedAt); err != nil {
return nil, err
}
out = append(out, e)
}
if err := rows.Err(); err != nil {
return nil, err
}
sort.Slice(out, func(i, j int) bool {
if out[i].MentionCount != out[j].MentionCount {
return out[i].MentionCount > out[j].MentionCount
}
return out[i].Name < out[j].Name
})
return out, nil
}
// PurgeOrphans 删除没有任何关系的孤立实体,返回删除数。
//
// dryRun 为 true 时只统计。与 PurgeNoise 分开:噪音是「这个名字本身不该在」,
// 孤立是「这个名字虽然可能合理,但它已经不在图里了」——两件事,别混在一个开关里。
func (g *GraphDB) PurgeOrphans(dryRun bool) (int, error) {
g.mu.Lock()
defer g.mu.Unlock()
orphans, err := g.orphanEntitiesLocked()
if err != nil {
return 0, err
}
if dryRun || len(orphans) == 0 {
return len(orphans), nil
}
ids := make([]interface{}, 0, len(orphans))
for _, e := range orphans {
ids = append(ids, e.ID)
}
if _, err := g.db.Exec(
`DELETE FROM entities WHERE id IN (`+placeholders(len(ids))+`)`, ids...); err != nil {
return 0, err
}
if _, err := g.purgeStaleSceneRefsLocked(); err != nil {
return 0, err
}
return len(orphans), nil
}