Files
HomeAgent/internal/memory/document/document_test.go
JianFeeeee e2cb6e70e0 feat(memory): doc 层去掉 jieba 摘要 + doc_query 返回真实内容 + 三层记忆提示词
上一轮(memory-blockflow-repair-plan)修好了 L1↔L2 结构,但功能不可用。
用生产数据实测:三个互不相关查询分数全挤在 0.75~0.79,区分度≈0,
命中内容与查询无关。本次修四层。

## ① 向量输入:Summary + Content → 事件正文

denseFor 原用 `VectorizeDense(doc.Summary + " " + doc.Content)`,而 Summary
由 summarizeEntries → memory.ExtractKeywords → TokenizeContentWords → jieba.Tag()
生成。同一来源的摘要逐字同质:

    来自 1 个来源的 1 条对话 (src0) 涉及: 用户, 提问
    来自 1 个来源的 1 条对话 (src1) 涉及: 用户, 提问

真实 chineseclip 实测(4 篇主题完全不同的短正文):两两余弦 0.85~0.95。
这与设计矛盾 —— ARCHITECTURE 写的是「统一多模态空间(主)」,而 Summary
是主路径的输入,却由最末一级的词法分词产出。

改为 documentTextForVector(doc):按事件顺序拼原文(块里的正文)。
存量无 Events 的文档退回 Content。

★ 媒体不单独生成摘要:媒体块与文本块在同一事件内
  (Doc.Events[i].Blocks),事件正文进入向量即覆盖其语义。
  为媒体单独写描述正是 CAS 时代「描述式索引」的错误,v1.2.0 已拆除。
  「按图搜图」是视觉路(RecallBlocks)的职责,已有独立通路。

## ② doc 层移除 jieba 语义生成

- summarizeEntries:jieba 词袋 → 取首条事件正文首行(真实内容,可读)
- 删除 extractTags / extractEntities(纯 jieba 词袋)
- 删除 Doc.Tags / Doc.Entities(前者唯一消费点是 doc_query 返回的
  「标签: map, content」噪声行;后者零消费点)
- doc_commit 删除 tags 参数(无处可存,留着会骗模型)

★ TF-IDF 回退路径**保留**(Store.tfidfEmb/tfidfIdx/ensureTFIDF/Reindex):
  它是「稠密空间没加载」时的兜底,按 ARCHITECTURE「统一多模态空间(主)
  → 词嵌入 → TF-IDF(回退)」必须保留。但它不能是主路径 —— 一旦稠密空间
  可用,jieba 就不参与。

## ③ doc_query 返回真实内容

旧返回:「已加载 3 篇文档记忆: #1(来自 1 个来源的 1 条对话 (agent) 涉及: ok)」
——全 jieba 词袋,模型无法判断有没有用(这是它几乎不调用 doc_query 的直接原因)。

新返回:
    已召回 3 篇历史记忆,原文已按各自**原始时间戳**插回对话时序(不是当前对话):

    [1] 2026-06-07 08:09 · webui · 2 条事件 · 含 1 份媒体
        配色方案怎么定?
        结论:紫蓝红三色带按六比三比一…

    共回填 N 条事件。要引用其中的细节,直接看对话时序里对应时间点即可。

每条给出「时间 · 来源 · 事件数 · 媒体数」+ 真实正文前几行。

## ④ 提示词:agent 终于能理解三层记忆

- doc_query 描述:一句话 → 覆盖「L2 是什么 / 何时用 / 调用后原文回填到时序 /
  与 memory_recall 分工 / 消费式副作用」
- doc_commit 描述:说清「适合写整段过程结论,不适合写单个事实」
- 系统提示词新增【三层记忆】段(内核运行时块):L1 对话时序 / L2 历史记忆 /
  L3 图记忆各自是什么、怎么取、如何选择
- 预填充文案(crossModalMarkdown):统计描述 → 时间+来源+正文开头,
  并在块尾明确引导「要完整原文用 doc_query」

## ⑤ 向量算法版本(否则修复对存量无效)

实测踩到的坑:改完 denseFor 后,1055 篇存量文档分数纹丝不动(0.78/0.79/0.76,
区分度 0.003)—— 因为 DenseFP 只记「哪个向量空间」,不记「用什么文本算的」,
指纹不变 ⇒ 全部命中跳过条件 ⇒ 永不重算。

修法:DenseFP = 空间指纹 + "|" + docVectorAlgoVersion(v2)。
算法一变,存量自动重算一次。

## 判据(每条都有变异自证)

document/retrieval_quality_test.go(6 条):
- Top1HitsCorrectDoc / DistinctQueriesHitDistinctDocs / ScoreGapIsSignificant
- VectorInputIsBodyNotSummary(正文哨兵必须进向量)
- SummaryNoiseMustNotPolluteVector(主题不同的短正文,两两余弦 <0.80)
- AlgoVersionChangeForcesRecompute(换算法必须触发存量重算)

