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:
1 parent
6cb41a8794
commit
cb564fe981
5 files changed
+532
-66
No files matched your search
@@ -73,12 +73,23 @@ type Retriever struct {
|
||||
recencyHalfLife time.Duration
|
||||
log *slog.Logger
|
||||
|
||||
// seedMu 保护一次性索引回填状态(seeded 与 fallbackWarned):
|
||||
// 回填每进程最多一次,而语义回退的告警也只报一次,避免每个
|
||||
// Retrieve 都写日志。
|
||||
// seedMu 保护一次性索引回填状态与回退路径的一次性告警位
|
||||
// (seeded / fallbackWarned / budgetWarned):回填每进程最多一次,
|
||||
// 语义回退与其预算超限的告警也只报一次,避免每个 Retrieve
|
||||
// 都写日志。
|
||||
seedMu sync.Mutex
|
||||
seeded bool
|
||||
fallbackWarned bool
|
||||
budgetWarned bool
|
||||
|
||||
// fallbackBudget 是内存余弦回退把候选正文向量化时允许花掉的
|
||||
// 时间上限(见 WithFallbackBudget)。非正时取 defaultFallbackBudget。
|
||||
fallbackBudget time.Duration
|
||||
|
||||
// fallbackMu 保护回退路径的向量缓存指针,使它可按需初始化
|
||||
// (缓存本体自带锁,见 vectorCache)。
|
||||
fallbackMu sync.Mutex
|
||||
fallbackCache *vectorCache
|
||||
}
|
||||
|
||||
// RetrieverOption 定制由 NewRetriever 构建的 Retriever。
|
||||
@@ -163,6 +174,22 @@ func WithRecencyHalfLife(days float64) RetrieverOption {
|
||||
}
|
||||
}
|
||||
|
||||
// WithFallbackBudget 设置内存余弦回退为「本次候选」向量化时允许花掉的
|
||||
// 时间上限。非正值被忽略,保留默认值(defaultFallbackBudget)。
|
||||
//
|
||||
// 它是性能护栏而不是功能开关:持久索引正常作答时这条路径根本不执行,
|
||||
// 一旦执行(索引确实缺候选向量),它把「整库候选依次向量化」的代价
|
||||
// 封在预算内 —— 超预算就用手上已有的向量打分,绝不为了补齐语义分
|
||||
// 让一个回合继续等下去(真机事故:302 条候选 = 39.3 秒,见
|
||||
// retrieve_fallback.go 的文件注释)。测试用短预算来验证这条护栏。
|
||||
func WithFallbackBudget(d time.Duration) RetrieverOption {
|
||||
return func(r *Retriever) {
|
||||
if d > 0 {
|
||||
r.fallbackBudget = d
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// NewRetriever 在 store 之上构建 Retriever。nil store 非法,会在
|
||||
// 调用时 panic;调用方应传入非 nil 的 store。
|
||||
//
|
||||
@@ -302,40 +329,25 @@ func (r *Retriever) visibleCandidates(ctx context.Context, personaID string) ([]
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// keywordScores 用内存内 TF-IDF 倒排索引(embedding.Index)为每条条目
|
||||
// 与 query 打分,复用 embedding 包的分词器。得分
|
||||
// 以最大值为基准归一化到 [0,1],以便与语义
|
||||
// 得分可比。
|
||||
func (r *Retriever) keywordScores(items []MemoryItem, query string) map[string]float64 {
|
||||
scores := make(map[string]float64, len(items))
|
||||
index := embedding.NewIndex[string]()
|
||||
for _, it := range items {
|
||||
index.Add(keywordText(it), it.ID)
|
||||
}
|
||||
hits := index.Search(query)
|
||||
if len(hits) == 0 {
|
||||
return scores
|
||||
}
|
||||
max := hits[0].Score
|
||||
if max <= 0 {
|
||||
return scores
|
||||
}
|
||||
for _, h := range hits {
|
||||
scores[h.ID] = h.Score / max
|
||||
}
|
||||
return scores
|
||||
}
|
||||
|
||||
// semanticScores 计算每个条目对 query 的语义相关性。接入了向量
|
||||
// 索引(WithVectorIndex)时,它经 idx.Search 从持久化索引
|
||||
// 作答 —— 真正的向量路径(design §3)。索引没有给出任何候选命中时
|
||||
// (索引空、索引报错或 query 为空),它**回退**到内存余弦:配置了
|
||||
// (索引空或索引报错),它**回退**到内存余弦:配置了
|
||||
// 增强却仍拿到一片 0 分,等于增强既无效又遮住了可用的路径
|
||||
// (审计 P0-3;design/03 §3「真正启用向量检索」、design/04 §7)。
|
||||
// 两条路径都没有时返回空 map,因此语义相关性
|
||||
// 不贡献任何分数。
|
||||
//
|
||||
// query 为空(无任何可向量化的文本)时整条语义路径**直接跳过**:
|
||||
// 没有 query 就没有可比的语义方向,拿空串去向量化既问不出东西,
|
||||
// 又要把整库候选逐条 embed 一遍 —— 真机实测这条弯路是 39.3 秒的来源
|
||||
// (见 retrieve_fallback.go)。此时排名由关键词与新近度决定,
|
||||
// 与 Retrieve 的既有口径一致。
|
||||
func (r *Retriever) semanticScores(ctx context.Context, items []MemoryItem, query string) map[string]float64 {
|
||||
scores := make(map[string]float64, len(items))
|
||||
if !hasSemanticQuery(query) {
|
||||
return scores
|
||||
}
|
||||
if r.vindex != nil {
|
||||
// 持久索引为空时先做一次性回填(每进程最多一次),使既有
|
||||
// 条目也进入语义路径,而不是只有新写的条目可被检索。
|
||||
@@ -350,32 +362,14 @@ func (r *Retriever) semanticScores(ctx context.Context, items []MemoryItem, quer
|
||||
if r.embedder == nil {
|
||||
return scores
|
||||
}
|
||||
return r.inMemorySemanticScores(ctx, items, query)
|
||||
}
|
||||
|
||||
texts := make([]string, len(items))
|
||||
for i, it := range items {
|
||||
texts[i] = it.Content
|
||||
}
|
||||
vectors, err := r.embedder.Embed(ctx, texts)
|
||||
if err != nil {
|
||||
r.log.Warn("memory: semantic embed of candidates failed, semantic scores skipped", "error", err)
|
||||
return scores
|
||||
}
|
||||
queryVectors, err := r.embedder.Embed(ctx, []string{query})
|
||||
if err != nil || len(queryVectors) == 0 {
|
||||
r.log.Warn("memory: semantic embed of query failed, semantic scores skipped", "error", err)
|
||||
return scores
|
||||
}
|
||||
qvec := queryVectors[0]
|
||||
for i, vec := range vectors {
|
||||
if i >= len(items) {
|
||||
break
|
||||
}
|
||||
sim := cosineSim(qvec, vec)
|
||||
if sim > 0 {
|
||||
scores[items[i].ID] = sim
|
||||
}
|
||||
}
|
||||
return scores
|
||||
// hasSemanticQuery 报告 query 是否含有可供向量化的语义内容:
|
||||
// 非空且非纯空白。附件类回合(主人只发文件、正文为空)正是空 query,
|
||||
// 它不该触发任何向量化工作。
|
||||
func hasSemanticQuery(query string) bool {
|
||||
return strings.TrimSpace(query) != ""
|
||||
}
|
||||
|
||||
// vectorIndexScores 从持久化向量索引作答语义相关性:
|
||||
@@ -385,8 +379,10 @@ func (r *Retriever) semanticScores(ctx context.Context, items []MemoryItem, quer
|
||||
// 索引是**全局**的(承载所有角色/作用域的向量),而本次检索的候选
|
||||
// 只是其中一个子集:不属于候选的命中不参与排名,也不得充当
|
||||
// 归一化基准(也不得因占满 topK 名额而把候选挤出命中)。因此
|
||||
// 这里只保留候选集内的命中。query 为空、索引报错或候选内
|
||||
// 无命中时退化为空 map —— 调用方据此回退内存余弦。
|
||||
// 这里只保留候选集内的命中。索引报错或候选内无命中时退化为
|
||||
// 空 map —— 调用方据此回退内存余弦。query 为空的早退只是防御:
|
||||
// 语义路径的调用方(semanticScores)已经在更外层拦掉了空 query,
|
||||
// 不会再把「没问过索引」误当成「索引答不出」。
|
||||
func (r *Retriever) vectorIndexScores(ctx context.Context, items []MemoryItem, query string) map[string]float64 {
|
||||
scores := make(map[string]float64, len(items))
|
||||
if strings.TrimSpace(query) == "" {
|
||||
@@ -447,14 +443,6 @@ func (r *Retriever) projectFor(ctx context.Context, it MemoryItem) *projectindex
|
||||
return p
|
||||
}
|
||||
|
||||
// keywordText 是用于关键词匹配的索引文本:content 加 tags。
|
||||
func keywordText(it MemoryItem) string {
|
||||
if len(it.Tags) == 0 {
|
||||
return it.Content
|
||||
}
|
||||
return it.Content + " " + strings.Join(it.Tags, " ")
|
||||
}
|
||||
|
||||
// Inject 为 personaID 检索最多 topK 条相关记忆,并把它们格式化为
|
||||
// 紧凑的上下文块,可供注入提示词。没有检索到任何内容时
|
||||
// 返回空字符串,因此没有相关记忆时调用方可以忽略
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
package memory
|
||||
|
||||
// 本文件收拢语义路径的**内存余弦回退**:持久向量索引没给出候选命中时,
|
||||
// 用内存余弦补齐语义分(索引正常作答时这里一行都不执行)。
|
||||
//
|
||||
// 它为什么必须是有界的(2026-10-05 真机事故):这条路径原先一次性把
|
||||
// **整库候选正文**塞进一个 embedding 请求,再接一次 query。主人真机
|
||||
// 只发了一个不带正文的附件(空 query),索引因此「没答上」,回退于是
|
||||
// 把 302 条候选正文全部向量化 —— 单个回合的 memory.recall 花了 39.3 秒,
|
||||
// 而模型本身只用 2.6 秒。因此这里给每一处代价都封了顶:
|
||||
//
|
||||
// - 单请求文本条数上限(fallbackEmbedBatch):既不把一个巨大的
|
||||
// 请求丢给远端接口,也让「回退一共嵌入了多少条」可数;
|
||||
// - 每次回退的时间预算(defaultFallbackBudget / WithFallbackBudget):
|
||||
// 超预算就用手上已有的向量打分,其余条目本轮没有语义分 ——
|
||||
// 回退是兜底,绝不允许它把回合拖住;
|
||||
// - 正文→向量的进程内缓存(fallbackVectorCacheMax):同一批正文
|
||||
// 不再每回合重复向量化(embedder 对相同输入是确定性的)。
|
||||
//
|
||||
// 空 query 在更外层就被拦掉(semanticScores 的 hasSemanticQuery 门),
|
||||
// 因此真机那次「整库向量化」不会再发生:没有 query 就没有语义方向,
|
||||
// 排名交给关键词与新近度。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding"
|
||||
)
|
||||
|
||||
const (
|
||||
// fallbackEmbedBatch 是回退路径单次 Embed 携带的候选条数上限。
|
||||
// 取 32:常见的 OpenAI 兼容 /embeddings 接口容许的批量都远大于它,
|
||||
// 而它小到不会让单次请求本身成为长尾。
|
||||
fallbackEmbedBatch = 32
|
||||
|
||||
// defaultFallbackBudget 是单次回退把候选正文向量化时允许花掉的
|
||||
// 时间上限。取 1 秒:主人对「一个回合的记忆召回」的要求是亚秒级,
|
||||
// 回退作为兜底绝不能把这笔代价变成无界的等待 —— 宁可本轮少补一些
|
||||
// 语义分(并留下 Warn),也不让回合变慢。索引正常作答时用不到它。
|
||||
defaultFallbackBudget = 1 * time.Second
|
||||
|
||||
// fallbackVectorCacheMax 是回退向量缓存的条目上限。1024 条的
|
||||
// 最坏内存占用约 1024 × 1024 维 × 4 字节 ≈ 4 MiB(维度由
|
||||
// embedder 决定),对常驻进程可接受;超出按 FIFO 淘汰。
|
||||
fallbackVectorCacheMax = 1024
|
||||
)
|
||||
|
||||
// inMemorySemanticScores 是索引答不出候选命中时的语义兜底:先取 query
|
||||
// 向量(代价与索引路径相同,都是一次 embed),再用候选正文与它的余弦
|
||||
// 相似度打分。候选向量的取得是**有界**的(见 candidateVectors)。
|
||||
//
|
||||
// query 向量embed失败时返回空分值:没有 query 向量,余弦无从谈起 ——
|
||||
// 但候选正文一个字节都没有发出去。
|
||||
func (r *Retriever) inMemorySemanticScores(ctx context.Context, items []MemoryItem, query string) map[string]float64 {
|
||||
scores := make(map[string]float64, len(items))
|
||||
queryVectors, err := r.embedder.Embed(ctx, []string{query})
|
||||
if err != nil || len(queryVectors) == 0 {
|
||||
r.log.Warn("memory: semantic embed of query failed, semantic scores skipped", "error", err)
|
||||
return scores
|
||||
}
|
||||
qvec := queryVectors[0]
|
||||
|
||||
vectors := r.candidateVectors(ctx, items)
|
||||
for _, it := range items {
|
||||
vec, ok := vectors[it.Content]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if sim := cosineSim(qvec, vec); sim > 0 {
|
||||
scores[it.ID] = sim
|
||||
}
|
||||
}
|
||||
return scores
|
||||
}
|
||||
|
||||
// candidateVectors 返回候选条目的「正文 → 向量」映射:正文命中进程内
|
||||
// 缓存的条目直接复用,只有未命中的才在预算内分批向量化。因此同一批
|
||||
// 候选在第二回合起几乎不需要再调 embedder(缓存未满时),真机事故里
|
||||
// 那种「每回合把整库重算一遍」不再发生。
|
||||
func (r *Retriever) candidateVectors(ctx context.Context, items []MemoryItem) map[string]embedding.Vector {
|
||||
cache := r.vectorCache()
|
||||
out := make(map[string]embedding.Vector, len(items))
|
||||
missing := make([]MemoryItem, 0, len(items))
|
||||
for _, it := range items {
|
||||
if v, ok := cache.get(it.Content); ok {
|
||||
out[it.Content] = v
|
||||
continue
|
||||
}
|
||||
missing = append(missing, it)
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
r.embedCandidates(ctx, missing, out, cache)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// embedCandidates 分批向量化 missing 里的正文,把结果写进 out 与缓存。
|
||||
//
|
||||
// 预算用尽(或调用方取消)时它**就地收手**:out 里已有的向量照旧参与
|
||||
// 本轮打分,剩下的条目只是没有语义分 —— 绝不为了补齐它们继续阻塞检索。
|
||||
// 单批失败按整段失败处理(把半批结果当完整结果用,会让同一份语义分
|
||||
// 随批次边界漂移),并记一条带来源的日志。
|
||||
func (r *Retriever) embedCandidates(ctx context.Context, missing []MemoryItem, out map[string]embedding.Vector, cache *vectorCache) {
|
||||
bctx, cancel := context.WithTimeout(ctx, r.embedBudget())
|
||||
defer cancel()
|
||||
|
||||
embedded := 0
|
||||
for start := 0; start < len(missing); start += fallbackEmbedBatch {
|
||||
end := min(start+fallbackEmbedBatch, len(missing))
|
||||
batch := missing[start:end]
|
||||
texts := make([]string, len(batch))
|
||||
for i, it := range batch {
|
||||
texts[i] = it.Content
|
||||
}
|
||||
vecs, err := r.embedder.Embed(bctx, texts)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
if bctx.Err() != nil {
|
||||
r.warnFallbackBudget(len(missing), embedded)
|
||||
return
|
||||
}
|
||||
r.log.Warn("memory: semantic embed of candidates failed, semantic scores skipped",
|
||||
"error", err, "candidates", len(missing))
|
||||
return
|
||||
}
|
||||
for i, v := range vecs {
|
||||
if i >= len(batch) {
|
||||
break
|
||||
}
|
||||
out[batch[i].Content] = v
|
||||
cache.put(batch[i].Content, v)
|
||||
embedded++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// vectorCache 返回回退路径的向量缓存,按需初始化(零值 Retriever 也能用)。
|
||||
func (r *Retriever) vectorCache() *vectorCache {
|
||||
r.fallbackMu.Lock()
|
||||
defer r.fallbackMu.Unlock()
|
||||
if r.fallbackCache == nil {
|
||||
r.fallbackCache = newVectorCache(fallbackVectorCacheMax)
|
||||
}
|
||||
return r.fallbackCache
|
||||
}
|
||||
|
||||
// embedBudget 返回回退路径的时间预算,未配置时取默认值。
|
||||
func (r *Retriever) embedBudget() time.Duration {
|
||||
if r.fallbackBudget > 0 {
|
||||
return r.fallbackBudget
|
||||
}
|
||||
return defaultFallbackBudget
|
||||
}
|
||||
|
||||
// vectorCache 是「正文 → 向量」的有界 FIFO 缓存。键就是将要发给
|
||||
// embedder 的那段文本本身,因此内容变了自然换键,不会命中旧向量
|
||||
// (embedder 对相同输入确定性,缓存因此与实时计算等价)。
|
||||
type vectorCache struct {
|
||||
mu sync.Mutex
|
||||
vecs map[string]embedding.Vector
|
||||
order []string
|
||||
limit int
|
||||
}
|
||||
|
||||
// newVectorCache 构建容量为 limit 的缓存;非正 limit 取默认上限。
|
||||
func newVectorCache(limit int) *vectorCache {
|
||||
if limit <= 0 {
|
||||
limit = fallbackVectorCacheMax
|
||||
}
|
||||
return &vectorCache{vecs: make(map[string]embedding.Vector, limit), limit: limit}
|
||||
}
|
||||
|
||||
// get 返回 key 的向量;未命中返回 false。
|
||||
func (c *vectorCache) get(key string) (embedding.Vector, bool) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
v, ok := c.vecs[key]
|
||||
return v, ok
|
||||
}
|
||||
|
||||
// put 写入 key 的向量,已存在则保留先写入的那份(同一正文的向量相同,
|
||||
// 重写只会白费一次淘汰)。超过上限时按写入顺序淘汰最旧的一条。
|
||||
func (c *vectorCache) put(key string, v embedding.Vector) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if _, ok := c.vecs[key]; ok {
|
||||
return
|
||||
}
|
||||
if len(c.order) >= c.limit {
|
||||
delete(c.vecs, c.order[0])
|
||||
c.order = c.order[1:]
|
||||
}
|
||||
c.vecs[key] = v
|
||||
c.order = append(c.order, key)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding"
|
||||
)
|
||||
|
||||
// 本文件收拢排名里的**关键词侧**:TF-IDF 打分与用于匹配的索引文本。
|
||||
// 它们与语义侧(retrieve_semantic.go / retrieve_fallback.go)对称,
|
||||
// 拆开的目的是让 retrieve.go 保持可读(文件 ≤500 行,AGENTS §10)。
|
||||
|
||||
// keywordScores 用内存内 TF-IDF 倒排索引(embedding.Index)为每条条目
|
||||
// 与 query 打分,复用 embedding 包的分词器。得分
|
||||
// 以最大值为基准归一化到 [0,1],以便与语义
|
||||
// 得分可比。
|
||||
func (r *Retriever) keywordScores(items []MemoryItem, query string) map[string]float64 {
|
||||
scores := make(map[string]float64, len(items))
|
||||
index := embedding.NewIndex[string]()
|
||||
for _, it := range items {
|
||||
index.Add(keywordText(it), it.ID)
|
||||
}
|
||||
hits := index.Search(query)
|
||||
if len(hits) == 0 {
|
||||
return scores
|
||||
}
|
||||
max := hits[0].Score
|
||||
if max <= 0 {
|
||||
return scores
|
||||
}
|
||||
for _, h := range hits {
|
||||
scores[h.ID] = h.Score / max
|
||||
}
|
||||
return scores
|
||||
}
|
||||
|
||||
// keywordText 是用于关键词匹配的索引文本:content 加 tags。
|
||||
func keywordText(it MemoryItem) string {
|
||||
if len(it.Tags) == 0 {
|
||||
return it.Content
|
||||
}
|
||||
return it.Content + " " + strings.Join(it.Tags, " ")
|
||||
}
|
||||
@@ -6,9 +6,9 @@ import (
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding"
|
||||
)
|
||||
|
||||
// 本文件收拢语义打分的辅助件:内存余弦相似度与「索引没答上、改走
|
||||
// 内存回退」的一次性告警。它们的用量小但分散,独立成文件是为了让
|
||||
// retrieve.go 保持可读(文件 ≤500 行,AGENTS §10)。
|
||||
// 本文件收拢语义打分的辅助件:内存余弦相似度与语义路径的
|
||||
// 一次性告警(回退发生、回退超预算)。它们的用量小但分散,
|
||||
// 独立成文件是为了让 retrieve.go 保持可读(文件 ≤500 行,AGENTS §10)。
|
||||
|
||||
// cosineSim 计算两个向量的余弦相似度,由调用方夹到 [0,1]。
|
||||
// 长度为零或不一致的向量得 0。
|
||||
@@ -42,3 +42,20 @@ func (r *Retriever) warnSemanticFallback() {
|
||||
r.fallbackWarned = true
|
||||
r.log.Debug("memory: vector index had no candidate hit, using in-memory cosine fallback")
|
||||
}
|
||||
|
||||
// warnFallbackBudget 报告一次「内存余弦回退没能在预算内把候选正文
|
||||
// 向量化完,本轮语义分只有一部分」。它是 Warn 而不是 Debug:语义分
|
||||
// 少了一部分会静默改变排名,运维必须看得见(AGENTS §10 日志带来源)。
|
||||
// 同样每个检索器只报一次 —— 该状态在一次运行里基本不变(索引缺向量
|
||||
// 或 embedder 慢),逐次告警只会把日志刷满;条数由日志字段给出,
|
||||
// 需要细看时看 candidates/embedded。
|
||||
func (r *Retriever) warnFallbackBudget(candidates, embedded int) {
|
||||
r.seedMu.Lock()
|
||||
defer r.seedMu.Unlock()
|
||||
if r.budgetWarned {
|
||||
return
|
||||
}
|
||||
r.budgetWarned = true
|
||||
r.log.Warn("memory: in-memory cosine fallback exceeded its embed budget, semantic scores partial",
|
||||
"candidates", candidates, "embedded", embedded, "budget", r.embedBudget())
|
||||
}
|
||||
Reference in new issue
Block a user