mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-21 17:38:10 +00:00
追「实例看起来没更新」时发现知识库检索本身也不可信,先把病因查清再动手: - **两段式召回不是瓶颈**:Store 的结果与全量暴力 cosine 完全一致; - **真因是向量没有区分度**:词向量取平均后各向异性明显,真实 KB(33 条)上自检索 top-1 只有 15%、前两名平均只差 0.013,排序基本是噪声; - 且全为停用词的查询会得到**空向量**("最近更新"),直接搜不出任何东西。 先在真实数据上把候选方案量了一遍(用自检索 top-1 / MRR)再动手:IDF 维度加权零收益、 去均值反而更差,**都不做**;唯一有收益的是与词法路(TF-IDF)融合。 改动: - `Store` 增设词法路索引,`Search` 融合两路:各自按**查询内最大值**归一化后加权。 权重 0.5 由权重扫描定:1.0(旧行为)MRR 0.271 / 0.8→0.354 / 0.7→0.358 / **0.5→0.376** / 0.3→0.336 / 0.0→0.307;语义查询也从"全是 openharmony 噪声"变成命中正确条目 (「首启人格门禁」→changelog_v1.2.1、「插件怎么开发和部署」→plugin_dev_build); - `vector.Store` 的候选中选阈值改为**可设**(默认 0.05 保持既有行为):TF-IDF 余弦量级 只有 0.0~0.2,沿用 0.05 会把词法路有效候选**静默砍掉**——这一条正是 0.376→0.197 的 差距来源,且当时没有任何报错; - Add/Remove/scanAll/ReindexWithVectorizer 同步维护两路;分数相同时按名字定序(结果可重复)。 **顺带修一个真实毛病**:Add/Remove 原先用**无追踪的 goroutine** 写索引(因为 writeIndex→BuildTree 会 RLock,而调用方持写锁,同步调用会死锁)→ 失败只打日志, 且与调用方竞态(测试的临时目录清理就撞上了)。改为持锁就地 flush (buildTreeLocked / writeIndexLocked)。 判据(不依赖人工标注问答对):新增 `internal/knowledge/rankdiag_test.go`,用**自检索 top-1 / MRR** 量区分度,`KB_DIAG=1` 跑、`KB_DIAG_ASSERT=1` 断言(MRR ≥ 0.34)。 另有不依赖真实数据的单测 6 条(空稠密向量靠词法路救回、稠密并列时词法路定序、 词法路阈值接线、Add/Remove 双路一致、并列时确定性、空库不 panic)。 **反向验证**(证明判据真能发现缺陷):权重退回 1.0、词法路阈值改回 0.05、 把阈值写死回 0.05 —— 对应测试逐条变红。另:我第一版夹具余弦 0.365/0.273 远高于阈值, 注入缺陷也不报错(等于没验),故加了「夹具前提」断言并改成两层判据 (语义层由 vector 包测试证明、接线层由知识库测试钉住)。 顺带纳入上一轮漏提交的 `TestAddOverwriteReplacesVector`(同名覆盖必须摘掉旧向量, 生产改动当时已提交,测试一直未入库)。
177 lines
5.9 KiB
Go
177 lines
5.9 KiB
Go
package knowledge
|
||
|
||
import (
|
||
"testing"
|
||
|
||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/vector"
|
||
)
|
||
|
||
// fakeDense 是可控的稠密向量器:按文本查表,缺省给同一个向量。
|
||
// 用它把「稠密路无区分度/给空向量」这类真实故障在单测里复现出来。
|
||
type fakeDense struct {
|
||
byText map[string]vector.Vector
|
||
def vector.Vector
|
||
}
|
||
|
||
func (f fakeDense) Vectorize(text string) vector.Vector {
|
||
if v, ok := f.byText[text]; ok {
|
||
return v
|
||
}
|
||
return f.def
|
||
}
|
||
|
||
// EmbedImage 满足 vector.Vectorizer 接口(本用例只用到文本路)。
|
||
func (f fakeDense) EmbedImage([]byte, string) (vector.Vector, error) {
|
||
return f.def, nil
|
||
}
|
||
|
||
func newTestStore(t *testing.T, dense vector.Vectorizer) *Store {
|
||
t.Helper()
|
||
st := NewStore(t.TempDir())
|
||
if dense != nil {
|
||
st.SetVectorizer(dense)
|
||
}
|
||
return st
|
||
}
|
||
|
||
func mustAdd(t *testing.T, st *Store, name, content string) {
|
||
t.Helper()
|
||
if err := st.Add(name, content); err != nil {
|
||
t.Fatalf("add %s: %v", name, err)
|
||
}
|
||
}
|
||
|
||
func names(hits []*Knowledge) []string {
|
||
out := make([]string, len(hits))
|
||
for i, h := range hits {
|
||
out[i] = h.Name
|
||
}
|
||
return out
|
||
}
|
||
|
||
// base36 生成互不重复的短串(造独特词的量级要大,不能用 a..z 循环重复)。
|
||
func base36(n int) string {
|
||
const digits = "0123456789abcdefghijklmnopqrstuvwxyz"
|
||
if n == 0 {
|
||
return "0"
|
||
}
|
||
out := ""
|
||
for n > 0 {
|
||
out = string(digits[n%36]) + out
|
||
n /= 36
|
||
}
|
||
return out
|
||
}
|
||
|
||
// 稠密路给**空向量**(真实场景:查询词全在停用词表里,例如"最近更新")时,
|
||
// 词法路必须把结果救回来——修复前这里直接返回空。
|
||
func TestSearchLexicalRescuesEmptyDenseQuery(t *testing.T) {
|
||
dense := fakeDense{def: vector.Vector{"0": 1}}
|
||
st := newTestStore(t, dense)
|
||
mustAdd(t, st, "changelog_v1", "最近更新了很多东西 发布说明")
|
||
mustAdd(t, st, "weather_doc", "天气预报 晴转多云")
|
||
|
||
// 让"更新"的稠密向量为空(模拟停用词化)
|
||
st.SetVectorizer(fakeDense{
|
||
byText: map[string]vector.Vector{"更新": {}},
|
||
def: vector.Vector{"0": 1},
|
||
})
|
||
hits := st.Search("更新", 5)
|
||
if len(hits) == 0 {
|
||
t.Fatal("稠密路给空向量时不该返回空结果(词法路应救回来)")
|
||
}
|
||
if hits[0].Name != "changelog_v1" {
|
||
t.Fatalf("应命中含「更新」的条目,实际: %v", names(hits))
|
||
}
|
||
}
|
||
|
||
// 稠密路对所有文本给**同一个向量**(真实故障:词向量平均后各向异性、区分度极低)时,
|
||
// 排序必须由词法路决定。
|
||
func TestSearchLexicalBreaksDenseTies(t *testing.T) {
|
||
same := vector.Vector{"0": 1, "1": 1}
|
||
st := newTestStore(t, fakeDense{def: same})
|
||
mustAdd(t, st, "plugin_dev_build", "插件构建与部署 hmapdev 命令")
|
||
mustAdd(t, st, "cangjie_manual", "仓颉编程语言知识手册")
|
||
mustAdd(t, st, "privacy_policy", "隐私政策")
|
||
|
||
hits := st.Search("hmapdev 构建", 3)
|
||
if len(hits) == 0 || hits[0].Name != "plugin_dev_build" {
|
||
t.Fatalf("稠密路并列时应由词法路选出 plugin_dev_build,实际: %v", names(hits))
|
||
}
|
||
}
|
||
|
||
// 词法路的候选中选阈值必须是 0:TF-IDF 余弦量级只有 0.0~0.2,沿用稠密路的 0.05
|
||
// 会把有效候选静默砍掉(真实 KB 实测自检索 MRR 0.307→0.193)。
|
||
//
|
||
// 判据分两层,各钉一半:
|
||
// - **语义层**由 internal/memory/vector 的 TestSearchScoredRespectsMinScore 证明
|
||
// (同一候选在默认阈值下被过滤、阈值 0 时被召回);
|
||
// - **接线层**在这里钉住:知识库的词法路用的就是阈值 0 的那个 store。
|
||
// 不在这里造「低余弦夹具」的原因:分词器会丢掉纯拉丁 token、也会过滤未登录词,
|
||
// 造出来的夹具余弦根本压不到阈值以下(我先试了两种,余弦 0.23/0.27,
|
||
// 前提断言直接把这两版夹具否掉了)。
|
||
func TestLexicalStoreUsesZeroMinScore(t *testing.T) {
|
||
st := newTestStore(t, fakeDense{def: vector.Vector{"0": 1}})
|
||
if got := st.lex.MinScore(); got != 0 {
|
||
t.Fatalf("词法路阈值必须为 0,实际 %v(沿用稠密路阈值会静默丢候选)", got)
|
||
}
|
||
if got := st.vec.MinScore(); got != vector.DefaultMinScore {
|
||
t.Fatalf("稠密路阈值应保持默认 %v,实际 %v", vector.DefaultMinScore, got)
|
||
}
|
||
}
|
||
|
||
// Add / Remove 必须同时维护两路索引:只维护一路会让被删条目继续被检索命中
|
||
// (或新条目只在其中一路可见)。
|
||
func TestAddRemoveKeepsBothPaths(t *testing.T) {
|
||
st := newTestStore(t, fakeDense{def: vector.Vector{"0": 1}})
|
||
mustAdd(t, st, "alpha", "alpha 独有词 alphaonly")
|
||
mustAdd(t, st, "beta", "beta 独有词 betaonly")
|
||
|
||
has := func(q, want string) bool {
|
||
for _, h := range st.Search(q, 5) {
|
||
if h.Name == want {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
if !has("alphaonly", "alpha") {
|
||
t.Fatal("新增条目应可被检索到")
|
||
}
|
||
if err := st.Remove("alpha"); err != nil {
|
||
t.Fatalf("remove: %v", err)
|
||
}
|
||
if has("alphaonly", "alpha") {
|
||
t.Fatal("已删除条目仍被检索命中(两路索引有一路没清)")
|
||
}
|
||
if !has("betaonly", "beta") {
|
||
t.Fatal("删除其它条目不应影响 beta")
|
||
}
|
||
}
|
||
|
||
// 分数相同时必须按名字定序,保证结果可重复(否则同一查询两次结果可能不同)。
|
||
func TestSearchDeterministicOnTies(t *testing.T) {
|
||
same := vector.Vector{"0": 1}
|
||
st := newTestStore(t, fakeDense{def: same})
|
||
for _, n := range []string{"ccc", "aaa", "bbb"} {
|
||
mustAdd(t, st, n, "完全一样的内容")
|
||
}
|
||
first := names(st.Search("完全一样的内容", 3))
|
||
for i := 0; i < 5; i++ {
|
||
got := names(st.Search("完全一样的内容", 3))
|
||
for j := range first {
|
||
if got[j] != first[j] {
|
||
t.Fatalf("结果不确定:第 %d 次 %v != 首次 %v", i, got, first)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 两路都空时不能 panic,且应返回空。
|
||
func TestSearchEmptyStore(t *testing.T) {
|
||
st := newTestStore(t, fakeDense{def: vector.Vector{"0": 1}})
|
||
if hits := st.Search("随便", 5); len(hits) != 0 {
|
||
t.Fatalf("空库应返回空,实际 %v", names(hits))
|
||
}
|
||
}
|