Embedding 不是把文本转成数组就结束,真正麻烦的是后面的检索链路

很多人对 Embedding 的认知停在一句话:"把文本调个 API 转成一串浮点数,然后算相似度。"代码也确实就几行,跑起来还挺像那么回事。但等你拿真实文档库一测,会发现召回总是差口气——明明库里有答案,检索就是捞不上来。

说白了,Embedding 这一环里,真正决定成败的不是那几行调用,而是模型选得对不对、距离度量配不配、文档怎么切、向量往哪儿放。这四件事里任何一件想当然,召回率都会悄悄漏水。

选 Embedding 模型,别只看维度和榜单名次

第一个误区是"维度越高越准"。维度高确实能装下更多语义,但也意味着更大的存储、更慢的检索、更高的 API 成本,而准确率的提升经常在某个维度之后就饱和了。新一代模型流行 Matryoshka 表示法,允许你把一个 3072 维的向量直接截断到 768 维还能用——这恰恰说明高维里有大量信息是冗余的。

第二个误区是只看 MTEB 榜单的总排名。要看的是和你场景对得上的那一栏:你做的是检索(retrieval)就别看语义相似度(STS)的分;你的语料是中文就去看中文子集,英文榜首在中文上可能很一般。检索任务还有个对称性问题——用户的 query 通常很短,文档很长,这叫非对称检索,和"判断两句话像不像"是不同的能力。

而最容易让人栽跟头、文档里又写得很小的一个点是 instruction prefix。像 bge、e5、gte 这类模型,是带着特定前缀训练的:e5 要求查询加 query:、文档加 passage:,bge 要求查询加一句类似"为这个句子生成表示以用于检索相关文章:"的指令。不加这个前缀,召回会明显变差,而代码不会报任何错,你只会觉得"这模型怎么这么菜"。OpenAI 的 text-embedding-3 系列则不需要前缀。所以接一个新模型,第一件事是去读它的模型卡片,看它要不要前缀、输出归一化没有。

def embed_query(text: str) -> list[float]:
    # bge 系列:查询侧必须加检索指令前缀,文档侧不加
    return model.encode("为这个句子生成表示以用于检索相关文章:" + text,
                         normalize_embeddings=True)


def embed_passage(text: str) -> list[float]:
    return model.encode(text, normalize_embeddings=True)

余弦、点积、欧几里得:先问向量归一化了没

距离度量该用哪个,是个被问烂、也常被答偏的问题。直接说结论:如果向量都做了 L2 归一化,余弦相似度、点积、欧几里得距离三者的排序结果是完全等价的

原因不复杂。余弦相似度本来就等于"归一化之后的点积"。而对两个单位向量,欧几里得距离的平方等于 2 - 2 * 点积,是点积的单调减函数。所以一旦向量都归一化了,三种度量只是把同一个排序换了种数值表达,你检索出来的 top-k 是同一批文档。这时候"用哪个"基本由向量库支持哪个 metric、哪个算得快决定,而不是由准确率决定。

真正有区别的是向量没归一化的情况。这时点积会受模长影响——一个语义一般但模长大的向量,点积可能压过一个语义更贴切但模长小的向量,于是检索偏向"长"向量而不是"对"向量。所以没归一化时用点积要小心。

落到实践就一句话:先确认你的模型输出有没有归一化(很多句向量模型有 normalize_embeddings 开关,OpenAI 的接口默认已归一化),归一化了就随便挑库支持的 metric,通常用 cosine;没归一化又想用点积,先想清楚模长会不会污染排序。

分块:检索质量其实从切文档那一刻就定了

分块(chunking)是整条链路里最被低估的一环。很多人觉得它只是"为了不超 context 限制",于是拿个固定字数一刀切完事。但分块真正影响的是检索精度。

想一下:你把一篇五千字的文章整个塞进一个 chunk 算 embedding,这个向量是全文语义的"平均"。如果用户问的点只在其中一句话里,那一句的语义会被其余四千多字稀释得几乎看不见,检索自然捞不到。这就是 chunk 太大的代价——语义被平均、被冲淡,而且命中后塞给模型的无关内容也多。

反过来 chunk 太小也不行。切到只剩一句"它的吞吐量提升了三倍",这个"它"指代谁丢了,上下文没了,这个向量检索出来也用不了。

