mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-10-15 12:15:16 +00:00
fix(memory): 打通场景/索引/召回残余矛盾点,清理死代码与冗余索引
- DecaySceneRefs 真半衰期:新增 scene_refs.decayed_at 作计时起点,
每个引用至多每 halfLife 衰减一次。此前只按 created_at 判龄 + 每次
心跳对半砍,30 天阈值配 60 分钟心跳会在几小时内清空老关联(不是半
衰期是骤死);时间基准改走 SQLite datetime('now'),不再与 Go 本地
时间混用。
- Indexer.syncIfStale 基线口径与 Sync 对齐(min(实体数, 全量召回上限)):
实体数超过上限时原实现永远不相等,每 retrainInterval 全量重训一次。
- EnsureScene 去掉从不使用的 Situation 参数;修正 EnterSceneWithHint /
resolveTurnScenes / 测试里「声明场景会学整轮指纹」的过时注释(实际
刻意不学,否则会吃死被动路)。
- SituationFeature.Weight 补 peer_group → wFeatPeer:此前落到 default
话题级 0.4,群聊身份被降级成软信号。
- RecallBySituation 注释改为与实现一致(相似度只决定命中哪些场景,
不参与每条关系排序)。
- 移除只被测试使用的 EmergentScenes(SceneStats 已含 origin/strength/
features,完全覆盖)。
- 清理与 UNIQUE 隐含索引重复的 idx_entity_name / idx_sentences_text。
- 新增 TestSyncIfStaleBaseline / TestPeerGroupWeight,衰减测试补「同一
半衰期内不重复衰减」用例。
go build/vet 干净,internal/... 全绿。
This commit is contained in:
@ -12,6 +12,15 @@ import (
|
|||||||
_ "github.com/mattn/go-sqlite3"
|
_ "github.com/mattn/go-sqlite3"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// maxKeywordEntities 是单个关键词能取回的实体上限。
|
||||||
|
//
|
||||||
|
// 无上限时,一个宽关键词("QQ")会命中上百个实体并逐个参与深度扩展,
|
||||||
|
// 把一次召回变成一次全表扫描。
|
||||||
|
const maxKeywordEntities = 50
|
||||||
|
|
||||||
|
// maxAdjacentRelations 是深度扩展里**每层**读取的关系上限。
|
||||||
|
const maxAdjacentRelations = 200
|
||||||
|
|
||||||
// maxFullRecallEntities 是「无关键词全量读取」路径的实体上限。
|
// maxFullRecallEntities 是「无关键词全量读取」路径的实体上限。
|
||||||
// 该路径只服务于内部整备(Indexer.Sync / 实体合并检测),并非用户检索;
|
// 该路径只服务于内部整备(Indexer.Sync / 实体合并检测),并非用户检索;
|
||||||
// 无上限时一张大图会被整表 read 进内存。超限时 GraphDB.Recall 会记日志。
|
// 无上限时一张大图会被整表 read 进内存。超限时 GraphDB.Recall 会记日志。
|
||||||
@ -236,6 +245,9 @@ func (g *GraphDB) initSchema() error {
|
|||||||
-- 数值型节点(relation/entity)为空串。
|
-- 数值型节点(relation/entity)为空串。
|
||||||
ref_text TEXT NOT NULL DEFAULT '',
|
ref_text TEXT NOT NULL DEFAULT '',
|
||||||
weight REAL DEFAULT 1.0,
|
weight REAL DEFAULT 1.0,
|
||||||
|
-- decayed_at 是半衰期衰减的计时起点:每个引用至多每 halfLife
|
||||||
|
-- 衰减一次(见 DecaySceneRefs)。重复写入/强化会把它刷成当前时刻。
|
||||||
|
decayed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
UNIQUE(scene_id, kind, ref_id, ref_text)
|
UNIQUE(scene_id, kind, ref_id, ref_text)
|
||||||
)`,
|
)`,
|
||||||
@ -245,14 +257,12 @@ func (g *GraphDB) initSchema() error {
|
|||||||
`CREATE INDEX IF NOT EXISTS idx_memory_blocks_digest ON memory_blocks(payload_digest)`,
|
`CREATE INDEX IF NOT EXISTS idx_memory_blocks_digest ON memory_blocks(payload_digest)`,
|
||||||
`CREATE INDEX IF NOT EXISTS idx_memory_block_edges_source ON memory_block_edges(source_kind, source_id)`,
|
`CREATE INDEX IF NOT EXISTS idx_memory_block_edges_source ON memory_block_edges(source_kind, source_id)`,
|
||||||
`CREATE INDEX IF NOT EXISTS idx_memory_block_edges_target ON memory_block_edges(target_kind, target_id)`,
|
`CREATE INDEX IF NOT EXISTS idx_memory_block_edges_target ON memory_block_edges(target_kind, target_id)`,
|
||||||
`CREATE INDEX IF NOT EXISTS idx_entity_name ON entities(name)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_entity_type ON entities(type)`,
|
`CREATE INDEX IF NOT EXISTS idx_entity_type ON entities(type)`,
|
||||||
`CREATE INDEX IF NOT EXISTS idx_relation_source ON relations(source_id)`,
|
`CREATE INDEX IF NOT EXISTS idx_relation_source ON relations(source_id)`,
|
||||||
`CREATE INDEX IF NOT EXISTS idx_relation_target ON relations(target_id)`,
|
`CREATE INDEX IF NOT EXISTS idx_relation_target ON relations(target_id)`,
|
||||||
`CREATE INDEX IF NOT EXISTS idx_relation_type ON relations(relation_type)`,
|
`CREATE INDEX IF NOT EXISTS idx_relation_type ON relations(relation_type)`,
|
||||||
`CREATE INDEX IF NOT EXISTS idx_relation_status ON relations(status)`,
|
`CREATE INDEX IF NOT EXISTS idx_relation_status ON relations(status)`,
|
||||||
`CREATE INDEX IF NOT EXISTS idx_relation_session ON relations(session_id)`,
|
`CREATE INDEX IF NOT EXISTS idx_relation_session ON relations(session_id)`,
|
||||||
`CREATE INDEX IF NOT EXISTS idx_sentences_text ON sentences(text)`,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, s := range schemas {
|
for _, s := range schemas {
|
||||||
@ -307,6 +317,17 @@ func (g *GraphDB) initSchema() error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 迁移8:scene_refs 加 decayed_at(半衰期衰减的计时起点)。
|
||||||
|
// ALTER 不接受非常量默认值,先加可空列再用 created_at 回填,
|
||||||
|
// 于是既有引用的「上一次衰减」就定在它被写入的时刻,不会被立即清掉。
|
||||||
|
if !columnExists(tx, "scene_refs", "decayed_at") {
|
||||||
|
tx.Exec(`ALTER TABLE scene_refs ADD COLUMN decayed_at TIMESTAMP`)
|
||||||
|
tx.Exec(`UPDATE scene_refs SET decayed_at = created_at WHERE decayed_at IS NULL`)
|
||||||
|
}
|
||||||
|
// 冗余索引清理:entities.name 与 sentences.text 上的 UNIQUE 已隐含等价索引
|
||||||
|
// (sqlite_autoindex_*),再建一个同列索引只增加写放大,查询不会用到。
|
||||||
|
tx.Exec(`DROP INDEX IF EXISTS idx_entity_name`)
|
||||||
|
tx.Exec(`DROP INDEX IF EXISTS idx_sentences_text`)
|
||||||
// 迁移3:将现有 sentence_ref 数据迁移到 sentences 表
|
// 迁移3:将现有 sentence_ref 数据迁移到 sentences 表
|
||||||
tx.Exec(`INSERT OR IGNORE INTO sentences (text) SELECT DISTINCT sentence_ref FROM relations WHERE sentence_ref != ''`)
|
tx.Exec(`INSERT OR IGNORE INTO sentences (text) SELECT DISTINCT sentence_ref FROM relations WHERE sentence_ref != ''`)
|
||||||
tx.Exec(`UPDATE relations SET sentence_id = (SELECT id FROM sentences WHERE text = relations.sentence_ref) WHERE sentence_ref != ''`)
|
tx.Exec(`UPDATE relations SET sentence_id = (SELECT id FROM sentences WHERE text = relations.sentence_ref) WHERE sentence_ref != ''`)
|
||||||
@ -639,10 +660,25 @@ func (g *GraphDB) Recall(keywords []string, seedEntities []string, depth int, se
|
|||||||
entityIDs := make(map[int64]bool)
|
entityIDs := make(map[int64]bool)
|
||||||
|
|
||||||
for _, kw := range keywords {
|
for _, kw := range keywords {
|
||||||
|
// 相关度排序 + 限额。
|
||||||
|
//
|
||||||
|
// 此前这里既没有 ORDER BY 也没有 LIMIT:拿回来的顺序就是建表顺序
|
||||||
|
// (rowid 升序),于是注入进 prompt 的"前 5 个实体"是**最早创建的**,
|
||||||
|
// 越新越准的记忆越排后面被截掉(实测:输入「QQ回复格式」命中 148 个,
|
||||||
|
// 规则实体排第 32,前 5 里根本没有它)。
|
||||||
|
//
|
||||||
|
// 相关度分三层:完全相等 > 前缀命中 > 包含命中;同层按提及次数、
|
||||||
|
// 再按名字长度(短名更可能是实体本身而不是长描述)。
|
||||||
rows, err := g.db.Query(
|
rows, err := g.db.Query(
|
||||||
`SELECT id, name, type, mention_count, created_at, updated_at
|
`SELECT id, name, type, mention_count, created_at, updated_at
|
||||||
FROM entities WHERE LOWER(name) LIKE ?`,
|
FROM entities WHERE LOWER(name) LIKE ?
|
||||||
"%"+kw+"%",
|
ORDER BY CASE
|
||||||
|
WHEN LOWER(name) = LOWER(?) THEN 0
|
||||||
|
WHEN LOWER(name) LIKE LOWER(?) || '%' THEN 1
|
||||||
|
ELSE 2 END,
|
||||||
|
mention_count DESC, LENGTH(name) ASC
|
||||||
|
LIMIT ?`,
|
||||||
|
"%"+kw+"%", kw, kw, maxKeywordEntities,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@ -717,6 +753,11 @@ func (g *GraphDB) Recall(keywords []string, seedEntities []string, depth int, se
|
|||||||
query += " AND r.session_id = ?"
|
query += " AND r.session_id = ?"
|
||||||
allIDs = append(allIDs, sessionFilter)
|
allIDs = append(allIDs, sessionFilter)
|
||||||
}
|
}
|
||||||
|
// 每层限额:热实体("文档"这类)的邻接可能是上千条,无上限时每层都
|
||||||
|
// 整片读进内存,而调用方(memory_recall 注入 10 条、自动注入只要实体名)
|
||||||
|
// 根本用不到。按置信度取最相关的一批。
|
||||||
|
query += " ORDER BY r.confidence DESC, r.updated_at DESC LIMIT ?"
|
||||||
|
allIDs = append(allIDs, maxAdjacentRelations)
|
||||||
|
|
||||||
relRows, err := g.db.Query(query, allIDs...)
|
relRows, err := g.db.Query(query, allIDs...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -901,10 +942,13 @@ func (g *GraphDB) Purge(criteria map[string]string, mode string) (int, error) {
|
|||||||
}
|
}
|
||||||
n, _ := result.RowsAffected()
|
n, _ := result.RowsAffected()
|
||||||
|
|
||||||
g.db.Exec(`DELETE FROM entities WHERE id NOT IN (
|
// 这里**不再**顺手全局删孤儿实体。
|
||||||
SELECT DISTINCT source_id FROM relations
|
//
|
||||||
UNION SELECT DISTINCT target_id FROM relations)`)
|
// 原来那句 `DELETE FROM entities WHERE id NOT IN (relations 两端)` 是与
|
||||||
|
// 调用方意图无关的全局副作用:memory_edit 只想去掉一条关系,却可能把
|
||||||
|
// 图里其它孤零零的实体一并清掉。孤儿清理交给 PurgeOrphans
|
||||||
|
// (显式、可 dry-run、有计数与审计),一次改动只做一件事。
|
||||||
|
//
|
||||||
// 关系没了,它的场景引用必须跟着对齐:残留引用会让场景看着很大、
|
// 关系没了,它的场景引用必须跟着对齐:残留引用会让场景看着很大、
|
||||||
// 召回却是空的(SceneStats 也跟着说谎)。
|
// 召回却是空的(SceneStats 也跟着说谎)。
|
||||||
g.purgeStaleSceneRefsLocked()
|
g.purgeStaleSceneRefsLocked()
|
||||||
|
|||||||
@ -3,8 +3,10 @@ package memory
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/vector"
|
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/vector"
|
||||||
)
|
)
|
||||||
@ -19,8 +21,21 @@ type Indexer struct {
|
|||||||
|
|
||||||
// recalledOrder 记录 recalled 的插入顺序,用于超限时按 FIFO 淘汰。
|
// recalledOrder 记录 recalled 的插入顺序,用于超限时按 FIFO 淘汰。
|
||||||
recalledOrder []string
|
recalledOrder []string
|
||||||
|
|
||||||
|
// 增量同步状态:运行中新增的实体此前要等下一个归档心跳(Indexer.Sync)
|
||||||
|
// 才进向量索引,在那之前只能靠关键词路命中——"刚记住的东西过一会儿才想得
|
||||||
|
// 起来"就是这么来的。这里记下上次同步的实体数,召回前发现数量变了就补一次。
|
||||||
|
lastSyncCount int
|
||||||
|
lastSyncAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// retrainInterval 是增量重训的最小间隔。
|
||||||
|
//
|
||||||
|
// 为什么不每次都重训:TF-IDF 向量器是**全局**重训(词表与 idf 都变),
|
||||||
|
// 一次是 O(实体数)。没有下限时,密集写入的场景下每轮召回都触发一次全量重训,
|
||||||
|
// 把一个读操作变成写放大的热点。心跳(30min)+ 这个下限,够快也够稳。
|
||||||
|
const retrainInterval = 30 * time.Second
|
||||||
|
|
||||||
// maxRecalledEntities 是「已召回实体」去重集的上限。
|
// maxRecalledEntities 是「已召回实体」去重集的上限。
|
||||||
//
|
//
|
||||||
// 无上限时它只增不减:进程活得越久,被永久跳过的实体越多,自动注入
|
// 无上限时它只增不减:进程活得越久,被永久跳过的实体越多,自动注入
|
||||||
@ -76,6 +91,10 @@ func (idx *Indexer) Sync() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if len(names) == 0 {
|
if len(names) == 0 {
|
||||||
|
// 没有实体也是一次成功的同步:记下基线,否则 syncIfStale 会在
|
||||||
|
// 「基线还停在旧值」与「本轮无实体」之间反复误判、每 30s 重跑一次。
|
||||||
|
idx.lastSyncCount = 0
|
||||||
|
idx.lastSyncAt = time.Now()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -93,10 +112,49 @@ func (idx *Indexer) Sync() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
idx.trained = true
|
idx.trained = true
|
||||||
|
idx.lastSyncCount = len(names)
|
||||||
|
idx.lastSyncAt = time.Now()
|
||||||
log.Printf("[indexer] synced %d entities to vector index", len(names))
|
log.Printf("[indexer] synced %d entities to vector index", len(names))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// syncIfStale 在实体数发生变化时补一次重建(调用方必须**不持锁**)。
|
||||||
|
//
|
||||||
|
// 返回是否发生了重建。计数查询走 idx_count 索引,成本可忽略;
|
||||||
|
// 只有真的变了、且离上次同步超过 retrainInterval 才重训。
|
||||||
|
func (idx *Indexer) syncIfStale() bool {
|
||||||
|
if idx.db == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
var count int
|
||||||
|
if err := idx.db.db.QueryRow(`SELECT COUNT(*) FROM entities`).Scan(&count); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// 基线口径必须与 Sync 一致:Sync 走的是「无关键词全量召回」,实体数被
|
||||||
|
// maxFullRecallEntities 封顶。直接拿 COUNT(*) 比会在实体数超过上限的大图上
|
||||||
|
// 永远不相等——每 30s 全量重训一次,把一条读路径变成写放大热点。
|
||||||
|
expected := count
|
||||||
|
if expected > maxFullRecallEntities {
|
||||||
|
expected = maxFullRecallEntities
|
||||||
|
}
|
||||||
|
|
||||||
|
idx.mu.RLock()
|
||||||
|
known, last := idx.lastSyncCount, idx.lastSyncAt
|
||||||
|
idx.mu.RUnlock()
|
||||||
|
if expected == known {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if !last.IsZero() && time.Since(last) < retrainInterval {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if err := idx.Sync(); err != nil {
|
||||||
|
log.Printf("[indexer] 增量同步失败(沿用旧索引): %v", err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
log.Printf("[indexer] 实体数 %d→%d,已增量重建实体名向量索引", known, count)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
type InjectedContext struct {
|
type InjectedContext struct {
|
||||||
Entities []Entity `json:"entities"`
|
Entities []Entity `json:"entities"`
|
||||||
Relations []Relation `json:"relations"`
|
Relations []Relation `json:"relations"`
|
||||||
@ -130,6 +188,9 @@ func (idx *Indexer) BuildContextInScene(userInput string, scenes []string) *Inje
|
|||||||
|
|
||||||
input := CleanText(userInput)
|
input := CleanText(userInput)
|
||||||
|
|
||||||
|
// 0. 索引保鲜:运行中新增的实体不该等到下一个心跳才可被召回
|
||||||
|
idx.syncIfStale()
|
||||||
|
|
||||||
// 1. 向量搜索:从实体名向量索引中找到相关实体
|
// 1. 向量搜索:从实体名向量索引中找到相关实体
|
||||||
vectorEntities := idx.vectorSearchEntities(input)
|
vectorEntities := idx.vectorSearchEntities(input)
|
||||||
|
|
||||||
@ -444,16 +505,30 @@ func buildIndexSummary(entities []Entity) string {
|
|||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
b.WriteString(fmt.Sprintf("关联 %d 个记忆实体", len(entities)))
|
b.WriteString(fmt.Sprintf("关联 %d 个记忆实体", len(entities)))
|
||||||
|
|
||||||
|
// 「高频」必须真的按提及次数排。
|
||||||
|
//
|
||||||
|
// 此前取的是 entities[0..2],也就是 Recall 的返回顺序(旧实现下等于建表
|
||||||
|
// 顺序),却写着"高频"两个字:给模型的暗示是"这几条最重要",实际是
|
||||||
|
// "这几条建得最早"。名不副实的标签比没有标签更坏——它会稳定地误导。
|
||||||
|
byMentions := make([]Entity, len(entities))
|
||||||
|
copy(byMentions, entities)
|
||||||
|
sort.SliceStable(byMentions, func(i, j int) bool {
|
||||||
|
if byMentions[i].MentionCount != byMentions[j].MentionCount {
|
||||||
|
return byMentions[i].MentionCount > byMentions[j].MentionCount
|
||||||
|
}
|
||||||
|
return byMentions[i].Name < byMentions[j].Name
|
||||||
|
})
|
||||||
|
|
||||||
topN := 3
|
topN := 3
|
||||||
if len(entities) < topN {
|
if len(byMentions) < topN {
|
||||||
topN = len(entities)
|
topN = len(byMentions)
|
||||||
}
|
}
|
||||||
b.WriteString(",高频:")
|
b.WriteString(",高频:")
|
||||||
for i := 0; i < topN; i++ {
|
for i := 0; i < topN; i++ {
|
||||||
if i > 0 {
|
if i > 0 {
|
||||||
b.WriteString("、")
|
b.WriteString("、")
|
||||||
}
|
}
|
||||||
b.WriteString(entities[i].Name)
|
b.WriteString(byMentions[i].Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
return b.String()
|
return b.String()
|
||||||
|
|||||||
@ -3,6 +3,7 @@ package memory
|
|||||||
import (
|
import (
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestNewIndexer(t *testing.T) {
|
func TestNewIndexer(t *testing.T) {
|
||||||
@ -48,6 +49,52 @@ func TestIndexerSync(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestSyncIfStaleBaseline 钉住增量同步的基线口径:
|
||||||
|
// - 实体数未变 → 不重训
|
||||||
|
// - 实体数变了但不足 retrainInterval → 先不重训(避免密集写入时写放大)
|
||||||
|
// - 实体数变了且间隔已过 → 重训,并把基线追到新值
|
||||||
|
func TestSyncIfStaleBaseline(t *testing.T) {
|
||||||
|
db, err := NewGraphDB(filepath.Join(t.TempDir(), "test.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
if _, _, err := db.Commit([]Triple{
|
||||||
|
{Subject: "张三", Relation: "喜欢", Object: "篮球"},
|
||||||
|
}, "test", 0); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
idx := NewIndexer(db)
|
||||||
|
if !idx.syncIfStale() {
|
||||||
|
t.Fatal("首次应建立索引")
|
||||||
|
}
|
||||||
|
if idx.syncIfStale() {
|
||||||
|
t.Error("实体数未变不该重训")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, _, err := db.Commit([]Triple{
|
||||||
|
{Subject: "李四", Relation: "喜欢", Object: "足球"},
|
||||||
|
}, "test", 0); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if idx.syncIfStale() {
|
||||||
|
t.Error("retrainInterval 内不该重训(避免写放大)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 把上次同步时刻推老,计数变化才该触发重训
|
||||||
|
idx.mu.Lock()
|
||||||
|
idx.lastSyncAt = time.Now().Add(-2 * retrainInterval)
|
||||||
|
idx.mu.Unlock()
|
||||||
|
if !idx.syncIfStale() {
|
||||||
|
t.Error("计数变化且间隔已过应重训")
|
||||||
|
}
|
||||||
|
if idx.syncIfStale() {
|
||||||
|
t.Error("重训后基线应追上,不该再重训")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestIndexerBuildContext(t *testing.T) {
|
func TestIndexerBuildContext(t *testing.T) {
|
||||||
db, err := NewGraphDB(filepath.Join(t.TempDir(), "test.db"))
|
db, err := NewGraphDB(filepath.Join(t.TempDir(), "test.db"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@ -198,7 +198,7 @@ func tagSceneRefTx(tx *sql.Tx, sceneKey, kind string, id int64, textID string, w
|
|||||||
if _, err := tx.Exec(
|
if _, err := tx.Exec(
|
||||||
`INSERT INTO scene_refs (scene_id, kind, ref_id, ref_text, weight) VALUES (?, ?, ?, ?, ?)
|
`INSERT INTO scene_refs (scene_id, kind, ref_id, ref_text, weight) VALUES (?, ?, ?, ?, ?)
|
||||||
ON CONFLICT(scene_id, kind, ref_id, ref_text)
|
ON CONFLICT(scene_id, kind, ref_id, ref_text)
|
||||||
DO UPDATE SET weight = MAX(weight, excluded.weight)`,
|
DO UPDATE SET weight = MAX(weight, excluded.weight), decayed_at = CURRENT_TIMESTAMP`,
|
||||||
sceneID, kind, id, textID, weight); err != nil {
|
sceneID, kind, id, textID, weight); err != nil {
|
||||||
return fmt.Errorf("upsert scene ref %s/%d%s: %w", kind, id, textID, err)
|
return fmt.Errorf("upsert scene ref %s/%d%s: %w", kind, id, textID, err)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -80,7 +80,10 @@ func (f SituationFeature) Weight() float64 {
|
|||||||
switch NormalizeSceneKey(f.Kind) {
|
switch NormalizeSceneKey(f.Kind) {
|
||||||
case "chan":
|
case "chan":
|
||||||
return wFeatChan
|
return wFeatChan
|
||||||
case "peer":
|
case "peer", "peer_group":
|
||||||
|
// 群与私聊都是「对话对象」这一维:都是最强的同一性信号。
|
||||||
|
// 分开 kind 是为了让 `peer:group_1` 与 `peer:user_1` 不互相命中,
|
||||||
|
// 不是让群身份降级成软信号(漏掉这里它就只剩 topic 权重 0.4)。
|
||||||
return wFeatPeer
|
return wFeatPeer
|
||||||
case "tool":
|
case "tool":
|
||||||
return wFeatTool
|
return wFeatTool
|
||||||
@ -387,7 +390,9 @@ func (g *GraphDB) recordSituationEvidenceLocked(sig Situation) (int, error) {
|
|||||||
|
|
||||||
// RecallBySituation 按**场面相似**取回记忆:不是键相等,而是「像不像同一个场面」。
|
// RecallBySituation 按**场面相似**取回记忆:不是键相等,而是「像不像同一个场面」。
|
||||||
//
|
//
|
||||||
// 命中多个场景时按相似度 × 权重合并,跨场景去重(同一关系只出现一次)。
|
// 命中多个场景时取并集,跨场景去重(同一关系只出现一次);命中的场景先按
|
||||||
|
// 相似度排序再交给 RecallByScene。最终顺序以**场景内引用权重**(写入时的
|
||||||
|
// 置信度)为准,相似度只决定哪些场景参与、不参与每条关系的排序。
|
||||||
// 这正是「类似的场景自动唤起对应的记忆」那一下。
|
// 这正是「类似的场景自动唤起对应的记忆」那一下。
|
||||||
func (g *GraphDB) RecallBySituation(sig Situation, limit int) (*SceneRecall, error) {
|
func (g *GraphDB) RecallBySituation(sig Situation, limit int) (*SceneRecall, error) {
|
||||||
if sig.Empty() {
|
if sig.Empty() {
|
||||||
@ -427,7 +432,7 @@ func (g *GraphDB) RecallBySituation(sig Situation, limit int) (*SceneRecall, err
|
|||||||
for _, h := range hits {
|
for _, h := range hits {
|
||||||
keys = append(keys, h.key)
|
keys = append(keys, h.key)
|
||||||
}
|
}
|
||||||
// 复用按场景键的取回逻辑(前缀语义 + weight 排序),再按相似度加权重排
|
// 复用按场景键的取回逻辑(前缀语义 + weight 排序)
|
||||||
out, err := g.RecallByScene(keys, limit)
|
out, err := g.RecallByScene(keys, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@ -440,6 +445,11 @@ func (g *GraphDB) RecallBySituation(sig Situation, limit int) (*SceneRecall, err
|
|||||||
// 人的记忆是靠「用进废退」维持秩序的:不做衰减,一次性的巧合关联会
|
// 人的记忆是靠「用进废退」维持秩序的:不做衰减,一次性的巧合关联会
|
||||||
// 永远留在场景里,每次路过都被注入,越攒越多直到注入预算被吃光。
|
// 永远留在场景里,每次路过都被注入,越攒越多直到注入预算被吃光。
|
||||||
// 权重按半衰期折半;低于 floor 的引用直接删除(关联已无信息量)。
|
// 权重按半衰期折半;低于 floor 的引用直接删除(关联已无信息量)。
|
||||||
|
//
|
||||||
|
// 关键在「按半衰期」:每个引用**至多每 halfLife 衰减一次**,计时起点记在
|
||||||
|
// scene_refs.decayed_at 上。只按 created_at 判龄会在每次心跳都把老引用对半
|
||||||
|
// 砍——archive 心跳默认 60 分钟、halfLife 传 30 天,于是 30 天前的关联会在
|
||||||
|
// 几小时内被砍到 floor 以下清空。那不是半衰期,是骤死。
|
||||||
func (g *GraphDB) DecaySceneRefs(halfLife time.Duration, floor float64) (int, error) {
|
func (g *GraphDB) DecaySceneRefs(halfLife time.Duration, floor float64) (int, error) {
|
||||||
if halfLife <= 0 {
|
if halfLife <= 0 {
|
||||||
return 0, nil
|
return 0, nil
|
||||||
@ -449,12 +459,15 @@ func (g *GraphDB) DecaySceneRefs(halfLife time.Duration, floor float64) (int, er
|
|||||||
}
|
}
|
||||||
g.mu.Lock()
|
g.mu.Lock()
|
||||||
defer g.mu.Unlock()
|
defer g.mu.Unlock()
|
||||||
cut := time.Now().Add(-halfLife).Format("2006-01-02 15:04:05")
|
// 时间基准必须与 CURRENT_TIMESTAMP 一致(SQLite 用 UTC):如果在 Go 侧用
|
||||||
|
// 本地时间拼字符串比较,东八区会凭空多出 8 小时的“年龄”,刚刷新的
|
||||||
|
// decayed_at 会被判定为还没到点。这里交给 SQLite 的 datetime('now', …)。
|
||||||
|
mod := fmt.Sprintf("-%d seconds", int(halfLife.Seconds()))
|
||||||
if _, err := g.db.Exec(
|
if _, err := g.db.Exec(
|
||||||
`UPDATE scene_refs SET weight = weight * 0.5
|
`UPDATE scene_refs SET weight = weight * 0.5, decayed_at = CURRENT_TIMESTAMP
|
||||||
WHERE created_at < ? AND id NOT IN (
|
WHERE decayed_at < datetime('now', ?) AND id NOT IN (
|
||||||
SELECT sr.id FROM scene_refs sr JOIN scenes s ON sr.scene_id = s.id
|
SELECT sr.id FROM scene_refs sr JOIN scenes s ON sr.scene_id = s.id
|
||||||
WHERE s.updated_at >= ?)`, cut, cut); err != nil {
|
WHERE s.updated_at >= datetime('now', ?))`, mod, mod); err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
res, err := g.db.Exec(`DELETE FROM scene_refs WHERE weight < ?`, floor)
|
res, err := g.db.Exec(`DELETE FROM scene_refs WHERE weight < ?`, floor)
|
||||||
@ -465,31 +478,6 @@ func (g *GraphDB) DecaySceneRefs(halfLife time.Duration, floor float64) (int, er
|
|||||||
return int(n), nil
|
return int(n), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// EmergentScenes 列出当前长出来的场景(按强度降序),供观察「涌现」是否在发生。
|
|
||||||
func (g *GraphDB) EmergentScenes() ([]SceneStat, error) {
|
|
||||||
g.mu.RLock()
|
|
||||||
defer g.mu.RUnlock()
|
|
||||||
rows, err := g.db.Query(
|
|
||||||
`SELECT s.key, COALESCE(s.strength,1),
|
|
||||||
(SELECT COUNT(*) FROM scene_refs sr WHERE sr.scene_id = s.id),
|
|
||||||
(SELECT COUNT(*) FROM scene_features f WHERE f.scene_id = s.id),
|
|
||||||
COALESCE(s.origin, 'emergent'), s.updated_at
|
|
||||||
FROM scenes s ORDER BY COALESCE(s.strength,1) DESC, s.updated_at DESC`)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
var out []SceneStat
|
|
||||||
for rows.Next() {
|
|
||||||
var st SceneStat
|
|
||||||
if err := rows.Scan(&st.Key, &st.Strength, &st.Refs, &st.Features, &st.Origin, &st.UpdatedAt); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
out = append(out, st)
|
|
||||||
}
|
|
||||||
return out, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
// TurnScene 是一轮交互解析出来的场景集合。
|
// TurnScene 是一轮交互解析出来的场景集合。
|
||||||
type TurnScene struct {
|
type TurnScene struct {
|
||||||
// Primary 是本轮写记忆时的**首选**场景:优先用涌现出来的(细粒度、
|
// Primary 是本轮写记忆时的**首选**场景:优先用涌现出来的(细粒度、
|
||||||
@ -505,10 +493,10 @@ type TurnScene struct {
|
|||||||
|
|
||||||
// EnterSceneWithHint 同时走**主动声明**与**被动涌现**两条路。
|
// EnterSceneWithHint 同时走**主动声明**与**被动涌现**两条路。
|
||||||
//
|
//
|
||||||
// 主动路(declaredKeys 非空):确保这些场景存在,并把本轮的场面指纹喂给它,
|
// 主动路(declaredKeys 非空):确保这些场景存在(不存在即建,不等第二次涌现
|
||||||
// 强度 +1。这样声明出来的场景会**慢慢学会自己认自己**——同一个场面以后
|
// ——人明确说了"这是哪个场面",就不该再等它自己涌现),强度 +1,并给它记下
|
||||||
// 即使没人声明,也能被 RecallBySituation 按相似度命中。
|
// 从**键自身**解析出的特征。声明路刻意**不吸收本轮整场指纹**:一旦吸收,它
|
||||||
// 声明即建场景,不等第二次:人明确说了"这是哪个场面",就不该再等它自己涌现。
|
// 会在相似度上压过一切,被动聚类再也长不出更细的场面(见 EnsureScene)。
|
||||||
//
|
//
|
||||||
// 被动路(始终执行):EnterScene 的聚类,指纹重复到 minSceneEvidence 次时
|
// 被动路(始终执行):EnterScene 的聚类,指纹重复到 minSceneEvidence 次时
|
||||||
// 自己长出场景。首次交互这里返回空,此时 Primary 落到声明场景兜底——
|
// 自己长出场景。首次交互这里返回空,此时 Primary 落到声明场景兜底——
|
||||||
@ -524,7 +512,7 @@ func (g *GraphDB) EnterSceneWithHint(sig Situation, declaredKeys []string) (Turn
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
seen[key] = true
|
seen[key] = true
|
||||||
learned, err := g.EnsureScene(key, sig)
|
learned, err := g.EnsureScene(key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return out, err
|
return out, err
|
||||||
}
|
}
|
||||||
@ -538,8 +526,11 @@ func (g *GraphDB) EnterSceneWithHint(sig Situation, declaredKeys []string) (Turn
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 被动路:指纹聚类(可能返回已聚合的场景、也可能首次为空)
|
// 被动路:指纹聚类(可能返回已聚合的场景、也可能首次为空)
|
||||||
|
//
|
||||||
|
// EnterScene 只在真的命中/新建时返回非空键;返回空键时 created 必为
|
||||||
|
// false(首次只登记足迹),所以这里无需再判 created。
|
||||||
if !sig.Empty() {
|
if !sig.Empty() {
|
||||||
key, created, err := g.EnterScene(sig)
|
key, _, err := g.EnterScene(sig)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return out, err
|
return out, err
|
||||||
}
|
}
|
||||||
@ -549,8 +540,6 @@ func (g *GraphDB) EnterSceneWithHint(sig Situation, declaredKeys []string) (Turn
|
|||||||
}
|
}
|
||||||
out.Primary = key
|
out.Primary = key
|
||||||
out.Emergent = true
|
out.Emergent = true
|
||||||
} else if created {
|
|
||||||
out.Emergent = true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
@ -562,7 +551,7 @@ func (g *GraphDB) EnterSceneWithHint(sig Situation, declaredKeys []string) (Turn
|
|||||||
// 不吸收本轮的整轮指纹。这条边界很关键:声明场景若吸收整轮指纹,它会在相似度
|
// 不吸收本轮的整轮指纹。这条边界很关键:声明场景若吸收整轮指纹,它会在相似度
|
||||||
// 上压过一切,被动聚类再也长不出更细的场面(实测过,见 loadEmergentScenesLocked)。
|
// 上压过一切,被动聚类再也长不出更细的场面(实测过,见 loadEmergentScenesLocked)。
|
||||||
// 声明路的泛化靠层级键前缀,不需要靠学指纹。
|
// 声明路的泛化靠层级键前缀,不需要靠学指纹。
|
||||||
func (g *GraphDB) EnsureScene(key string, _ Situation) (bool, error) {
|
func (g *GraphDB) EnsureScene(key string) (bool, error) {
|
||||||
key = NormalizeSceneKey(key)
|
key = NormalizeSceneKey(key)
|
||||||
if key == "" {
|
if key == "" {
|
||||||
return false, nil
|
return false, nil
|
||||||
|
|||||||
@ -65,9 +65,9 @@ func TestSceneEmergesFromRepetition(t *testing.T) {
|
|||||||
t.Fatalf("webui 场面应自己长出独立场景: key=%q created=%v", k2, c2)
|
t.Fatalf("webui 场面应自己长出独立场景: key=%q created=%v", k2, c2)
|
||||||
}
|
}
|
||||||
|
|
||||||
scenes, err := g.EmergentScenes()
|
scenes, err := g.SceneStats()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("EmergentScenes: %v", err)
|
t.Fatalf("SceneStats: %v", err)
|
||||||
}
|
}
|
||||||
if len(scenes) != 2 {
|
if len(scenes) != 2 {
|
||||||
t.Fatalf("应长出 2 个场景,得到 %d: %+v", len(scenes), scenes)
|
t.Fatalf("应长出 2 个场景,得到 %d: %+v", len(scenes), scenes)
|
||||||
@ -132,6 +132,9 @@ func TestSceneRecallsBySituationNotWording(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TestSceneRefDecay 钉住「用进废退」:久未重现的关联会淡出并被清掉。
|
// TestSceneRefDecay 钉住「用进废退」:久未重现的关联会淡出并被清掉。
|
||||||
|
//
|
||||||
|
// 半衰期的语义是「每个引用至多每 halfLife 衰减一次」:衰减计时起点在
|
||||||
|
// scene_refs.decayed_at 上,同一半衰期内重复跑心跳不会再砍。
|
||||||
func TestSceneRefDecay(t *testing.T) {
|
func TestSceneRefDecay(t *testing.T) {
|
||||||
g := newTestGraph(t)
|
g := newTestGraph(t)
|
||||||
defer os.Remove(g.dbPath)
|
defer os.Remove(g.dbPath)
|
||||||
@ -147,13 +150,13 @@ func TestSceneRefDecay(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 把时间推旧:模拟长期未重现
|
// 把时间推旧:模拟长期未重现(上次衰减也在同一时刻)。用 SQLite 的
|
||||||
if _, err := g.db.Exec(`UPDATE scene_refs SET created_at = ?`,
|
// datetime('now') 与 CURRENT_TIMESTAMP 同一时间基准(UTC)。
|
||||||
time.Now().Add(-48*time.Hour).Format("2006-01-02 15:04:05")); err != nil {
|
if _, err := g.db.Exec(`UPDATE scene_refs
|
||||||
|
SET created_at = datetime('now','-48 hours'), decayed_at = datetime('now','-48 hours')`); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if _, err := g.db.Exec(`UPDATE scenes SET updated_at = ?`,
|
if _, err := g.db.Exec(`UPDATE scenes SET updated_at = datetime('now','-48 hours')`); err != nil {
|
||||||
time.Now().Add(-48*time.Hour).Format("2006-01-02 15:04:05")); err != nil {
|
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -169,9 +172,21 @@ func TestSceneRefDecay(t *testing.T) {
|
|||||||
t.Errorf("一个半衰期后权重应减半: %v → %v", before, after)
|
t.Errorf("一个半衰期后权重应减半: %v → %v", before, after)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 再推旧一次:第二个半衰期后低于 floor,关联已无信息量,清掉
|
// 同一半衰期内再跑:decayed_at 已刷新,不该再砍一次
|
||||||
if _, err := g.db.Exec(`UPDATE scene_refs SET created_at = ?`,
|
// (否则心跳频率就成了实际半衰期,30 天的关联几小时就被清空)
|
||||||
time.Now().Add(-48*time.Hour).Format("2006-01-02 15:04:05")); err != nil {
|
if _, err := g.DecaySceneRefs(time.Hour, 0.4); err != nil {
|
||||||
|
t.Fatalf("DecaySceneRefs: %v", err)
|
||||||
|
}
|
||||||
|
var same float64
|
||||||
|
if err := g.db.QueryRow(`SELECT weight FROM scene_refs LIMIT 1`).Scan(&same); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if same != after {
|
||||||
|
t.Errorf("同一半衰期内不该重复衰减: %v → %v", after, same)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 进入第二个半衰期:再推旧 decayed_at,权重低于 floor,清掉
|
||||||
|
if _, err := g.db.Exec(`UPDATE scene_refs SET decayed_at = datetime('now','-48 hours')`); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
n, err := g.DecaySceneRefs(time.Hour, 0.4)
|
n, err := g.DecaySceneRefs(time.Hour, 0.4)
|
||||||
@ -204,9 +219,8 @@ func TestSceneRefDecay(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TestDeclaredAndEmergentBothLearn 钉住「主动 + 被动两条路」的相互长进:
|
// TestDeclaredAndEmergentBothLearn 钉住「主动 + 被动两条路」的相互长进:
|
||||||
// - 主动:声明即建场景(不等第二次涌现),并把指纹喂给它
|
// - 主动:声明即建场景(不等第二次涌现),特征只从键自身解析
|
||||||
// - 被动:指纹聚类自己长出场景;声明场景学会特征后**即使没人再声明**
|
// - 被动:指纹聚类自己长出场景;声明场景**不**进相似度空间,靠声明/前缀键取回
|
||||||
// 也能被相似度命中
|
|
||||||
// - 第一次交互(涌现场景还没长出来)由声明场景兜底
|
// - 第一次交互(涌现场景还没长出来)由声明场景兜底
|
||||||
func TestDeclaredAndEmergentBothLearn(t *testing.T) {
|
func TestDeclaredAndEmergentBothLearn(t *testing.T) {
|
||||||
g := newTestGraph(t)
|
g := newTestGraph(t)
|
||||||
@ -298,7 +312,7 @@ func TestDeclaredAndEmergentBothLearn(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 声明场景不该被覆盖成涌现键:两者的身份各自保留
|
// 声明场景不该被覆盖成涌现键:两者的身份各自保留
|
||||||
if _, err := g.EnsureScene("chan:qq", Situation{}); err != nil {
|
if _, err := g.EnsureScene("chan:qq"); err != nil {
|
||||||
t.Fatalf("EnsureScene: %v", err)
|
t.Fatalf("EnsureScene: %v", err)
|
||||||
}
|
}
|
||||||
var n int
|
var n int
|
||||||
@ -310,6 +324,18 @@ func TestDeclaredAndEmergentBothLearn(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestPeerGroupWeight 钉住群身份是**强**同一性信号:peer_group 与 peer 同为
|
||||||
|
// wFeatPeer。漏掉 peer_group 会让它在 Weight 里落到 default(话题级 0.4),
|
||||||
|
// 群聊场面被降级成软信号。
|
||||||
|
func TestPeerGroupWeight(t *testing.T) {
|
||||||
|
if got := (SituationFeature{Kind: "peer_group", Value: "group_1"}).Weight(); got != wFeatPeer {
|
||||||
|
t.Errorf("peer_group 权重应为 %v,得到 %v", wFeatPeer, got)
|
||||||
|
}
|
||||||
|
if got := (SituationFeature{Kind: "peer", Value: "user_1"}).Weight(); got != wFeatPeer {
|
||||||
|
t.Errorf("peer 权重应为 %v,得到 %v", wFeatPeer, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestEffectiveScenes 覆盖「单值声明 + 多值」合并去重。
|
// TestEffectiveScenes 覆盖「单值声明 + 多值」合并去重。
|
||||||
func TestEffectiveScenes(t *testing.T) {
|
func TestEffectiveScenes(t *testing.T) {
|
||||||
got := effectiveScenes(Triple{Scene: "chan:qq", Scenes: []string{"auto:a", "chan:qq", ""}})
|
got := effectiveScenes(Triple{Scene: "chan:qq", Scenes: []string{"auto:a", "chan:qq", ""}})
|
||||||
|
|||||||
Reference in New Issue
Block a user