第 3 章 向量与 Embedding:RAG 的语义基石 前两章我们用的是"假向量"。真实 RAG 里,向量是 Embedding 模型生成的——向量的质量直接决定 RAG 的上限,而度量选错会让一切归零。这一章解决三个问题:稠密 vs 稀疏向量到底差在哪、度量(Metric)该怎么选、怎么把真实 Embedding 模型接进 Zvec。 3.1 为什么需要向量:从关键词到语义 传统关键词检索的硬伤是"字面匹配"——搜"汽车"找不到"轿车",搜"如何退款"找不到"退货流程"。Embedding 模型解决的就是这个问题: Embedding 模型把任意文本映射到一个高维空间,语义相近的内容在这个空间里距离也近。于是"找相似"就变成了"算距离"——这就是语义检索的数学本质。
前两章我们用的是"假向量"。真实 RAG 里,向量是 Embedding 模型生成的——向量的质量直接决定 RAG 的上限,而度量选错会让一切归零。这一章解决三个问题:稠密 vs 稀疏向量到底差在哪、度量(Metric)该怎么选、怎么把真实 Embedding 模型接进 Zvec。
传统关键词检索的硬伤是"字面匹配"——搜"汽车"找不到"轿车",搜"如何退款"找不到"退货流程"。Embedding 模型解决的就是这个问题:
┌──────────────┐ ┌──────────────┐ │ "汽车" │ Embedding 模型 │ [0.12, -0.07, 0.33, ...] ← 向量 │ "轿车" │ ──────────────► │ [0.11, -0.08, 0.31, ...] ← 向量很接近 │ "自行车" │ │ [0.45, 0.20, -0.12, ...] ← 向量差很远 └──────────────┘ └──────────────┘
Embedding 模型把任意文本映射到一个高维空间,语义相近的内容在这个空间里距离也近。于是"找相似"就变成了"算距离"——这就是语义检索的数学本质。
💡 RAG 的第一性原理:LLM 不可能记住你的私有知识,但你可以在它生成前,用向量把"最相关的几段知识"喂给它。这段"找相关"的质量,就是 RAG 效果的天花板。
Zvec 支持两种向量,它们的"思维方式"完全不同。理解这个区别,是理解第 7 章混合检索的前提。
| 维度 | 稠密向量(Dense) | 稀疏向量(Sparse) |
|---|---|---|
| 结构 | 固定长度的实值数组,几乎每维都非零 | 极高维(≈词表大小),但只有极少数维度非零 |
| 生成 | 深度学习模型(如 BGE、OpenAI text-embedding) | 词项权重(如 BM25、SPLADE、BGE-M3 稀疏头) |
| 含义 | 每一维都参与表达语义,整体捕捉上下文 | 每个非零维度对应一个具体词项及其权重 |
| 擅长 | 语义相似("汽车"≈"轿车","退款"≈"退货") | 精确词项匹配(专有名词、型号、人名、代码符号) |
| 弱点 | 不可解释;对罕见专有名词不敏感 | 缺乏语义("car"和"automobile"无关) |
| 典型形态 | [0.012, -0.034, ..., 0.018](768 维) |
{4017: 2.31, 309: 1.85}(维度索引:权重) |
用一段伪代码直观看两者的存储差异:
# 稠密向量:768 维,几乎每维都有值(神经网络输出) dense = [0.012, -0.034, 0.005, 0.041, -0.022, ..., 0.018] # 长度固定 = 768 # 稀疏向量:词表 50000 维,但只存非零项(关键词权重) sparse = { 4017: 2.31, # "puppy" 的权重 309: 1.85, # "dog" 的权重 1822: 1.12, # "pet" 的权重 # 其余约 49996 维隐式为零 }
⚠️ RAG 召回率的常见瓶颈:只用了稠密向量。后果是——用户搜"报错码 ERR_4093"或"产品型号 X-Pro2"时,因为这类专有标识在训练语料里罕见,稠密向量把它们和别的词"拉得不够开",导致召回不到。稀疏向量(或全文检索)就是补这条腿的。第 7 章会把两条腿合起来。
向量"相似"需要一把尺子来量,这把尺子就是度量。Zvec 支持三种:
| 度量 | 含义 | 分数方向 | 典型场景 |
|---|---|---|---|
| COSINE(余弦距离) | 1 − 余弦相似度 | 越小越相似(0=完全相同) | 文本 Embedding(最常见) |
| L2(欧氏距离) | 向量差的模长 | 越小越相似 | 部分图像/音频 Embedding |
| IP(内积) | 点积 | 越大越相似 | 已归一化的向量、稀疏向量 |
⚠️ COSINE 在 Zvec 里是"距离"而非"相似度":分数越小越相似(0 表示方向完全一致)。这点和很多教程的"余弦相似度越大越好"相反——第 1 章的输出里
d1 0.0008排第一就是这个原因。记住:看 score 排序前,先确认度量方向。
这是 RAG 里最隐蔽、最致命的 bug。Embedding 模型在训练时优化的是某一种距离目标——比如 OpenAI 的模型、BGE 系列大多基于归一化向量的余弦相似度训练。如果你在 Zvec 里用了不一致的度量,语义关系会被扭曲,召回质量断崖式下跌。
模型训练目标 Zvec 里该用 结果 ───────────────────────────────────────────────── 余弦相似度 ──► COSINE ✅ 正确,语义关系保真 余弦相似度 ──► L2 ❌ 距离失真,召回质量下降 归一化点积训练 ──► IP(向量已归一化) ✅ 等价于余弦,更快 未归一化点积 ──► IP ⚠️ 会偏好长向量,需谨慎
怎么确认模型用什么度量? 看模型卡的说明。绝大多数主流文本 Embedding 模型(OpenAI text-embedding-3、BGE-large-zh、bge-m3、e5、gte 系列)都用 COSINE。稀疏向量则通常用 IP(内积,因为稀疏向量本质是词项权重,加权点积衡量匹配度)。
💡 经验法则:文本 RAG 起步,稠密向量无脑选
COSINE,稀疏向量选IP。等你熟悉了再根据模型卡细调。
Zvec 本身不绑定任何 Embedding 模型——它是"向量仓库",不负责"造向量"。这正是好设计:模型和数据库解耦,你可以随时换模型。接入方式分两类:在线 API(如 OpenAI)和本地模型(如 BGE、Sentence-Transformers)。
适合不想本地部署、语料量中等的场景。注意一个核心工程要点:入库和查询必须用同一个模型,否则向量在不同空间里无法比较。
import zvec from openai import OpenAI client = OpenAI() # 用同一个函数把文本变成向量——保证入库/查询的 Embedding 完全一致 def embed(texts: list[str]) -> list[list[float]]: resp = client.embeddings.create( model="text-embedding-3-small", # 固定模型名,全链路统一 input=texts, ) return [d.embedding for d in resp.data] # ① 建库:维度要和模型输出一致(text-embedding-3-small 是 1536 维,COSINE 度量) schema = zvec.CollectionSchema( name="rag_openai", fields=[zvec.FieldSchema(name="text", data_type=zvec.DataType.STRING), zvec.FieldSchema(name="source", data_type=zvec.DataType.STRING, index_param=zvec.InvertIndexParam())], vectors=[zvec.VectorSchema(name="dense", data_type=zvec.DataType.VECTOR_FP32, dimension=1536, index_param=zvec.HnswIndexParam(metric_type=zvec.MetricType.COSINE))], ) kb = zvec.create_and_open(path="./rag_openai", schema=schema) # ② 入库:文本 ──embed──► 向量 ──写进 Zvec chunks = ["Zvec 是进程内向量数据库。", "RAG 用检索增强大模型生成。"] vectors = embed(chunks) kb.insert([ zvec.Doc(id=f"c{i}", vectors={"dense": vectors[i]}, fields={"text": chunks[i], "source": "doc.md"}) for i in range(len(chunks)) ]) kb.optimize() # ③ 查询:问题 ──embed──► 查询向量 ──检索(同一个 embed 函数!) q_vec = embed(["什么是 RAG?"])[0] hits = kb.query(queries=zvec.Query(field_name="dense", vector=q_vec), topk=2) for h in hits: print(h.id, round(h.score, 4), h.fields["text"])
适合数据敏感、要离线、或想省 API 费用的场景。下面用通用的 Sentence-Transformers 写法(BGE、e5、gte 都兼容这套接口),原理和模式 A 完全一致:
from sentence_transformers import SentenceTransformer import zvec # 本地加载模型;BGE 系列同样可用 Sentence-Transformers 加载 model = SentenceTransformer("BAAI/bge-small-zh-v1.5") # 输出 512 维,COSINE def embed(texts: list[str]) -> list[list[float]]: # BGE 等模型推荐对 query 加前缀(参考其模型卡),这里简化演示 return model.encode(texts, normalize_embeddings=True).tolist() # 维度必须等于模型输出维度;度量与模型训练度量对齐 schema = zvec.CollectionSchema( name="rag_local", fields=[zvec.FieldSchema(name="text", data_type=zvec.DataType.STRING)], vectors=[zvec.VectorSchema(name="dense", data_type=zvec.DataType.VECTOR_FP32, dimension=512, # bge-small-zh 输出 512 维 index_param=zvec.HnswIndexParam(metric_type=zvec.MetricType.COSINE))], ) kb = zvec.create_and_open(path="./rag_local", schema=schema) # 后续 insert / query 与模式 A 完全相同,只是 embed() 换成了本地模型
💡 工程心法:把 embed() 抽成一个统一函数。无论在线还是本地,都通过同一个
embed(texts)入口产生向量。这样入库和查询天然用同一个模型、同一个预处理,杜绝"模型不一致"的 bug。这也是第 9 章端到端 RAG 的代码骨架。
| 考量点 | 怎么判断 | 影响 |
|---|---|---|
| 语言 | 中文为主 → BGE-zh / bge-m3;英文为主 → e5 / gte / OpenAI | 召回质量 |
| 维度 | 模型卡写明(512/768/1024/1536…) | Schema 的 dimension 必须对齐,定了就难改 |
| 度量 | 模型卡写明(多数是 cosine) | Zvec 的 metric_type 必须对齐 |
| 是否支持稀疏 | bge-m3 同时输出稠密+稀疏;SPLADE 输出稀疏 | 决定能否做混合检索(第 7 章) |
| 部署方式 | 在线 API / 本地 GPU / 本地 CPU | 成本、延迟、隐私 |
⚠️ 维度一旦定下,更换模型很痛苦。因为换模型意味着维度/空间都变了,所有旧向量作废,必须全量重新 Embedding 并重建 Collection。所以选模型时多花一小时评估,比事后重建省得多。
COSINE 和 L2 两个 Collection 写入相同数据,对同一个问题检索,对比 top-5 的差异——直观感受度量错配如何扭曲结果。"GPT-4 Turbo"、"大语言模型"、"如何配置环境变量"。用稠密向量检索"GPT-4",再用稠密向量检索"大型语言模型",观察哪些被召回,体会稠密向量的语义优势和专有名词弱点。embed() 函数,确认两边用的是同一个模型和预处理。embed() 函数,保证入库/查询一致。「稠密 vs 稀疏向量」示例对同一批语料分别用稠密 / 稀疏检索,直观对比召回差异。下面是完整脚本:
"""稠密 vs 稀疏向量检索对比完整示例。 建一个同时含稠密 + 稀疏向量的库,对同一查询分别用两种向量检索, 观察召回差异,理解为什么生产级 RAG 需要混合检索(第 7 章)。 依赖:pip install zvec """ import hashlib import numpy as np import zvec def fake_dense(text, dim=8): """占位稠密向量(哈希,无语义)。真实场景用 bge-m3 / e5 等模型。""" h = hashlib.sha256(text.encode("utf-8")).digest() vec = np.zeros(dim, dtype=np.float32) for i in range(dim): vec[i] = ((h[i % len(h)] ^ (i * 17)) % 1000) / 1000.0 norm = np.linalg.norm(vec) or 1.0 return (vec / norm).tolist() def fake_sparse(text, vocab_size=5000): """占位稀疏向量:按词 hash 到维度,权重取词频。真实场景用 bge-m3 稀疏头 / SPLADE。""" weights = {} for tok in text.lower().split(): idx = int(hashlib.md5(tok.encode("utf-8")).hexdigest(), 16) % vocab_size weights[idx] = weights.get(idx, 0.0) + 1.0 return weights def main(): schema = zvec.CollectionSchema( name="dual_kb", fields=[zvec.FieldSchema(name="text", data_type=zvec.DataType.STRING)], vectors=[ zvec.VectorSchema(name="dense", data_type=zvec.DataType.VECTOR_FP32, dimension=8, index_param=zvec.HnswIndexParam(metric_type=zvec.MetricType.COSINE)), zvec.VectorSchema(name="sparse", data_type=zvec.DataType.SPARSE_VECTOR_FP32, index_param=zvec.HnswIndexParam(metric_type=zvec.MetricType.IP)), ], ) col = zvec.create_and_open(path="./dense_sparse_kb", schema=schema) docs = ["ERR_4093 连接超时的处理方法", "商品退款流程与退货政策", "向量数据库的混合检索原理", "报错码 ERR_5001 的含义"] col.insert([ zvec.Doc(id=f"d{i}", vectors={"dense": fake_dense(t), "sparse": fake_sparse(t)}, fields={"text": t}) for i, t in enumerate(docs) ]) col.optimize() query = "ERR_4093 怎么解决" print(f"查询: {query}\n") # 稠密检索:可能因 ERR_4093 是普通字符而召回不准 hits_dense = col.query( queries=zvec.Query(field_name="dense", vector=fake_dense(query)), topk=3) print("=== 稠密检索(语义)===") for h in hits_dense: print(f" {h.id} score={h.score:.4f} {h.fields['text']}") # 稀疏检索:精确锁定 ERR_4093 这个词项 hits_sparse = col.query( queries=zvec.Query(field_name="sparse", vector=fake_sparse(query)), topk=3) print("\n=== 稀疏检索(关键词)===") for h in hits_sparse: print(f" {h.id} score={h.score:.4f} {h.fields['text']}") print("\n解读:稀疏向量对精确关键词更敏感;稠密向量对语义更敏感。") print(" 二者各有盲区 → 第 7 章混合检索。") if __name__ == "__main__": main()
💡 运行后你会看到稠密检索对
ERR_4093这类精确标识召回不准、稀疏检索却能精准锁定——这正是第 7 章混合检索的动机。先用占位向量看懂差异,再换 bge-m3 体验真实效果。配套示例还提供了 bge-m3 双输出的真实封装,供第 7、9 章使用。
建库的第一块拼图——Schema——是下一章的主题。详见第 4 章。