mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-22 18:08:04 +00:00
背景(实测):带条件的记忆召不回来。生产库里明明有 「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通道语音输入 等仍在其后。
264 lines
9.0 KiB
Go
264 lines
9.0 KiB
Go
package memory
|
||
|
||
import (
|
||
"sort"
|
||
"strings"
|
||
)
|
||
|
||
// ──────────────────────────────────────────────
|
||
// 图记忆「噪音实体」判定与清理
|
||
//
|
||
// 为什么需要这一层:doc→graph 蒸馏在 1cb3e87 从「CutExact 滑窗词链」换成
|
||
// NLP 依存提取器后,落库闸门只剩 validEntityName(长度 2–50、含字母/汉字)。
|
||
// 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
|
||
}
|