所以分块是在找平衡,常见有几种策略,从糙到精:固定长度切最简单,但会从句子中间、段落中间硬切断;递归字符切分(recursive splitting)会优先按段落分,分不开再退到句子、再退到词,尽量不切碎语义单元;语义分块更进一步,逐句算 embedding,在相似度突然下降的地方——也就是话题转折处——下刀。

几个配套细节同样关键。重叠(overlap):让相邻 chunk 重叠 10% 到 20%,避免一个完整意思正好被切在边界上两边都不全。按结构切:Markdown 按标题层级切、代码按函数切,比按字数切天然得多。带元数据:每个 chunk 存上来源、标题、章节、位置,检索时能过滤、能给模型交代出处。还有一个进阶技巧叫父子分块(small-to-big)——用小 chunk 去做检索保证精度,命中后却返回它所属的大 chunk 给模型,兼顾"找得准"和"上下文全"。

from langchain_text_splitters import RecursiveCharacterTextSplitter

splitter = RecursiveCharacterTextSplitter(
    chunk_size=500,            # 目标长度要配合 embedding 模型的最佳输入长度
    chunk_overlap=80,          # 约 16% 重叠,保住边界语义
    separators=["\n\n", "\n", "。", "!", "?", " ", ""],  # 优先按段落、再句子退让
)
chunks = splitter.split_text(document)
records = [
    {"text": c, "meta": {"source": "handbook.md", "chunk_id": i}}
    for i, c in enumerate(chunks)
]

记住 chunk 大小要和 embedding 模型的最佳输入长度匹配——模型在 512 token 上训练得最好,你非要塞 2000 token 进去,它也只能截断或勉强压缩。

向量库:先按数据量级和已有架构来选

最后是存哪儿。Chroma 因为开箱即用、能嵌入式跑,几乎是所有教程的默认选择,但它的定位就是原型和小数据量。选向量库,先看两个东西:数据量级,和你现有的架构。

数据量级上,几万到十几万条向量,Chroma、内存里的 FAISS、甚至 SQLite 配 sqlite-vec 都够用。到百万级,就该上 Qdrant、Milvus 或者 pgvector 了。上亿规模、要分布式扩展,基本是 Milvus 的场子。

但比量级更优先的判断是:你是不是已经在用 Postgres。如果是,优先考虑 pgvector——多一个向量库就多一套部署、备份、监控、一致性问题,能不引入新组件就别引。Qdrant 的优点是性能好、带强大的 payload 过滤、Rust 写的省资源,从单机到集群都顺,是不想碰 Postgres 又要上规模时的好选择。Milvus 功能最全、最能扛超大规模,代价是组件多、运维重,小项目用它属于杀鸡用牛刀。

选完库还有两个参数要懂。一是索引类型:暴力 flat 索引精确但慢,数据一多就扛不住;生产基本用 HNSW(一种图索引),它是近似最近邻——快得多,但召回不是 100%。HNSW 有几个旋钮:mef_construction 影响建索引的质量和耗时,ef_search 在查询时调——调大召回升、速度降,这是你能现场拧的"召回与延迟"权衡。二是带过滤的检索:实际场景常要"在某个分类、某个时间段内做向量检索",注意 pre-filter(先按元数据筛再算向量)和 post-filter(先算向量再筛)对召回和性能影响不同,Qdrant、Milvus 对此都有专门优化。如果内存吃紧,还可以开标量量化或 PQ 压缩,用一点精度换大幅的内存下降。

把模型、度量、分块、向量库这四件事按你的语料和规模认真配一遍,你的召回率往往不用换模型就能上一个台阶。但即便这一层都做对了,纯向量检索本身还有天花板——下一篇我们就来拆:为什么你的 RAG 还是答不好,问题到底出在找、排,还是写。

版权声明: 如无特别声明,本文版权归 sshipanoo 所有,转载请注明本文链接。

(采用 CC BY-NC-SA 4.0 许可协议进行授权)

本文标题:Embedding 这水比你想的深

本文链接:https://www.sshipanoo.com/blog/ai/llm-advanced/03-Embedding这水比你想的深/

本文最后一次更新为 天前,文章中的某些内容可能已过时!