mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-24 02:48: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通道语音输入 等仍在其后。
123 lines
4.2 KiB
Go
123 lines
4.2 KiB
Go
// memgc 清理图记忆里已存在的「噪音实体」「孤立实体」及其关系。
|
||
//
|
||
// 为什么需要这个命令:噪音闸门(internal/memory.IsNoiseEntity)只能拦住
|
||
// **新写入**的噪音。旧库里那批(常用词 / 归档内部标记 / 模板摘要回声)是
|
||
// 闸门上线前攒下的存量,没人清就一直在——热实体被它们占着,召回预算被
|
||
// 同构垃圾边挤满。清理是一次性动作,但需要可重复执行、可先看不做。
|
||
//
|
||
// 两件事分开开关:-orphans 处理的是「零关系的空节点」(清理噪音后另一端
|
||
// 留下的壳),它们的名字本身可能没问题,但已经不在图里了。
|
||
//
|
||
// 用法(默认 dry-run,只列不删):
|
||
//
|
||
// memgc -db /home/newqqagent/memory/graph.db
|
||
// memgc -db /home/newqqagent/memory/graph.db -orphans -apply
|
||
//
|
||
// 清理生产库前请先备份:sqlite3 graph.db ".backup 'graph.db.bak-<ts>'"
|
||
// 不要用 cp —— WAL 模式下会复制出主库与 -wal 不一致的快照。
|
||
package main
|
||
|
||
import (
|
||
"flag"
|
||
"fmt"
|
||
"log"
|
||
|
||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
||
)
|
||
|
||
func main() {
|
||
path := flag.String("db", "", "graph.db 路径(必填)")
|
||
apply := flag.Bool("apply", false, "真正删除;不加则只 dry-run 打印")
|
||
orphans := flag.Bool("orphans", false, "同时处理「零关系孤立实体」(先被清理的噪音在另一端留下的空节点)")
|
||
tagScene := flag.String("tag-scene", "", "存量引导:把实体名匹配 -entity-glob 的活跃关系标进该场景键(如 chan:qq)")
|
||
entityGlob := flag.String("entity-glob", "", "配合 -tag-scene 的 GLOB 模式(如 *QQ*)。GLOB 区分大小写,避免把 /home/newqqagent 这类路径卷进场景")
|
||
sceneStats := flag.Bool("scene-stats", false, "只打印场景规模摘要")
|
||
flag.Parse()
|
||
|
||
if *path == "" {
|
||
flag.Usage()
|
||
log.Fatal("memgc: 必须指定 -db")
|
||
}
|
||
|
||
g, err := memory.NewGraphDB(*path)
|
||
if err != nil {
|
||
log.Fatalf("memgc: open %s: %v", *path, err)
|
||
}
|
||
defer g.Close()
|
||
|
||
if *sceneStats {
|
||
stats, err := g.SceneStats()
|
||
if err != nil {
|
||
log.Fatalf("memgc: scene stats: %v", err)
|
||
}
|
||
fmt.Printf("场景 %d 个:\n", len(stats))
|
||
for _, st := range stats {
|
||
fmt.Printf(" %-40s refs=%-5d relations=%-5d entities=%-5d updated=%s\n",
|
||
st.Key, st.Refs, st.Relations, st.Entities, st.UpdatedAt.Format("2006-01-02 15:04"))
|
||
}
|
||
return
|
||
}
|
||
|
||
// 存量引导:场景是后引入的维度,老库里的规则(那批 QQ 规则就是典型)
|
||
// 没有任何场景引用,不补挂就永远吃不到场景召回。
|
||
if *tagScene != "" {
|
||
if *entityGlob == "" {
|
||
log.Fatal("memgc: -tag-scene 需要配套 -entity-glob(如 '*QQ*');不做自动猜测")
|
||
}
|
||
n, err := g.TagSceneByEntityGlob(*tagScene, *entityGlob, !*apply)
|
||
if err != nil {
|
||
log.Fatalf("memgc: tag scene: %v", err)
|
||
}
|
||
if *apply {
|
||
fmt.Printf("[APPLIED] 已把 %d 条关系标进场景 %q\n", n, *tagScene)
|
||
} else {
|
||
fmt.Printf("[DRY-RUN] 将把 %d 条关系标进场景 %q(未写库)\n", n, *tagScene)
|
||
}
|
||
return
|
||
}
|
||
|
||
junk, err := g.NoiseEntities()
|
||
if err != nil {
|
||
log.Fatalf("memgc: scan: %v", err)
|
||
}
|
||
|
||
fmt.Printf("噪音实体 %d 个:\n", len(junk))
|
||
for _, e := range junk {
|
||
fmt.Printf(" %-64s type=%-8s mentions=%d\n", e.Name, e.Type, e.MentionCount)
|
||
}
|
||
|
||
de, dr, err := g.PurgeNoise(!*apply)
|
||
if err != nil {
|
||
log.Fatalf("memgc: purge: %v", err)
|
||
}
|
||
if *apply {
|
||
fmt.Printf("[APPLIED] 噪音:已删除 实体=%d 关系=%d\n", de, dr)
|
||
} else {
|
||
fmt.Printf("[DRY-RUN] 噪音:将删除 实体=%d 关系=%d(未写库,加 -apply 才落地)\n", de, dr)
|
||
}
|
||
|
||
if *orphans {
|
||
list, err := g.OrphanEntities()
|
||
if err != nil {
|
||
log.Fatalf("memgc: orphans: %v", err)
|
||
}
|
||
fmt.Printf("孤立实体(零关系)%d 个:\n", len(list))
|
||
for _, e := range list {
|
||
fmt.Printf(" %-64s type=%-8s mentions=%d\n", e.Name, e.Type, e.MentionCount)
|
||
}
|
||
n, err := g.PurgeOrphans(!*apply)
|
||
if err != nil {
|
||
log.Fatalf("memgc: purge orphans: %v", err)
|
||
}
|
||
if *apply {
|
||
fmt.Printf("[APPLIED] 孤立实体:已删除 %d 个\n", n)
|
||
} else {
|
||
fmt.Printf("[DRY-RUN] 孤立实体:将删除 %d 个\n", n)
|
||
}
|
||
}
|
||
|
||
if *apply {
|
||
fmt.Println("提示:运行中的进程会在下一个 archive 心跳(Indexer.Sync)重建实体名向量索引,无需重启。")
|
||
}
|
||
}
|