给一个 QQ 群聊机器人配长期记忆,需要选个 embedding 模型跑在 2 核 7.7G 的小机器上。本以为是十分钟的事,实际做下来推翻了自己三次结论。
记录下这五个坑,每一个都足以让人选错模型。
坑一:小样本量测不出差异
第一轮拿 20 组三元组(anchor / 相近句 / 无关句)测,结果:
| 模型 | 准确率 |
|---|---|
| bge-m3 | 10/20 |
| qwen3-embedding:0.6b | 9/20 |
10 比 9,差一个样本。当时的判断是”统计上无意义,两者质量相当”,于是转而看性能指标。
后来把样本扩到 60 组,同样的两个模型:
| 模型 | 准确率 |
|---|---|
| bge-m3 | 27/60 |
| qwen3-embedding:0.6b | 20/60 |
35% 的差距。20 组时看到的”相当”纯属噪声。
embedding 的质量差异需要足够样本才能浮现,20 组远远不够。如果当时就照小样本的结论选型,会选错。
坑二:测试集太简单,所有模型都满分
最初的测试集长这样:
('我昨天通宵打游戏了', '熬夜玩了一整晚的游戏', '明天要交房租了')negative 是完全无关的话题。结果除了一个纯英文模型(12/20),其余全部 20/20 满分——测试集根本没有区分度。
改成 hard negative:negative 与 anchor 同话题,但语义或情感相反。
('这家拉面真的绝了', '那面馆味道超好吃', '这家拉面难吃到想吐')('我把他删了', '已经把那人拉黑', '他把我删了')('这药一天吃三次', '每日三次服用', '这药三天吃一次')准确率立刻从 20/20 掉到 7~15/20,模型之间拉开了差距。
注意 hard negative 在字面上比 positive 更接近 anchor(“这家拉面”四个字完全重合),这恰好是真实检索场景的样子——同一话题的不同观点,措辞往往高度相似。
坑三:性能指标测错了对象
我最初测的是”一个请求塞 32 条文本”的吞吐,结论是 qwen3 比 bge-m3 慢 80%,据此推荐了 bge-m3。
后来去翻应用侧的代码,发现它的批处理是这样的:
semaphore = asyncio.Semaphore(self.max_concurrent)
async def encode_with_semaphore(text, ...): async with _semaphore: embedding = await self._get_embedding_direct(text, ...) # 每条一个独立请求
tasks = [encode_with_semaphore(text, ...) for index, text in uncached_items]await asyncio.gather(*tasks)它从不发多文本请求。配置里那个 batch_size = 32 只是分组大小,组内仍然是每条一个 HTTP 请求,靠信号量控制并发。
按真实模式(单条请求 + 并发 5)重测:
| 模型 | 单条中位 | p90 | 32 条 @ 并发 5 |
|---|---|---|---|
| qwen3-embedding:0.6b | 264ms | 309ms | 8.24s |
| bge-m3 | 325ms | 357ms | 8.58s |
结论完全反过来了:qwen3 单条快 19%,并发下也略快。我之前推荐 bge-m3 的主要理由根本不存在。
选型前先去看应用实际怎么调 API,别拿benchmark 的默认姿势替代真实负载。
顺带一提,2 核机器上并发 5 几乎没带来加速(单条 325ms,32 条理论上 2.1s,实际 8.58s)——CPU 早就饱和了,那个 max_concurrent 配置调大没有意义。
坑四:上下文长度可能是虚的
paraphrase-multilingual 在 hard negative 上表现最好(15/20,远超其他),批量还快 2.6 倍。差点就选它了。
但它标称上下文 512 token,而应用侧的分块上限是 3200 字符。于是构造了一个截断检测:前半段相同、后半段不同的长文本,如果模型只看得到前半,就分不出来。
HEAD = '今天群里聊起了最近玩的那款开放世界游戏,大家讨论了画面表现和优化情况,' * 12TAIL_SAME = '后来话题转到了考研,有人说今年报名人数又涨了,竞争压力很大。' * 8TAIL_DIFF = '后来话题转到了房价,有人说楼市终于开始松动,一线城市也在跌。' * 8
anchor = HEAD + TAIL_SAMEpositive = HEAD + TAIL_SAME改写negative = HEAD + TAIL_DIFF660 字文本的结果:
| 模型 | 同尾相似度 | 异尾相似度 | 差值 |
|---|---|---|---|
| bge-m3 | 0.8516 | 0.7344 | +0.1173 |
| paraphrase-multilingual | 0.8189 | 0.8189 | 0.0000 |
完全相同——后半段一个字都没参与编码。它的质量优势建立在”只看前 400 字”上,直接出局。
反过来,这个测试也能证伪相反的怀疑。我一度怀疑 qwen3 有问题,因为它的延迟几乎不随长度变化:
| 文本长度 | bge-m3 | qwen3-embedding:0.6b |
|---|---|---|
| 35 字 | 334ms | 165ms |
| 420 字 | 1268ms | 165ms |
| 1050 字 | 3394ms | 184ms |
| 2100 字 | 8381ms | 206ms |
60 倍长度只涨 25% 耗时,太反常了,第一反应是”肯定截断了”。但 2050 字的截断测试显示它确实看到了尾部(差值 +0.1055)。
真实原因是架构差异:bge-m3 是 BERT/XLM-R,llama.cpp 对这类 encoder 的长序列没做针对性优化;qwen3-embedding 是 decoder 架构且是 Q8 量化,走的是高度优化的路径。延迟异常不等于截断,要用实验区分。
坑五:参数量不等于质量
qwen3-embedding 有 0.6B 和 4B 两个版本,直觉上 4B 应该明显更好。实测:
| 模型 | 维度 | hard negative | 短文本延迟 |
|---|---|---|---|
| qwen3-embedding:0.6b | 1024 | 20/60 | 574ms |
| qwen3-embedding:4b | 2560 | 20/60 | 1802ms |
完全一样的 20/60,短文本却慢了 3 倍,还把维度顶到 2560(意味着要改向量库配置、重建索引)。这个任务上参数量不转化为质量。
最后的结论
选了 bge-m3。60 组 hard negative 27/60 领先明显,短文本速度与 qwen3 持平,1024 维不用改配置。qwen3 的长文本优势在这个场景用不上——记忆片段是 LLM 抽取的短事实,不是长文档。
完整对比:
| 模型 | 维度/上下文 | hard negative | 短文本 | 长文本 2100 字 |
|---|---|---|---|---|
| bge-m3 | 1024 / 8192 | 27/60 | 582ms | 8381ms |
| qwen3-embedding:0.6b | 1024 / 32768 | 20/60 | 574ms | 206ms |
| qwen3-embedding:4b | 2560 / 32768 | 20/60 | 1802ms | 581ms |
| granite-embedding:278m | 768 / 512 | — | 257ms | — |
| paraphrase-multilingual | 768 / 512 | 15/20(小样本) | 254ms | 截断 |
| snowflake-arctic-embed2 | 1024 / 8192 | — | 342ms | — |
补测:换成商业 API 会不会更好
发出来之后被问得最多的一句话:本地小模型不行,上 OpenAI / Voyage 是不是就解决了。
于是把 OpenRouter 上全部可用的 embedding 模型(29 个,另有 2 个 nvidia 免费模型受数据政策限制没跑成)跑了同一套 60 组 hard negative。校准先做:bge-m3 复现 27/60,和上面本地测的完全一致;qwen3-4b 21/60 对本地的 20/60。难度对齐了,横向比较才成立。
| 模型 | 维度 | hard negative | 主客体互换 | 单条中位 | 美元/Mtok |
|---|---|---|---|---|---|
| google/gemini-embedding-001 | 3072 | 34/60 | 1/8 | 831ms | 0.150 |
| mistralai/codestral-embed-2505 | 1536 | 33/60 | 1/8 | 1022ms | 0.150 |
| qwen/qwen3-embedding-8b | 4096 | 30/60 | 0/8 | 2891ms | 0.010 |
| baai/bge-m3 | 1024 | 27/60 | 0/8 | 693ms | 0.010 |
| voyageai/voyage-4 | 1024 | 26/60 | 0/8 | 533ms | 0.060 |
| voyageai/voyage-4-large | 1024 | 24/60 | 0/8 | 559ms | 0.120 |
| voyageai/voyage-multimodal-3.5 | 1024 | 21/60 | 0/8 | 666ms | 0.120 |
| qwen/qwen3-embedding-4b | 2560 | 21/60 | 0/8 | 1635ms | 0.020 |
| voyageai/voyage-4-lite | 1024 | 18/60 | 0/8 | 546ms | 0.020 |
| openai/text-embedding-3-large | 3072 | 17/60 | 0/8 | 982ms | 0.130 |
| google/gemini-embedding-2-preview | 3072 | 16/60 | 0/8 | 747ms | 0.200 |
| google/gemini-embedding-2 | 3072 | 16/60 | 0/8 | 752ms | 0.200 |
| perplexity/pplx-embed-v1-4b | 2560 | 16/60 | 0/8 | 619ms | 0.030 |
| intfloat/multilingual-e5-large | 1024 | 15/60 | 0/8 | 1948ms | 0.010 |
| baai/bge-large-en-v1.5 | 1024 | 15/60 | 1/8 | 671ms | 0.010 |
| thenlper/gte-base | 768 | 14/60 | 1/8 | 1105ms | 0.005 |
| intfloat/e5-base-v2 | 768 | 14/60 | 0/8 | 1096ms | 0.005 |
| baai/bge-base-en-v1.5 | 768 | 14/60 | 1/8 | 599ms | 0.005 |
| sentence-transformers/all-mpnet-base-v2 | 768 | 13/60 | 1/8 | 1208ms | 0.005 |
| intfloat/e5-large-v2 | 1024 | 12/60 | 3/8 | 678ms | 0.010 |
| mistralai/mistral-embed-2312 | 1024 | 12/60 | 0/8 | 945ms | 0.100 |
| thenlper/gte-large | 1024 | 11/60 | 1/8 | 662ms | 0.010 |
| sentence-transformers/all-minilm-l6-v2 | 384 | 11/60 | 0/8 | 1254ms | 0.005 |
| openai/text-embedding-ada-002 | 1536 | 11/60 | 0/8 | 734ms | 0.100 |
| perplexity/pplx-embed-v1-0.6b | 1024 | 10/60 | 0/8 | 494ms | 0.004 |
| sentence-transformers/multi-qa-mpnet-base-dot-v1 | 768 | 10/60 | 1/8 | 1104ms | 0.005 |
| sentence-transformers/all-minilm-l12-v2 | 384 | 9/60 | 0/8 | 2951ms | 0.005 |
| openai/text-embedding-3-small | 1536 | 5/60 | 0/8 | 786ms | 0.020 |
| sentence-transformers/paraphrase-minilm-l6-v2 | 384 | 4/60 | 0/8 | 1068ms | 0.005 |
bge-m3 排第 4。 排在它前面的三个:gemini-001 贵 15 倍,codestral 贵 15 倍,qwen3-8b 慢 4 倍。花钱能买到的质量提升是 +7 组,相对 26%。
托管 API 的延迟全面高于那台 2 核机器。 最快的 pplx-embed 是 494ms,同一个 bge-m3 走 OpenRouter 要 693ms,而它在本地的单条中位是 325ms——网络往返吃掉的比 2 核 CPU 还多。p90 更脏:gemini-embedding-2-preview 6324ms、qwen3-4b 5409ms,记忆检索这种稀疏调用场景下,这些尖刺会直接变成用户的等待时间。
坑五在商业模型上更明显。 voyage-4-large(24/60)不如便宜一半的 voyage-4(26/60);text-embedding-3-small 只有 5/60,是全场最差的付费模型;gemini-embedding-2(0.20 美元,更新的那个)16/60,被上一代 gemini-embedding-001(0.15 美元)的 34/60 打了对折。价格和版本号都不是质量的代理指标。
一个必须说明的限制:OpenRouter 的 /embeddings 端点会静默丢弃 task_type。验证方法是对比同一段文本带与不带该参数的返回向量——gemini 带 SEMANTIC_SIMILARITY 和不带的余弦是 1.000000,与重复调用的噪声底完全一致,说明参数没有传下去。所以 gemini 那两行是在没有任务类型优化的条件下拿的分,偏低。对照组:Voyage 的 input_type 是生效的(余弦掉到 0.716),而对称相似度任务本来就不该传它,所以 Voyage 的分数是公平的。
一个更值得说的发现
本地这批模型在 hard negative 上的准确率都低于随机猜测。最好的 bge-m3 也只有 27/60(45%)。加上补测的 29 个商业模型之后,也只有两个勉强越过随机线(34/60 和 33/60),其余 27 个仍在 50% 以下。
按类别拆开看 bge-m3:
| 类别 | bge-m3 |
|---|---|
| 结论差异(考上 / 没考上) | 8/10 |
| 时间数量(三小时 / 十分钟) | 5/8 |
| 情感极性(绝了 / 难吃) | 7/15 |
| 否定(不去 / 去) | 4/10 |
| 细微语义(一天三次 / 三天一次) | 2/9 |
| 主客体互换(我删他 / 他删我) | 1/8 |
主客体互换几乎全错。embedding 对语序和角色基本不敏感,“我欠他钱”和”他欠我钱”在向量空间里几乎是同一个点。情感极性也很差——“这家店服务态度很好”和”这店服务态度极差”话题完全一致,向量自然接近。
补测的 29 个模型让这条结论只增强不减弱:主客体互换合计 11/232,4.7%,而随机猜测是 50%。没有任何一个模型超过 3/8,唯一那个 3/8 是整体只有 12/60 的 e5-large-v2,基本是噪声。跨 8 家厂商、包含 2026 年的旗舰模型,在这一类上是系统性反相关——不是猜不准,是稳定地猜反。
这不是选型能解决的,是稠密向量检索的本质局限。实际系统里得靠别的手段补:知识图谱存结构化的主客体关系、稀疏检索(BM25/FTS5)抓字面差异、或者上 reranker 做二次排序。
如果你的检索场景依赖区分”谁对谁做了什么”或者”喜欢还是讨厌”,纯向量检索会让你失望。
如何复现
环境:Ollama + 任意 OpenAI 兼容 embedding 端点。先拉模型:
for m in bge-m3 qwen3-embedding:0.6b qwen3-embedding:4b \ paraphrase-multilingual granite-embedding:278m snowflake-arctic-embed2; do ollama pull "$m"done质量测试(hard negative)
import json, math, sys, urllib.request
URL = 'http://127.0.0.1:11434/v1/embeddings'
# (anchor, positive=同义改写, hard negative=同话题但语义/极性不同)# 六个类别各若干组,实际用了 60 组,这里节选T = [ # 情感极性反转 ('这家拉面真的绝了', '那面馆味道超好吃', '这家拉面难吃到想吐'), ('他最近心情不太好', '那家伙这阵子情绪低落', '他最近心情特别好'), ('这游戏优化稀烂', '这游戏帧数掉得离谱', '这游戏优化好得离谱'), # 主客体互换 ('我把他删了', '已经把那人拉黑了', '他把我删了'), ('他欠我钱', '那人还没还我钱', '我欠他钱'), ('我请他吃饭', '这顿我买单请客', '他请我吃饭'), # 否定与肯定 ('我不去了', '这次我就不参加了', '我去'), ('这个方案不可行', '这路子走不通', '这个方案完全可行'), # 时间/数量 ('这个bug我调了三小时', '排查这问题花了一下午', '这功能我十分钟就写完了'), ('等了半天才上菜', '上菜慢得离谱', '菜上得特别快'), # 同话题不同结论 ('我考研上岸了', '研究生考上了', '我考研没考上'), ('面试通过了', '拿到offer了', '面试挂了'), # 细微语义 ('这药一天吃三次', '每日三次服用', '这药三天吃一次'), ('从北京飞上海', '北京出发去上海', '从上海飞北京'), ('保修期三年', '三年内免费维修', '保修期三个月'),]
def embed(model, texts, timeout=1200): req = urllib.request.Request( URL, data=json.dumps({'model': model, 'input': texts}).encode(), headers={'Content-Type': 'application/json'}) return [x['embedding'] for x in json.loads(urllib.request.urlopen(req, timeout=timeout).read())['data']]
def cos(a, b): return sum(x * y for x, y in zip(a, b)) / ( math.sqrt(sum(x * x for x in a)) * math.sqrt(sum(y * y for y in b)))
model = sys.argv[1]flat = [t for tri in T for t in tri]vs = []for i in range(0, len(flat), 30): # 分批,避免单请求过大 vs.extend(embed(model, flat[i:i + 30]))
hit, margins, fails = 0, [], []for i, tri in enumerate(T): a, p, n = vs[3 * i], vs[3 * i + 1], vs[3 * i + 2] sp, sn = cos(a, p), cos(a, n) if sp > sn: hit += 1 else: fails.append(tri[0]) margins.append(sp - sn)
print(json.dumps({ 'model': model, 'dim': len(vs[0]), 'acc': f'{hit}/{len(T)}', 'avg_margin': round(sum(margins) / len(margins), 3), 'fails': fails[:6],}, ensure_ascii=False))跑 python3 qual.py bge-m3。注意别用简单 negative,否则所有模型都是满分。
截断检测
HEAD = '今天群里聊起了最近玩的那款开放世界游戏,大家讨论了画面表现和优化情况,' * 50T1 = '后来话题转到了考研,有人说今年报名人数又涨了,竞争压力很大。' * 10T2 = '后来话题转到了房价,有人说楼市终于开始松动,一线城市也在跌。' * 10
a = HEAD + T1p = HEAD + T1.replace('考研', '研究生考试').replace('报名', '报考')n = HEAD + T2
v = embed(model, [a, p, n])sp, sn = cos(v[0], v[1]), cos(v[0], v[2])print(f'同尾={sp:.4f} 异尾={sn:.4f} 差={sp-sn:+.4f}', '看到了尾部' if sp - sn > 0.01 else '★尾部丢失(截断)')差值接近 0 就是被截断了。调整 HEAD 的倍数可以定位实际的截断阈值。
按真实模式测延迟
关键是先确认你的应用怎么调 API,再照着测。如果是每条一个请求 + 信号量并发:
import concurrent.futures as cf, time
def one(model, text): req = urllib.request.Request( URL, data=json.dumps({'model': model, 'input': text}).encode(), headers={'Content-Type': 'application/json'}) s = time.perf_counter() urllib.request.urlopen(req, timeout=900).read() return time.perf_counter() - s
one(model, '预热') # 先加载模型serial = sorted(one(model, TEXTS[i % 10]) for i in range(10))print(f'单条中位={serial[5]*1000:.0f}ms p90={serial[9]*1000:.0f}ms')
t0 = time.perf_counter()with cf.ThreadPoolExecutor(max_workers=5) as ex: # 对应 max_concurrent list(ex.map(lambda i: one(model, TEXTS[i % 10] + f' 第{i}条'), range(32)))print(f'32条@并发5 = {time.perf_counter()-t0:.2f}s')长度敏感性
base = '今天群里聊起了最近玩的那款开放世界游戏,大家讨论了画面表现和优化情况,'for mult in (1, 4, 12, 30, 60): txt = base * mult ts = [one(model, txt) for _ in range(3)] print(f'{len(txt):5d}字 -> 中位 {sorted(ts)[1]*1000:7.0f}ms')延迟不随长度增长要警惕,但先用截断检测确认,可能只是架构更优。
跑托管模型
补测那部分复用同一套代码,只换端点和请求头:
URL = 'https://openrouter.ai/api/v1/embeddings'HEADERS = {'Content-Type': 'application/json', 'Authorization': 'Bearer ' + os.environ['OPENROUTER_API_KEY']} # 别写进源码模型清单别手抄,models 页面是前端渲染的,直接问 API:
curl -s 'https://openrouter.ai/api/v1/models?output_modalities=embeddings' \ | python3 -c "import json,sys; [print(m['id']) for m in json.load(sys.stdin)['data']]"两点注意。一是测延迟必须串行,用线程池并发跑模型会把自己的排队算进被测对象的耗时里。二是先验证厂商特有参数有没有被中转层透传,方法是对比带与不带该参数的返回向量:
noise = cos(embed(m, T), embed(m, T)) # 同参数两次 = 噪声底effect = cos(embed(m, T), embed(m, T, task_type='...')) # 带参数# effect 约等于 noise,说明参数被丢了,这个模型的分数不能按"官方最佳用法"解读60 组 × 3 条 × 29 个模型合计不到 10 万 token,全跑一遍是 1 美分量级,横向补测的成本可以忽略。
一个环境注意事项
用 Ollama 跑 embedding 服务的话,记得设 OLLAMA_KEEP_ALIVE=-1。默认 5 分钟空闲就卸载模型,而记忆检索是稀疏调用,每次都要重新加载,310ms 会变成 2.1s。代价是模型常驻内存(bge-m3 约 2.4G)。
另外别指望用 TEI(text-embeddings-inference)替代 llama.cpp 来提速:它加载 bge-m3 的 ONNX fp32 时 RSS 会冲到 5.6G+,7.7G 内存的机器上即使独占也会 OOM。GGUF 的内存效率高得多。