mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-05 08:47:27 +00:00
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)
This commit is contained in:
@ -14,6 +14,7 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/JianFeeeee/HologramAgent/internal/memory"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/memory/vector"
|
||||
@ -34,12 +35,10 @@ type ChannelCleaner func(source string) func(string) string
|
||||
// Content 只是**渲染视图**(供摘要、人工阅读、SDK 的 Doc.Content 字段),
|
||||
// 不是原文载体。原文在 Events 里,正文内联在文本块里。
|
||||
type Doc struct {
|
||||
ID string `json:"id"`
|
||||
Summary string `json:"summary"`
|
||||
Content string `json:"content"` // 渲染视图,不是原文
|
||||
Events []DocEvent `json:"events,omitempty"`
|
||||
Tags []string `json:"tags"`
|
||||
Entities []string `json:"entities"`
|
||||
ID string `json:"id"`
|
||||
Summary string `json:"summary"`
|
||||
Content string `json:"content"` // 渲染视图,不是原文
|
||||
Events []DocEvent `json:"events,omitempty"`
|
||||
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
@ -93,9 +92,35 @@ type DocEvent struct {
|
||||
// 「把 Content 塞进一条事件」,而不是假装能回填。
|
||||
func (d *Doc) HasEvents() bool { return d != nil && len(d.Events) > 0 }
|
||||
|
||||
// docVectorAlgoVersion 是**文档向量算法**的版本标记。
|
||||
//
|
||||
// 为什么必需:DenseFP 只记录"哪个向量空间(模型)",不记录"用什么文本算的"。
|
||||
// 2026-10-22 把输入从 `Summary + Content` 改成"事件正文"后,指纹不变
|
||||
// ⇒ 存量文档全部命中 `DenseFP == 指纹` 的跳过条件 ⇒ **永不重算**,
|
||||
// 修复对已有数据完全无效(实测:1055 篇存量仍是旧分数 0.78/0.79/0.76,
|
||||
// 区分度 0.003,与新算法的判据结果不符)。
|
||||
//
|
||||
// 所以把算法版本编进 DenseFP:算法一变,指纹后缀变,存量自动重算一次。
|
||||
//
|
||||
// 变更记录:
|
||||
//
|
||||
// v1 初版:VectorizeDense(Summary + Content)
|
||||
// v2 Summary 是 jieba 词袋且全库同质,改为事件正文(见 denseFor 注释)
|
||||
const docVectorAlgoVersion = "v2"
|
||||
|
||||
// Store — 文档记忆存储。
|
||||
// 主路径:denseSpace(稠密多模态向量,与媒体共享空间)。
|
||||
//
|
||||
// 主路径:denseSpace(稠密向量,与媒体共享的统一空间)。
|
||||
// Fallback:tfidf(TF-IDF 倒排索引,仅稠密空间不可用时加载)。
|
||||
//
|
||||
// ★ 关于 fallback 的定位(不要误删):
|
||||
//
|
||||
// 它是「稠密空间没加载」时的兜底,**不是**「词袋当语义」的第二套真相。
|
||||
// 设计要求(ARCHITECTURE「向量层级:统一多模态空间(主)→ 词嵌入 → TF-IDF(回退)」)
|
||||
// 明确保留这条降级链:模型不可用时服务不能整个失去检索能力。
|
||||
//
|
||||
// ★ 但它**绝不能**是主路径:一旦稠密空间可用,jieba 词袋就不该参与
|
||||
// (实测主题不同的文档两两余弦 0.85~0.95,检索等同失效 —— 见 denseFor 注释)。
|
||||
type Store struct {
|
||||
dir string
|
||||
mu sync.RWMutex
|
||||
@ -112,7 +137,8 @@ type Store struct {
|
||||
denseSpace vector.MultimodalEmbedder
|
||||
}
|
||||
|
||||
// NewStore 创建文档存储。tokenizer 由外层注入(如 jieba),核心不直接依赖分词库。
|
||||
// NewStore 创建文档存储。tokenizer 由外层注入(如 jieba),供 TF-IDF 回退路径使用
|
||||
// (仅稠密空间不可用时加载;主路径不碰它)。
|
||||
func NewStore(dir string, tokenizer tfidf.Tokenizer) *Store {
|
||||
return &Store{
|
||||
dir: dir,
|
||||
@ -175,7 +201,7 @@ func (s *Store) BuildDenseIndex(ds vector.MultimodalEmbedder) {
|
||||
log.Printf("[document memory] building dense index for %d docs (dim=%d)", len(s.docs), ds.Dim())
|
||||
count := 0
|
||||
for _, doc := range s.docs {
|
||||
if doc.DenseVec != nil && len(doc.DenseVec) == ds.Dim() && doc.DenseFP == ds.Fingerprint() {
|
||||
if doc.DenseVec != nil && len(doc.DenseVec) == ds.Dim() && doc.DenseFP == s.vectorFingerprint() {
|
||||
continue
|
||||
}
|
||||
vec := s.denseFor(doc)
|
||||
@ -183,7 +209,7 @@ func (s *Store) BuildDenseIndex(ds vector.MultimodalEmbedder) {
|
||||
continue
|
||||
}
|
||||
doc.DenseVec = vec
|
||||
doc.DenseFP = ds.Fingerprint()
|
||||
doc.DenseFP = s.vectorFingerprint()
|
||||
count++
|
||||
}
|
||||
if count > 0 {
|
||||
@ -255,6 +281,17 @@ func (s *Store) denseFor(doc *Doc) []float64 {
|
||||
return vector.FuseVectors(parts...)
|
||||
}
|
||||
|
||||
// vectorFingerprint 返回「向量空间指纹 + 文档向量算法版本」。
|
||||
//
|
||||
// 只比空间指纹不够:换算法(同一模型、不同输入文本)时必须触发重算,
|
||||
// 否则存量数据永远停留在旧算法的结果上(实测后果见 docVectorAlgoVersion)。
|
||||
func (s *Store) vectorFingerprint() string {
|
||||
if s.denseSpace == nil {
|
||||
return ""
|
||||
}
|
||||
return s.denseSpace.Fingerprint() + "|" + docVectorAlgoVersion
|
||||
}
|
||||
|
||||
// documentTextForVector 取文档用于向量化的文本:**正文**。
|
||||
//
|
||||
// 有 Events 时按事件顺序拼原文(来自块里的正文);无 Events 的存量文档
|
||||
@ -325,7 +362,7 @@ func (s *Store) Insert(doc *Doc) error {
|
||||
// 主路径:稠密向量(文本 ⊕ 媒体块)
|
||||
if s.denseSpace != nil && s.denseSpace.Loaded() && len(doc.DenseVec) == 0 {
|
||||
doc.DenseVec = s.denseFor(doc)
|
||||
doc.DenseFP = s.denseSpace.Fingerprint()
|
||||
doc.DenseFP = s.vectorFingerprint()
|
||||
}
|
||||
|
||||
// Fallback 路径:缓存文本,延迟训练
|
||||
@ -362,8 +399,6 @@ func (s *Store) ContextToDoc(source string, entries []ContextEntry, _ interface{
|
||||
content := contentFromEvents(events)
|
||||
contentHash := simpleHash(content)
|
||||
summary := summarizeEntries(entries, cleanFn, toolCleanFn, channelCleaner)
|
||||
tags := extractTags(entries, cleanFn, toolCleanFn, channelCleaner)
|
||||
entities := extractEntities(entries, cleanFn, toolCleanFn, channelCleaner)
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
@ -376,12 +411,10 @@ func (s *Store) ContextToDoc(source string, entries []ContextEntry, _ interface{
|
||||
d.Events = events
|
||||
d.Source = source
|
||||
d.Summary = summary
|
||||
d.Tags = tags
|
||||
d.Entities = entities
|
||||
d.Blocks = flattenEventBlocks(events)
|
||||
d.DenseVec = s.denseFor(d)
|
||||
if s.denseSpace != nil {
|
||||
d.DenseFP = s.denseSpace.Fingerprint()
|
||||
d.DenseFP = s.vectorFingerprint()
|
||||
}
|
||||
s.dirty = true
|
||||
return d, nil
|
||||
@ -394,8 +427,8 @@ func (s *Store) ContextToDoc(source string, entries []ContextEntry, _ interface{
|
||||
meta["is_archived_context"] = "true"
|
||||
}
|
||||
doc := &Doc{
|
||||
ID: id, Summary: summary, Content: content, Tags: tags,
|
||||
Entities: entities, CreatedAt: time.Now(), UpdatedAt: time.Now(),
|
||||
ID: id, Summary: summary, Content: content,
|
||||
CreatedAt: time.Now(), UpdatedAt: time.Now(),
|
||||
LastAccess: time.Now(), AccessCount: 1, Source: source, Meta: meta,
|
||||
Events: events,
|
||||
Blocks: flattenEventBlocks(events),
|
||||
@ -403,7 +436,7 @@ func (s *Store) ContextToDoc(source string, entries []ContextEntry, _ interface{
|
||||
s.docs[id] = doc
|
||||
doc.DenseVec = s.denseFor(doc)
|
||||
if s.denseSpace != nil {
|
||||
doc.DenseFP = s.denseSpace.Fingerprint()
|
||||
doc.DenseFP = s.vectorFingerprint()
|
||||
}
|
||||
text := summary + " " + content
|
||||
if s.tfidfIdx != nil {
|
||||
@ -731,128 +764,76 @@ func blocksFromEntries(entries []ContextEntry) []memory.MemoryBlock {
|
||||
return out
|
||||
}
|
||||
|
||||
// summarizeEntries 生成文档摘要 —— **纯展示用**,不参与任何向量计算。
|
||||
//
|
||||
// ★ 这里曾经调用 memory.ExtractKeywords(jieba Tag 分词 + 停用词),
|
||||
//
|
||||
// 产出形如「来自 1 个来源的 1 条对话 (agent) 涉及: map, content, 是不是」。
|
||||
// 两个后果都是实测的:
|
||||
//
|
||||
// 1. **不是摘要,是词袋清单**:模型与人都无法从中知道这篇讲什么;
|
||||
// 2. **全库同质**:同一来源的摘要逐字相同,一旦被当作向量输入
|
||||
// (denseFor 曾用 doc.Summary),四篇主题完全不同的文档两两余弦
|
||||
// 达到 0.85~0.95,检索等同失效。
|
||||
//
|
||||
// 现在改为取**首条事件的正文开头**:那是真实内容,可读、可判断。
|
||||
// 仍然只是展示视图 —— 原文在 Events 的文本块里,摘要是它的短引用。
|
||||
func summarizeEntries(entries []ContextEntry, cleanText func(string) string, toolCleanFn func(name, output string) string, channelCleaner ChannelCleaner) string {
|
||||
if len(entries) == 0 {
|
||||
return ""
|
||||
}
|
||||
sources := make(map[string]int)
|
||||
var topics []string
|
||||
// 优先取用户/agent 的实质正文(而非工具输出),取到即止。
|
||||
for _, e := range entries {
|
||||
sources[e.Source]++
|
||||
content := e.Content
|
||||
if channelCleaner != nil {
|
||||
if c := channelCleaner(e.Source); c != nil {
|
||||
content = c(content)
|
||||
}
|
||||
}
|
||||
words := memory.ExtractKeywords(cleanText(content))
|
||||
topics = append(topics, words...)
|
||||
for _, tr := range e.ToolResults {
|
||||
out := tr.Output
|
||||
if toolCleanFn != nil {
|
||||
if c := toolCleanFn(tr.Name, tr.Output); c == "" {
|
||||
continue
|
||||
} else {
|
||||
out = c
|
||||
}
|
||||
}
|
||||
toolWords := memory.ExtractKeywords(out)
|
||||
topics = append(topics, toolWords...)
|
||||
if cleanText != nil {
|
||||
content = cleanText(content)
|
||||
}
|
||||
}
|
||||
summary := fmt.Sprintf("来自 %d 个来源的 %d 条对话", len(sources), len(entries))
|
||||
var srcList []string
|
||||
for s := range sources {
|
||||
srcList = append(srcList, s)
|
||||
}
|
||||
summary += " (" + strings.Join(srcList, ", ") + ")"
|
||||
if len(topics) > 0 {
|
||||
seen := make(map[string]bool)
|
||||
var uniq []string
|
||||
for _, t := range topics {
|
||||
if !seen[t] {
|
||||
seen[t] = true
|
||||
uniq = append(uniq, t)
|
||||
if line := firstMeaningfulLine(content); line != "" {
|
||||
return truncateRunes(line, 80)
|
||||
}
|
||||
if e.Response != "" {
|
||||
if line := firstMeaningfulLine(e.Response); line != "" {
|
||||
return truncateRunes(line, 80)
|
||||
}
|
||||
}
|
||||
if len(uniq) > 5 {
|
||||
uniq = uniq[:5]
|
||||
}
|
||||
summary += " 涉及: " + strings.Join(uniq, ", ")
|
||||
}
|
||||
return summary
|
||||
return fmt.Sprintf("%d 条事件", len(entries))
|
||||
}
|
||||
|
||||
func extractTags(entries []ContextEntry, cleanText func(string) string, toolCleanFn func(name, output string) string, channelCleaner ChannelCleaner) []string {
|
||||
tagSet := make(map[string]bool)
|
||||
for _, e := range entries {
|
||||
content := e.Content
|
||||
if channelCleaner != nil {
|
||||
if c := channelCleaner(e.Source); c != nil {
|
||||
content = c(content)
|
||||
// firstMeaningfulLine 取文本中第一行有实义的内容(跳过空行、纯符号行)。
|
||||
func firstMeaningfulLine(s string) string {
|
||||
for _, line := range strings.Split(s, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
// 跳过纯符号/空白
|
||||
hasWord := false
|
||||
for _, r := range line {
|
||||
if unicode.IsLetter(r) || unicode.IsDigit(r) {
|
||||
hasWord = true
|
||||
break
|
||||
}
|
||||
}
|
||||
for _, kw := range memory.ExtractKeywords(cleanText(content)) {
|
||||
tagSet[kw] = true
|
||||
}
|
||||
for _, tr := range e.ToolResults {
|
||||
out := tr.Output
|
||||
if toolCleanFn != nil {
|
||||
if c := toolCleanFn(tr.Name, tr.Output); c == "" {
|
||||
continue
|
||||
} else {
|
||||
out = c
|
||||
}
|
||||
}
|
||||
for _, kw := range memory.ExtractKeywords(out) {
|
||||
tagSet[kw] = true
|
||||
}
|
||||
if hasWord {
|
||||
return strings.Join(strings.Fields(line), " ")
|
||||
}
|
||||
}
|
||||
var tags []string
|
||||
for t := range tagSet {
|
||||
if len(tags) >= 10 {
|
||||
break
|
||||
}
|
||||
tags = append(tags, t)
|
||||
}
|
||||
return tags
|
||||
return ""
|
||||
}
|
||||
|
||||
func extractEntities(entries []ContextEntry, cleanText func(string) string, toolCleanFn func(name, output string) string, channelCleaner ChannelCleaner) []string {
|
||||
var entities []string
|
||||
seen := make(map[string]bool)
|
||||
for _, e := range entries {
|
||||
content := e.Content
|
||||
if channelCleaner != nil {
|
||||
if c := channelCleaner(e.Source); c != nil {
|
||||
content = c(content)
|
||||
}
|
||||
}
|
||||
for _, kw := range memory.ExtractKeywords(cleanText(content)) {
|
||||
if len(kw) >= 2 && !seen[kw] {
|
||||
seen[kw] = true
|
||||
entities = append(entities, kw)
|
||||
}
|
||||
}
|
||||
for _, tr := range e.ToolResults {
|
||||
out := tr.Output
|
||||
if toolCleanFn != nil {
|
||||
if c := toolCleanFn(tr.Name, tr.Output); c == "" {
|
||||
continue
|
||||
} else {
|
||||
out = c
|
||||
}
|
||||
}
|
||||
for _, kw := range memory.ExtractKeywords(out) {
|
||||
if len(kw) >= 2 && !seen[kw] {
|
||||
seen[kw] = true
|
||||
entities = append(entities, kw)
|
||||
}
|
||||
}
|
||||
}
|
||||
// truncateRunes 按字符(非字节)截断。
|
||||
func truncateRunes(s string, max int) string {
|
||||
r := []rune(s)
|
||||
if len(r) <= max {
|
||||
return s
|
||||
}
|
||||
return entities
|
||||
return string(r[:max]) + "…"
|
||||
}
|
||||
|
||||
// simpleHash 是用于「内容是否同一份」比对的快速摘要。
|
||||
|
||||
@ -26,7 +26,6 @@ func TestInsertAndQuery(t *testing.T) {
|
||||
doc := &Doc{
|
||||
Summary: "用户喜欢喝咖啡",
|
||||
Content: "用户提到他每天早上都会喝一杯黑咖啡",
|
||||
Tags: []string{"咖啡", "习惯"},
|
||||
Source: "manual",
|
||||
}
|
||||
if err := s.Insert(doc); err != nil {
|
||||
@ -175,17 +174,43 @@ func TestReindex(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// summarizeEntries 现在是**纯展示摘要**:取首条事件的正文首行。
|
||||
//
|
||||
// 它曾经用 jieba 词袋生成「来自 N 个来源的 M 条对话 涉及: …」——
|
||||
// 那是词袋清单,且全库同质(一旦当向量输入就会让检索失效)。
|
||||
func TestSummarizeEntries(t *testing.T) {
|
||||
entries := []ContextEntry{
|
||||
{Source: "user", Content: "今天天气如何"},
|
||||
{Source: "user", Content: "今天天气如何,要不要带伞"},
|
||||
{Source: "user", Content: "明天会下雨吗"},
|
||||
}
|
||||
summary := summarizeEntries(entries, func(s string) string { return s }, nil, nil)
|
||||
if summary == "" {
|
||||
t.Error("summary should not be empty")
|
||||
t.Fatal("summary 不应为空")
|
||||
}
|
||||
if !contains(summary, "2") {
|
||||
t.Errorf("summary should mention count, got: %s", 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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -196,16 +221,6 @@ func TestExtractKeywords(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractTags(t *testing.T) {
|
||||
entries := []ContextEntry{
|
||||
{Content: "我喜欢喝咖啡和编程"},
|
||||
}
|
||||
tags := extractTags(entries, func(s string) string { return s }, nil, nil)
|
||||
if len(tags) == 0 {
|
||||
t.Error("should extract tags")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInsertEmptyDoc(t *testing.T) {
|
||||
dir, err := os.MkdirTemp("", "doc_empty_*")
|
||||
if err != nil {
|
||||
@ -346,92 +361,6 @@ func TestRemoveNonexistent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSummarizeEntriesWithToolCleanFn(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
toolCleanFn func(name, output string) string
|
||||
wantTopics []string
|
||||
notTopics []string
|
||||
}{
|
||||
{
|
||||
name: "nil toolCleanFn uses raw output",
|
||||
toolCleanFn: nil,
|
||||
wantTopics: []string{"手机", "电脑"},
|
||||
notTopics: nil,
|
||||
},
|
||||
{
|
||||
name: "NoMemory returns empty skips tool output",
|
||||
toolCleanFn: func(name, output string) string {
|
||||
return ""
|
||||
},
|
||||
wantTopics: nil,
|
||||
notTopics: []string{"手机", "电脑"},
|
||||
},
|
||||
{
|
||||
name: "Cleaner applies filter",
|
||||
toolCleanFn: func(name, output string) string {
|
||||
return "电脑 编程"
|
||||
},
|
||||
wantTopics: []string{"电脑", "编程"},
|
||||
notTopics: nil,
|
||||
},
|
||||
}
|
||||
|
||||
entry := ContextEntry{
|
||||
Content: "今天天气",
|
||||
ToolResults: []ToolResultItem{
|
||||
{Name: "test_tool", Output: "手机 电脑"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
summary := summarizeEntries([]ContextEntry{entry}, func(s string) string { return s }, tc.toolCleanFn, nil)
|
||||
for _, w := range tc.wantTopics {
|
||||
if !contains(summary, w) {
|
||||
t.Errorf("summary should contain %q, got: %s", w, summary)
|
||||
}
|
||||
}
|
||||
for _, n := range tc.notTopics {
|
||||
if contains(summary, n) {
|
||||
t.Errorf("summary should NOT contain %q, got: %s", n, summary)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractTagsWithToolCleanFn(t *testing.T) {
|
||||
entries := []ContextEntry{
|
||||
{
|
||||
Content: "对话",
|
||||
ToolResults: []ToolResultItem{
|
||||
{Name: "search", Output: "编程和咖啡"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// toolCleanFn 返回 "" → NoMemory,工具输出被跳过
|
||||
tagsSkip := extractTags(entries, func(s string) string { return s }, func(name, output string) string { return "" }, nil)
|
||||
for _, tag := range tagsSkip {
|
||||
if tag == "编程" || tag == "咖啡" {
|
||||
t.Errorf("NoMemory tool should not contribute keywords, got tag: %s", tag)
|
||||
}
|
||||
}
|
||||
|
||||
// toolCleanFn 返回清洗文本 → 用清洗后内容提取关键词
|
||||
tagsClean := extractTags(entries, func(s string) string { return s }, func(name, output string) string { return "咖啡 编程" }, nil)
|
||||
found := false
|
||||
for _, tag := range tagsClean {
|
||||
if tag == "编程" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("cleaner output keywords should appear in tags, got: %v", tagsClean)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextToDocContentPreservesRawToolOutput(t *testing.T) {
|
||||
dir, err := os.MkdirTemp("", "doc_toolclean_*")
|
||||
if err != nil {
|
||||
@ -519,7 +448,7 @@ func TestBuildDenseIndexPersistsAcrossRestart(t *testing.T) {
|
||||
}
|
||||
s1.SetDenseSpace(sp)
|
||||
s1.BuildDenseIndex(sp)
|
||||
if got := s1.Get("doc_persist"); got == nil || len(got.DenseVec) != 8 || got.DenseFP != sp.fp {
|
||||
if got := s1.Get("doc_persist"); got == nil || len(got.DenseVec) != 8 || got.DenseFP != s1.vectorFingerprint() {
|
||||
t.Fatalf("迁移未在内存生效: %+v", got)
|
||||
}
|
||||
// 不调用 Stop 就另开一个实例:体现「迁移当场落盘」,不依赖关停
|
||||
@ -532,10 +461,10 @@ func TestBuildDenseIndexPersistsAcrossRestart(t *testing.T) {
|
||||
if loaded == nil {
|
||||
t.Fatal("重启后文档不见了")
|
||||
}
|
||||
if len(loaded.DenseVec) != 8 || loaded.DenseFP != sp.fp {
|
||||
if len(loaded.DenseVec) != 8 || loaded.DenseFP != sp.fp+"|"+docVectorAlgoVersion {
|
||||
t.Fatalf("迁移结果未落盘:期望 dim=8 fp=%q,实际 dim=%d fp=%q"+
|
||||
"(后果:每次启动都重算同一批文档,磁盘状态永不收敛)",
|
||||
sp.fp, len(loaded.DenseVec), loaded.DenseFP)
|
||||
sp.fp+"|"+docVectorAlgoVersion, len(loaded.DenseVec), loaded.DenseFP)
|
||||
}
|
||||
// 已对齐 → 一次向量计算都不该发生
|
||||
sp2 := &fakeSpace{fp: sp.fp, dim: 8}
|
||||
|
||||
@ -379,10 +379,49 @@ func (b *bigramSpace) EmbedImageDense(img []byte, mime string) ([]float64, error
|
||||
|
||||
var _ vector.MultimodalEmbedder = (*bigramSpace)(nil)
|
||||
|
||||
func truncateRunes(s string, n int) string {
|
||||
r := []rune(s)
|
||||
if len(r) <= n {
|
||||
return s
|
||||
// ⑥ 向量算法变更必须触发存量重算(否则修复对已有数据完全无效)。
|
||||
//
|
||||
// ★ 这是实测踩到的坑:把 denseFor 的输入从 `Summary + Content` 改成事件正文后,
|
||||
// DenseFP 仍是同一个空间指纹 ⇒ 存量文档全部命中 `DenseFP == 指纹` 的跳过条件
|
||||
// ⇒ **永不重算**。生产实测后果:1055 篇存量文档分数纹丝不动(0.78/0.79/0.76,
|
||||
// 区分度 0.003),修复等于没做。
|
||||
//
|
||||
// 判据:把 DenseFP 写成"空间指纹但无算法版本"(= 旧算法留下的状态),
|
||||
// 重新 BuildDenseIndex 必须重算。
|
||||
func TestRetrieval_AlgoVersionChangeForcesRecompute(t *testing.T) {
|
||||
s := NewStore(t.TempDir(), memory.TokenizeWords)
|
||||
if err := s.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(r[:n]) + "..."
|
||||
t.Cleanup(s.Stop)
|
||||
|
||||
ds := &bigramSpace{}
|
||||
s.SetDenseSpace(ds)
|
||||
|
||||
// 模拟旧算法留下的状态:指纹是**纯空间指纹**(无 |v2 后缀)
|
||||
legacyVec := make([]float64, ds.Dim())
|
||||
legacyVec[0] = 1
|
||||
if err := s.Insert(&Doc{
|
||||
ID: "doc_legacy_vec", Summary: "旧向量", Content: "旧算法的向量",
|
||||
DenseVec: legacyVec, DenseFP: ds.Fingerprint(), // ← 无算法版本
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
before := s.Get("doc_legacy_vec").DenseVec[0]
|
||||
s.BuildDenseIndex(ds)
|
||||
after := s.Get("doc_legacy_vec")
|
||||
|
||||
if after.DenseFP != s.vectorFingerprint() {
|
||||
t.Errorf("算法版本未写入 DenseFP:got %q,want %q",
|
||||
after.DenseFP, s.vectorFingerprint())
|
||||
}
|
||||
if after.DenseFP == ds.Fingerprint() {
|
||||
t.Error("DenseFP 仍是纯空间指纹 —— 换算法后存量永远不会重算")
|
||||
}
|
||||
// 已对齐后再次 BuildDenseIndex 不应重复计算
|
||||
ds2 := &bigramSpace{}
|
||||
s.SetDenseSpace(ds2)
|
||||
s.BuildDenseIndex(ds2)
|
||||
_ = before
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user