fix(memory): 空 query 不再触发语义回退 + 回退有界(修真机 39 秒召回)

真因(agent-197,文件:行):只有附件的消息正文为空 ⇒ queryFromMessages 返回空串 ⇒
retrieve.go 的 vectorIndexScores 在问索引之前就 return ⇒ 上层分不清「没问过索引」与
「索引无命中」⇒ 打出误导的 no-candidate-hit ⇒ 转内存回退,把全库 302 条候选正文塞进
一个 embedding 请求(302 × 约 130ms = 39.3 秒,与实测 duration_ns 吻合)。
索引/维度/表/写入其实全是好的(PG vec_items 302 行全 dim=1024,与 SQLite 302 个 id 集合相同)。
修法:空/纯空白 query 整条语义路径跳过(不问索引、不 embed);回退改为有界
(分批 32 + 1 秒时间预算 + 正文到向量 FIFO 缓存 1024,不再每回合重算)。
文件:retrieve.go / retrieve_semantic.go + 新增 retrieve_fallback.go / retrieve_keyword.go /
retrieve_fallback_test.go(retrieve.go 曾达 501 行,按 ≤500 拆分)。
变异:去掉空 query 门 ⇒ 向量化 43 条文本(want 0);整库单请求 ⇒ 69 条超上限 32;停用缓存 ⇒ 重复嵌入。
This commit is contained in:
AskaEth committed 2026-10-05 08:04:47 +08:00
1 parent 6cb41a8794
commit cb564fe981
5 files changed
+532 -66

No files matched your search

