Embedding 模型怎么选:别看榜单,测你自己的文档(第70篇-E56)

Embedding 模型怎么选:别看榜单,测你自己的文档(第70篇-E56)
系列「企业级 AI Agent 实现拆解」E56 篇Part 13 RAG 篇第五章。上一篇 把三个切分器的算法拆完了。这篇进到向量化这一步模型怎么选。先坦白一件事。这篇按计划该摆一张「OpenAI vs 国产 vs 本地效果/成本/速度实测对比」的大表。我不打算那么写两个原因**第一价格和榜单每个月都在变。**写死在文章里三个月后就是过期信息还会有人拿它当依据做决策。**第二个更要紧跑分跟你的语料没关系。**我用一份员工手册测出来的第一名换到你的医疗病历、法律合同、代码注释上排名可能整个翻过来。别人的分数参考价值比你以为的低得多。所以这篇给四样不会过期的东西一套能跑的评测集换模型改一行、一套生产里的同类实现DeepFlux 的rageval附真实跑分、一张源码级差异表Config 层面的事实、一个成本算式自己代入当期单价。先解释两个词后面一直会用跑分跟手机跑分一个意思。拿一套固定题目测模型得出数字用来排名。你在各家宣传页看到的「MTEB 中文榜第一」就是这个。评测集一套题。每道题写清「问什么」和「哪片文档才是正确答案」再配一段几十行的程序把题跑一遍、自动算分。英文里题库叫 dataset、跑分程序叫 harness本文统称评测集。这篇的核心主张就一句别抄榜单的跑分自己搭个评测集用你自己的文档出分。读完这篇你会知道三个指标 Recall1 / Recall3 / MRR各自会在什么时候骗你一套 130 行的评测集Evaluate(ctx, name, emb, ...)换模型只改一个参数实测两个模型 Recall1 差 17 个点但 Recall3 反过来——单看一个指标会选错逐题看排名能识别出「这题换模型也救不了」生产版评测集长什么样DeepFluxrageval的四组真实对照其中两条反直觉一个看起来完美的 0% 负样本率实际是「根本没捞上来」「换 embedding 模型 整库重建」这条规则怎么写进 DDL 强制执行七家 embedder adapter 的源码级差异批量策略三种流派、Model字段的真实语义把「贵不贵」算清楚的公式以及维度本身就是存储成本什么时候该停止折腾模型去优化别的环节一、三个指标各自的盲区先把话说清楚不然看数据会看糊。给定一个问题和一批候选片检索会把所有片按相似度排个序。三个指标都是在问「正确的那片排第几」指标定义什么时候看它Recall1正确片排第 1 的比例只取 TopK1或者要「直接给答案」Recall3正确片排进前 3 的比例RAG 通常 TopK3~5这个最贴近真实MRR排名倒数的平均值第 1 名得 1 分第 2 名 0.5 分第 10 名 0.1 分想区分「差一点」和「差很远」Recall 只看有没有进榜MRR 还看进榜的位置。三个一起看才完整。为什么强调这个看实测数据。二、实测单看一个指标会选错模型我拿 12 片语料、6 道题跑了两个本地确定性模型——不是云端大模型是我自己写的两个几十行的土办法专门用来证明这套评测集能区分模型好坏charBag把每个字哈希到固定维度上计数。约等于「字面重合度」bigram把相邻两个字作为一组去哈希。比单字多一点顺序信息语料 12 片评测集 6 题 模型 维度 调用次 文本条 Recall1 Recall3 MRR charBag 单字袋≈字面匹配 4096 8 18 0.50 0.83 0.68 bigram 二元组袋 4096 8 18 0.67 0.67 0.74看这两行按Recall1选 →bigram赢0.67 vs 0.50按Recall3选 →charBag赢0.83 vs 0.67按MRR选 →bigram赢0.74 vs 0.68同一份数据换个指标换个冠军。这不是我编的巧合是两种模型的能力形状不同charBag更容易把正确答案带进前三但不容易排到第一bigram更容易一击命中但也更容易彻底错过。选指标之前先确定你的 TopK 是几。TopK3 就以 Recall3 为主TopK1 就以 Recall1 为主。拿错指标选模型是在优化一个你根本不会用到的场景。三、逐题看比看总分有用总分告诉你「哪个好」逐题告诉你「为什么」和「接下来该干什么」。【charBag 单字袋≈字面匹配】 年假能休几天 正确片排第 1 位Top1annual ← 标题词直接命中 生病了要交什么材料 正确片排第 1 位Top1sick ← 问法和原文用词不同 报销的截止时间 正确片排第 2 位Top1sick ← 同义改写 辞职要提前多久说 正确片排第 1 位Top1resign ← 「辞职」vs 原文「离职」 三万块的费用谁签字 正确片排第 10 位Top1reimburse ← 需要推理金额区间 公司发不发五险一金 正确片排第 2 位Top1device ← 口语化提问 【bigram 二元组袋】 年假能休几天 正确片排第 1 位Top1annual ← 标题词直接命中 生病了要交什么材料 正确片排第 4 位Top1probation ← 问法和原文用词不同 报销的截止时间 正确片排第 1 位Top1reimburse ← 同义改写 辞职要提前多久说 正确片排第 1 位Top1resign ← 「辞职」vs 原文「离职」 三万块的费用谁签字 正确片排第 5 位Top1reimburse ← 需要推理金额区间 公司发不发五险一金 正确片排第 1 位Top1insurance ← 口语化提问三类信息一眼看得出来① 有的题谁都会做。「年假能休几天」「辞职要提前多久说」两个模型都排第 1。这类题不区分模型加进评测集只是浪费——但也别删它们是回归测试的底线。② 有的题模型间互有胜负。「生病了要交什么材料」charBag第 1、bigram第 4「报销的截止时间」正好反过来。这些才是真正在做区分的题。**③ 有一题两个都跪「三万块的费用谁签字」。**第 10 位和第 5 位。第三类最值钱。这道题的正确答案是「五千元至两万元须经分管副总审批」——要答对得先理解「三万块」落在哪个金额区间。**这不是换模型能解决的问题。**向量检索匹配的是语义相似度不做数值比较。你把模型从土办法换成最贵的云端模型它照样不知道 30000 20000。逐题明细里出现「所有模型都排到很后面」的题那是信号不是噪声。它在说这个需求需要的不是更好的 embedding是关键词混合检索、查询改写、或者干脆用结构化查询。charBag那题 Top1 命中的是reimburse报销时限因为「费用」两个字在那片里——纯字面干扰。这也解释了为什么单靠字面匹配的检索会翻车。四、评测集的代码核心就一个函数接口入参funcEvaluate(ctx context.Context,namestring,emb embedding.Embedder,chunks[]Chunk,cases[]Case,batchint)(*Result,error)**第三个参数是embedding.Embedder接口。**换模型就是换这一个实参评测逻辑一行不动——这是第 67 篇《Document 组件源码》讲的接口设计在选型场景下的直接红利。评测集的结构简单到没什么可说typeChunkstruct{IDstringTextstring}typeCasestruct{QuerystringWantIDstring// 唯一正确的那片Commentstring// 这题在考什么写清楚将来回看有用}Comment字段别省。三个月后你回来看数据「这题为什么会错」全靠它。顺手做成本统计包一层typecountingEmbedderstruct{inner embedding.Embedder callsinttextsint}func(c*countingEmbedder)EmbedStrings(ctx context.Context,texts[]string,opts...embedding.Option)([][]float64,error){c.callsc.textslen(texts)returnc.inner.EmbedStrings(ctx,texts,opts...)}十行统计出「调了几次 API、送了多少条文本」。上面那张表里调用次8 文本条18就是它数出来的12 片按 10 一批 2 次加 6 次查询 8 次12 6 18 条。这个套路不止用于评测。生产上想给任何 Embedder 加计数、限流、重试、降级都是这么包一层——因为Embedder就一个方法装饰起来毫无负担。第 71 篇《Embedder 缓存层》会讲官方的缓存层用的也是同一个套路。打分部分ranked:make([]scored,len(chunks))fori,ch:rangechunks{ranked[i]scored{ch.ID,cosine(qv[0],vectors[i])}}sort.SliceStable(ranked,func(i,jint)bool{returnranked[i].scoreranked[j].score})rank:0fori,s:rangeranked{ifs.idc.WantID{ranki1break}}switch{caserank1:hit1;hit3;mrrSum1caserank0rank3:hit3;mrrSum1/float64(rank)caserank0:mrrSum1/float64(rank)}用sort.SliceStable而不是sort.Slice分数打平时保持原始顺序这样同一份数据每次跑出来的名次一致不会因为排序不稳定让数据抖动。评测集最重要的品质是可重复。换成真模型emb,err:dashscope.NewEmbedder(ctx,dashscope.EmbeddingConfig{APIKey:os.Getenv(DASHSCOPE_API_KEY),Model:text-embedding-v3,})r,err:Evaluate(ctx,dashscope-v3,emb,chunks,cases,10)改这两行其余不动。想同时比三家就往models数组里多塞几个。五、生产里的评测集长什么样上面那套 130 行是玩具够讲清楚原理不够用来做真决策。DeepFlux 里有一套真的server/tools/rageval/1900 行非测试代码 1280 行测试。做的事和玩具版完全一样——喂语料和题目出分数——但多出来的部分全是被生产逼出来的。玩具版本文第四节rageval生产指标Recall1/3、MRRRecall20、MRR10、nDCG10、negative_rate正确答案一个WantID分级相关度 0-3 负样本清单题目分类无query_class分桶semantic / exact_id / table / ambiguous / multi-hop语料硬编码在 Go 里YAML 数据集带 sha256结果打印到终端JSON 归档报告里每个数字可追溯三个差别值得单独说。① 多了一个指标negative_rate不该出现的东西出现了没有玩具版只问「正确答案排第几」。生产还得问反面有没有把不该给的东西捞上来。评测集里每道题可以挂一张负样本清单// NegRef · 毒药/负样本:命中即扣分。typeNegRefstruct{DocIDstringyaml:doc_idReasonstringyaml:reason}源码注释管它叫「毒药」很贴切。比如问「年假能休几天」把《劳务派遣人员管理办法》里的年假条款捞上来——它确实相关但适用对象不是提问的人答上去就是错的。这种错比「没检索到」危险得多因为它会生成一个看起来有理有据的错误答案。negative_rate就是统计这个**毒药进了 top-3 的题占多大比例。**这个指标只有越低越好跟其他三个方向相反。② 正确答案是分级的不是唯一的typeRelevantRefstruct{DocIDstringyaml:doc_idRelevanceintyaml:relevance// 0-3(2 记入 MRR;1 记入 Recall)SpanStartintyaml:span_startSpanEndintyaml:span_end}注意那行注释里的两条不同门槛Recall 宽松relevance 1沾边就算捞到了MRR 严格relevance 2只有真正相关的才配算排名为什么要分开因为真实文档里「有点关系」和「就是答案」是两回事。一份文档提到了年假但只是引用了政策编号它 relevance1——检索到不算错但它排第一就是失败。用同一条线卡两个指标就分不出这个区别。数据集加载时还有一道硬校验// Validate · 确定性标注完整性:相关/负样本 doc_id 必须在 corpus 中,// 否则标注悬空,指标无意义。标注里写了一个语料中不存在的doc_id直接报错退出不是警告。因为悬空标注会让分数变得没有意义却依然显示为一个正常数字——这比报错难发现得多。③ 一次真实对照reranker 到底买到了什么同一份语料zh-eng-v2-00135 道题、同一个 embedding 模型只差一个重排序配置recall20mrr10ndcg10negative_ratep50 延迟qwen3.7-text-embedding无 reranker1.0000.8710.95511.4%262msqwen3.7-text-embedding gte-rerank-v21.0000.8570.9528.6%640ms达标线≥0.80≥0.70≥0.70≤0.10**加了 reranker三个指标里有两个变差了。**MRR 掉 1.4 个点nDCG 掉 0.3 个点延迟从 262ms 涨到 640ms。但它把negative_rate从 11.4% 压到 8.6%——而这恰好是唯一一项没达标的指标线是 ≤10%。所以这个决策是花两倍延迟、掉一点排序质量换那三道题不再把毒药送进 top-3。不配 reranker这套配置就不达标。回头看第一节那句话这就是它在生产里的样子只看 MRR你会拒绝这个 reranker。只看 negative_rate你会以为它免费。顺带一个观察还有第三份 run 用的是另一个 embedderbge-small-zh-v1.5512 维 同一个 reranker四项指标跟 qwen 那组逐位相同只有延迟不同481ms vs 640ms。合理的解释是两个 embedder 的 top-20 候选集都已经把相关文档全捞进来了recall20 都是 1.000后面的排序完全由 reranker 决定上游换谁都一样。如果这个解释成立那这个数据集上更贵的 embedder 买不到精度只影响延迟和成本。但这条是我的推断——归档 README 里没把这份列进有效 run 表我没有进一步的证据。④ 最值钱的一条几个模型跑出一样的分数那是 bugrageval的归档目录开头挂着一条作废声明值得整段抄⚠️ 2026-07-29 之前的所有 run 作废harness 构造SearchHandler时没有注入NamespaceRepo……FlagVectorV2永远读不到 → 一律走SearchPlanned查旧列embedding vector(1536)。而非 1536 维的真 embedder 数据全写在embedding_v2向量腿恒返回 0 行整轮评测退化为纯词法腿。判定证据三个架构/维度完全不同的模型跑出逐位相同的指标——bge-small-zh-v1.5(512d)、百炼 text-embedding-v4(1024d)、qwen3.7-text-embedding(1024d) 全部是0.700 / 0.562 / 0.653。修复后vec26此前恒 0。把判定逻辑单独拎出来三个维度和架构都不同的模型跑出小数点后全部相同的分数——这不可能是巧合。唯一的解释是它们的输出根本没参与计算。这是本文最实用的一条经验而且反直觉。评测集的正常状态是换模型分数就得动分数纹丝不动看起来像「稳定」实际是「你测的东西跟模型无关」。同一个坑还有第二层。rageval默认跑的是假模型--deterministicfalse 是关键默认 true 是 PR smoke 模式用 hash embedder 指标无业务含义。默认模式用哈希充当 embedding目的是让 CI 能快速跑通流程。它一样会输出一份格式完整、看起来很正常的分数报告——只是那些数字跟检索质量没有任何关系。忘记加--deterministicfalse你拿到的就是一份精美的假数据。所以评测集交付时要配一条自检先确认换模型时分数会变再相信它给出的任何排名。⑤ 「换模型 整库重建」可以写进 DDL第七节会讲这条规则。DeepFlux 把它做成了迁移里的一段断言——000127要把memories.embedding从 1536 维改成 512 维SELECTcount(embedding)INTOnFROMmemories;IFn0THENRAISE EXCEPTIONmemories.embedding 有 % 行非空向量1536 维。512 维模型与之不兼容ALTER 会丢弃全部旧向量。请先决定重索引策略……,n;ENDIF;只在整列为 NULL 时才允许改类型否则主动报错终止迁移。迁移注释写得很直白512 维模型与 1536 维旧向量语义空间不兼容静默丢弃是最坏做法。这就是「换 embedding 模型 整库重新向量化」从一句口头纪律变成一道不可绕过的闸。顺带说明为什么会有这次维度变更原来对齐 OpenAItext-embedding-3-small写死 1536 维但那个 embedder 服务在生产从未部署memories.embedding全表是 NULL——**向量去重和语义召回一直是哑的只是没人发现。**换成本地 ONNX 的bge-small-zh-v1.5512 维后维度必须跟着收窄。HNSW 索引绑定列类型改维度前必须先DROP INDEX再重建——这个细节第 72 篇《pgvector 入门》会讲。六、七家 adapter 的源码级差异这部分是从eino-ext/components/embedding/各家源码里读出来的不涉及效果和价格所以不会过期。adapterModel字段填什么维度可配BaseURL批量策略openai模型名✅text-embedding-3及以后可改也支持 Azure原样转发dashscope模型名v1/v2/v3✅ 仅 v31024/768/512写死在常量里原样转发arkendpoint IDep-*✅可改多模态时逐条循环ollama本地模型名❌可改默认本机一把全送gemini模型名———qianfan模型名———tencentcloud不用填写死hunyuan-embedding❌—硬编码 200 一批四个值得单说的点①ark的Model填的不是模型名// Model specifies the ID of endpoint on ark platform// RequiredModelstringjson:model火山方舟这里要填的是你在平台上创建的推理接入点 IDep-开头那串不是doubao-embedding这种模型名。第一次用几乎必踩。它的认证也比别家复杂APIKey或AccessKey/SecretKey二选一APIKey优先用 AK/SK 配预置接入点时还要额外填ProjectName。② 批量策略有三种流派只有一家帮你分批帮你分批的tencentcloud源码里硬编码了上限还贴了 SDK 文档链接// NOTE: len of req.InputList must less equal than 200, so we need to split texts into batches// reference: https://pkg.go.dev/github.com/tencentcloud/...batchSize:200forl:0;llen(texts);lbatchSize{r:min(lbatchSize,len(texts))req.InputListcommon.StringPtrs(texts[l:r])// ...}服务端分批的ollama客户端一把全送本地服务自己排队req:api.EmbedRequest{Model:e.conf.Model,Input:texts,// 全部塞进去// ...}resp,err:e.cli.Embed(ctx,req)啥也不管的openai / dashscope你传多少就往上游发多少。超了上游的条数限制就报错分批是你自己的事。这就是第 66 篇《最简 RAG》那段代码里为什么要自己写const batch 10循环——不是我谨慎是这层真的不管。③dashscope的 BaseURL 改不了const(baseUrlhttps://dashscope.aliyuncs.com/compatible-mode/v1dimensions1024)包级常量EmbeddingConfig里没有对应字段。要指向别的端点代理、私有网关、兼容层只能用openaiadapter 手动填BaseURL——反正 dashscope 走的就是 OpenAI 兼容协议内部也是转手给libs/acl/openai。顺带一句dimensions 1024是默认值NewEmbedder里会在你没填时兜上ifecfg.Dimensionsnil{dim:dimensions ecfg.Dimensionsdim}④ 调用时可以临时换模型embedding.Options里只有一个通用字段typeOptionsstruct{Model*string}funcWithModel(modelstring)Option{/* ... */}各家实现的姿势是统一的——拿构造时的配置当默认值允许调用时覆盖options:embedding.GetCommonOptions(embedding.Options{Model:e.conf.Model,},opts...)所以做 A/B 测试不用建两个 Embedder一个实例加embedding.WithModel(...)就能切。这也是第 67 篇那套 Option 范式的又一次复用。七、成本怎么算不给单价会过期给算式。建库成本一次性总 token 数 ≈ 总字数 ÷ 每 token 字数 建库费用 总 token 数 × 单价中文粗算 1 个 token ≈ 1~1.5 个汉字具体看模型的分词器。10 万字的文档大概 7~10 万 token 量级。隐藏成本重建次数。这是最容易漏算的一项换 embedding 模型 整库重新向量化。不同模型的向量空间不通用混着存算出来的相似度是废的第 66 篇强调过。所以选型阶段每试一个模型就是一次全量建库。先用小样本评测集筛掉大部分候选只对最后一两个跑全量——这就是这套评测集省钱的地方12 片语料 8 次调用比全量重建便宜好几个数量级。查询成本长期每日查询费用 日查询量 × 单条查询 token 数 × 单价单条查询就是用户那句话十几个 token单价上通常微不足道。真正的长期成本在建库和重建。维度也是成本而且是存储成本。1024 维 × float32(4 字节) 4 KB / 片 10 万片 400 MB 纯向量再加索引结构的开销。所以dashscopev3 允许把维度降到 768 或 512 不是花活——512 维直接省一半存储检索也快。代价是精度略降。这件事该怎么定**用评测集测。**把同一个模型的 1024 / 768 / 512 三个维度各跑一遍看 Recall3 掉多少。掉 1 个点就换来一半存储通常划算。八、什么时候该停止折腾模型一个实操判断Recall3 上到 0.9 以上就别再换模型了。剩下那 10% 里多半是「三万块的费用谁签字」这一类——需要数值推理、多跳推理、精确匹配的题。这些换模型解决不了。该去做的事按性价比排回头看切片。第 68 篇《切片策略》那组数据同一份文档、同一个模型切法从 0/4 变到 4/4。切片的杠杆比模型大得多而且不花钱查询改写 重排序第 74 篇《多查询 重排序》会讲。用户问得含糊时先让 LLM 把问题改写成几个更明确的说法再检索混合检索。向量负责语义关键词BM25 / 全文索引负责精确匹配型号、编号、金额。两条路的结果合并加缓存第 71 篇会讲。同样的文本反复算向量是纯浪费官方embedding/cache就是干这个的小结别抄别人的跑分榜单是通用语料你的语料有行业术语和内部黑话排名会变单指标会选错实测两个模型Recall1 一个赢、Recall3 另一个赢、MRR 又反过来。先确定 TopK再选指标逐题明细比总分有用所有模型都排到很后面的题是「该换技术路线」的信号不是「该换模型」评测集的关键是接口入参Evaluate(ctx, name, emb embedding.Embedder, ...)换模型改一个实参包一层就能统计成本Embedder只有一个方法装饰它毫无负担生产版要多一个反向指标negative_rate不该召回的召回了多少。生产实测 reranker 掉 1.4 点 MRR、涨 1 倍延迟换来 neg 从 11.4% 压到 8.6%——而那是唯一没达标的项正确答案要分级Recall 认relevance1MRR 只认2。一条线卡两个指标就分不出「沾边」和「就是答案」几个模型跑出逐位相同的分数 评测集坏了不是模型稳定。DeepFlux 靠这个信号发现向量腿恒空作废了修复前的全部 run换模型 整库重建这条可以写进 DDL000127在列非空时RAISE EXCEPTION宁可迁移失败也不静默丢向量只有 tencentcloud 帮你分批硬编码 200openai / dashscope 原样转发超限自己接着ark的Model填 endpoint IDdashscope的 BaseURL 是常量改不了维度是存储成本512 维省一半空间掉多少精度用评测集测Recall3 过 0.9 就转战别处切片、查询改写、混合检索、缓存下一篇第 71 篇拆Embedder接口和官方的 Redis 缓存层Cacher/Generator两个接口怎么分工以及HashGenerator里一个让 key 变得比原文更长的实现细节。代码状态说明第二~四节评测集代码约 130 行 harness 60 行本地模型在eino v0.9.13下go vet通过并真机运行分数和逐题排名都是真实输出原样粘贴。但那两个模型是我自己写的本地土办法字袋 / 二元组袋不是任何云端模型分数只用于证明评测集能区分模型不代表任何商业模型的水平。各家云模型的效果和延迟我没有 API Key没有实测。第五节rageval的代码、数据集 schema、四组跑分 JSON、迁移断言全部来自 DeepFlux 仓库既有产物server/tools/rageval/、docs/sales/evidence/runs/、000127迁移不是我为这篇文章跑的我只做了引用和解读。其中「两个 embedder 加 reranker 后指标逐位相同 上游被 reranker 抹平」一条是我的推断已在正文标注——归档 README 未将那份 run 列入有效表。修复前的两份 run 已被官方作废正文没有引用它们的数字只引用了作废这件事本身。第六节adapter 差异全部来自eino-ext源码可自行核对。