mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-22 09:58:06 +00:00
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:
88
cmd/memgc/main.go
Normal file
88
cmd/memgc/main.go
Normal file
@ -0,0 +1,88 @@
|
||||
// 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, "同时处理「零关系孤立实体」(先被清理的噪音在另一端留下的空节点)")
|
||||
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()
|
||||
|
||||
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)重建实体名向量索引,无需重启。")
|
||||
}
|
||||
}
|
||||
@ -235,3 +235,58 @@ func TestGetFloatInt(t *testing.T) {
|
||||
t.Errorf("expected 5.0, got %f", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDocToTriplesDropsNoiseEntities 钉住 doc→graph 的噪音闸门。
|
||||
//
|
||||
// 背景:doc→graph 在 1cb3e87 从「CutExact 滑窗词链」换成 NLP 依存提取器后,
|
||||
// 唯一还拦常用词的那层(CutExact:去停用词 + validEntityName)失去调用点,
|
||||
// 闸门只剩 validEntityName——它只管名字像不像名字,不管名字是不是常用词。
|
||||
// 实测生产库里因此攒下「文档 --主题--> 来自 N 个来源的 M 条对话 …」这类
|
||||
// 模板回声,以及 context_archived 这个内部标记。
|
||||
func TestDocToTriplesDropsNoiseEntities(t *testing.T) {
|
||||
doc := &document.Doc{
|
||||
Summary: "来自 1 个来源的 2 条对话 (agent) 涉及: qq, 通道",
|
||||
Content: "",
|
||||
Source: "context_archived",
|
||||
}
|
||||
triples := docToTriples(doc, nil)
|
||||
|
||||
for _, tr := range triples {
|
||||
if memory.IsNoiseEntity(tr.Subject) || memory.IsNoiseEntity(tr.Object) {
|
||||
t.Errorf("docToTriples 漏出噪音实体: %+v", tr)
|
||||
}
|
||||
}
|
||||
|
||||
// 模板摘要不当「主题」、context_archived 不当「来源」:两条模板三元组都该被拦下。
|
||||
for _, tr := range triples {
|
||||
if tr.Relation == "主题" {
|
||||
t.Errorf("模板摘要被写成主题: %+v", tr)
|
||||
}
|
||||
if tr.Relation == "来源" && tr.Object == "context_archived" {
|
||||
t.Errorf("归档内部标记被写成来源: %+v", tr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDocToTriplesKeepsTemplateAnchors 保证闸门没把正常的模板三元组一起误杀。
|
||||
func TestDocToTriplesKeepsTemplateAnchors(t *testing.T) {
|
||||
doc := &document.Doc{
|
||||
Summary: "多轮对话",
|
||||
Content: "",
|
||||
Source: "qq",
|
||||
}
|
||||
triples := docToTriples(doc, nil)
|
||||
|
||||
var hasTopic, hasSource bool
|
||||
for _, tr := range triples {
|
||||
if tr.Subject == "文档" && tr.Relation == "主题" && tr.Object == "多轮对话" {
|
||||
hasTopic = true
|
||||
}
|
||||
if tr.Subject == "文档" && tr.Relation == "来源" && tr.Object == "qq" {
|
||||
hasSource = true
|
||||
}
|
||||
}
|
||||
if !hasTopic || !hasSource {
|
||||
t.Errorf("正常模板三元组被误杀: topic=%v source=%v, triples=%+v", hasTopic, hasSource, triples)
|
||||
}
|
||||
}
|
||||
|
||||
@ -468,7 +468,11 @@ func docToTriples(doc *document.Doc, embedder nlp.Vectorizer) []memory.Triple {
|
||||
})
|
||||
}
|
||||
|
||||
return triples
|
||||
// 噪音闸门:NLP 提取器不认常用词(「结果 / 什么 / 待命」都能当主语),
|
||||
// 而落库闸门 validEntityName 只管名字像不像名字。这一层是防止
|
||||
// 「每个文档的常用词都变成实体」的唯一防线(CutExact 时代的那层已随
|
||||
// 提取器换代丢失,见 memory.IsNoiseEntity 的说明)。
|
||||
return memory.FilterNoiseTriples(triples)
|
||||
}
|
||||
|
||||
// isTemplateSummary 识别 summarizeEntries 生成的模板化摘要
|
||||
|
||||
@ -191,6 +191,18 @@ var stopWords = map[string]bool{
|
||||
"人": true, "人们": true, "东西": true, "事情": true,
|
||||
"问题": true, "情况": true, "时候": true, "地方": true,
|
||||
"方式": true, "方法": true, "原因": true, "结果": true,
|
||||
// ── 人称代词补全(我们/你们/他们 早有,咱俩/咱们 漏了)──
|
||||
"咱俩": true, "咱们": true,
|
||||
// ── 限定/指代类:本身没有独立的指称对象 ──
|
||||
"任何": true, "此": true, "本": true, "其中": true, "以及": true,
|
||||
"那么": true, "这样": true, "那样": true, "一样": true,
|
||||
"还有": true, "还要": true, "只是": true, "老是": true,
|
||||
// ── 方位/整体类泛指 ──
|
||||
"全部": true, "所有": true, "有些": true, "一些": true,
|
||||
"别的": true, "其他": true, "其余": true, "各自": true, "本身": true,
|
||||
"上面": true, "下面": true, "里面": true, "外面": true,
|
||||
"前面": true, "后面": true, "左边": true, "右边": true,
|
||||
"中间": true, "附近": true, "周围": true, "部分": true, "方面": true,
|
||||
// ── 高频语气组合 ──
|
||||
"好的": true, "好吧": true, "好": true,
|
||||
"好了": true, "对了": true, "行了": true,
|
||||
|
||||
@ -1097,6 +1097,12 @@ func (g *GraphDB) ClearSentenceID(relationID int64) error {
|
||||
func (g *GraphDB) CleanupOrphanedSentences() (int, error) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
return g.cleanupOrphanedSentencesLocked()
|
||||
}
|
||||
|
||||
// cleanupOrphanedSentencesLocked 是 CleanupOrphanedSentences 的加锁内联版,
|
||||
// 供已在写锁内的调用方(PurgeNoise)复用,避免自锁死。
|
||||
func (g *GraphDB) cleanupOrphanedSentencesLocked() (int, error) {
|
||||
tx, err := g.db.Begin()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
|
||||
256
internal/memory/noise.go
Normal file
256
internal/memory/noise.go
Normal 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
|
||||
}
|
||||
272
internal/memory/noise_test.go
Normal file
272
internal/memory/noise_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -416,6 +416,11 @@ func extractKeyTriples(userContent, assistantContent string, embedder nlp.Vector
|
||||
}
|
||||
}
|
||||
|
||||
// 注意:这里**不**做噪音过滤。对话蒸馏的抽取器(与 doc→graph 共用一个
|
||||
// NLP 提取器)历史上就没有常用词闸门:CutExact 那层当年只挂在 doc→graph 上。
|
||||
// 而且 pipeline_test 明确断言「我 --读书--> 杭州」必须被抽出(代词作主语是
|
||||
// 该路的既定行为)。要不要在对话路也拦常用词是行为决策,不在此处擅改,
|
||||
// 参见 memory.IsNoiseEntity 的说明。
|
||||
return triples
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user