Files
HomeAgent/internal/memory/bilingual_test.go
JianFeeeee 5836c2ce5c refactor(memory): 拆除描述式媒体索引,媒体成为一等块并按原生向量融合
背景:此前媒体是靠「生成的描述文本」将就进记忆的——写 marker 进正文、
再由正则反解成 media_refs 与图库里的 type=Media 实体。这条链路有三个
致命缺陷:描述由异步模型生成(未生成前媒体等于不存在)、语义检索实质上
只搜描述文字、图库里的「媒体节点」是描述文本的投影而不是媒体本身。

本提交把这条链路整体拆除,媒体改为按自己的原生向量参与记忆:

一、描述链彻底删除(无残留、无兼容分支)
- media.Item 去掉 Description/DescribedBy 与对应列;
- 删除 Store.Describe / Store.Search / Store.Pending;
- 删除 Agent.mediaDescribeLoop / describePendingMedia 与配置项
  core.memory.media.describe_on_ingest;
- SDK 侧 MediaAttachment 去掉 Description(见 SDK 仓独立提交)。

二、marker 机制删除,媒体归属改为结构化块边
- 删除 mediaMarkerLine/parseMediaMarkers/mediaEntityName/mediaTriplesFromText/
  extractMediaDigests/sentenceWithMediaMarkers/docMediaContext;
- memory.Triple 新增 MediaDigests 结构化字段;句子文本保持原样,
  不再被 marker 污染;
- 块以 sentence --contains--> block / document --contains--> block 结构边
  挂到承载节点(新增 documents 表与 document 节点种类);
- 模型未给原句时用「主谓宾。」拼一句自然语言作落点,不造 marker 文本。

三、旧数据迁移(幂等)
- 新增 GraphDB.MigrateLegacyMediaEntities:把 type=Media 的旧实体按短 digest
  还原成原生块、挂回原句子、删除旧实体与描述关系;Agent 启动时执行;
- CleanupOrphanedSentences 同时看关系引用与块边,避免把只靠块存活的句子
  连同块边一起删掉。

四、向量融合:媒体按图本身被召回
- 新增 vector.FuseVectors(逐维求和 + L2 归一化);
- Doc.DenseVec = 文本向量 ⊕ 文档块的媒体向量(同 fingerprint 才融合),
  新增 Doc.DenseFP,指纹变化触发重算;
- ContextEvent.DenseVec 同理融合事件块;事件新增 DenseFP,Prune 只在
  同一统一空间内比稠密余弦;
- 跨模态视觉路只召回「仍被某层记忆块持有」的媒体,CAS 全库字节不再
  直接充当记忆检索结果。

五、同时纳入本分支既有的嵌入基础改造(此前工作区未提交,缺它 HEAD 不可构建)
- internal/tfidf 懒回退包、千问三段式多模态 ONNX 空间的 Go 侧
  (qwen/embedder.go、image.go、model_input.go)、CLIP 移除、
  sdk.NewStore 分词器签名与调用点、embed 侧车 systemd 单元。

验证:go build ./... 、go vet ./...(含 -tags medialive)均通过;
在 HEAD 的独立 worktree 上重放本次暂存集后 go test -short ./internal/...
全部通过(端口冲突类用例在隔离环境中亦通过)。未提交工作区中与本改造
无关的改动(HarmonyOS、waiter、devicebridge、plan.md 等)。
2026-09-11 11:45:24 +08:00

