diff --git a/deploy/packaging/package-linux.sh b/deploy/packaging/package-linux.sh index 124042e9..056a6ae5 100644 --- a/deploy/packaging/package-linux.sh +++ b/deploy/packaging/package-linux.sh @@ -728,6 +728,18 @@ build_tar() { [ -f "$src" ] && cp "$src" "$staging/usr/bin/$bin" done + # ★ 启动依赖图必须与 homed 同目录。内核启动时比对「磁盘图 digest」与 + # 「二进制内嵌 digest」,读不到图 ⇒ 生产构建直接拒绝启动(退出码 1)。 + # 此前 build_tar 漏了这一条:装 tar 包的机器 homed 一启动就死。 + if [ -f "$BUILD_DIR/depgraph.bin" ]; then + cp "$BUILD_DIR/depgraph.bin" "$staging/usr/bin/depgraph.bin" + chmod 644 "$staging/usr/bin/depgraph.bin" + else + echo "ERROR: 缺少 $BUILD_DIR/depgraph.bin —— 内核与依赖图必须来自同一次构建。" >&2 + echo " 先跑 make build(它会生成图并注入 digest),再打包。" >&2 + return 1 + fi + # setup script local setup_src="$PROJECT_ROOT/deploy/packaging/linux/setup.sh" [ -f "$setup_src" ] && cp "$setup_src" "$staging/usr/lib/hologram/setup.sh" diff --git a/docs/zh/memory-usability-plan.md b/docs/zh/memory-usability-plan.md new file mode 100644 index 00000000..d80bb8cc --- /dev/null +++ b/docs/zh/memory-usability-plan.md @@ -0,0 +1,291 @@ +# 三层记忆可用性修复计划:统一向量化 + 提示词让 agent 真正理解 + +> 状态:**待执行**(2026-10-22) +> 前置:`docs/zh/memory-blockflow-repair-plan.md`(L1↔L2 结构同构,已实施) +> 本文修的是**上一轮没修的那一层**:结构对了,但检索质量与提示词仍然不可用。 + +--- + +## 〇、起点:承认上一轮的判断错误 + +上一轮我修了 L1↔L2 的结构(事件列表 + 正文进块 + 按原时序回填), +然后声称「机制已通」。**结构确实通了,但功能不可用。** + +用生产数据实测(1054 篇文档、chineseclip 512 维)三个互不相关的查询: + +``` +查询 "doc 层召回是怎么设计的" + score=0.7834 来自 4 个来源的 61 条对话 (cold_storage, agentcli...) + score=0.7795 来自 1 个来源的 2 条对话 (agent) 涉及: map, content, dsh, 占用 + score=0.7768 来自 1 个来源的 1 条对话 (agent) 涉及: 自检, OK + +查询 "hmap 插件安装验签" + score=0.7922 来自 1 个来源的 1 条对话 (agent) 涉及: map, content, 是不是, pi + +查询 "QQ 头像" + score=0.7601 来自 1 个来源的 1 条对话 (agent) 涉及: CQ, qq, ok +``` + +**分数全挤在 0.75~0.79,区分度≈0,命中内容与查询无关。** + +### 判据缺失(最该记的一条) + +上一轮写了 16 条判据、6 个变异,全部通过。但**没有一条验证「检索结果必须与查询相关」**。 +所以「全绿」证明的只是我实现了我以为的东西 —— 不是它可用。 + +> ★ 教训:**结构判据不能替代质量判据**。 +> 「字段非空」「条数正确」「不 panic」都可能在检索完全失效时依然全绿。 + +--- + +## 一、根因(三条,按依赖排序) + +### 根因 1:向量的输入是 jieba 词袋,不是正文 + +`internal/memory/document/document.go` — `denseFor`: + +```go +if tv, err := s.denseSpace.VectorizeDense(doc.Summary + " " + doc.Content); err == nil { +``` + +而 `Summary` 由 `summarizeEntries` 生成,后者调用链是: + +``` +summarizeEntries → memory.ExtractKeywords → TokenizeContentWords → jieba.Tag() +extractTags → 同上 +extractEntities → 同上 +``` + +`TokenizeContentWords`(`internal/memory/cut.go:256`)就是 `jieba.Tag()` 分词 + 停用词过滤, +产出形如 `涉及: map, content, 是不是, pi`。 + +**这是与设计直接矛盾的**。`ARCHITECTURE.md` L131 / L174 明写向量层级: + +``` +统一多模态空间(主,带 fingerprint)→ StaticEmbedder 词嵌入 → TF-IDF(回退) +``` + +`Summary` 是**主路径的输入**,却由**最末一级的词法分词**产出。 +全库所有文档的 Summary 因此高度同质(都是「来自 N 个来源的 M 条对话 涉及: …」), +余弦相似度自然全挤在高位、无区分度。 + +### 根因 2:媒体块没有语义,且不需要单独生成 + +设计要求「doc 支持多模态摘要」。当前实现里: + +- `Doc.Summary` 只有文本路径,**媒体块从不参与** +- `crossModalMarkdown` 里媒体只渲染成 `[image/png blk_media_xxx]`(`blockLabel`:MIME + 短 ID) +- 一篇带 20 张图的文档,摘要里没有「有图」的任何信息 + +**但「多模态摘要」这个提法本身是歧路**(用户 2026-10-22 指出): + +> 媒体 doc 块一般是和文本紧邻的,所以对应的文本就足够了 + +`Doc.Events[i].Blocks` 里媒体块与文本块**在同一事件内**。媒体块的语义由 +**同事件的文本**天然覆盖,不需要为每个媒体单独生成描述(那正是 CAS 时代 +「描述式索引」被拆除的同一个错误)。 + +⇒ 正确做法不是「给媒体写摘要」,而是:**向量用事件正文算,媒体自动被覆盖**。 + +### 根因 3:提示词与注入文案没有任何引导 + +**(a)`doc_query` 的工具描述只有一句话** + +```go +toolDefWith("doc_query", "查询文档记忆。输入查询内容,返回相关文档摘要。", ...) +``` + +逐条对照问题: + +| 应有信息 | 现状 | +|---|---| +| L2 是什么 | ❌ 只说「文档记忆」,没有定义 | +| 何时该用 | ❌ 无 | +| 调用后会发生什么 | ❌ 「返回相关文档摘要」**与真实行为不符**(真实是逐条按原时序回填到对话时序) | +| 与 `memory_recall` 的分工 | ❌ 无 | + +对比同文件里 `【输出规则】`(6 条)、`【工具执行顺序】`(4 条)的详细程度, +`doc_query` 是最敷衍的一个。 + +**(b)预填充注入文案没有引导** + +`crossModalMarkdown` 输出: + +``` +【跨模态相关记忆】 +[1] 来自 4 个来源的 61 条对话 (cold_storage, agentcli...) (来源:context_archived) 相关度:0.78(文本命中) +``` + +**没有一个字说「可以用 doc_query 取全文」**。模型看到这段只能忽略。 + +**(c)系统提示词从未解释三层记忆是什么** + +`grep 记忆 internal/agent/core/tooldefs.go` 全量命中里,没有任何一段说明 +Context / Document / Graph 三者的分工与使用时机。agent 只能从工具名猜。 + +--- + +## 二、修复步骤(按依赖,每步先写会红的判据) + +### 步骤 1:判据先行 —— 检索质量 + +**先写测试,此时必须红。** + +`internal/memory/document/retrieval_quality_test.go`: + +- 构造真实语料(约 50 篇,内容取自生产副本的真实事件正文 + 人工标注的查询-答案对) +- 断言:给定查询,**正确文档必须排第一**(top1 准确率 100%,或至少 top3 内) +- 断言:互不相关的查询,top1 结果**必须不同** +- 断言:正确命中的分数必须**显著高于**非命中(区分度阈值) + +**变异**:把 `denseFor` 改回 `Summary + Content` ⇒ 必须红。 +**变异**:把正文替换成 jieba 词袋 ⇒ 必须红。 + +这一步是整个计划的地基。没有它,后面每一步都只是「我以为改对了」。 + +### 步骤 2:向量输入改为正文 + +`internal/memory/document/document.go` — `denseFor`: + +``` +现在:VectorizeDense(doc.Summary + " " + doc.Content) +改为:VectorizeDense(事件正文拼接) + 有 Events → 按事件顺序取 InOut() 的 input + response(文本块内容) + 无 Events(存量)→ 退回 Content +``` + +**媒体如何被覆盖**:媒体块与文本块在同一事件内,事件的正文进入向量即覆盖它。 +不需要为媒体单独编码 —— 若将来要「按图搜图」,那是视觉路(`RecallBlocks`)的职责, +已有独立通路,与文本路不冲突。 + +### 步骤 3:删掉 jieba 摘要路径 + +`Summary` / `Tags` / `Entities` 目前全部由 `ExtractKeywords`(jieba)产出。 + +- `Tags`:唯一消费点是 `doc_query` 返回值里的「标签: …」(`toolcall.go:1038`), + **是词袋噪声,删除该行** +- `Entities`:`Doc.Entities` 无任何非测试读取点(跨包的 `injected.Entities` + 是 `memory.Entity` 另一个类型,无关),**字段可删** +- `Summary`:改为**纯展示**(人工阅读 / 日志),由正文首句截取生成,**不参与向量** + +⚠️ 需先确认:`Doc.Tags` / `Doc.Entities` 是否有 WebUI / SDK 侧读取。 +(已知 `sdk/memory_impl.go` 的 `Doc` 只有 `ID/Title/Content`,无 Tags/Entities。) + +### 步骤 4:预填充注入正文片段 + 引导 + +`crossModalMarkdown` 改为: + +``` +【历史记忆(按相关度)】 +[1] 2026-10-12 11:09 · qq · 相关度 0.83 + 紫蓝红三色带的配色参考,老大要求…(正文首 2 行) +[2] …(含 2 张图:image/png, image/jpeg) + +这些是**历史**片段(不是当前对话)。要看完整原文, +调用 doc_query 把对应事件按原时间戳拉回对话时序。 +``` + +要点: +- 去掉「来自 N 个来源的 M 条对话」这类统计描述 +- 给出**时间 + 来源 + 正文开头**(真实内容,让模型能判断相关性) +- 媒体只报数量与类型,不编描述 +- **明确告知 doc_query 的作用** + +### 步骤 5:`doc_query` 工具描述重写 + +按 `memory_*` 的详细度,必须覆盖: + +- L2 是什么(历史事件层,不直接进当前对话) +- 何时用(记得办过但想不起细节 / 跨会话跨天 / 需要原始过程而非结论) +- 调用后发生什么(**把事件逐条按原时间戳回填到对话时序**,不是返回一段摘要) +- 与 `memory_recall` 的分工(图记忆=关系与最新值;doc=整段过程与原文) +- 副作用(从 L2 取出,取走后该文档不再在 L2) + +### 步骤 6:系统提示词增加「三层记忆」段落 + +在 `buildSystemPrompt` 里加一段,说清三层是什么、怎么用: + +``` +【三层记忆】记忆分三层,你现在看到的是第一层。 +- 对话时序(L1):最近的对话,已在你的上下文里。超过窗口的会被移出。 +- 历史记忆(L2):移出后的事件与原文仍完整保存。自动预填充会给你相关片段; + 要给看全文/完整过程,用 doc_query —— 它会把那些事件按原时间戳插回对话时序。 +- 图记忆(L3):关系与事实的长期存储(谁是什么、什么等于什么、最新值是什么)。 + 用 memory_recall 查;用 memory_commit 记。 +选择:要"原文与过程"→ doc_query;要"关系与当前值"→ memory_recall; + 要"成文知识"→ knowledge_search。 +``` + +**注意**:这段属于「内核运行时块」(提示词分层规范:内核行为由内核生成), +不是工具用法(工具用法归 `description`)。它讲的是**内核的真实执行语义** +(回填到时序、自动预填充),符合分层原则。 + +--- + +## 三、判据清单(每步都要能红) + +| # | 判据 | 变异(必须红) | +|---|---|---| +| 1 | 检索质量:top1 命中 / 不相关查询 top1 不同 / 分数有区分度 | `denseFor` 改回 `Summary+Content`;或换回 jieba 词袋 | +| 2 | 向量输入是正文:断言 `denseFor` 读的是 Events 正文(可用哨兵串) | 正文里埋哨兵,改回 Summary ⇒ 哨兵不出现在向量输入 | +| 3 | 摘要不含 jieba 词袋:`Summary` 不出现「来自 N 个来源的 M 条对话」 | 恢复 `summarizeEntries` ⇒ 红 | +| 4 | 预填充文案含正文与引导:必须含 `doc_query` 字样 + 正文片段 | 改回旧渲染 ⇒ 红 | +| 5 | 工具描述覆盖五项(L2 定义/何时用/调用后行为/分工/副作用) | 删任一项 ⇒ 红 | +| 6 | 系统提示词含三层记忆段落且语义与实现一致 | 回填行为改掉而提示词不改 ⇒ 红(跨文件一致性) | + +**判据 6 特别说明**:提示词说「按原时间戳插回时序」这件事, +必须与 `restoreDocEvents` 的真实行为绑在一条判据里 —— +否则就是「提示词在说谎」,那比没说更糟。 + +--- + +## 四、禁止事项 + +| 禁止 | 原因 | +|---|---| +| 用 jieba 词袋当向量输入或摘要内容 | 与「统一向量化」直接矛盾;全库同质,检索失效 | +| 为媒体块单独生成文本描述 | CAS 时代的「描述式索引」错误;媒体语义由同事件文本覆盖 | +| 只改结构不改提示词 | 机制改了模型不知道 = 等于没改 | +| 用「字段非空 / 条数正确 / 不 panic」当检索判据 | 检索完全失效时这些也全绿 | +| 声称「机制已通」而不给出生产数据的检索质量实测 | 上一轮的教训 | + +--- + +## 五、验证 + +1. `go test ./... -count=1` 全绿(现有 58 包基线) +2. 新判据全绿,且 6 个变异各自被抓住 +3. **生产副本实测**:用真实数据跑检索,给出 top1/top3 命中率与分数分布 + (对比修复前的 0.75~0.79 无区分度) +4. 生产部署后用真实对话观察:agent 是否在需要时调用 `doc_query` + +--- + +## 六、标准流程(本次严格执行) + +上次直接提交 `main` + rebase + amend + 绕过 CI,不符合 `docs/git-branching.md`。 +这次必须: + +``` +1. git checkout -b feature/memory-usability main +2. 按步骤 1→6 提交(每步独立 commit,先写判据再改) +3. git push -u origin feature/memory-usability +4. 开 PR +5. 等 CI 10 项全部通过 +6. 合并 main(不绕过必需检查) +7. 部署 + 生产验证 +``` + +⚠️ 远端仓库已改名 `JianFeeeee/HologramAgent`(origin 与 gitcode 均已更新)。 + +--- + +## 七、待确认 + +1. **`Doc.Tags` / `Doc.Entities` 是否可删?** + 我查到非测试读取点只有 `doc_query` 返回里的「标签:」一行(可删)。 + 但需确认 WebUI / 外部插件是否依赖(SDK 的 `Doc` 无这两个字段)。 + +2. **判据 1 的语料从哪来?** + 建议从生产副本抽 50 篇真实文档 + 人工标注 10~15 个查询-答案对。 + 是否允许我把这些真实文本(脱敏后)作为测试 fixture 入库? diff --git a/internal/agent/core/crossmodal.go b/internal/agent/core/crossmodal.go index 1b12856b..bdedea28 100644 --- a/internal/agent/core/crossmodal.go +++ b/internal/agent/core/crossmodal.go @@ -5,6 +5,7 @@ import ( "log" "sort" "strings" + "unicode" "github.com/JianFeeeee/HologramAgent/internal/memory" "github.com/JianFeeeee/HologramAgent/internal/memory/document" @@ -234,37 +235,142 @@ func fuseCrossModal(textHits, visualHits []CrossModalHit, topK int, cfg CrossMod return merged } -// crossModalMarkdown 把融合候选渲染成注入上下文的文本。 -// 文档行给出摘要;媒体行只给 MIME + 短块 ID(不再有生成的描述)。 +// crossModalMarkdown 把融合候选渲染成注入提示词的文本。 +// +// ## 为什么改 +// +// 旧输出是这样的: +// +// 【跨模态相关记忆】 +// [1] 来自 4 个来源的 61 条对话 (cold_storage, agentcli...) (来源:context_archived) 相关度:0.78(文本命中) +// +// 三个问题,都被生产日志证实: +// +// 1. 只有**统计描述**(几个来源几条对话),不是内容 —— 模型无法判断相关性; +// 2. **没有一个字**说"你可以把这些原文取回来" —— 模型看完只能忽略; +// 3. 来源写了 `context_archived` 这种内部标记,对模型无意义。 +// +// 现在每条给出「时间 · 来源 · 正文开头」,并在块尾**明确告知 doc_query 的作用**: +// 这才是让预填充真正被用起来的必要条件。 func (a *Agent) crossModalMarkdown(hits []CrossModalHit) string { if len(hits) == 0 { return "" } var lines []string for i, h := range hits { - marker := "" - switch { - case h.DoubleHit: - marker = "(图文双命中)" - case h.Doc != nil: - marker = "(文本命中)" - case h.Media != nil: - marker = "(视觉命中)" - } - parts := []string{fmt.Sprintf("[%d]", i+1)} + var parts []string + parts = append(parts, fmt.Sprintf("[%d]", i+1)) + + // 时间 + 来源:让模型知道"这是什么时候、谁的事" if h.Doc != nil { - parts = append(parts, h.Doc.Summary) - if h.Doc.Source != "" { - parts = append(parts, fmt.Sprintf("(来源:%s)", h.Doc.Source)) + if h.Doc.HasEvents() { + first := h.Doc.Events[0].Timestamp + last := h.Doc.Events[len(h.Doc.Events)-1].Timestamp + if first.Equal(last) { + parts = append(parts, first.Format("2006-01-02 15:04")) + } else { + parts = append(parts, fmt.Sprintf("%s→%s", + first.Format("01-02 15:04"), last.Format("01-02 15:04"))) + } + // 真实来源(跳过内部归档标记,它对模型无意义) + if src := firstRealSource(h.Doc.Events); src != "" { + parts = append(parts, src) + } + } else { + parts = append(parts, h.Doc.CreatedAt.Format("2006-01-02 15:04")) } } + + // 正文开头:真实内容,模型可据此判断相关性 + if h.Doc != nil { + if head := docHeadlineForPrompt(h.Doc); head != "" { + parts = append(parts, head) + } + } + // 媒体只报类型与数量,不编描述(描述式索引已在 v1.2.0 拆除) if h.Media != nil { - if line := blockLabel(h.Media); line != "" { - parts = append(parts, line) + label := string(h.Media.Modality) + if h.Media.MIME != "" { + label = h.Media.MIME + } + parts = append(parts, "["+label+"]") + } + if h.DoubleHit { + parts = append(parts, "(图文双命中)") + } + parts = append(parts, fmt.Sprintf("相关度:%.2f", h.Fused)) + lines = append(lines, strings.Join(parts, " · ")) + } + + var b strings.Builder + b.WriteString("【历史记忆(L2,自动预填充)】\n") + b.WriteString("下面是按语义相似度从历史层挑出的相关片段 —— **不是当前对话**。\n") + b.WriteString(strings.Join(lines, "\n")) + b.WriteString("\n要其中某条的完整原文与当时过程:用 doc_query,它会把那些事件" + + "按原始时间戳插回你的对话时序。") + return b.String() +} + +// firstRealSource 取事件里第一个非内部标记的来源。 +// +// `context_archived` 是内核归档动作的标记,不是"谁说的",对模型无意义。 +func firstRealSource(events []document.DocEvent) string { + for _, ev := range events { + if ev.Source != "" && ev.Source != "context_archived" && ev.Source != "cold_storage" { + return ev.Source + } + } + if len(events) > 0 { + return events[0].Source + } + return "" +} + +// docHeadlineForPrompt 取文档正文的第一句,作为注入行里的内容提示。 +// +// 不截断成"摘要":这里只是一行提示,完整内容在 L2 里、需要时用 doc_query 取。 +func docHeadlineForPrompt(d *document.Doc) string { + if d == nil { + return "" + } + if d.HasEvents() { + for _, ev := range d.Events { + in, resp, _ := ev.InOut() + for _, cand := range []string{in, resp} { + if line := firstMeaningfulLineForPrompt(cand); line != "" { + return truncateRunesForPrompt(line, 60) + } } } - parts = append(parts, fmt.Sprintf("相关度:%.2f%s", h.Fused, marker)) - lines = append(lines, strings.Join(parts, " ")) + return "" } - return "【跨模态相关记忆】\n" + strings.Join(lines, "\n") + return truncateRunesForPrompt(firstMeaningfulLineForPrompt(d.Content), 60) +} + +func firstMeaningfulLineForPrompt(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 + } + } + if hasWord { + return strings.Join(strings.Fields(line), " ") + } + } + return "" +} + +func truncateRunesForPrompt(s string, max int) string { + r := []rune(s) + if len(r) <= max { + return s + } + return string(r[:max]) + "…" } diff --git a/internal/agent/core/prompt_memory_test.go b/internal/agent/core/prompt_memory_test.go new file mode 100644 index 00000000..e49b60ad --- /dev/null +++ b/internal/agent/core/prompt_memory_test.go @@ -0,0 +1,192 @@ +package core + +// 三层记忆的**提示词诚实性**判据。 +// +// 这类判据的目的是防止「提示词在说谎」——它比不说更糟: +// 模型按提示词行事,而实现不是那样,于是行为与预期系统性错位。 +// +// 生产实测(2026-10-22 日志):agent 几乎不调用 doc_query,即使调用也 +// 只看返回值、不知道原文已被回填到对话时序。根因是: +// · 工具描述只有「查询文档记忆。返回相关文档摘要」一句(且与真实行为不符) +// · 系统提示词从未解释 L1/L2/L3 是什么 + +import ( + "strings" + "testing" + "time" + + "github.com/JianFeeeee/HologramAgent/internal/memory/document" +) + +// ① doc_query 的描述必须覆盖五项,且与真实行为一致。 +func TestDocQueryDescriptionCoversRealBehavior(t *testing.T) { + a := newToolTestAgent(t) + desc := findToolDescription(t, a, "doc_query") + + mustHave := []struct { + name string + sub string + }{ + {"L2 是什么", "历史记忆层"}, + {"何时该用", "何时用"}, + {"调用后行为=回填时序", "插回你的对话时序"}, + {"原始时间戳", "原始时间戳"}, + {"与 memory_recall 分工", "memory_recall"}, + {"副作用=消费式取出", "消费式"}, + } + for _, m := range mustHave { + if !strings.Contains(desc, m.sub) { + t.Errorf("doc_query 描述缺少「%s」(应含 %q):\n%s", m.name, m.sub, desc) + } + } + // 不得再声称"返回摘要"—— 那是旧实现的谎 + if strings.Contains(desc, "返回相关文档摘要") { + t.Error("doc_query 描述仍声称『返回相关文档摘要』,与真实行为(回填原文到时序)不符") + } +} + +// ② doc_commit 描述必须说清「适合/不适合写什么」,并与另外两层分工。 +func TestDocCommitDescriptionExplainsScope(t *testing.T) { + a := newToolTestAgent(t) + desc := findToolDescription(t, a, "doc_commit") + + for _, sub := range []string{"适合写什么", "不适合写什么", "memory_commit", "knowledge_create"} { + if !strings.Contains(desc, sub) { + t.Errorf("doc_commit 描述缺少 %q:\n%s", sub, desc) + } + } +} + +// ③ 系统提示词必须有三层记忆段落,且语义与实现一致。 +// +// ★ 这条是**跨文件一致性**判据:提示词说"按原始时间戳插回时序", +// +// 实现就必须真的那样做。若哪天 restoreDocEvents 被改回造合成事件, +// 这里必须红 —— 否则提示词又开始说谎。 +func TestSystemPromptExplainsThreeLayers(t *testing.T) { + a := newToolTestAgent(t) + // memContext 为空即可:三层记忆段落是内核无条件注入的 + prompt := a.buildSystemPrompt("", "测试输入") + + for _, sub := range []string{ + "【三层记忆】", + "对话时序(L1", + "历史记忆(L2", + "图记忆(L3", + "doc_query", + "memory_recall", + } { + if !strings.Contains(prompt, sub) { + t.Errorf("系统提示词缺少三层记忆段落的 %q", sub) + } + } + // 提示词承诺「按原始时间戳插回」,实现必须真的这么做 + if !strings.Contains(prompt, "原始时间戳") { + t.Error("提示词没有说明 doc_query 会按原始时间戳回填") + } + if !strings.Contains(prompt, "插回你的对话时序") { + t.Error("提示词没有说明 doc_query 把事件插回时序(模型会以为只是返回摘要)") + } +} + +// ④ doc_commit 不得再有 tags 参数(Doc.Tags 已删除,留着会骗模型)。 +func TestDocCommitHasNoTagsParam(t *testing.T) { + a := newToolTestAgent(t) + params := findToolParams(t, a, "doc_commit") + if _, ok := params["tags"]; ok { + t.Error("doc_commit 仍有 tags 参数,但 Doc.Tags 已删除 —— 参数会静默丢弃") + } +} + +// ---- helpers ---- + +func findToolDescription(t *testing.T, a *Agent, name string) string { + t.Helper() + for _, raw := range a.buildToolDefs() { + m, ok := raw.(map[string]interface{}) + if !ok { + continue + } + fn, ok := m["function"].(map[string]interface{}) + if !ok { + continue + } + if fn["name"] == name { + d, _ := fn["description"].(string) + return d + } + } + t.Fatalf("工具 %s 不在 buildToolDefs 里", name) + return "" +} + +func findToolParams(t *testing.T, a *Agent, name string) map[string]interface{} { + t.Helper() + for _, raw := range a.buildToolDefs() { + m, ok := raw.(map[string]interface{}) + if !ok { + continue + } + fn, ok := m["function"].(map[string]interface{}) + if !ok { + continue + } + if fn["name"] == name { + p, _ := fn["parameters"].(map[string]interface{}) + props, _ := p["properties"].(map[string]interface{}) + return props + } + } + t.Fatalf("工具 %s 不在 buildToolDefs 里", name) + return nil +} + +// ⑤ 预填充注入的文案必须:给真实内容 + 明确引导用 doc_query。 +// +// 断点史:旧文案只给「来自 4 个来源的 61 条对话 相关度:0.78」—— +// 统计描述,没有任何内容,也没有一个字提示可以取全文。 +// 生产日志里 agent 从不使用预填充给的信息,直接原因就是这个。 +// +// ★ 用**真实归档路径**(ContextToDoc)构造文档,不手工拼 DocEvent: +// +// 块 ID 由 entryTextBlockID 派生,手工拼的 ID 不匹配会被判为非本事件块, +// 那样测的是"我拼的数据对不对",不是生产过程。 +func TestPrefillMarkdownCarriesContentAndGuide(t *testing.T) { + a := newToolTestAgent(t) + + if _, err := a.docStore.ContextToDoc("context_archived", []document.ContextEntry{{ + Timestamp: time.Date(2026, 7, 8, 9, 10, 0, 0, time.UTC), + Source: "webui", + Content: "配色方案怎么定?", + Response: "紫蓝红三色带的配色方案按六比三比一,主色紫、辅色蓝、点缀红。", + }}, nil, nil, nil, nil); err != nil { + t.Fatal(err) + } + doc := a.docStore.RecentDocs(1)[0] + + md := a.crossModalMarkdown([]CrossModalHit{{Doc: doc, DocScore: 0.8, Fused: 0.8}}) + + if md == "" { + t.Fatal("预填充文案为空") + } + // 1) 必须给真实内容(正文片段),不是模板摘要 + if !strings.Contains(md, "紫蓝红") && !strings.Contains(md, "配色方案") { + t.Errorf("预填充文案没有真实正文片段:\n%s", md) + } + // 2) 不得再出现 jieba 模板摘要 + if strings.Contains(md, "涉及:") { + t.Errorf("预填充文案仍是 jieba 模板摘要:\n%s", md) + } + // 3) 必须有时间 + if !strings.Contains(md, "2026-07-08") { + t.Errorf("预填充文案没有时间:\n%s", md) + } + // 4) ★ 必须明确引导使用 doc_query + if !strings.Contains(md, "doc_query") { + t.Errorf("预填充文案没有引导使用 doc_query:\n%s", md) + } + // 5) 必须说明它来自历史层而非当前对话 + if !strings.Contains(md, "L2") && !strings.Contains(md, "历史记忆") { + t.Errorf("预填充文案没有说明来源层:\n%s", md) + } +} diff --git a/internal/agent/core/recalldoc_test.go b/internal/agent/core/recalldoc_test.go index 11849601..5049e9be 100644 --- a/internal/agent/core/recalldoc_test.go +++ b/internal/agent/core/recalldoc_test.go @@ -284,3 +284,73 @@ func TestRecallByDoc_StressManyEvents(t *testing.T) { } t.Logf("回填 %d 条耗时 %v", got, elapsed) } + +// ⑥ doc_query 的返回值必须**带真实内容**,不能只有统计描述。 +// +// 断点史(生产日志实测):旧返回值是 +// +// 已加载 3 篇文档记忆: #1(来自 1 个来源的 1 条对话 (agent) 涉及: ok), #2(...) +// +// 全 jieba 词袋,零信息量 ⇒ agent 无法判断这笔召回有没有用 ⇒ 几乎不再调用。 +// +// 本判据要求返回值同时具备三件事: +// 1. 明确说明原文去了哪里("插回对话时序") +// 2. 每条给出时间 + 来源(可判断"这是哪次的事") +// 3. 每条给出真实正文片段(可判断"这是不是我要的") +func TestRecallByDoc_ReturnValueCarriesRealContent(t *testing.T) { + a := newToolTestAgent(t) + + const sentinel = "紫蓝红三色带按六比三比一" + archiveEvents(t, a, []ContextEvent{{ + Timestamp: time.Date(2026, 6, 7, 8, 9, 10, 0, time.UTC), + Source: "webui", + Input: "配色方案怎么定?", + Response: "结论:" + sentinel + ",主色紫、辅色蓝、点缀红。", + }}) + + out := a.executeDocTool(agentAPI.ToolCall{ + Name: "doc_query", + Arguments: map[string]interface{}{"query": "配色方案 紫蓝红", "top_k": float64(3)}, + }) + + // 1) 必须说明原文去了时序 + if !strings.Contains(out, "时序") { + t.Errorf("返回值没说原文去了哪里:\n%s", out) + } + // 2) 必须有时间(年月日或月-日) + if !strings.Contains(out, "2026-06-07") && !strings.Contains(out, "06-07") { + t.Errorf("返回值没有时间信息:\n%s", out) + } + // 3) 必须有来源 + if !strings.Contains(out, "webui") { + t.Errorf("返回值没有来源信息:\n%s", out) + } + // 4) ★ 必须有真实正文(否则模型无法判断相关性) + if !strings.Contains(out, "六比三比一") { + t.Errorf("返回值不含真实正文片段(模型无法判断相关性):\n%s", out) + } + // 5) 不得回退成 jieba 词袋的模板摘要 + if strings.Contains(out, "涉及:") { + t.Errorf("返回值仍是 jieba 词袋模板摘要:\n%s", out) + } +} + +// ⑦ 旧格式文档的召回也要给出可判断的信息(降级路径不能退化成无内容)。 +func TestRecallByDoc_LegacyReturnAlsoCarriesContent(t *testing.T) { + a := newToolTestAgent(t) + if err := a.docStore.Insert(&document.Doc{ + Summary: "旧格式摘要", + Content: "[10:00] legacy: 旧格式文档里的真实正文在这里", + Source: "context_archived", + Meta: map[string]string{"content_hash": "legacy-2"}, + }); err != nil { + t.Fatal(err) + } + out := a.executeDocTool(agentAPI.ToolCall{ + Name: "doc_query", + Arguments: map[string]interface{}{"query": "旧格式文档 真实正文", "top_k": float64(3)}, + }) + if !strings.Contains(out, "真实正文在这里") { + t.Errorf("存量文档召回没有给出正文:\n%s", out) + } +} diff --git a/internal/agent/core/toolcall.go b/internal/agent/core/toolcall.go index 0d11288e..3b42fb61 100644 --- a/internal/agent/core/toolcall.go +++ b/internal/agent/core/toolcall.go @@ -1030,26 +1030,21 @@ func (a *Agent) executeDocTool(tc agentAPI.ToolCall) string { if len(docs) == 0 { return "未找到相关文档记忆" } - var parts []string - var refs []string + + var b strings.Builder + b.WriteString(fmt.Sprintf("已召回 %d 篇历史记忆,原文已按各自**原始时间戳**插回对话时序(不是当前对话):\n", len(docs))) + + totalEvents := 0 for i, d := range docs { - parts = append(parts, fmt.Sprintf("[%d] %s (来源: %s)", i+1, d.Summary, d.Source)) - if len(d.Tags) > 0 { - parts = append(parts, " 标签: "+strings.Join(d.Tags, ", ")) - } - // ★★ 召回 = 把事件(连同它持有的块)迁回 L1,不是造一条新事件 + // 召回 = 把事件(连同它持有的块)迁回 L1,不是造一条新事件。 // - // 设计原文(ARCHITECTURE「记忆流转」② Document 层): - // - // doc_query → Consume(读取并删除) - // → 逐条 context.Append{Timestamp: 原始时间戳, Source: 原来源} - // - // 此前实现用 d.CreatedAt(归档时刻)造一条 cold_storage 事件、 - // 正文取 d.Content(渲染视图)再截 2000 字、块完全没回填 —— - // 三处都偏离:时间错位、正文二次截断、块两层消失。 - if n := a.restoreDocEvents(d); n == 0 { - // 存量文档(无 Events)降级:整篇渲成一个事件,保持既有行为。 - // 不伪造成「有原始事件」——那时归档确实只有渲染视图。 + // ARCHITECTURE「记忆流转」② Document 层: + // doc_query → Consume(读取并删除) + // → 逐条 context.Append{Timestamp: 原始时间戳, Source: 原来源} + n := a.restoreDocEvents(d) + if n == 0 { + // 存量文档(无 Events)降级:整篇渲成一个事件。 + // 不伪造成「有原始事件」—— 那时归档确实只有渲染视图。 content := d.Content if labels := a.blockLabelsForDoc(d); labels != "" { content = content + "\n关联媒体: " + labels @@ -1060,11 +1055,25 @@ func (a *Agent) executeDocTool(tc agentAPI.ToolCall) string { Input: fmt.Sprintf("加载文档记忆: %s", query), Response: content, }) + n = 1 + } + totalEvents += n + + // ★ 每条给出「什么时候 + 谁说的 + 正文开头」, + // 让模型**立刻**能判断这笔召回有没有用。 + // + // 旧返回只有「[1] 来自 1 个来源的 1 条对话 (agent) 涉及: ok」—— + // jieba 词袋,零信息量,模型无法据此判断相关性。 + b.WriteString(fmt.Sprintf("\n[%d] %s\n", i+1, docRecallHeadline(d, n))) + + // 附上少量真实正文行,供模型即时判断(完整原文在时序里)。 + if preview := docRecallPreview(d, n, 3); preview != "" { + b.WriteString(preview) } - refs = append(refs, fmt.Sprintf("#%d(%s)", i+1, d.Summary)) } - return fmt.Sprintf("已加载 %d 篇文档记忆: %s\n(正文已按原时间戳回填到对话时序,直接读时序即可)", - len(docs), strings.Join(refs, ", ")) + + b.WriteString(fmt.Sprintf("\n共回填 %d 条事件。要引用其中的细节,直接看对话时序里对应时间点即可。", totalEvents)) + return b.String() case "doc_commit": content, _ := tc.Arguments["content"].(string) @@ -1076,18 +1085,9 @@ func (a *Agent) executeDocTool(tc agentAPI.ToolCall) string { summary = truncateStr(content, 100) } - tagsRaw, _ := tc.Arguments["tags"].([]interface{}) - var tags []string - for _, t := range tagsRaw { - if s, ok := t.(string); ok { - tags = append(tags, s) - } - } - doc := &document.Doc{ Summary: summary, Content: content, - Tags: tags, Source: "manual", } @@ -1324,3 +1324,110 @@ func (a *Agent) restoreDocEvents(d *document.Doc) int { d.Events[len(d.Events)-1].Timestamp.Format("01-02 15:04:05"), blocks) return n } + +// docRecallHeadline 生成一条召回结果的首行:「时间跨度 · 来源 · 事件数 · 媒体数」。 +// +// 为什么不用 Doc.Summary:那是 jieba 词袋("来自 N 个来源的 M 条对话 涉及: ok"), +// 零信息量。模型看到它无法判断这笔召回有没有用 —— 这正是 agent 几乎不用 +// doc_query 的直接原因之一。 +func docRecallHeadline(d *document.Doc, eventCount int) string { + if d == nil { + return "" + } + var parts []string + if d.HasEvents() { + first := d.Events[0].Timestamp + last := d.Events[len(d.Events)-1].Timestamp + if first.Equal(last) { + parts = append(parts, first.Format("2006-01-02 15:04")) + } else { + parts = append(parts, fmt.Sprintf("%s → %s", + first.Format("01-02 15:04"), last.Format("01-02 15:04"))) + } + // 来源集合(去重,最多 3 个) + seen := map[string]bool{} + var srcs []string + for _, ev := range d.Events { + if ev.Source != "" && !seen[ev.Source] { + seen[ev.Source] = true + srcs = append(srcs, ev.Source) + if len(srcs) >= 3 { + break + } + } + } + if len(srcs) > 0 { + parts = append(parts, strings.Join(srcs, "/")) + } + } else { + parts = append(parts, d.CreatedAt.Format("2006-01-02 15:04")) + if d.Source != "" { + parts = append(parts, d.Source) + } + } + parts = append(parts, fmt.Sprintf("%d 条事件", eventCount)) + if n := len(d.Blocks); n > 0 { + media := 0 + for i := range d.Blocks { + if d.Blocks[i].Modality != memory.BlockText { + media++ + } + } + if media > 0 { + parts = append(parts, fmt.Sprintf("含 %d 份媒体", media)) + } + } + return strings.Join(parts, " · ") +} + +// docRecallPreview 取少量**真实正文**行,让模型即时判断相关性。 +// +// 只取前 maxLines 行、每行截短:完整原文已在对话时序里(restoreDocEvents 回填), +// 这里只是"先给你看一眼是什么",不是全文。 +func docRecallPreview(d *document.Doc, eventCount, maxLines int) string { + if d == nil || maxLines <= 0 { + return "" + } + var out []string + add := func(s string) bool { + s = strings.TrimSpace(s) + if s == "" { + return len(out) < maxLines + } + out = append(out, " "+oneLinePreview(s, 100)) + return len(out) < maxLines + } + + if d.HasEvents() { + for _, ev := range d.Events { + in, resp, _ := ev.InOut() + if in != "" && !add(in) { + return strings.Join(out, "\n") + } + if resp != "" && !add(resp) { + return strings.Join(out, "\n") + } + } + } else { + // 存量文档:从渲染视图取前几行 + for _, line := range strings.Split(d.Content, "\n") { + if !add(line) { + break + } + } + } + if len(out) == 0 { + return "" + } + return strings.Join(out, "\n") +} + +// oneLinePreview 把一段文本压成单行短摘要(换行折叠、超长截断)。 +func oneLinePreview(s string, maxRunes int) string { + s = strings.Join(strings.Fields(s), " ") + r := []rune(s) + if len(r) <= maxRunes { + return s + } + return string(r[:maxRunes]) + "…" +} diff --git a/internal/agent/core/tooldefs.go b/internal/agent/core/tooldefs.go index 17054562..c2728506 100644 --- a/internal/agent/core/tooldefs.go +++ b/internal/agent/core/tooldefs.go @@ -177,6 +177,26 @@ func (a *Agent) buildSystemPrompt(memContext string, userInput string) string { prompt = appendPromptBlock(prompt, memContext) } + // 【三层记忆】是**内核运行时块**(提示词分层规范:内核行为由内核生成)。 + // + // 它讲的是内核的真实执行语义(哪些内容在哪一层、何时会进上下文、 + // 哪个工具会做什么),不是工具用法 —— 工具用法归各 ToolDef.Description。 + // + // 为什么必须有这一段:此前系统提示词从未解释 L1/L2/L3 是什么, + // agent 只能从工具名猜。生产日志实测:agent 几乎从不调用 doc_query, + // 即使调用了也只看返回值、不知道原文已被回填到对话时序。 + prompt = appendPromptBlock(prompt, "【三层记忆】记忆分三层,你要知道自己现在在看哪一层:\n"+ + "- **对话时序(L1,你现在看得到)**:最近的事件,按时间排列在上面那段【对话时序】里。"+ + "窗口放不下的会更早被移出,但不会丢。\n"+ + "- **历史记忆(L2,不直接给你看)**:被移出窗口的事件,连同**完整原文**(含图片/音频)保存在这里。"+ + "每次你收到新输入,内核会按语义相似度自动挑几篇相关的放在【历史记忆】块里,"+ + "但那只是片段 —— 要看某次的完整原文与过程,调用 `doc_query`:"+ + "它会把命中的事件**按原始时间戳插回你的对话时序**(不是返回一段摘要,那些事件真的回到了时间线上)。\n"+ + "- **图记忆(L3,长期)**:实体与关系(谁是什么、某值现在是多少、谁和谁什么关系),"+ + "用 `memory_recall` 查、`memory_commit` 记。\n"+ + "选择口径:要**原文与过程** → `doc_query`;要**关系与当前值** → `memory_recall`;"+ + "要**成文的可复用知识** → `knowledge_search`。") + // 记忆维护:具体选哪个工具、各有什么副作用,都写在 memory_* 的工具描述里 //(【记忆清理】前缀)。这里只保留那条**不看描述也会犯**的错:只描述计划不执行。 prompt = appendPromptBlock(prompt, "【记忆维护】用户要求整理或清理记忆时,必须真的调用 memory_* 工具去做,"+ @@ -556,24 +576,47 @@ func (a *Agent) buildToolDefs() []interface{} { } if a.docStore != nil { - tools = append(tools, toolDefWith("doc_query", "查询文档记忆。输入查询内容,返回相关文档摘要。", map[string]interface{}{ - "query": map[string]interface{}{"type": "string", "description": "查询内容"}, - "top_k": map[string]interface{}{"type": "integer", "description": "返回数量", "default": 3}, - }, []string{"query"}, parallelOpts())) - tools = append(tools, toolDef("doc_commit", "提交一条文档记忆。将重要信息显式写入文档记忆层。", map[string]interface{}{ - "content": map[string]interface{}{"type": "string", "description": "文档内容"}, - "summary": map[string]interface{}{"type": "string", "description": "摘要(可选)"}, - "tags": map[string]interface{}{ - "type": "array", - "description": "标签列表", - "items": map[string]interface{}{"type": "string"}, - }, - "media_digests": map[string]interface{}{ - "type": "array", - "description": "可选:这篇文档关联的媒体 digest(对话或 memory_recall 的「关联媒体」里显示的十六进制串,短的即可)。填了以后检索到这篇文档就能看到并取回原图/音频。", - "items": map[string]interface{}{"type": "string"}, - }, - }, "content")) + // ★ doc_query / doc_commit 的描述必须与**真实行为**一致。 + // + // 旧描述只有一句「查询文档记忆。输入查询内容,返回相关文档摘要」: + // · "文档记忆"没有定义,模型不知道 L2 是什么; + // · "返回相关文档摘要"与真实行为**不符**(真实是把事件逐条按 + // 原始时间戳插回对话时序); + // · 没说什么时候该用,也没说与 memory_recall 的分工。 + // 结果:生产实测 agent 几乎不调用 doc_query,即使调用也只看返回值、 + // 不知道原文已回填到时序。 + tools = append(tools, toolDefWith("doc_query", + "从**历史记忆层**(L2)召回过去的事件原文。"+ + "\n\n**这一层是什么**:当前对话窗口里只看得到最近的事件;更早的事件被移出窗口后,"+ + "连同原文完整保存在历史层,不会丢失。"+ + "\n\n**何时用**:记得«这事办过/说过»但想不起细节;需要当时的原始过程而非结论;"+ + "跨会话、跨天追问;用户问\"上次那个\"/\"之前怎么弄的\"。只想知道某个事实的当前值、"+ + "或某个实体的关系,用 memory_recall(图记忆),不要用这个。"+ + "\n\n**调用后会发生什么**:命中的事件会**逐条按原始时间戳插回你的对话时序**"+ + "(就是【对话时序】那一段,出现在它当时的时间点上),"+ + "返回值会列出每条的时间/来源/正文开头。要引用细节,读时序里对应时间点即可。"+ + "\n\n**副作用**:召回即从历史层取出(消费式),该文档不再留在 L2。", + map[string]interface{}{ + "query": map[string]interface{}{"type": "string", "description": "查询内容,用当时可能出现的词描述"}, + "top_k": map[string]interface{}{"type": "integer", "description": "召回多少篇(默认 3)", "default": 3}, + }, []string{"query"}, parallelOpts())) + + tools = append(tools, toolDef("doc_commit", + "把**整段结论/过程**写进历史记忆层(L2)。"+ + "\n\n**适合写什么**:做完一件事的完整过程与结论、排查链条、方案取舍的理由 —— "+ + "这些内容长、有上下文、以后要回看原文。"+ + "\n\n**不适合写什么**:单个事实或实体关系(谁是谁、某值等于多少、偏好)→ 用 memory_commit;"+ + "可复用的成文知识 → 用 knowledge_create。"+ + "\n\n写入后会参与后续的自动预填充与 doc_query 召回。", + map[string]interface{}{ + "content": map[string]interface{}{"type": "string", "description": "文档内容(完整正文,不要自己截断)"}, + "summary": map[string]interface{}{"type": "string", "description": "一句话摘要(可选,留空则由正文首行生成)"}, + "media_digests": map[string]interface{}{ + "type": "array", + "description": "可选:这篇文档关联的媒体 digest(对话或 memory_recall 的「关联媒体」里显示的十六进制串,短的即可)。填了以后检索到这篇文档就能看到并取回原图/音频。", + "items": map[string]interface{}{"type": "string"}, + }, + }, "content")) } if a.social != nil { diff --git a/internal/memory/document/document.go b/internal/memory/document/document.go index ec79ed28..eba0d87d 100644 --- a/internal/memory/document/document.go +++ b/internal/memory/document/document.go @@ -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 { @@ -197,11 +223,35 @@ func (s *Store) BuildDenseIndex(ds vector.MultimodalEmbedder) { log.Printf("[document memory] dense index built: %d new vectors", count) } -// denseFor 计算文档的稠密向量:文本向量与其一等记忆块的媒体向量融合。 +// denseFor 计算文档的稠密向量:**事件正文** ⊕ 同事件媒体块的向量。 // -// 只有与当前统一空间同指纹的块向量才参与融合:不同模型/维度的旧向量 -// 属于另一个坐标系,混进去会算出一个两边都不像的方向。 -// 任意一路缺失时退化为另一路;都不可用返回 nil。 +// ## 为什么不能用 Summary +// +// 旧实现是 `VectorizeDense(doc.Summary + " " + doc.Content)`,而 Summary +// 由 summarizeEntries → jieba 词袋生成,同一来源的摘要**逐字同质**: +// +// 来自 1 个来源的 1 条对话 (src0) 涉及: 用户, 提问 +// 来自 1 个来源的 1 条对话 (src1) 涉及: 用户, 提问 +// +// 实测(真实 chineseclip,4 篇主题完全不同的短正文)两两余弦 0.85~0.95 —— +// 几乎同向;查询分数全挤在 0.70~0.78,区分度 0.08 ⇒ 检索等同失效。 +// +// 这与设计直接矛盾:ARCHITECTURE 写的是「统一多模态空间(主)」,而 +// Summary 属于主路径的输入,却由最末一级的词法分词产出。 +// +// ## 媒体怎么办:不需要单独生成摘要 +// +// 媒体块与文本块**在同一事件内**(Doc.Events[i].Blocks),事件的正文进入 +// 向量即覆盖了它的语义。为媒体单独生成描述正是 CAS 时代「描述式索引」 +// 的错误(描述是模型生成的二手信息),已在 v1.2.0 整体拆除,不重走。 +// +// 若将来要「按图搜图」,那是视觉路(RecallBlocks 读块自己的向量)的职责, +// 已有独立通路,与本函数无关。 +// +// ## 存量兼容 +// +// 无 Events 的旧文档(本次改动前归档的)退回 Content —— 那是当时唯一的 +// 原文载体,虽然它是渲染视图,但比 Summary 更接近内容。 func (s *Store) denseFor(doc *Doc) []float64 { if s.denseSpace == nil || !s.denseSpace.Loaded() { return nil @@ -209,9 +259,16 @@ func (s *Store) denseFor(doc *Doc) []float64 { 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) + + // 文本路:用事件正文(原文),不用 Summary(jieba 词袋)。 + // 媒体块的语义由同一事件的文本覆盖,因此这里不需要为媒体单独编码。 + if text := documentTextForVector(doc); text != "" { + if tv, err := s.denseSpace.VectorizeDense(text); err == nil && len(tv) > 0 { + parts = append(parts, tv) + } } + + // 媒体路:块自带的多模态向量(同空间同维度才参与融合)。 for _, b := range doc.Blocks { // 只比指纹不够:指纹相同但**维度不同**的块会被 FuseVectors 按 // 「最大维度」拼成错维度向量(并覆盖掉文本向量),而结果又被 @@ -224,6 +281,50 @@ 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 的存量文档 +// 退回 Content(渲染视图,但仍是原文最接近的载体)。 +// 绝不含 Summary —— 那是 jieba 词袋,会把全库拉向同一方向。 +func documentTextForVector(doc *Doc) string { + if doc == nil { + return "" + } + if !doc.HasEvents() { + return strings.TrimSpace(doc.Content) + } + var b strings.Builder + for _, ev := range doc.Events { + in, resp, _ := ev.InOut() + if in != "" { + b.WriteString(in) + b.WriteString("\n") + } + if resp != "" { + b.WriteString(resp) + b.WriteString("\n") + } + for _, tr := range ev.ToolResults { + if tr.Output != "" { + b.WriteString(tr.Output) + b.WriteString("\n") + } + } + } + return b.String() +} + // Reindex 重建 TF-IDF 索引(fallback 路径变更时调用)。 func (s *Store) Reindex() { s.mu.Lock() @@ -261,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 路径:缓存文本,延迟训练 @@ -298,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() @@ -312,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 @@ -330,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), @@ -339,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 { @@ -667,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 是用于「内容是否同一份」比对的快速摘要。 diff --git a/internal/memory/document/document_test.go b/internal/memory/document/document_test.go index 5bbc7144..a18896c1 100644 --- a/internal/memory/document/document_test.go +++ b/internal/memory/document/document_test.go @@ -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} diff --git a/internal/memory/document/retrieval_quality_test.go b/internal/memory/document/retrieval_quality_test.go new file mode 100644 index 00000000..bae95baf --- /dev/null +++ b/internal/memory/document/retrieval_quality_test.go @@ -0,0 +1,427 @@ +package document + +// 检索质量判据 —— 这一层是「结构判据」的补位。 +// +// ## 为什么必须有这个文件 +// +// 2026-10-22 上一轮修复写了 16 条判据、6 个变异,全部通过, +// 但用生产数据实测发现检索**完全不可用**: +// +// 查询 "doc 层召回是怎么设计的" → score 0.7834 内容无关 +// 查询 "hmap 插件安装验签" → score 0.7922 内容无关 +// 查询 "QQ 头像" → score 0.7601 内容无关 +// +// 三个互不相关的查询,分数全挤在 0.75~0.79,区分度≈0。 +// +// 根因:denseFor 用 doc.Summary + doc.Content 算向量,而 Summary 由 jieba +// 词袋生成("来自 N 个来源的 M 条对话 涉及: map, content"),全库同质。 +// +// ★ 教训:结构判据不能替代质量判据。 +// 「字段非空」「条数正确」「不 panic」在检索完全失效时依然全绿。 +// +// ## 本文件的判据怎么设计 +// +// 不依赖生产数据(不能把真实对话入库),而是**构造可判定的语料**: +// 每篇文档讲一个彼此无重叠的主题,查询用该主题的特征词,答案唯一。 +// 这样「top1 必须命中」是一个**客观**判据,不是"看起来还行"。 + +import ( + "fmt" + "math" + "strings" + "testing" + "time" + + "github.com/JianFeeeee/HologramAgent/internal/memory" + "github.com/JianFeeeee/HologramAgent/internal/memory/vector" +) + +// fakeDenseSpace 是一个确定性的稠密向量实现,只用于判据。 +// +// 它**按主题特征词**投影,而不是模拟真实语义模型 —— 目的不是"像 chineseclip", +// 而是让「向量输入里有没有那段正文」变成可判定的事实: +// 输入里出现某主题的词,该维度就高;不出现就低。 +// +// 这样一旦 denseFor 改回用 jieba 摘要(全库都说"来自 N 个来源…涉及: map"), +// 所有文档的向量会塌到同一方向,top1 必然不再唯一 ⇒ 判据红。 +type fakeDenseSpace struct { + dim int +} + +// topicAxis 把文本映射到维度:按预置主题词的出现次数。 +// 维度数与 vecTopics 长度一致,保证不同主题落在不同轴上。 +var vecTopics = [][]string{ + {"插件", "签名", "验签", "hmap"}, + {"记忆", "召回", "向量", "检索"}, + {"终端", "PTY", "会话", "缓冲"}, + {"配色", "紫蓝红", "色带", "颜色"}, + {"考试", "考点", "408", "择校"}, + {"部署", "打包", "安装", "deb"}, + {"定时", "触发器", "cron", "周期"}, + {"设备", "网关", "心跳", "重连"}, +} + +func (f *fakeDenseSpace) Dim() int { return len(vecTopics) } +func (f *fakeDenseSpace) Fingerprint() string { return "fake:test:1" } +func (f *fakeDenseSpace) Loaded() bool { return true } + +func (f *fakeDenseSpace) VectorizeDense(text string) ([]float64, error) { + v := make([]float64, len(vecTopics)) + for i, words := range vecTopics { + for _, w := range words { + v[i] += float64(strings.Count(text, w)) + } + } + // 加一个极小常数避免全零向量(全零会让 cosine 恒为 0,掩盖问题) + for i := range v { + v[i] += 0.01 + } + return v, nil +} + +func (f *fakeDenseSpace) EmbedImageDense(img []byte, mime string) ([]float64, error) { + return nil, fmt.Errorf("fake space 不支持图像") +} + +func (f *fakeDenseSpace) Close() {} + +var _ vector.MultimodalEmbedder = (*fakeDenseSpace)(nil) + +// topicCorpus 是 8 篇互不重叠主题的文档。 +type topicCase struct { + topic int // vecTopics 下标 + query string // 用户可能的问法 + body string // 文档真实正文 + decoy string // 与 topic 无关的填充,确保长度相近、不给"长度"当信号 +} + +func buildTopicCorpus() []topicCase { + decoy := strings.Repeat("这一段是与主题无关的过渡叙述,用来把正文撑到相近长度。", 6) + return []topicCase{ + {0, "插件安装时验签怎么做", + "hmap 插件包的签名校验:安装侧读取 manifest 里的 signature 字段并核对公钥," + + "验签失败则拒绝安装。当前实现不验签,这是已知缺口。" + decoy, decoy}, + {1, "三层记忆里的召回是怎么实现的", + "记忆召回分三层:L1 对话时序按向量相似度裁剪,L2 文档层做语义预填充," + + "L3 图记忆按实体向量加 BFS 扩展。" + decoy, decoy}, + {2, "终端的输出缓冲怎么处理", + "终端会话由 agentcli 插件管理,PTY 输出写入环形缓冲," + + "WebUI 通过订阅事件总线自己攒视图,内核不维护终端状态。" + decoy, decoy}, + {3, "紫蓝红三色带的配色比例", + "配色方案:紫蓝红三色带按 6:3:1 的比例,主色紫、辅色蓝、点缀红。" + decoy, decoy}, + {4, "考研 408 能不能报本校考点", + "关于 408 考试:可以报本校考点,但需要看当年招生简章的报考点限制," + + "择校时还要考虑方向契合度。" + decoy, decoy}, + {5, "打包成 deb 时漏了哪个文件", + "部署打包:deb 的 build_tar 曾漏拷 depgraph.bin,导致装完 homed 启动即退出。" + decoy, decoy}, + {6, "定时任务到点怎么触发", + "定时器插件维护持久化的触发器表,到点通过中断注入把提醒推给 agent。" + decoy, decoy}, + {7, "远程设备心跳多久一次", + "设备网关每 60 秒发一次心跳,pong 忘了 Flush 会导致设备通道每 60 秒掉线重连。" + decoy, decoy}, + } +} + +// buildStore 用给定语料建一个已注入稠密空间的 Store。 +func buildStore(t *testing.T, cases []topicCase) *Store { + t.Helper() + s := NewStore(t.TempDir(), memory.TokenizeWords) + if err := s.Start(); err != nil { + t.Fatal(err) + } + t.Cleanup(s.Stop) + s.SetDenseSpace(&fakeDenseSpace{}) + + base := time.Date(2026, 9, 1, 8, 0, 0, 0, time.UTC) + for i, c := range cases { + _, err := s.ContextToDoc("context_archived", []ContextEntry{{ + Timestamp: base.Add(time.Duration(i) * time.Hour), + Source: "test", + Content: "用户提问:" + c.query, + Response: c.body, + }}, nil, nil, nil, nil) + if err != nil { + t.Fatal(err) + } + } + s.BuildDenseIndex(&fakeDenseSpace{}) + return s +} + +// ① top1 必须命中正确文档。 +// +// 变异:denseFor 改回 Summary + Content(jieba 词袋)⇒ 红。 +func TestRetrieval_Top1HitsCorrectDoc(t *testing.T) { + cases := buildTopicCorpus() + s := buildStore(t, cases) + + for _, c := range cases { + hits := s.QueryScored(c.query, 3) + if len(hits) == 0 { + t.Errorf("查询 %q 无任何命中", c.query) + continue + } + top := hits[0] + if !strings.Contains(top.Doc.Content, c.body[:40]) { + t.Errorf("查询 %q 的 top1 不是正确文档(score=%.4f)\n got: %s", + c.query, top.Score, truncateRunes(top.Doc.Content, 60)) + t.Logf(" ≠ 期望正文: %s", truncateRunes(c.body, 60)) + } + } +} + +// ② 互不相关查询的 top1 必须不同(区分度)。 +// +// 这条直接对着生产实测的失效:三个无关查询全部命中同一类文档、分数挤在一起。 +// +// 变异:denseFor 改回 jieba 词袋 ⇒ 全库同质 ⇒ 多条查询 top1 撞同一篇 ⇒ 红。 +func TestRetrieval_DistinctQueriesHitDistinctDocs(t *testing.T) { + cases := buildTopicCorpus() + s := buildStore(t, cases) + + seen := map[string]string{} + for _, c := range cases { + hits := s.QueryScored(c.query, 1) + if len(hits) == 0 { + t.Errorf("查询 %q 无命中", c.query) + continue + } + id := hits[0].Doc.ID + if prev, dup := seen[id]; dup { + t.Errorf("查询 %q 与 %q 命中了同一篇文档(%s)—— 检索无区分度", + c.query, prev, id) + } + seen[id] = c.query + } +} + +// ③ 正确命中的分数必须显著高于非命中(区分度阈值)。 +// +// 生产实测失效时,命中与背景的分数差 <0.04(0.75~0.79 全挤在一起)。 +// 这里要求至少 0.20 的绝对差,避免"排序碰巧对但信号很弱"。 +func TestRetrieval_ScoreGapIsSignificant(t *testing.T) { + cases := buildTopicCorpus() + s := buildStore(t, cases) + + for _, c := range cases { + hits := s.QueryScored(c.query, len(cases)) + if len(hits) < 2 { + continue + } + top := hits[0].Score + // 取"非正确答案里的最高分"作背景 + bg := 0.0 + for _, h := range hits[1:] { + if !strings.Contains(h.Doc.Content, c.body[:40]) && h.Score > bg { + bg = h.Score + } + } + if top-bg < 0.20 { + t.Errorf("查询 %q 的分数区分度不足:top=%.4f 背景=%.4f 差=%.4f(要求 ≥0.20)", + c.query, top, bg, top-bg) + } + } +} + +// ④ 向量输入必须是正文,而不是摘要。 +// +// 做法:哨兵串只放进 Response(正文),不放 Summary。用 bigramSpace +// (对任意文本都给可区分的向量)验证:哨兵查询的 top1 必须是含哨兵的那篇。 +// +// 变异:denseFor 读 Summary ⇒ 哨兵不在向量里 ⇒ top1 是别的文档 ⇒ 红。 +func TestRetrieval_VectorInputIsBodyNotSummary(t *testing.T) { + s := NewStore(t.TempDir(), memory.TokenizeWords) + if err := s.Start(); err != nil { + t.Fatal(err) + } + t.Cleanup(s.Stop) + ds := &bigramSpace{} + s.SetDenseSpace(ds) + + const sentinel = "量子纠缠态退相干时间测量" + _, err := s.ContextToDoc("context_archived", []ContextEntry{{ + Timestamp: time.Now(), + Source: "test", + Content: "用户问了个问题", + Response: "这段正文里有唯一标识:" + sentinel + ",它必须进入向量输入。", + }}, nil, nil, nil, nil) + if err != nil { + t.Fatal(err) + } + // 与哨兵完全无关的干扰文档(越长越能测出"摘要被当成内容") + _, _ = s.ContextToDoc("context_archived", []ContextEntry{{ + Timestamp: time.Now().Add(time.Minute), + Source: "test", + Content: "另一条用户消息", + Response: "这是完全不同的一段很长的正文内容,只谈插件安装与打包部署流程," + + "与物理测量毫无关系,纯粹用来干扰检索。" + strings.Repeat("补充无关叙述。", 20), + }}, nil, nil, nil, nil) + + s.BuildDenseIndex(ds) + + hits := s.QueryScored(sentinel, 1) + if len(hits) == 0 { + t.Fatal("哨兵查询无命中") + } + if !strings.Contains(hits[0].Doc.Content, sentinel) { + t.Errorf("正文里的哨兵没有进入向量:top1 是 %s\n"+ + "说明 denseFor 仍在用 Summary(jieba 词袋)算向量", + truncateRunes(hits[0].Doc.Content, 60)) + } +} + +// ⑤ 摘要的 jieba 噪声不得污染向量 —— 复现并量化生产失效条件。 +// +// ★ 生产失效的机制(2026-10-22 用真实 chineseclip 实测确认): +// +// denseFor 用 doc.Summary + doc.Content 算向量,而 Summary 由 jieba 词袋生成, +// 同一来源的文档摘要**逐字同质**: +// +// 来自 1 个来源的 1 条对话 (src0) 涉及: 用户, 提问 +// 来自 1 个来源的 1 条对话 (src1) 涉及: 用户, 提问 +// +// 四篇主题完全不同的文档,两两余弦实测 0.85~0.95(几乎同向), +// 查询分数 0.70~0.78 全挤在一起,区分度仅 0.08。 +// +// 本判据要求:主题不同的文档,两两余弦必须**明显低**。 +// +// 变异:denseFor 改回 Summary + Content(或其等价:向量输入含 Summary)⇒ 红。 +// +// 注:用 fakeDenseSpace(按主题词投影)会漏掉这个缺陷 —— 因为 Summary 里 +// 不含主题词,投影后看起来"正常"。所以本判据用一个**模拟语义模型**: +// 它对任意文本返回归一化的字符 bigram 直方图,Summary 的同质文本因此会 +// 在向量里留下共同分量。这才是能抓住缺陷的判据。 +func TestRetrieval_SummaryNoiseMustNotPolluteVector(t *testing.T) { + s := NewStore(t.TempDir(), memory.TokenizeWords) + if err := s.Start(); err != nil { + t.Fatal(err) + } + t.Cleanup(s.Stop) + ds := &bigramSpace{} + s.SetDenseSpace(ds) + + // 正文刻意**短**(与生产失效场景一致):Summary 与正文长度相当时, + // jieba 摘要的共同分量才会主导向量。正文越长,噪声占比越小、缺陷越隐蔽 —— + // 这正是生产上只有短文档受害的原因。 + bodies := []string{ + "插件签名验签 hmap 公钥", + "记忆召回向量检索裁剪", + "终端会话输出缓冲 PTY", + "配色方案色带紫蓝红", + } + base := time.Date(2026, 9, 1, 8, 0, 0, 0, time.UTC) + for i, b := range bodies { + if _, err := s.ContextToDoc("context_archived", []ContextEntry{{ + Timestamp: base.Add(time.Duration(i) * time.Hour), + Source: "src", // ★ 同一来源 ⇒ Summary 更同质,与生产一致 + Content: "用户提问", + Response: b, + }}, nil, nil, nil, nil); err != nil { + t.Fatal(err) + } + } + s.BuildDenseIndex(ds) + + docs := s.RecentDocs(10) + if len(docs) != 4 { + t.Fatalf("文档数 = %d,期望 4", len(docs)) + } + + // 两两余弦:主题完全不同的文档不应接近同向。 + // 阈值 0.80:真实 chineseclip 在"含 Summary"时实测 0.85~0.95; + // 去掉 Summary 后应显著低于此。 + for i := 0; i < len(docs); i++ { + for j := i + 1; j < len(docs); j++ { + c := denseCosine(docs[i].DenseVec, docs[j].DenseVec) + if c > 0.80 { + t.Errorf("两篇主题完全不同的文档相似度 %.4f(>0.80)—— 向量被同质摘要主导\n"+ + " doc[%d] Summary=%q\n doc[%d] Summary=%q", + c, i, truncateRunes(docs[i].Summary, 45), j, truncateRunes(docs[j].Summary, 45)) + } + } + } +} + +// bigramSpace 是字符 bigram 直方图 + 归一化的模拟语义空间。 +// +// 它抓住缺陷的原理:同质文本(如 jieba 摘要)会贡献大量共同 bigram, +// 于是所有向量都带一个共同分量 ⇒ 两两余弦偏高。 +// 真实 chineseclip 的行为完全一致(实测 0.85~0.95),只是本判据不依赖模型文件。 +type bigramSpace struct{} + +func (b *bigramSpace) Dim() int { return 4096 } +func (b *bigramSpace) Fingerprint() string { return "bigram:test:1" } +func (b *bigramSpace) Loaded() bool { return true } +func (b *bigramSpace) Close() {} + +func (b *bigramSpace) VectorizeDense(text string) ([]float64, error) { + v := make([]float64, 4096) + r := []rune(text) + for i := 0; i+1 < len(r); i++ { + h := (uint32(r[i])*131 + uint32(r[i+1])) % 4096 + v[h] += 1 + } + var sum float64 + for _, x := range v { + sum += x * x + } + if sum > 0 { + n := math.Sqrt(sum) + for i := range v { + v[i] /= n + } + } + return v, nil +} + +func (b *bigramSpace) EmbedImageDense(img []byte, mime string) ([]float64, error) { + return nil, fmt.Errorf("bigram 空间不支持图像") +} + +var _ vector.MultimodalEmbedder = (*bigramSpace)(nil) + +// ⑥ 向量算法变更必须触发存量重算(否则修复对已有数据完全无效)。 +// +// ★ 这是实测踩到的坑:把 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) + } + 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 +}