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

Embedding 不止是"转成数组"

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

原因在于,Embedding 这一环真正决定成败的不是那几行调用,而是四件事:模型选得对不对、距离度量配不配、文档怎么切、向量往哪儿放。任何一件想当然,召回率都会悄悄漏水。这一篇逐个讲清楚。

选模型:维度、榜单与 instruction prefix

选 Embedding 模型有三个常见误区。

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

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

第三个误区最隐蔽,是忽略 instruction prefix。bge、e5、gte 这类模型是带着特定前缀训练的:e5 要求查询加 query:、文档加 passage:,bge 要求查询加一句类似"为这个句子生成表示以用于检索相关文章:"的指令。不加这个前缀,召回会明显变差,而代码不会报任何错。OpenAI 的 text-embedding-3 系列则不需要前缀。所以接入一个新模型,第一件事是读它的模型卡片,确认要不要前缀、输出是否归一化。

from sentence_transformers import SentenceTransformer

model = SentenceTransformer("BAAI/bge-large-zh-v1.5")
QUERY_PREFIX = "为这个句子生成表示以用于检索相关文章:"


def embed_query(text: str) -> list[float]:
    # bge 系列:查询侧必须加检索指令前缀
    return model.encode(QUERY_PREFIX + text, normalize_embeddings=True).tolist()


def embed_passage(text: str) -> list[float]:
    # 文档侧不加前缀
    return model.encode(text, normalize_embeddings=True).tolist()

距离度量:先确认向量归一化没有

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

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

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

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

文本分块:检索质量从切文档时就定了

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

设想把一篇五千字的文章整个塞进一个 chunk 算 embedding,这个向量是全文语义的平均。如果用户问的点只在其中一句话里,那一句的语义会被其余四千多字稀释得几乎看不见,检索自然捞不到。这是 chunk 太大的代价——语义被平均冲淡,命中后塞给模型的无关内容也多。反过来,chunk 太小也不行:切到只剩"它的吞吐量提升了三倍",这个"它"指代谁丢了,上下文没了,这个向量检索出来也用不了。

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

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

from langchain_text_splitters import RecursiveCharacterTextSplitter

splitter = RecursiveCharacterTextSplitter(
    chunk_size=500,            # 目标长度,要配合 embedding 模型的最佳输入长度
    chunk_overlap=80,          # 约 16% 重叠,保住边界语义
    separators=["\n\n", "\n", "。", "!", "?", " ", ""],  # 段落优先,逐级退让
)


