embedding.Embed 同时受条数界(8)与字符界(480 字)约束 ⇒ 一次 embedOnce(B 条) 会发 C 个请求(C 由正文长度决定,1..B),因此: 正常批 C 含一条坏正文 ≤ C + B(短正文 9;均长 75 汉字的现实批 10;病理上限 2B=16) 服务整体挂死 首个 chunk 即失败 + 逐条 B ⇒ 1 + B = 9(与字符维无关) 修正 embed_isolation.go 文件头的上界表、approxRequests 的「两者一致」表述、 embedReuseStats.Sent 的注释,以及 embed_resilience_test.go 的上界注释与日志文案。 零行为改动:判据、实现、请求数都未动,测试全绿。
286 lines
11 KiB
Go
286 lines
11 KiB
Go
package memory
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"log/slog"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory/vector"
|
||
)
|
||
|
||
// 本文件是「增量向量化」:召回链只嵌**索引里还没有**的行。
|
||
//
|
||
// ## 背景(M15,2026-10-13 登记的放大源)
|
||
//
|
||
// 整理轮每轮把整个分区重新向量化(user 分区一轮 ≈ 250 个请求)、入库审查每条把
|
||
// 200 条既有候选重新嵌一遍(26 个请求)—— 而 `vec_items` 里本来就有这些行的向量
|
||
// (写入侧 store_vector.go 每条 Add 都写一条)。同一段正文在下一轮从零再算一遍是
|
||
// 纯浪费,也是 M15 记的「请求放大」在**记忆层**的那一半(另一半是 embedding 包的
|
||
// 切片形状,已由 M15 把 4 条/片改成 8 条/片)。
|
||
//
|
||
// ## 增量判据:向量存在性 + 维度一致(不用内容哈希)
|
||
//
|
||
// 复用条件是「`vec_items` 里该 item.ID 的向量存在,且长度等于索引维度」。不用
|
||
// 内容哈希,理由是可证的一条:**记忆正文在本仓不存在就地改写** —— `Store.Add` 是
|
||
// 唯一 INSERT 路径、merge 是软删除(`status`+`merged_into`)、reclassify 只改分类列、
|
||
// 结构性改写一律「新行 + 旧行置为被合并」(design/23 §15 D3-6)。正文只增不改 ⇒
|
||
// 「有向量」等价于「那个向量就是这段正文的向量」,哈希比对不提供额外信息。
|
||
//
|
||
// ⚠️ 已知边界(既存缺口,不在本切片内):`vec_items` 不带**模型标签**。若换了
|
||
// embedding 模型而维度恰好相同,复用会把两种表示混进同一轮的余弦里。可用的判据
|
||
// 只有维度(本层用的就是它)——要根治得给向量表加一列 model,属迁移级改动。
|
||
//
|
||
// ## 算完回填索引(否则「增量」只会增到同样的那几条)
|
||
//
|
||
// 只复用不填,第二轮还是复用到同样那几条:索引的覆盖面本来只有「索引接入之后
|
||
// 写入的条目 + 一次性 ≤512 条回填」(retrieve_index.go),一个 2500 条的分区因此
|
||
// 长期大部分没有向量。所以本层在算完之后把向量 upsert 回索引(与
|
||
// store_vector.indexItem 同一件事、同一条 id),使**第二轮起**同一分区的请求数
|
||
// 趋近于 0。回填失败只计数 + 一条 WARN,绝不让召回失败(召回已经在手上的向量上
|
||
// 跑完了)。
|
||
|
||
// ErrNilEmbedder 在「要增量向量化却没有 embedder」时返回。生产调用点都先判了
|
||
// nil(未配置语义 embedding 就走词项余弦),因此它只防内部装配错误。
|
||
var ErrNilEmbedder = errors.New("memory: nil embedder")
|
||
|
||
// embedReuseStats 是一轮召回**实际向远端发了什么**的计数。它进日志而不是进
|
||
// 候选/留痕表:请求数是运维观测,不改变任何判定结果。
|
||
type embedReuseStats struct {
|
||
// Requested 是参与召回的条目数(本轮需要向量的条目)。
|
||
Requested int
|
||
// Reused 是索引里已有向量、一次远端都没发的条目数。
|
||
Reused int
|
||
// Sent 是真正送进 embedder 的**条数**;HTTP 请求数 ≥ ⌈Sent/批上限⌉ ——
|
||
// embedding 包还会按字符界(480 字)再切,长正文时请求数更多(见 approxRequests)。
|
||
Sent int
|
||
// Dropped 是逐条隔离重试后仍失败、本轮没有向量的条目数(缺口)。
|
||
Dropped int
|
||
// Filled 是本轮回填进索引的向量数。
|
||
Filled int
|
||
// FillFailed 是回填失败的条数(不影响召回)。
|
||
FillFailed int
|
||
}
|
||
|
||
// approxRequests 估算本轮发出的 HTTP 请求数:条数 ÷ 批上限(向上取整)。
|
||
//
|
||
// 🔴 它是**按条数界算的下限估算**,不是精确值:embedding.Embed 还按字符界
|
||
// (DefaultBatchChars=480)切片,长正文批会被多切一刀 ⇒ 真实请求数 ≥ 本估算
|
||
// (短正文、≤480 字/批时两者相等)。它只进日志做观测,不是任何判据。
|
||
func (s embedReuseStats) approxRequests(batch int) int {
|
||
if s.Sent <= 0 {
|
||
return 0
|
||
}
|
||
if batch <= 0 {
|
||
batch = tidyEmbedBatch
|
||
}
|
||
return (s.Sent + batch - 1) / batch
|
||
}
|
||
|
||
// logEmbedReuse 如实记一笔本轮「复用 / 真发请求 / 缺口」。缺口必须看得见
|
||
// (绝不静默),正常的复用只记 Debug —— 它每轮每分区都会发生,不该刷屏。
|
||
func logEmbedReuse(log *slog.Logger, caller string, stats embedReuseStats, batch int) {
|
||
if log == nil || stats.Requested == 0 {
|
||
return
|
||
}
|
||
attrs := []any{
|
||
"caller", caller,
|
||
"items", stats.Requested,
|
||
"reused", stats.Reused,
|
||
"sent", stats.Sent,
|
||
"requests", stats.approxRequests(batch),
|
||
"dropped", stats.Dropped,
|
||
"filled", stats.Filled,
|
||
}
|
||
if stats.Dropped > 0 {
|
||
log.Warn("memory: semantic recall ran with embedding gaps", attrs...)
|
||
return
|
||
}
|
||
log.Debug("memory: semantic recall reused indexed vectors", attrs...)
|
||
}
|
||
|
||
// embedIncremental 返回与 items 对齐的向量(拿不到的位置为 nil):索引里已有的
|
||
// 直接复用,只有缺的才送进 embedder。batch 是单次 Embed 的条数上限(生产传
|
||
// tidyEmbedBatch,它 = embedding.DefaultBatchLimit)。
|
||
//
|
||
// 失败语义(design/23 §15.3 已登记的韧性口径:**一片失败不丢整条**):
|
||
// - 单批内部失败由 embedIsolated **逐条隔离**,仍失败的那几条留下**缺口**,其余
|
||
// 条目照常带向量返回;
|
||
// - 整批一条都没拿到(整体性故障)时停止后续批,已拿到的向量照常返回;
|
||
// - 只有「一条向量都没拿到」才返回错误 —— 调用方据此走既有的词项余弦降级。
|
||
func (s *Store) embedIncremental(ctx context.Context, emb embedding.Embedder, items []MemoryItem, batch int) ([]embedding.Vector, embedReuseStats, error) {
|
||
stats := embedReuseStats{Requested: len(items)}
|
||
vecs := make([]embedding.Vector, len(items))
|
||
if len(items) == 0 {
|
||
return vecs, stats, nil
|
||
}
|
||
if emb == nil {
|
||
return nil, stats, ErrNilEmbedder
|
||
}
|
||
idx := s.vectorIndex()
|
||
if idx != nil && emb.Dim() > 0 && emb.Dim() != idx.Dim() {
|
||
// 🔴 维度不一致说明索引与当前 embedder 不是同一套表示(换过模型 / 换过维度)。
|
||
// 此时**既不复用也不回填**:复用会把两种维度的向量混进同一轮余弦(长度不符
|
||
// ⇒ 相似度恒 0,静默降质),回填则必然逐条 ErrDimMismatch。退回「全量嵌入」,
|
||
// 并如实留一条 WARN,让「索引该重建了」这件事看得见。
|
||
s.log.Warn("memory: embedder dim differs from vector index dim, incremental reuse disabled",
|
||
"embedder_dim", emb.Dim(), "index_dim", idx.Dim())
|
||
idx = nil
|
||
}
|
||
if idx != nil {
|
||
reused, err := s.reuseIndexedVectors(ctx, idx, items, vecs)
|
||
if err != nil {
|
||
// 索引读失败**不**降级语义召回:退回「全量嵌入」(改动前的行为),代价是
|
||
// 这一轮又放大成 ⌈N/批⌉ 个请求 —— 如实记 WARN,让请求数异常有据可查。
|
||
s.log.Warn("memory: read indexed vectors failed, embedding the whole partition", "error", err)
|
||
vecs = make([]embedding.Vector, len(items))
|
||
} else {
|
||
stats.Reused = reused
|
||
}
|
||
}
|
||
missing := missingVectorIndexes(vecs)
|
||
if len(missing) == 0 {
|
||
return vecs, stats, nil
|
||
}
|
||
stats.Sent = len(missing)
|
||
if err := s.embedMissing(ctx, emb, items, missing, vecs, &stats, batch); err != nil {
|
||
return nil, stats, err
|
||
}
|
||
if idx != nil {
|
||
s.fillIndexVectors(ctx, idx, items, missing, vecs, &stats)
|
||
}
|
||
return vecs, stats, nil
|
||
}
|
||
|
||
// reuseIndexedVectors 把索引里已有的向量摊到 vecs 的对应位置,返回复用条数。
|
||
// 只读:它不写 vecs 之外的任何东西,也不产生 embedding 请求。
|
||
func (s *Store) reuseIndexedVectors(ctx context.Context, idx vector.Index, items []MemoryItem, vecs []embedding.Vector) (int, error) {
|
||
ids := make([]string, 0, len(items))
|
||
positions := make([]int, 0, len(items))
|
||
for i, it := range items {
|
||
if it.ID == "" {
|
||
continue
|
||
}
|
||
ids = append(ids, it.ID)
|
||
positions = append(positions, i)
|
||
}
|
||
if len(ids) == 0 {
|
||
return 0, nil
|
||
}
|
||
stored, err := idx.Vectors(ctx, ids)
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
reused := 0
|
||
for k, pos := range positions {
|
||
if k >= len(stored) || len(stored[k]) == 0 {
|
||
continue
|
||
}
|
||
vecs[pos] = embedding.Vector(stored[k])
|
||
reused++
|
||
}
|
||
return reused, nil
|
||
}
|
||
|
||
// missingVectorIndexes 返回还没有向量的那些条目的下标(顺序即条目顺序,
|
||
// 入库审查的新条目因此在最前)。
|
||
func missingVectorIndexes(vecs []embedding.Vector) []int {
|
||
missing := make([]int, 0, len(vecs))
|
||
for i, v := range vecs {
|
||
if len(v) == 0 {
|
||
missing = append(missing, i)
|
||
}
|
||
}
|
||
return missing
|
||
}
|
||
|
||
// embedMissing 只嵌入缺向量的那些条目,把结果写回 vecs 的对应位置。
|
||
//
|
||
// 批级失败(整体性故障)当场收手:对已经挂掉的服务继续发后续批就是 M15 记的
|
||
// 那种放大。缺口条数进 stats,绝不静默。
|
||
func (s *Store) embedMissing(ctx context.Context, emb embedding.Embedder, items []MemoryItem,
|
||
missing []int, vecs []embedding.Vector, stats *embedReuseStats, batch int) error {
|
||
if batch <= 0 {
|
||
batch = tidyEmbedBatch
|
||
}
|
||
var firstErr error
|
||
for start := 0; start < len(missing); start += batch {
|
||
end := min(start+batch, len(missing))
|
||
idxs := missing[start:end]
|
||
texts := make([]string, len(idxs))
|
||
for k, i := range idxs {
|
||
texts[k] = items[i].Content
|
||
}
|
||
got, err := embedIsolated(ctx, emb, texts)
|
||
if err != nil {
|
||
if firstErr == nil {
|
||
firstErr = err
|
||
}
|
||
break
|
||
}
|
||
for k, i := range idxs {
|
||
if k >= len(got) || len(got[k]) == 0 {
|
||
continue
|
||
}
|
||
vecs[i] = got[k]
|
||
}
|
||
}
|
||
stats.Dropped = stats.Sent - countVectorsAt(vecs, missing)
|
||
if stats.Sent == stats.Dropped {
|
||
if firstErr != nil {
|
||
return firstErr
|
||
}
|
||
return ErrEmbedUnavailable
|
||
}
|
||
if stats.Dropped > 0 {
|
||
s.log.Warn("memory: embedding left gaps in semantic recall",
|
||
"missing", len(missing), "dropped", stats.Dropped, "error", firstErr)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// countVectorsAt 数 idxs 这些位置里已有向量的条数。
|
||
func countVectorsAt(vecs []embedding.Vector, idxs []int) int {
|
||
n := 0
|
||
for _, i := range idxs {
|
||
if i < len(vecs) && len(vecs[i]) > 0 {
|
||
n++
|
||
}
|
||
}
|
||
return n
|
||
}
|
||
|
||
// fillIndexVectors 把本轮**算出来的**向量 upsert 回索引(与 store_vector.indexItem
|
||
// 同一件事、同一条 id),使下一轮不再重算同一批正文。
|
||
//
|
||
// 失败只计数 + 一条 WARN:召回已经在本轮拿到的向量上跑完了,回填失败不该让它
|
||
// 变成一次失败。维度不符(索引与当前 embedder 不是同一套表示)时当场收手 ——
|
||
// 继续逐条试只会刷出上千条同样的失败。
|
||
func (s *Store) fillIndexVectors(ctx context.Context, idx vector.Index, items []MemoryItem,
|
||
idxs []int, vecs []embedding.Vector, stats *embedReuseStats) {
|
||
mismatch := false
|
||
for _, i := range idxs {
|
||
if items[i].ID == "" || len(vecs[i]) == 0 {
|
||
continue
|
||
}
|
||
err := idx.Add(ctx, items[i].ID, vector.Vector(vecs[i]))
|
||
if err == nil {
|
||
stats.Filled++
|
||
continue
|
||
}
|
||
stats.FillFailed++
|
||
if errors.Is(err, vector.ErrDimMismatch) {
|
||
mismatch = true
|
||
break
|
||
}
|
||
}
|
||
switch {
|
||
case mismatch:
|
||
s.log.Warn("memory: vector index dim mismatch, index fill stopped for this round",
|
||
"filled", stats.Filled, "failed", stats.FillFailed)
|
||
case stats.FillFailed > 0:
|
||
s.log.Warn("memory: vector index fill failed for some items",
|
||
"filled", stats.Filled, "failed", stats.FillFailed)
|
||
}
|
||
}
|