mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-21 01:18:08 +00:00
fix(memory): 修 beta.2 压测发现的三个向量/文档缺陷
来源:v1.2.0-beta.2 全方位压测(报告 /var/tmp/stress/REPORT.md)
1. 文档向量迁移结果不落盘(生产已复现)
- BuildDenseIndex 改完内存不置 dirty;docStore.Stop() 全仓无调用者 → flush 成死代码
- 后果:每次启动重算同一批文档(线上 496 篇约 17s),磁盘 dense_fp 永不收敛
- 修:迁移当场落盘(抽出 flushLocked 以免重入锁)+ main.go 关停链 defer docStore.Stop()
- 生产证据:线上 488 篇文档仅 4 篇含 dense_fp,且这 4 篇均为运行期 Insert 的新文档
2. 块指纹对但维度错时污染文档向量(健壮性缺口)
- denseFor 只校验 b.Fingerprint,不校验长度;FuseVectors 取最大维度并跳过长度不符者
→ 512 维文本 + 2048 维块 = 2048 维且打上当前指纹
→ 该文档在检索侧被长度守卫永久跳过,且每次启动重算(不收敛)
- 修:denseFor 要求 len(b.Vector) == 空间维度
- 可达性:内核两个块产出点均成对取自同一行(it.Vec ↔ it.VecModel),故属防御性修复
3. Insert 与 loadAll 的 ID 约定不对称(低)
- Insert 落盘任意 <id>.json,loadAll 只加载 doc_ 前缀 → 自定义 ID 文档重启后静默消失
- 修:loadAll 只要求 .json(空 ID 仍跳过)
回归测试 4 条:迁移跨重启落盘 + 已对齐 0 重算(用向量空间调用计数判定,
不靠日志)、坏块不参与融合且同维度正常块仍参与、自定义 ID 可加载、Stop 落盘。
反向验证(纪律要求):临时回退本次修复后,前 3 条均变红且报错正是缺陷签名
(dim=999/space-OLD-999 永不收敛、文档向量 2048 维、文件重启后消失);恢复后全绿。
验证:go build ./cmd/homed ok;go vet ./internal/memory/document ./cmd/homed ok;
go test ./internal/memory/... 7 包全绿。
This commit is contained in:
@ -336,6 +336,10 @@ func main() {
|
||||
if err := docStore.Start(); err != nil {
|
||||
log.Printf("[homed] warning: document store: %v", err)
|
||||
}
|
||||
// 关停时落盘。文档记忆的内存态变更(迁移结果、访问计数等)只在 flush
|
||||
// 里写盘,而 flush 的唯一入口是 Stop()——此前全仓无人调用它,
|
||||
// 于是迁移结果永不落盘、每次启动白算一遍。
|
||||
defer docStore.Stop()
|
||||
|
||||
// 媒体存储(内容寻址):记忆块的内容后端。
|
||||
// 开关默认开;关闭后全部媒体接线静默跳过,对话行为与本特性上线前一致。
|
||||
|
||||
@ -135,6 +135,14 @@ func (s *Store) BuildDenseIndex(ds vector.MultimodalEmbedder) {
|
||||
doc.DenseFP = ds.Fingerprint()
|
||||
count++
|
||||
}
|
||||
if count > 0 {
|
||||
// 迁移结果必须落盘。否则磁盘上的 DenseFP 永远对不上当前空间,
|
||||
// 判定条件永远成立:每次启动都重算同一批文档,磁盘状态永不收敛。
|
||||
// 迁移是一次性的昂贵操作(实测 200 篇约 7s),所以当场写盘,
|
||||
// 而不是只依赖关停时的 flush——被 kill -9 也不会白算。
|
||||
s.dirty = true
|
||||
s.flushLocked()
|
||||
}
|
||||
log.Printf("[document memory] dense index built: %d new vectors", count)
|
||||
}
|
||||
|
||||
@ -148,12 +156,17 @@ func (s *Store) denseFor(doc *Doc) []float64 {
|
||||
return nil
|
||||
}
|
||||
fp := s.denseSpace.Fingerprint()
|
||||
dim := s.denseSpace.Dim()
|
||||
var parts [][]float64
|
||||
if tv, err := s.denseSpace.VectorizeDense(doc.Summary + " " + doc.Content); err == nil && len(tv) > 0 {
|
||||
parts = append(parts, tv)
|
||||
}
|
||||
for _, b := range doc.Blocks {
|
||||
if len(b.Vector) > 0 && b.Fingerprint == fp {
|
||||
// 只比指纹不够:指纹相同但**维度不同**的块会被 FuseVectors 按
|
||||
// 「最大维度」拼成错维度向量(并覆盖掉文本向量),而结果又被
|
||||
// 标上当前指纹——于是该文档在检索侧被长度守卫永久跳过,
|
||||
// 且每次启动都会重算。维度不符的块一律不参与融合。
|
||||
if len(b.Vector) == dim && b.Fingerprint == fp {
|
||||
parts = append(parts, b.Vector)
|
||||
}
|
||||
}
|
||||
@ -513,7 +526,10 @@ func (s *Store) loadAll() error {
|
||||
return err
|
||||
}
|
||||
for _, e := range entries {
|
||||
if !strings.HasSuffix(e.Name(), ".json") || !strings.HasPrefix(e.Name(), "doc_") {
|
||||
// 只要求 .json:Insert 接受任意 ID 并落盘为 <id>.json,若这里再按
|
||||
// doc_ 前缀过滤,传自定义 ID 的文档重启后会静默消失。
|
||||
// 空 ID 仍会被下面的校验跳过。
|
||||
if !strings.HasSuffix(e.Name(), ".json") {
|
||||
continue
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(s.dir, e.Name()))
|
||||
@ -534,6 +550,11 @@ func (s *Store) loadAll() error {
|
||||
func (s *Store) flush() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.flushLocked()
|
||||
}
|
||||
|
||||
// flushLocked 是 flush 的核心,调用方必须已持有 s.mu。
|
||||
func (s *Store) flushLocked() {
|
||||
if !s.dirty {
|
||||
return
|
||||
}
|
||||
|
||||
@ -467,3 +467,188 @@ func TestContextToDocContentPreservesRawToolOutput(t *testing.T) {
|
||||
t.Error("summary should not be empty")
|
||||
}
|
||||
}
|
||||
|
||||
// ———— 回归:向量迁移的落盘与维度一致性 ————
|
||||
//
|
||||
// 以下四条来自 v1.2.0-beta.2 的压测(报告 /var/tmp/stress/REPORT.md):
|
||||
// 迁移结果不落盘(每次启动白算一遍)、块指纹对但维度错时污染文档向量、
|
||||
// 以及 Insert 与 loadAll 的 ID 约定不对称。
|
||||
|
||||
// fakeSpace 是可控的统一向量空间;calls 记录被真正要求算向量的次数,
|
||||
// 用来直接证明「已对齐的文档不再重算」——比读日志断言可靠。
|
||||
type fakeSpace struct {
|
||||
fp string
|
||||
dim int
|
||||
calls int
|
||||
}
|
||||
|
||||
func (f *fakeSpace) VectorizeDense(string) ([]float64, error) {
|
||||
f.calls++
|
||||
v := make([]float64, f.dim)
|
||||
for i := range v {
|
||||
v[i] = float64(i + 1)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
func (f *fakeSpace) EmbedImageDense([]byte, string) ([]float64, error) { return f.VectorizeDense("") }
|
||||
func (f *fakeSpace) Fingerprint() string { return f.fp }
|
||||
func (f *fakeSpace) Dim() int { return f.dim }
|
||||
func (f *fakeSpace) Loaded() bool { return true }
|
||||
func (f *fakeSpace) Close() {}
|
||||
|
||||
// 迁移结果必须落盘:迁移后换一个 Store 实例(模拟重启)读到的应是新空间向量,
|
||||
// 且再跑一次迁移不应重算任何文档。
|
||||
func TestBuildDenseIndexPersistsAcrossRestart(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
sp := &fakeSpace{fp: "space-NEW-8", dim: 8}
|
||||
|
||||
s1 := NewStore(dir, memory.TokenizeWords)
|
||||
if err := s1.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 模拟上个向量空间留下的状态:维度与指纹都对不上
|
||||
stale := make([]float64, 999)
|
||||
for i := range stale {
|
||||
stale[i] = 0.01
|
||||
}
|
||||
if err := s1.Insert(&Doc{ID: "doc_persist", Summary: "迁移", Content: "落盘",
|
||||
DenseVec: stale, DenseFP: "space-OLD-999"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s1.SetDenseSpace(sp)
|
||||
s1.BuildDenseIndex(sp)
|
||||
if got := s1.Get("doc_persist"); got == nil || len(got.DenseVec) != 8 || got.DenseFP != sp.fp {
|
||||
t.Fatalf("迁移未在内存生效: %+v", got)
|
||||
}
|
||||
// 不调用 Stop 就另开一个实例:体现「迁移当场落盘」,不依赖关停
|
||||
s2 := NewStore(dir, memory.TokenizeWords)
|
||||
if err := s2.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s2.Stop()
|
||||
loaded := s2.Get("doc_persist")
|
||||
if loaded == nil {
|
||||
t.Fatal("重启后文档不见了")
|
||||
}
|
||||
if len(loaded.DenseVec) != 8 || loaded.DenseFP != sp.fp {
|
||||
t.Fatalf("迁移结果未落盘:期望 dim=8 fp=%q,实际 dim=%d fp=%q"+
|
||||
"(后果:每次启动都重算同一批文档,磁盘状态永不收敛)",
|
||||
sp.fp, len(loaded.DenseVec), loaded.DenseFP)
|
||||
}
|
||||
// 已对齐 → 一次向量计算都不该发生
|
||||
sp2 := &fakeSpace{fp: sp.fp, dim: 8}
|
||||
s2.SetDenseSpace(sp2)
|
||||
s2.BuildDenseIndex(sp2)
|
||||
if sp2.calls != 0 {
|
||||
t.Fatalf("已对齐的文档被重算了 %d 次(期望 0)", sp2.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// 块向量维度与当前空间不符时不得参与融合:否则 512 维文本 + 2048 维块
|
||||
// 会被 FuseVectors 按最大维度拼成 2048 维、并带上当前指纹,导致该文档在
|
||||
// 检索侧被长度守卫永久跳过且每次启动重算。
|
||||
func TestDenseForIgnoresBlockWithMismatchedDim(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
sp := &fakeSpace{fp: "space-NEW-8", dim: 8}
|
||||
s := NewStore(dir, memory.TokenizeWords)
|
||||
if err := s.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.Stop()
|
||||
|
||||
bad := make([]float64, 2048)
|
||||
for i := range bad {
|
||||
bad[i] = 0.02
|
||||
}
|
||||
good := make([]float64, 8)
|
||||
for i := range good {
|
||||
good[i] = 0.5
|
||||
}
|
||||
doc := &Doc{ID: "doc_bad", Summary: "坏块", Content: "文本向量应当生效",
|
||||
Blocks: []memory.MemoryBlock{
|
||||
{ID: "blk_bad", Vector: bad, Fingerprint: sp.fp}, // 指纹对、维度错 → 必须忽略
|
||||
{ID: "blk_good", Vector: good, Fingerprint: sp.fp}, // 指纹与维度都对 → 参与融合
|
||||
}}
|
||||
if err := s.Insert(doc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.SetDenseSpace(sp)
|
||||
s.BuildDenseIndex(sp)
|
||||
|
||||
got := s.Get("doc_bad")
|
||||
if got == nil {
|
||||
t.Fatal("文档未加载")
|
||||
}
|
||||
if len(got.DenseVec) != 8 {
|
||||
t.Fatalf("坏块污染了文档向量:期望 %d 维,实际 %d 维(指纹 %q)",
|
||||
8, len(got.DenseVec), got.DenseFP)
|
||||
}
|
||||
// 同维度的正常块仍须参与融合:不应因为这次修复而整体失效
|
||||
textOnly := &fakeSpace{fp: sp.fp, dim: 8}
|
||||
textVec, _ := textOnly.VectorizeDense(doc.Summary + " " + doc.Content)
|
||||
if equalFloats(got.DenseVec, textVec) {
|
||||
t.Fatal("同维度的媒体块没有参与融合(修复过度)")
|
||||
}
|
||||
}
|
||||
|
||||
func equalFloats(a, b []float64) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Insert 接受任意 ID,loadAll 就必须把它读回来,否则自定义 ID 的文档
|
||||
// 重启后静默消失。
|
||||
func TestLoadAllLoadsCustomID(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
s1 := NewStore(dir, memory.TokenizeWords)
|
||||
if err := s1.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s1.Insert(&Doc{ID: "my-notes", Summary: "自定义 ID", Content: "内容"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s1.Stop()
|
||||
|
||||
s2 := NewStore(dir, memory.TokenizeWords)
|
||||
if err := s2.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s2.Stop()
|
||||
if s2.Get("my-notes") == nil {
|
||||
t.Fatal("自定义 ID 的文档重启后消失(Insert 与 loadAll 的 ID 约定不对称)")
|
||||
}
|
||||
}
|
||||
|
||||
// Stop 必须把内存态变更写盘(关停链上没有它时 flush 形同虚设)。
|
||||
func TestStopFlushesDirtyDocs(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
s1 := NewStore(dir, memory.TokenizeWords)
|
||||
if err := s1.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s1.Insert(&Doc{ID: "doc_flush", Summary: "关停落盘", Content: "内容"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 直接改内存并置脏,模拟「只在内存里发生的变更」
|
||||
s1.mu.Lock()
|
||||
s1.docs["doc_flush"].Summary = "关停落盘(已改)"
|
||||
s1.dirty = true
|
||||
s1.mu.Unlock()
|
||||
s1.Stop()
|
||||
|
||||
s2 := NewStore(dir, memory.TokenizeWords)
|
||||
if err := s2.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s2.Stop()
|
||||
if got := s2.Get("doc_flush"); got == nil || got.Summary != "关停落盘(已改)" {
|
||||
t.Fatalf("Stop 未落盘: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user