def split_document(text: str, source: str) -> list[dict]:
    chunks = splitter.split_text(text)
    return [
        {"text": c, "meta": {"source": source, "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 的优点是性能好、带强大的元数据过滤、Rust 编写省资源,从单机到集群都顺,是不想碰 Postgres 又要上规模时的好选择。Milvus 功能最全、最能扛超大规模,代价是组件多、运维重,小项目用它属于杀鸡用牛刀。

索引与过滤:HNSW 的几个旋钮

选完库还有两个参数要懂。

一是索引类型。暴力 flat 索引精确但慢,数据一多就扛不住;生产基本用 HNSW,一种图索引。它是近似最近邻——快得多,但召回不是 100%。HNSW 有几个旋钮:mef_construction 影响建索引的质量和耗时,ef_search 在查询时调,调大召回升、速度降。这是可以现场调整的"召回与延迟"权衡。

二是带过滤的检索。实际场景常要"在某个分类、某个时间段内做向量检索"。注意 pre-filter(先按元数据筛再算向量)和 post-filter(先算向量再筛)对召回和性能影响不同——post-filter 可能出现"召回 10 条、过滤后只剩 2 条"的情况。Qdrant、Milvus 对带过滤的检索都有专门优化。如果内存吃紧,还可以开标量量化或 PQ 压缩,用一点精度换大幅的内存下降。

完整示例:一个最小检索栈

把分块、embedding、入库、检索串起来,是一个可以直接跑的最小检索栈。这里用 Chroma 演示,它自带 HNSW 索引:

# retrieval_stack.py
import chromadb
from sentence_transformers import SentenceTransformer
from langchain_text_splitters import RecursiveCharacterTextSplitter

model = SentenceTransformer("BAAI/bge-large-zh-v1.5")
QUERY_PREFIX = "为这个句子生成表示以用于检索相关文章:"
splitter = RecursiveCharacterTextSplitter(
    chunk_size=500, chunk_overlap=80,
    separators=["\n\n", "\n", "。", "!", "?", " ", ""])

client = chromadb.PersistentClient(path="./vecdb")
collection = client.get_or_create_collection(
    name="docs", metadata={"hnsw:space": "cosine"})   # 余弦度量


def index_document(text: str, source: str) -> None:
    """分块 -> 嵌入(文档侧不加前缀)-> 入库"""
    chunks = splitter.split_text(text)
    collection.add(
        ids=[f"{source}-{i}" for i in range(len(chunks))],
        documents=chunks,
        embeddings=[model.encode(c, normalize_embeddings=True).tolist()
                    for c in chunks],
        metadatas=[{"source": source, "chunk_id": i}
                   for i in range(len(chunks))],
    )


def search(query: str, top_k: int = 5) -> list[dict]:
    """查询侧加检索前缀,再做向量检索"""
    q_vec = model.encode(QUERY_PREFIX + query,
                         normalize_embeddings=True).tolist()
    res = collection.query(query_embeddings=[q_vec], n_results=top_k)
    return [
        {"text": doc, "source": meta["source"], "score": 1 - dist}
        for doc, meta, dist in zip(
            res["documents"][0], res["metadatas"][0], res["distances"][0])
    ]


if __name__ == "__main__":
    index_document(open("handbook.md", encoding="utf-8").read(), "handbook")
    for hit in search("怎么重置密码"):
        print(f"[{hit['score']:.3f}] {hit['source']}: {hit['text'][:60]}")

这个栈不到七十行,已经覆盖了分块、查询前缀、归一化、余弦检索、元数据这几个关键点。后面要换模型、换向量库,只需替换对应的两三个函数,调用方不受影响。

几个常见踩坑

只看 MTEB 总排名选模型。要看与场景匹配的子任务和语言子集,检索任务尤其要看 retrieval 一栏。

用 bge、e5 却不加 instruction prefix。不加前缀召回明显下降,且代码不会报错,很难察觉。接入新模型先读模型卡片。

纠结余弦还是点积。向量归一化后三种度量排序等价;没归一化时点积才会因模长产生偏差。

用固定字数硬切文档。会从句子、段落中间切断,破坏语义单元。用递归切分并设置重叠。

chunk 大小不配合模型。模型有最佳输入长度,chunk 过大只会被截断或压缩。

直接上 Milvus 这类重型库。小数据量用 Chroma 或 pgvector 就够,盲目上分布式向量库只是徒增运维。

本篇要点

  • 维度不是越高越准,选模型要看与场景匹配的榜单子项,并确认是否需要 instruction prefix
  • 向量归一化后,余弦、点积、欧几里得距离的排序等价;没归一化时点积会受模长影响
  • 分块直接决定检索精度:chunk 过大语义被稀释、过小丢上下文,用递归切分加重叠
  • 向量库按数据量级和现有架构选:小数据用 Chroma,已有 Postgres 优先 pgvector,超大规模才上 Milvus
  • HNSW 是生产常用的近似索引,ef_search 可现场权衡召回与延迟
  • 把分块、嵌入、入库、检索整合成一个最小检索栈,换组件时调用方不受影响

下一篇

即便 Embedding 这一层全部做对,纯向量检索本身仍有天花板——它对专有名词、错误码这类精确匹配并不擅长。下一篇拆解 RAG 为什么还是答不好,从混合检索、重排序、查询改写到分层评估,把失效点逐层找出来。

参考资料

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

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

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

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

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