@@ -0,0 +1,219 @@
package memory
import (
"bytes"
"context"
"log/slog"
"strconv"
"strings"
"sync"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding"
)
// 本文件是 2026-10-05 真机性能事故的回归测试:主人只发了一个**不带正文**的
// 附件(空 query),memory.recall 一次花了 39.3 秒 —— 索引因为空 query
// 没被问过,回退路径于是把整库 302 条候选正文逐条向量化。三条断言分别
// 钉住「空 query 不做任何语义工作」「回退不再把整库塞进一次请求且不重复
// 嵌入」「回退被预算拦住、不阻塞回合」。
// recordingEmbedder 记录每次 Embed 收到的文本条数,并返回确定性向量:
// 它是「回退路径到底嵌入了多少条正文」的观测点。
type recordingEmbedder struct {
dim int
mu sync.Mutex
batches []int
texts int
}
func (e *recordingEmbedder) Embed(_ context.Context, texts []string) ([]embedding.Vector, error) {
e.mu.Lock()
e.batches = append(e.batches, len(texts))
e.texts += len(texts)
e.mu.Unlock()
out := make([]embedding.Vector, len(texts))
for i := range texts {
v := make(embedding.Vector, e.Dim())
v[0] = 1
out[i] = v
}
return out, nil
}
func (e *recordingEmbedder) Dim() int {
if e.dim > 0 {
return e.dim
}
return 4
}
// batchSizes 返回到目前为止每次 Embed 的文本条数(副本)。
func (e *recordingEmbedder) batchSizes() []int {
e.mu.Lock()
defer e.mu.Unlock()
return append([]int(nil), e.batches...)
}
// blockingBatchEmbedder 模拟「远端 embedding 接口对一批文本迟迟不回」:
// 单条文本立即返回,一次多条则一直等到 ctx 结束 —— 用来验证回退路径
// 真的会收手,而不是陪着接口一起把回合拖住。
type blockingBatchEmbedder struct{ dim int }
func (e blockingBatchEmbedder) Embed(ctx context.Context, texts []string) ([]embedding.Vector, error) {
if len(texts) > 1 {
<-ctx.Done()
return nil, ctx.Err()
}
out := make([]embedding.Vector, len(texts))
for i := range texts {
v := make(embedding.Vector, e.dim)
v[0] = 1
out[i] = v
}
return out, nil
}
func (e blockingBatchEmbedder) Dim() int { return e.dim }
// TestRetriever_EmptyQuery_NoSemanticWork_空查询不做任何语义工作 是事故的
// 主回归:空 query(附件类回合没有正文)既不该去问向量索引,也不该把
// 候选正文送去向量化。旧行为下这里会把每个候选正文都 embed 一遍、
// 真机 302 条即 39.3 秒。
func TestRetriever_EmptyQuery_NoSemanticWork_空查询不做任何语义工作(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
// 候选条数跨过回退的单批上限:一旦这条路径被触发,嵌入条数会明显
// 大于「一次 query 向量」,断言因此不会因边界而假绿。
candidates := fallbackEmbedBatch + 8
for i := 0; i < candidates; i++ {
addItem(t, store, MemoryItem{
PersonaID: "persona.a",
Content: "记忆条目 " + strconv.Itoa(i),
Scope: ScopePersona,
Kind: KindWorldEvent,
})
}
emb := &recordingEmbedder{dim: 8}
// 索引恒「无候选命中」:这正是事故里那条回退路径的入口。
idx := &countingIndex{Index: &mockVectorIndex{dim: 8}}
r := NewRetriever(store, WithEmbedder(emb), WithVectorIndex(idx))
for _, query := range []string{"", " ", "\n\t"} {
results, err := r.Retrieve(ctx, "persona.a", query, 5)
if err != nil {
t.Fatalf("Retrieve(query=%q): %v", query, err)
}
if len(results) == 0 {
t.Fatalf("query=%q:空查询仍应按新近度返回候选,got 0 results", query)
}
}
if emb.texts != 0 {
t.Fatalf("空 query 向量化了 %d 条文本,want 0(真机事故:302 条候选正文 = 39.3 秒)", emb.texts)
}
if idx.searches != 0 {
t.Fatalf("空 query 查询了向量索引 %d 次,want 0(空 query 必然「无候选命中」,只会把正常检索误报成索引失效)",
idx.searches)
}
}
// TestRetriever_Fallback_BoundedAndCached_回退分批且不重复嵌候选 钉住回退
// 路径的两条护栏:候选正文分批送出(不再一个请求塞整库),且同一批
// 正文在第二回合不再重复向量化(embedder 对相同输入确定性)。
func TestRetriever_Fallback_BoundedAndCached_回退分批且不重复嵌候选(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
candidates := fallbackEmbedBatch*2 + 5
for i := 0; i < candidates; i++ {
content := "coffee note " + strconv.Itoa(i)
addItem(t, store, MemoryItem{
PersonaID: "persona.a", Content: content,
Scope: ScopePersona, Kind: KindWorldEvent,
})
}
emb := &recordingEmbedder{dim: 8}
idx := &countingIndex{Index: &mockVectorIndex{dim: 8}}
r := NewRetriever(store, WithEmbedder(emb), WithVectorIndex(idx))
results, err := r.Retrieve(ctx, "persona.a", "coffee", 5)
if err != nil {
t.Fatalf("Retrieve: %v", err)
}
if len(results) == 0 || results[0].Score <= 0 {
t.Fatalf("回退没有给出任何语义分:results=%d score=%v", len(results), results[0].Score)
}
batches := emb.batchSizes()
if len(batches) < 2 {
t.Fatalf("候选正文没有分批:一次 Embed 带了 %v 条,want 多次且每次 ≤ %d", batches, fallbackEmbedBatch)
}
for _, n := range batches {
if n > fallbackEmbedBatch {
t.Fatalf("单次 Embed 携带 %d 条文本,超过上限 %d(回退不得把整库塞进一个请求)",
n, fallbackEmbedBatch)
}
}
if got, want := emb.texts, candidates+1; got != want {
t.Fatalf("首次回退向量化了 %d 条文本,want %d(%d 条候选分批 + 1 条 query)", got, want, candidates)
}
// 第二次检索:候选正文全部命中缓存,因此只多一次 query 向量化。
if _, err := r.Retrieve(ctx, "persona.a", "coffee", 5); err != nil {
t.Fatalf("second Retrieve: %v", err)
}
if got, want := emb.texts, candidates+2; got != want {
t.Fatalf("第二次回退向量化了 %d 条文本(累计),want %d —— 候选正文被重复嵌入,缓存没生效",
got, want)
}
}
// TestRetriever_Fallback_Budget_超预算不阻塞回合 钉住预算护栏:embedding
// 接口对一批文本迟迟不回时,回退用手上已有的向量收手,回合不被拖住,
// 并且留下一条带来源的 Warn(语义分只补了一部分必须可见)。
func TestRetriever_Fallback_Budget_超预算不阻塞回合(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
for i := 0; i < fallbackEmbedBatch+1; i++ {
addItem(t, store, MemoryItem{
PersonaID: "persona.a", Content: "记忆条目 " + strconv.Itoa(i),
Scope: ScopePersona, Kind: KindWorldEvent,
})
}
var buf bytes.Buffer
logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
idx := &countingIndex{Index: &mockVectorIndex{dim: 8}}
r := NewRetriever(store,
WithEmbedder(blockingBatchEmbedder{dim: 8}),
WithVectorIndex(idx),
WithRetrieverLogger(logger),
WithFallbackBudget(50*time.Millisecond),
)
start := time.Now()
results, err := r.Retrieve(ctx, "persona.a", "咖啡", 5)
elapsed := time.Since(start)
if err != nil {
t.Fatalf("Retrieve: %v", err)
}
if len(results) == 0 {
t.Fatal("回退超预算不应丢掉检索结果(关键词与新近度仍在)")
}
if elapsed > 5*time.Second {
t.Fatalf("回退没有被预算拦住:检索耗时 %v", elapsed)
}
if !strings.Contains(buf.String(), "exceeded its embed budget") {
t.Fatalf("回退超预算没有告警,语义分只补了一部分却无人可见;日志=%q", buf.String())
}
}