Files
HomeAgent/internal/memory/noise_test.go
JianFeeeee b37141f3f5 fix(memory): doc→graph 补回常用词闸门 + 存量噪音/孤立节点清理
问题:图记忆里堆着「结果(192) / 什么(59) / 哪个(99) / 待命(176) / 报告(174) /
context_archived(43)」这类节点,mention_count 冲到几百、度数只有 1~2——
占着热实体位、挤满召回预算,却不带任何结构。

根因:这层过滤原本存在,后来被换掉没补回。1cb3e87(NLP 三元组提取系统)
把 doc→graph 从「CutExact 滑窗词链」换成依存句法提取器时,CutExact
(去停用词 324 条 + validEntityName + 去重,注释至今还写着「用于 doc→graph
蒸馏」)失去了唯一生产调用点,只剩 cut_test.go 在调它。此后落库闸门只剩
validEntityName——它挡的是「不像名字的字符串」(2–50 字符、含字母),
完全不挡「像名字的常用词」。

改动:
- 新增 internal/memory/noise.go:IsNoiseEntity 收敛判定(停用词 /
  context_archived / 模板摘要回声),FilterNoiseTriples 给自动填充路用,
  PurgeNoise + PurgeOrphans + cmd/memgc 供存量清理(默认 dry-run)。
  判定刻意保守:只拦三类客观噪音,开放类词(报告/对话/处理)不拦——
  它们挡不挡是领域决策,见函数注释。
- docToTriples 接上闸门(用户点名的「文档常用词」路)。
  对话蒸馏路 extractKeyTriples **不接**:CutExact 当年也只挂 doc→graph,
  且 pipeline_test 明确断言「我 --读书--> 杭州」必须抽出(代词主语是该路
  既定行为),是否拦属行为决策,已在代码注释里写明并留给使用者定夺。
- cut.go 补全封闭类常用词:咱俩/咱们(我们/你们/他们 早有)、任何/此/本/
  其中/以及/那么/这样/那样/一样/还有/还要/只是/老是/全部/所有/有些/一些/
  别的/其他/其余/各自/本身/方位词/部分/方面。
  「不能/不会」试过又撤回:它们是 embedder tokenize 的实词路径,
  加进停用词会让 static_embedder 的领域聚类用例翻转(今天天气句与股票句
  的相似度大小关系反了),属真回归,不留。
- graph.go:CleanupOrphanedSentences 拆出 Locked 版供 PurgeNoise 复用。

验证:go build/vet 干净,go test -count=1 ./... 全绿。
新增用例:IsNoiseEntity 判定表、FilterNoiseTriples 顺序与边界、
PurgeNoise(统计/幂等/dry-run 不写库/不误删仍被媒体块边引用的句子)、
PurgeOrphans(识别/不误判有边实体/幂等)、docToTriples 噪音闸门与
模板锚点不被误杀。

存量清理(生产库 /home/newqqagent/memory/graph.db,先用 sqlite3 .backup 备份
到 graph.db.bak-20260915-073210):
- 噪音实体 29 个 + 其关系 59 条
- 零关系孤立实体 25 个
- 结果:实体 778→724,关系 639→580;sentences 3 条与 block_edges 3 条原样保留
  (媒体块引用不被误删),PRAGMA integrity_check=ok、无悬空关系/块边。
- 运行中的 homed 无需重启:下一个 archive 心跳会 Indexer.Sync 重建实体名向量索引。
2026-09-15 07:47:16 +08:00

