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

256
internal/memory/noise.go Normal file
View File

@ -0,0 +1,256 @@
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
}
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
}
return len(orphans), nil
}