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 重建实体名向量索引。
This commit is contained in:
JianFeeeee
2026-09-15 07:47:16 +08:00
parent 94995eaa64
commit b37141f3f5
8 changed files with 699 additions and 1 deletions

View File

@ -0,0 +1,272 @@
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)
}
}