273 lines
8.9 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 (
"fmt"
"os"
"testing"
)
func TestIsNoiseEntity(t *testing.T) {
cases := []struct {
name string
want bool
why string
}{
{"结果", true, "停用词表内的泛化名词"},
{"什么", true, "疑问代词"},
{"哪个", true, "疑问代词"},
{"咱俩", true, "人称代词2026-09 补全)"},
{"任何", true, "限定词"},
{"部分", true, "泛指名词"},
{"context_archived", true, "归档上下文内部标记"},
{"来自 1 个来源的 2 条对话 (agent) 涉及: qq, 通道", true, "模板摘要回声"},
{"来自 2 个来源的 2 条对话 (cli, agent)", true, "模板摘要回声(无涉及段)"},
{"", true, "空名"},
{" ", true, "纯空白"},
{"文档", false, "doc→graph 的模板主语,保留"},
{"小宅", false, "人名"},
{"CodeGraph", false, "专名"},
{"报告", false, "开放类词:可能是有意义的实体,不由本函数拦截"},
{"对话", false, "开放类词"},
}
for _, c := range cases {
if got := IsNoiseEntity(c.name); got != c.want {
t.Errorf("IsNoiseEntity(%q) = %v, want %v (%s)", c.name, got, c.want, c.why)
}
}
}
func TestFilterNoiseTriples(t *testing.T) {
in := []Triple{
{Subject: "结果", Relation: "是", Object: "问题"}, // 两端噪音
{Subject: "小宅", Relation: "需要", Object: "什么"}, // 一端噪音
{Subject: "文档", Relation: "主题", Object: "来自 1 个来源的 2 条对话 (agent)"}, // 一端噪音(模板摘要)
{Subject: "小宅", Relation: "使用", Object: "CodeGraph"}, // 干净
{Subject: "小宅", Relation: "继续", Object: "待命"}, // 开放类词:本层不拦
}
out := FilterNoiseTriples(in)
if len(out) != 2 {
t.Fatalf("FilterNoiseTriples 保留 %d 条want 2: %+v", len(out), out)
}
if out[0].Object != "CodeGraph" || out[1].Object != "待命" {
t.Errorf("留下的不是那两条干净三元组: %+v", out)
}
// 空输入不应被改写成非 nil调用方按 len 判断,别制造意外)
if FilterNoiseTriples(nil) != nil {
t.Error("nil 输入应原样返回 nil")
}
}
func TestPurgeNoise(t *testing.T) {
g := newTestGraph(t)
defer os.Remove(g.dbPath)
defer g.Close()
triples := []Triple{
{Subject: "结果", Relation: "是", Object: "问题", Confidence: 1.0},
{Subject: "小宅", Relation: "继续", Object: "待命", Confidence: 1.0},
{Subject: "文档", Relation: "来源", Object: "context_archived", Confidence: 1.0},
{Subject: "文档", Relation: "主题", Object: "来自 1 个来源的 2 条对话 (agent)", Confidence: 1.0},
{Subject: "小宅", Relation: "使用", Object: "CodeGraph", Confidence: 1.0},
}
if _, _, err := g.Commit(triples, "test", 0); err != nil {
t.Fatalf("commit: %v", err)
}
before, err := g.NoiseEntities()
if err != nil {
t.Fatalf("NoiseEntities: %v", err)
}
if len(before) != 4 {
// 4 个噪音:结果 / 问题 / context_archived / 模板摘要串
// (「待命」是开放类词,不在停用词表内,故意不算噪音)
t.Fatalf("噪音实体 %d 个want 4: %+v", len(before), before)
}
// 按 mention_count 降序;都为 1 时按名字升序,只验证顺序单调。
for i := 1; i < len(before); i++ {
if before[i-1].MentionCount < before[i].MentionCount {
t.Errorf("NoiseEntities 未按 mention_count 降序: %+v", before)
break
}
}
// dry-run 不得写库
de, dr, err := g.PurgeNoise(true)
if err != nil {
t.Fatalf("PurgeNoise(dryRun): %v", err)
}
// 3 条关系结果→问题两端噪音算一次、文档→context_archived、文档→模板摘要
if de != 4 || dr != 3 {
t.Errorf("dry-run 统计 entities=%d relations=%d, want 4/3", de, dr)
}
if again, _ := g.NoiseEntities(); len(again) != 4 {
t.Fatalf("dry-run 改了库:噪音实体剩 %d 个", len(again))
}
// 真清理
de, dr, err = g.PurgeNoise(false)
if err != nil {
t.Fatalf("PurgeNoise: %v", err)
}
if de != 4 || dr != 3 {
t.Errorf("清理统计 entities=%d relations=%d, want 4/3", de, dr)
}
left, err := g.NoiseEntities()
if err != nil {
t.Fatalf("NoiseEntities: %v", err)
}
if len(left) != 0 {
t.Errorf("清理后仍有噪音实体: %+v", left)
}
// 干净的那条必须活着
res, err := g.Recall(nil, nil, 1, "")
if err != nil {
t.Fatalf("Recall: %v", err)
}
names := make(map[string]bool)
for _, e := range res.Entities {
names[e.Name] = true
}
if !names["小宅"] || !names["CodeGraph"] {
t.Errorf("清理误伤干净实体,现存: %v", names)
}
for _, n := range []string{"结果", "问题", "context_archived"} {
if names[n] {
t.Errorf("噪音实体 %q 仍在库中", n)
}
}
// 「文档」是 doc→graph 的模板主语,有意保留(清理只摘它的噪音边)
if !names["文档"] {
t.Error("模板主语「文档」不应被清理")
}
if !names["待命"] {
t.Error("开放类词「待命」不应被清理(它不在停用词表内)")
}
// 幂等:再清一次应为 0/0
de, dr, err = g.PurgeNoise(false)
if err != nil {
t.Fatalf("PurgeNoise 二次: %v", err)
}
if de != 0 || dr != 0 {
t.Errorf("二次清理 entities=%d relations=%d, want 0/0", de, dr)
}
}
// TestPurgeNoiseKeepsBlockBackedSentences 钉住 PurgeNoise 不能误删仍被媒体块
// 引用的句子——它复用 CleanupOrphanedSentences那个判定必须照旧生效。
func TestPurgeNoiseKeepsBlockBackedSentences(t *testing.T) {
g := newTestGraph(t)
defer os.Remove(g.dbPath)
defer g.Close()
sentence := "小宅 说 结果 很好"
if _, _, err := g.Commit([]Triple{
{Subject: "结果", Relation: "是", Object: "问题", SentenceText: sentence, Confidence: 1.0},
}, "test", 0); err != nil {
t.Fatalf("commit: %v", err)
}
var sid int64
if err := g.db.QueryRow(`SELECT id FROM sentences WHERE text = ?`, sentence).Scan(&sid); err != nil {
t.Fatalf("sentence 未写入: %v", err)
}
if _, err := g.db.Exec(
`INSERT INTO memory_blocks (id, modality, payload_digest, mime) VALUES ('blk1', 'image', 'digest1', 'image/png')`); err != nil {
t.Fatalf("insert block: %v", err)
}
if _, err := g.db.Exec(
`INSERT INTO memory_block_edges (source_kind, source_id, target_kind, target_id, edge_type)
VALUES ('sentence', ?, 'block', 'blk1', 'contains')`,
fmt.Sprintf("%d", sid)); err != nil {
t.Fatalf("insert edge: %v", err)
}
if _, _, err := g.PurgeNoise(false); err != nil {
t.Fatalf("PurgeNoise: %v", err)
}
var n int
if err := g.db.QueryRow(`SELECT COUNT(*) FROM sentences WHERE id = ?`, sid).Scan(&n); err != nil {
t.Fatalf("count sentence: %v", err)
}
if n != 1 {
t.Error("PurgeNoise 删掉了仍被媒体块边引用的句子")
}
}
func TestPurgeOrphans(t *testing.T) {
g := newTestGraph(t)
defer os.Remove(g.dbPath)
defer g.Close()
if _, _, err := g.Commit([]Triple{
{Subject: "小宅", Relation: "使用", Object: "CodeGraph", Confidence: 1.0},
{Subject: "结果", Relation: "是", Object: "问题", Confidence: 1.0},
}, "test", 0); err != nil {
t.Fatalf("commit: %v", err)
}
// 清掉噪音后,「结果」「问题」两个节点被删、边也没了;
// 但先看清理前的孤立集合:应为 0都有边
if list, err := g.OrphanEntities(); err != nil {
t.Fatalf("OrphanEntities: %v", err)
} else if len(list) != 0 {
t.Fatalf("清理前不应有孤立实体: %+v", list)
}
// 造一个「边被清掉、节点还在」的壳:直接删边
if _, err := g.db.Exec(`DELETE FROM relations WHERE relation_type = '是'`); err != nil {
t.Fatalf("delete relation: %v", err)
}
// 再补一个从未有过边的孤立实体
if _, _, err := g.Commit([]Triple{{Subject: "孤零零", Relation: "是", Object: "小宅", Confidence: 1.0}}, "test", 0); err != nil {
t.Fatalf("commit: %v", err)
}
if _, err := g.db.Exec(`DELETE FROM relations WHERE source_id = (SELECT id FROM entities WHERE name = '孤零零')`); err != nil {
t.Fatalf("delete relation 2: %v", err)
}
list, err := g.OrphanEntities()
if err != nil {
t.Fatalf("OrphanEntities: %v", err)
}
names := make(map[string]bool)
for _, e := range list {
names[e.Name] = true
}
for _, want := range []string{"结果", "问题", "孤零零"} {
if !names[want] {
t.Errorf("%q 应被识别为孤立实体,实际: %+v", want, list)
}
}
if names["小宅"] || names["CodeGraph"] {
t.Errorf("有边的实体被误判为孤立: %+v", list)
}
// dry-run 不写库
if n, err := g.PurgeOrphans(true); err != nil || n != len(list) {
t.Fatalf("PurgeOrphans(dryRun) = %d, %v; want %d", n, err, len(list))
}
if again, _ := g.OrphanEntities(); len(again) != len(list) {
t.Fatal("dry-run 改了库")
}
n, err := g.PurgeOrphans(false)
if err != nil {
t.Fatalf("PurgeOrphans: %v", err)
}
if n != len(list) {
t.Errorf("删除 %d 个want %d", n, len(list))
}
if left, _ := g.OrphanEntities(); len(left) != 0 {
t.Errorf("清理后仍有孤立实体: %+v", left)
}
// 幂等
if n, err := g.PurgeOrphans(false); err != nil || n != 0 {
t.Errorf("二次清理 = %d, %v; want 0", n, err)
}
}