core/recalldoc_test.go(+2):返回值必须含时间/来源/真实正文/时序说明
core/prompt_memory_test.go(5 条):工具描述五项覆盖、doc_commit 范围、
  三层记忆段落、tags 参数已删、预填充文案含内容与 doc_query 引导

变异全部被抓住(截断落库 / Summary+Content / 去掉算法版本 / 旧预填充文案 /
一句话工具描述 / 删三层记忆段)。

## 验证

- go build ./... 通过;go vet ./... 干净
- go test ./... -count=1:58 包 0 失败
- go test -race ./internal/memory/document/ ./internal/agent/core/:通过
- 真实数据(chineseclip 512 维):
  · 旧格式存量(1055 篇,无 Events):4/5 → 仍是旧分数,等自然冲刷
  · 新格式文档(走 ContextToDoc):4/5 top1 命中正确,
    区分度最高 0.18(旧实现 0.003)
2026-10-22 19:49:25 +08:00

638 lines
17 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package document
import (
"fmt"
"os"
"sync"
"testing"
"time"
"github.com/JianFeeeee/HologramAgent/internal/memory"
)
func TestInsertAndQuery(t *testing.T) {
dir, err := os.MkdirTemp("", "doc_test_*")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(dir)
s := NewStore(dir, memory.TokenizeWords)
if err := s.Start(); err != nil {
t.Fatal(err)
}
defer s.Stop()
doc := &Doc{
Summary: "用户喜欢喝咖啡",
Content: "用户提到他每天早上都会喝一杯黑咖啡",
Source: "manual",
}
if err := s.Insert(doc); err != nil {
t.Fatal(err)
}
if doc.ID == "" {
t.Error("doc ID should be auto-generated")
}
stats := s.Stats()
if stats["doc_count"].(int) != 1 {
t.Errorf("expected 1 doc, got %d", stats["doc_count"])
}
}
func TestQuery(t *testing.T) {
dir, err := os.MkdirTemp("", "doc_query_*")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(dir)
s := NewStore(dir, memory.TokenizeWords)
s.Start()
defer s.Stop()
s.Insert(&Doc{Summary: "咖啡是一种饮品", Content: "咖啡因提神", Source: "manual"})
s.Insert(&Doc{Summary: "茶叶也有咖啡因", Content: "茶和咖啡都提神", Source: "manual"})
s.Insert(&Doc{Summary: "今天天气很好", Content: "适合出去散步", Source: "manual"})
results := s.Query("咖啡", 5)
if len(results) == 0 {
t.Fatal("expected results for '咖啡'")
}
if results[0].AccessCount <= 0 {
t.Error("access count should be updated on query")
}
}
func TestContextToDoc(t *testing.T) {
dir, err := os.MkdirTemp("", "doc_ctx_*")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(dir)
s := NewStore(dir, memory.TokenizeWords)
s.Start()
defer s.Stop()
entries := []ContextEntry{
{Timestamp: time.Now(), Source: "user", Content: "我喜欢编程", Response: "很好"},
{Timestamp: time.Now(), Source: "user", Content: "特别是Go语言", Response: "Go很棒"},
}
doc, err := s.ContextToDoc("test", entries, nil, nil, nil, nil)
if err != nil {
t.Fatal(err)
}
if doc == nil {
t.Fatal("expected non-nil doc")
}
if doc.Summary == "" {
t.Error("summary should not be empty")
}
if doc.Content == "" {
t.Error("content should not be empty")
}
}
func TestFindColdDocs(t *testing.T) {
dir, err := os.MkdirTemp("", "doc_cold_*")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(dir)
s := NewStore(dir, memory.TokenizeWords)
s.Start()
defer s.Stop()
hot := &Doc{Summary: "常用的信息", Content: "经常被查询", Source: "manual"}
hot.AccessCount = 10
hot.LastAccess = time.Now()
s.Insert(hot)
cold := &Doc{Summary: "很久没用的信息", Content: "几乎不被访问", Source: "manual"}
s.Insert(cold)
// Insert 会重置 LastAccess,手动改为过去的
cold.LastAccess = time.Now().Add(-100 * time.Hour)
cold.AccessCount = 1
// 应该只找到 cold(72h 前未访问且访问 ≤ 2)
coldDocs := s.FindColdDocs(72*time.Hour, 2)
if len(coldDocs) != 1 {
t.Fatalf("expected 1 cold doc, got %d", len(coldDocs))
}
if coldDocs[0].Summary != "很久没用的信息" {
t.Errorf("expected cold doc, got %s", coldDocs[0].Summary)
}
}
func TestRecentDocs(t *testing.T) {
dir, err := os.MkdirTemp("", "doc_recent_*")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(dir)
s := NewStore(dir, memory.TokenizeWords)
s.Start()
defer s.Stop()
s.Insert(&Doc{Summary: "第一条", Content: "a", Source: "manual"})
time.Sleep(time.Millisecond)
s.Insert(&Doc{Summary: "第二条", Content: "b", Source: "manual"})
recent := s.RecentDocs(1)
if len(recent) != 1 {
t.Fatalf("expected 1 recent doc, got %d", len(recent))
}
if recent[0].Summary != "第二条" {
t.Errorf("expected newest doc, got %s", recent[0].Summary)
}
}
func TestReindex(t *testing.T) {
dir, err := os.MkdirTemp("", "doc_reindex_*")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(dir)
s := NewStore(dir, memory.TokenizeWords)
s.Start()
defer s.Stop()
s.Insert(&Doc{Summary: "测试重索引", Content: "验证索引重建", Source: "manual"})
s.Reindex()
results := s.Query("重索引", 5)
if len(results) == 0 {
t.Error("reindex should preserve searchability")
}
}
// summarizeEntries 现在是**纯展示摘要**:取首条事件的正文首行。
//
// 它曾经用 jieba 词袋生成「来自 N 个来源的 M 条对话 涉及: …」——
// 那是词袋清单,且全库同质(一旦当向量输入就会让检索失效)。
func TestSummarizeEntries(t *testing.T) {
entries := []ContextEntry{
{Source: "user", Content: "今天天气如何,要不要带伞"},
{Source: "user", Content: "明天会下雨吗"},
}
summary := summarizeEntries(entries, func(s string) string { return s }, nil, nil)
if summary == "" {
t.Fatal("summary 不应为空")
}
// 必须是真实内容(首条事件正文),不是词袋统计
if !contains(summary, "天气") {
t.Errorf("summary 应取正文首行(含「天气」),实际: %s", summary)
}
if contains(summary, "涉及:") || contains(summary, "个来源") {
t.Errorf("summary 仍是 jieba 词袋模板,实际: %s", summary)
}
}
// 摘要不读工具输出:工具输出属于事件结构,不是「这篇讲什么」。
// 旧实现会把工具输出也拿去做关键词提取,于是摘要被工具噪音污染。
func TestSummarizeEntriesIgnoresToolOutput(t *testing.T) {
entry := ContextEntry{
Content: "今天天气怎么样",
ToolResults: []ToolResultItem{
{Name: "search", Output: "手机 电脑 显卡 内存"},
},
}
summary := summarizeEntries([]ContextEntry{entry}, func(s string) string { return s }, nil, nil)
if contains(summary, "手机") || contains(summary, "显卡") {
t.Errorf("摘要被工具输出污染(应只用正文),实际: %s", summary)
}
if !contains(summary, "天气") {
t.Errorf("摘要应取正文首行,实际: %s", summary)
}
}
func TestExtractKeywords(t *testing.T) {
kws := memory.ExtractKeywords("今天天气很好")
if len(kws) == 0 {
t.Error("should extract keywords from Chinese text")
}
}
func TestInsertEmptyDoc(t *testing.T) {
dir, err := os.MkdirTemp("", "doc_empty_*")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(dir)
s := NewStore(dir, memory.TokenizeWords)
s.Start()
defer s.Stop()
doc := &Doc{Summary: "", Content: "", Source: "manual"}
if err := s.Insert(doc); err != nil {
t.Fatal(err)
}
if doc.ID == "" {
t.Error("doc ID should be generated even for empty content")
}
}
func TestPersistence(t *testing.T) {
dir, err := os.MkdirTemp("", "doc_persist_*")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(dir)
// 写
s1 := NewStore(dir, memory.TokenizeWords)
s1.Start()
s1.Insert(&Doc{Summary: "持久化测试", Content: "应该被保存到磁盘", Source: "manual"})
s1.Stop()
// 读
s2 := NewStore(dir, memory.TokenizeWords)
s2.Start()
defer s2.Stop()
stats := s2.Stats()
if stats["doc_count"].(int) != 1 {
t.Errorf("expected 1 doc after reload, got %d", stats["doc_count"])
}
results := s2.Query("持久化", 5)
if len(results) == 0 {
t.Error("search should work after reload")
}
}
func contains(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}
func TestFlushNoDirty(t *testing.T) {
dir, err := os.MkdirTemp("", "doc_flush_*")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(dir)
s := NewStore(dir, memory.TokenizeWords)
s.Start()
// 不插任何文档,flush 不应报错
s.Stop()
}
func TestRemove(t *testing.T) {
dir, err := os.MkdirTemp("", "doc_remove_*")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(dir)
s := NewStore(dir, memory.TokenizeWords)
s.Start()
defer s.Stop()
s.Insert(&Doc{Summary: "会被删除", Content: "a", Source: "manual"})
s.Insert(&Doc{Summary: "会保留", Content: "b", Source: "manual"})
// 删除前应该有 2 个
stats := s.Stats()
if stats["doc_count"].(int) != 2 {
t.Fatalf("expected 2 docs before remove, got %d", stats["doc_count"])
}
// 遍历找到 "会被删除" 的 ID
var rmID string
for _, d := range s.docs {
if d.Summary == "会被删除" {
rmID = d.ID
break
}
}
if rmID == "" {
t.Fatal("could not find test doc")
}
s.Remove(rmID)
stats = s.Stats()
if stats["doc_count"].(int) != 1 {
t.Errorf("expected 1 doc after remove, got %d", stats["doc_count"])
}
// 搜索不应再找到
results := s.Query("删除", 5)
if len(results) > 0 {
t.Error("removed doc should not appear in search results")
}
}
func TestRemoveNonexistent(t *testing.T) {
dir, err := os.MkdirTemp("", "doc_rm_nonexist_*")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(dir)
s := NewStore(dir, memory.TokenizeWords)
s.Start()
defer s.Stop()
s.Insert(&Doc{Summary: "一个文档", Content: "x", Source: "manual"})
// 删除不存在的 ID 不应 panic
s.Remove("nonexistent_id")
stats := s.Stats()
if stats["doc_count"].(int) != 1 {
t.Errorf("expected 1 doc after remove nonexistent, got %d", stats["doc_count"])
}
}
func TestContextToDocContentPreservesRawToolOutput(t *testing.T) {
dir, err := os.MkdirTemp("", "doc_toolclean_*")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(dir)
s := NewStore(dir, memory.TokenizeWords)
s.Start()
defer s.Stop()
entries := []ContextEntry{
{
Timestamp: time.Now(),
Source: "user",
Content: "查天气",
ToolResults: []ToolResultItem{
{Name: "weather", Output: "{\"temp\": 25}"},
},
},
}
// toolCleanFn 返回清洗文本,但 Content 必须保留原始输出
cleaner := func(name, output string) string {
return "天气 温度"
}
doc, err := s.ContextToDoc("test", entries, nil, nil, cleaner, nil)
if err != nil {
t.Fatal(err)
}
if !contains(doc.Content, "{\"temp\": 25}") {
t.Errorf("Content should preserve raw tool output, got: %s", doc.Content)
}
if doc.Summary == "" {
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 != s1.vectorFingerprint() {
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+"|"+docVectorAlgoVersion {
t.Fatalf("迁移结果未落盘:期望 dim=8 fp=%q,实际 dim=%d fp=%q"+
"(后果:每次启动都重算同一批文档,磁盘状态永不收敛)",
sp.fp+"|"+docVectorAlgoVersion, 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)
}
}
// TestQueryScoredConcurrentAndDirty 锁死两点:
// 1. QueryScored 更新访问计数必须离开读锁(旧实现在 RLock 下写,-race 会报);
// 2. 更新后必须置脏,否则优雅关停也不落盘、FindColdDocs 的冷度判据跨重启失真。
func TestQueryScoredConcurrentAndDirty(t *testing.T) {
dir := t.TempDir()
s := NewStore(dir, memory.TokenizeWords)
if err := s.Start(); err != nil {
t.Fatal(err)
}
defer s.Stop()
for i := 0; i < 8; i++ {
// 文档必须互相区分:若 8 篇文本完全相同,查询词出现在每一篇里,
// IDF = log(N/df) = 0,词向量全零、检索恒为空,测试就成了假阴性。
summary, content := "无关的天气与散步记录", "今天适合出门"
if i < 3 {
summary, content = "并发检索目标", "并发内容"
}
if err := s.Insert(&Doc{ID: fmt.Sprintf("d%d", i), Summary: summary, Content: content}); err != nil {
t.Fatal(err)
}
}
// 清掉 Insert 置的脏位,验证 QueryScored 自己会置脏。
s.mu.Lock()
s.dirty = false
s.mu.Unlock()
var wg sync.WaitGroup
for g := 0; g < 8; g++ {
wg.Add(1)
go func() {
defer wg.Done()
for k := 0; k < 30; k++ {
s.QueryScored("并发检索目标", 5)
s.FindColdDocs(72*time.Hour, 2)
}
}()
}
wg.Wait()
s.mu.RLock()
dirty := s.dirty
access := s.docs["d0"].AccessCount
s.mu.RUnlock()
if access <= 1 {
t.Errorf("AccessCount 未随检索累加: %d", access)
}
if !dirty {
t.Error("QueryScored 更新访问计数后未置脏")
}
}