244 lines
8.1 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 memory
import (
"fmt"
"sort"
"testing"
)
type bilingualEvent struct {
idx int
source string
topic string
text string // cleaned text for vectorization
label string // short description
}
func TestBilingualPruningAccuracy(t *testing.T) {
zhPath := writeSynthModel(t, 300)
enPath := writeSynthModel(t, 300)
// Test with Chinese-only vs Chinese+English
type modelConfig struct {
name string
paths []string
}
configs := []modelConfig{
{"中文仅", []string{zhPath}},
{"中文+英文", []string{zhPath, enPath}},
}
events := genBilingualEvents()
for _, cfg := range configs {
t.Run(cfg.name, func(t *testing.T) {
e := NewStaticEmbedder(cfg.paths...)
if !e.Loaded() {
t.Skipf("%s: embedder not loaded", cfg.name)
}
t.Logf("%s: %d words", cfg.name, len(e.words))
type scored struct {
idx int
topic string
label string
score float64
}
queries := []struct {
q string
qTopic string
desc string
}{
{"老大说了关于 React 组件的事情", "老大私聊", "中英混合:老大+React"},
{"帮我查一下 Nginx 反向代理配置", "服务器运维", "中英混合:Nginx+反向代理"},
{"河南医药大学 Docker 部署", "大学招生", "中英混合:大学+Docker"},
{"JavaScript 基金定投收益计算", "股票基金", "中英混合:JS+基金"},
{"Server 前端组件封装 layout", "前端开发", "中英混合:Server+layout"},
{"河南医药大学录取分数线", "大学招生", "纯中文:大学"},
{"nginx reverse proxy config", "服务器运维", "纯英文:nginx"},
}
for _, q := range queries {
qVec := e.Vectorize(q.q)
t.Logf("\n query: %q (%s)", q.q, q.desc)
all := make([]scored, len(events))
for i, ev := range events {
text := textForBilingual(ev, cfg.paths)
vec := e.Vectorize(text)
all[i] = scored{idx: i, topic: ev.topic, label: ev.label, score: cosineSim(qVec, vec)}
}
sort.Slice(all, func(i, j int) bool { return all[i].score > all[j].score })
// Check top 5 for same-topic presence
var intraHits int
for _, s := range all[:5] {
if s.topic == q.qTopic {
intraHits++
}
}
topScore := all[0]
topIsCorrect := topScore.topic == q.qTopic
t.Logf(" top5 intra=%d/5, top1=%q(%s) score=%.4f %s",
intraHits, topScore.topic, topScore.label, topScore.score,
map[bool]string{true: "✅", false: "❌"}[topIsCorrect])
for _, s := range all[:5] {
mark := ""
if s.topic == q.qTopic {
mark = " ✓"
}
t.Logf(" [%.4f] [%-12s] %s%s", s.score, s.topic, trimLen(s.label, 50), mark)
}
if !topIsCorrect {
t.Logf(" [WARN] top1 mismatch for %q", q.desc)
}
}
})
}
}
func TestBilingualCrossLingualSimilarity(t *testing.T) {
zhPath := writeSynthModel(t, 300)
enPath := writeSynthModel(t, 300)
e := NewStaticEmbedder(zhPath, enPath)
if !e.Loaded() {
t.Skip("embedder not loaded")
}
pairs := []struct {
a, b string
desc string
}{
{"server", "服务器", "英中同义"},
{"computer", "电脑", "英中同义"},
{"老大", "boss", "中英同义"},
{"大学", "university", "中英同义"},
{"Nginx", "服务器", "专名+普通"},
{"股票", "stock", "中英同义"},
{"React", "前端", "专名+概念"},
{"JavaScript", "编程", "专名+概念"},
{"老大私聊", "boss private chat", "中英短语"},
{"河南医药大学录取", "Henan Medical University admission", "中英专名"},
{"nginx config", "Nginx 配置", "英中技术"},
}
t.Log("=== 跨语言相似度 ===")
for _, p := range pairs {
va := e.Vectorize(p.a)
vb := e.Vectorize(p.b)
sim := cosineSim(va, vb)
t.Logf(" %.4f %q ↔ %q [%s]", sim, trimLen(p.a, 30), trimLen(p.b, 30), p.desc)
}
}
func TestBilingualEdgeCases(t *testing.T) {
zhPath := writeSynthModel(t, 300)
e := NewStaticEmbedder(zhPath)
if !e.Loaded() {
t.Skip("embedder not loaded")
}
cases := []string{
"纯英文文本 nginx react docker javascript",
"纯中文 服务器 配置 反向代理 部署",
"中英混合 nginx 反向代理 配置",
"代码片段 const foo = 'bar'; function test()",
"URL路径 /api/v1/users/123",
"中文含标点!@#¥%……&*",
"空字符串",
}
t.Log("=== 边缘情况向量化 ===")
for _, c := range cases {
v := e.Vectorize(c)
var dims int
for range v {
dims++
}
t.Logf(" dims=%d %q", dims, trimLen(c, 60))
}
}
func TestBilingualVectorizeClean(t *testing.T) {
zhPath := writeSynthModel(t, 300)
e := NewStaticEmbedder(zhPath)
inputs := []string{
`来自小王的扶高升学咨询群群聊消息通过id99使用qq_get_message工具获取消息正文。获取内容后使用 output_send(channel="qq") 回复该群聊content 设为 JSON 字符串:{"content":"你的回复","group_id":979911915}`,
`【重要!老大消息】来自—/的私聊消息通过id54使用qq_get_message工具获取消息正文。获取内容后使用 output_send(channel="qq") 回复对方content 设为 JSON 字符串:{"content":"你的回复","user_id":2198972886}`,
`The nginx server is configured with reverse proxy. 帮我查一下 Docker 容器状态。`,
`老大你好React 组件已经封装好了Nginx 配置也改完了Docker 部署没问题。`,
}
for i, inp := range inputs {
rawVec := e.Vectorize(inp)
cleanVec := e.VectorizeClean(inp)
sim := cosineSim(rawVec, cleanVec)
rawTokens := len(e.tokenize(inp))
cleanTokens := len(e.tokenize(CleanText(inp)))
t.Logf("[%d] sim(raw,clean)=%.4f tokens: raw=%d clean=%d", i, sim, rawTokens, cleanTokens)
}
}
// --- bilingual test data ---
func genBilingualEvents() []bilingualEvent {
entries := []struct {
topic string
zh string // Chinese description
en string // English terms mixed in
source string
}{
{"大学招生", "河南医药大学录取分数线", "", "qq"},
{"大学招生", "医学院专业排名", "medical university ranking", "agent"},
{"大学招生", "高考志愿填报咨询", "college application consultation", "qq"},
{"大学招生", "河南医药大学 Docker 部署项目", "docker deployment project", "agent"},
{"老大私聊", "老大私聊消息回复", "boss private chat reply", "qq"},
{"老大私聊", "老大说了关于 React 组件的事情", "boss talked about React components", "agent"},
{"老大私聊", "回复老大关于服务器配置问题", "reply boss about nginx config", "agent"},
{"老大私聊", "老大要求检查 Docker 容器状态", "boss asked to check docker status", "qq"},
{"前端开发", "前端组件封装", "React component encapsulation", "cli"},
{"前端开发", "页面路由配置 layout 设计", "page route config layout design", "cli"},
{"前端开发", "JavaScript 交互逻辑开发", "javascript interaction logic", "agent"},
{"前端开发", "TypeScript 代码调试优化", "typescript code debug optimization", "agent"},
{"服务器运维", "Nginx 反向代理配置", "nginx reverse proxy config", "cli"},
{"服务器运维", "Docker 容器部署方案", "docker container deployment", "cli"},
{"服务器运维", "数据库备份恢复", "database backup recovery", "agent"},
{"服务器运维", "SSL 证书续期配置", "ssl certificate renewal", "agent"},
{"股票基金", "基金定投策略配置", "fund investment strategy", "qq"},
{"股票基金", "股票涨跌分析", "stock market analysis", "agent"},
{"股票基金", "理财收益 JavaScript 计算", "investment return javascript calculation", "agent"},
{"股票基金", "市场行情 API 数据获取", "market data api fetch", "qq"},
}
var events []bilingualEvent
for i, entry := range entries {
text := entry.zh
if entry.en != "" {
text += " " + entry.en
}
events = append(events, bilingualEvent{
idx: i,
source: entry.source,
topic: entry.topic,
text: text,
label: fmt.Sprintf("%s (%s)", trimLen(entry.zh, 30), trimLen(entry.en, 30)),
})
}
return events
}
func textForBilingual(ev bilingualEvent, modelPaths []string) string {
switch {
case ev.source == "agent" && ev.text != "":
return CleanText(ev.text)
default:
return CleanText(ev.text)
}
}