深入理解向量检索:从 Embedding 原理到数据库选型
基于一个真实的 RAG 项目——孔子角色扮演问答系统,我们用本地 BGE 模型和 ChromaDB 搭建了整个检索链路。本文是开发过程中的完整学习记录,涵盖 Embedding 的本质、模型选型、检索流程、相似度计算、分块策略以及向量数据库的选型权衡。
一、Embedding 的本质:把语义“翻译”成坐标
1.1 什么是 Embedding?
Embedding 就是把一段文本变成一串固定长度的数字(向量),而且意思越相近的文本,它们在数字空间里的坐标也越靠近。
在我们的项目中,使用的是BAAI/bge-large-zh-v1.5,每句话会被映射成一个1024 维的向量,并且已经做过 L2 归一化。
可以把这 1024 个数字想象成 1024 个“语义感知器”——它们不是人手工定义的规则(比如“第一个数代表仁,第二个代表义”),而是模型从海量数据中自己“冲”出来的抽象特征。
1.2 为什么需要 1024 维?用颜色 RGB 来理解
颜色只需要 3 个维度(R、G、B)就能精确定位,“红”和“橙”近,“红”和“蓝”远。但语义没有这样明确的物理维度——“仁”和“爱”的差别,根本不可能用 3 个数字描述清楚。
所以 BGE 团队通过实验选择 1024 维:768 维效果差一点,1024 维刚好饱和,再往上加维度边际收益骤减。
1.3 语义空间是“冲”出来的,不是写出来的
这一点非常重要,它和我们在 Prompt 工程里用到的风格锚点形成了鲜明对比:
风格锚点:人手写规则,比如“自称‘吾’”,模型照做——像建筑师按图纸盖楼。
Embedding:让模型看几亿对句子,用惩罚驱动参数收敛——语义空间是“水流几亿次冲出河床”的自然形成过程。
具体训练方式叫对比学习:
每次给模型看三句话:锚点句子、一个意思相同的正例、一个无关的负例。
模型把三句话都向量化,计算两两距离。
如果正例离锚点比负例还远 →惩罚!调参数让正例靠近、负例推远。
重复上亿次,语义空间自然成形。
1.4 L2 归一化:只看方向,不看亮度
长文本包含的词多,向量分量的绝对值往往更大,导致向量的“长度”更长。如果不处理,长文本会天然在搜索中占优——不是因为意思更相关,纯粹是词多“力气大”。
L2 归一化把每个向量都缩放到单位球面上(长度变成 1),彻底消除文本长短造成的偏差。归一化之后,余弦相似度就等于向量点积(分母永远为 1),GPU 可以直接用一次矩阵乘法完成所有相似度计算。
一个很形象的比喻:手电筒照夜空——
方向= 语义
亮度= 文本长短
归一化= 把所有手电筒调到同一亮度,只看它们照向哪颗星。
1.5 完整因果链:从训练到检索
text
训练阶段:数亿对相似句 + 对比学习惩罚 → 模型学会把相似句映射到球面上接近的位置 ↓ 推理阶段:用户问题 → 同一个模型 → 1024 维坐标(归一化后落在单位球面上) ↓ 检索阶段:在这个坐标周围画个圈,向量方向接近的论语章句全部捞上来 ↓ Top-K 结果
项目代码位置:
backend/app/rag/embedder.py第 7 行:MODEL_NAME = "BAAI/bge-large-zh-v1.5"第 38-45 行:
embed()函数,接收文本列表,输出 1024 维向量,normalize_embeddings=True第 41 行注释:“已 L2 归一化(适合余弦相似度检索)”
backend/app/rag/retriever.py第 11 行调用embed()进行实时检索
二、为什么要用本地 BGE,而不是 DeepSeek Embedding API?
既然 DeepSeek 也提供 Embedding API,价格极低(¥0.14/百万 token),为什么我们还要在本地管理一个 1.3GB 的 BGE 模型?
2.1 五维对比
| 维度 | 本地 BGE | DeepSeek Embedding API | 胜出 |
|---|---|---|---|
| 检索质量 | 中文 SOTA,C-MTEB 霸榜 | 通用,接近但非专精 | BGE |
| 成本 | 零(模型已下载到本地) | 极低但需要为每次调用付费 | BGE |
| 速度 | 本地计算 < 50ms | 网络往返 100~200ms | BGE |
| 隐私 | 文本不出本机 | 数据发送到 DeepSeek 服务器 | BGE |
| 运维 | 需要管理模型文件、加载、并发 | 一行requests.post(),零负担 | API |
前四项 BGE 胜出,只有运维复杂度上 API 占优。
2.2 我们选择本地部署的两个核心原因
学习价值:这是为了彻底看懂“模型怎么加载、怎么调用、怎么归一化”的全过程。API 把这一切全部藏在黑盒背后,不利于成长。
开发自由:知识库的配置(chunk 大小、重叠、注解)随时可以调整,重建索引多少次都零成本。如果用 API,每次重建 512 条数据大约 ¥0.0036,小钱但会积少成多,且在频繁迭代时产生心理摩擦。
2.3 什么时候应该反过来选 API?
三两个人的团队赶项目上线,不想浪费精力管模型运维
数据量巨大(百万级),本地实在跑不动
没有 GPU 或大内存的服务器
知识库配置非常稳定,几乎不需要重建
2.4 项目代码里如何落地
embedder.py展示了本地管理模型的全套动作:
第 10-24 行:懒加载 + 双重检查锁,确保 1.3GB 的模型只加载一次
第 27-35 行:自动下载逻辑——本地没有就从 ModelScope 拉取
第 7 行:版本写死
"BAAI/bge-large-zh-v1.5"——本地部署需要显式管理版本builder.py第 41 行:建索引时调用embed(),512 条论语全部向量化,零费用retriever.py第 11 行:每次查询调用embed(),毫秒级返回
三、一次完整的检索:从输入“什么是仁?”到返回 5 条章句
用户提问:“什么是仁?”,系统如何找到最相关的论语章句?整个过程分为四步。
3.1 四步流程
text
用户输入 "什么是仁?" │ ▼ ① embed([query]) BGE 模型:整句话穿过 12 层 Transformer → 每个词被语境更新 → Mean Pooling 取平均 → 1 个 1024 维向量 → L2 归一化(长度=1,落在单位球面上) │ ▼ ② collection.query(query_embeddings=..., n_results=5) 拿着这 1024 个数,问 ChromaDB:“找离它最近的 5 个点” │ ▼ ③ ChromaDB 内部 比较查询向量与 512 条已存向量的 L2 欧氏距离 → 排序 → 取最近 5 个(通过 HNSW 索引加速) → 返回 ids, documents, metadatas, distances │ ▼ ④ 处理返回结果 score = 1 / (1 + distance) · distance=0(完全相同)→ score=1.0 · distance≈1.414(互相垂直)→ score≈0.414 拼装返回:[{text, chapter, verse_index, score}, ...]3.2 BGE 的“整句理解”比词向量强在哪?
传统方法如 word2vec 是“词拼词”:每个词有一个固定的向量,句子向量就取所有词向量的平均值。这会导致同一个“仁”字,在“杏仁”和“仁爱”中向量完全一样,分不清多义词。
BGE 使用的是 Transformer 架构,整句话同时穿过 12 层网络,每个词的向量都会被上下文实时修正。可以用一个教室讨论的场景来理解:
“仁”看到前面有“什么是”,知道自己在被提问,于是更新了自己的表示。12 层网络等于 12 轮讨论,最终每个词都带上了整句话的语境信息。然后对所有这些“被语境更新过的词向量”做 Mean Pooling,得到一个真正代表整句话语义的 1024 维向量。
这就是为什么 BGE 能区分“杏仁”和“仁爱”——因为同一个汉字在不同的上下文里,最终的向量完全不同。
3.3 相关代码
backend/app/rag/retriever.py:第 1-28 行,完整的检索函数第 11 行:
embed([query])文本→向量第 11 行:
collection.query(...)向量→Top-K第 20 行:
score = 1/(1+distance)距离→分数
backend/app/rag/embedder.py第 38-45 行:embed()实现backend/app/rag/builder.py第 19-54 行:知识库构建时同样调用embed()
四、余弦相似度:为什么用夹角而非距离来量语义?
4.1 两个核心概念
余弦相似度:
cos(θ) = (A·B) / (|A| × |B|),只看方向,不看长度。1 表示方向完全相同,0 表示垂直无关,-1 表示完全相反。欧氏距离(L2):两点之间的直线距离,受向量长度影响很大。
4.2 一个让人瞬间明白的例子
假设有三句话的向量(长度不同):
A = “仁”(长度1)
B = “仁者爱人也”(长度4)
C = “恕”(长度1)
语义上,A 和 B 极近(夹角 10°),A 和 C 较远(夹角 30°)。但如果我们用欧氏距离去量:
text
欧氏距离:A→C (0.52) < A→B (3.02) → 错误判断“恕”更像“仁” 余弦相似度:cos(A,B)=0.985 > cos(A,C)=0.866 → 正确判断“仁者爱人”更像“仁”
根本原因:欧氏距离像是在地图上量两个点的直线米尺距离,长文本向量天然离得更远;余弦相似度则是指南针,只比较指向,长短无所谓。
4.3 归一化的深层意义
我们在 Embedding 时就做了 L2 归一化,这意味着所有向量的长度都等于 1,于是:
text
余弦相似度 = (A·B) / (1×1) = A·B
此时 GPU 可以用一次矩阵乘法同时计算查询向量与 512 个文档向量的点积,毫秒级完成所有相似度计算。归一化不仅消除了长短偏差,也让计算效率提升到了极致。
4.4 项目中的分数公式
retriever.py第 20 行:
python
score = 1 / (1 + L2_distance)
由于向量已归一化,L2 距离与余弦相似度有固定的数学关系,用这个公式转换后的分数范围大约在 0.33 到 1.0 之间,排序结果与直接使用余弦相似度完全一致,但对人更直观。
| cos_sim | L2 距离 | score |
|---|---|---|
| 1.0(完全相同) | 0 | 1.0 |
| 0.9 | ~0.447 | ~0.691 |
| 0.0(无关) | 1.414 | 0.414 |
记住一句简单的判断准则:比意思近还是比位置近?意思看方向,位置看米尺。语义检索中,方向永远更重要。
五、分块策略:为什么《论语》可以“一章一块”?
5.1 分块的基本权衡
Embedding 模型用 Mean Pooling 生成句向量,如果文本太长,过多的词向量混在一起取平均,会使最终的语义向量变得像“灰色”——什么都有一点,但特征不鲜明。所以必须把长文档切成小段(chunk)。
切多小?这是个经典权衡:
太小:丢失上下文,只剩下孤立的词或短句(“鲜矣仁”是谁说的?不知道)
太大:语义被稀释,检索精度下降(把整本《论语》变成一个向量,基本四不像)
常规做法:chunk_size 256~512 token,同时设置 overlap(重叠窗口)防止关键信息被拦腰截断
5.2 论语天然适合“按章分块”
《论语》的文本结构太友好了——每一章就是一个独立观点:
子曰:“巧言令色,鲜矣仁!”
这天然就是最理想的语义单元。因此我们:
不需要按固定字数硬切
不需要 overlap,因为每一章的语义都是完整的,不存在“拦腰截断”
全书 512 条章句 = 512 个 chunk,chunk_id 直接用
{篇名}_{序号},比如学而篇_0
这本质上是一个Q&A 结构的数据集——问一句答一句,天生适合语义检索。
5.3 如果是普通文档,怎么切?
对于一般的长文档,我们会这样做:
chunk_size: 256-512 token(BGE 训练时 passage 的典型长度)overlap: 50-100 token,滑动窗口确保每个关键信息至少完整地出现在一个 chunk 内
没有 overlap 的典型惨案:
text
块1: ...君子不重则不威,学则不固。主忠| ← 切断 块2: |信,无友不如己者,过则勿惮改... ← 切断 → “主忠信”这个词被劈成两半,用户搜索“忠信”可能找不到
5.4 未来的扩展:父-子分块
如果后续项目给《论语》加上白话注解,chunk 会变长,语义层次也会变复杂。那时我们可以采用Small-to-Big 检索:
子块:单条原文(用于检索,精度高)
父块:原文 + 注解(返回给 LLM 阅读,上下文完整)
这样既能保证检索命中率,又不会丢失大模型的阅读理解体验。
5.5 相关代码
backend/app/rag/chunker.py第 16-35 行:load_and_chunk(),按章分块,一句一章backend/app/rag/builder.py第 36-52 行:分块 → 向量化 → 写入 ChromaDBchunk_id 格式示例:
学而篇_0、为政篇_5
六、向量数据库选型:为什么用 ChromaDB 而不是 FAISS 或 Milvus?
6.1 向量数据库存什么?
它同时存三样东西:向量、原始文本、元数据。一次查询,三个一起返回,不用再去另外的数据库做关联。
和传统数据库(WHERE id=5精确匹配)不同,向量数据库的核心能力是相似度搜索:找到和给定向量最近的那几个点。
6.2 四大候选对比
| ChromaDB | FAISS | pgvector | Milvus | |
|---|---|---|---|---|
| 性质 | 嵌入式向量库 | 纯向量索引库 | PostgreSQL 扩展 | 分布式向量库 |
| 存什么 | 向量+原文+元数据 | 只存向量 | 向量+任何列(SQL) | 向量+字段 |
| 部署 | pip install零配置 | pip install | 需要 PostgreSQL | 独立服务 |
| 适用量级 | 千~百万 | 百万~亿 | 千~千万 | 百万~百亿 |
| 混合查询 | 基础元数据过滤 | 无(需自己实现) | SQL + 向量联合查询 | 向量+标量混合查询 |
| 持久化 | 本地文件 | 需手动序列化 | PostgreSQL WAL | 自带 |
6.3 本项目选择 ChromaDB 的逻辑
我们的需求清单非常明确:
只有512 条数据(极小规模)
开发期零配置,不想额外部署服务
需要同时存储原文和元数据
要求轻量级持久化,重启不丢失
一个人开发,学习曲线要最低
ChromaDB 完美命中了每一点:pip install chromadb即用,PersistentClient写入本地文件,collection.add(documents=..., metadatas=...)一次存入所有信息。其他方案则各有硬伤:
FAISS:只存向量,需要自己维护 ID 到原文和元数据的映射,多写不少代码
pgvector:必须额外部署 PostgreSQL,开发期太重
Milvus:需要独立服务 + etcd + MinIO,512 条数据简直是牛刀杀鸡
6.4 未来什么时候切换?
pgvector:当项目数据库从 SQLite 升级到 PostgreSQL 时,可以顺便使用 pgvector,把关系库和向量库合二为一,省掉一个服务。
FAISS:当需要 GPU 加速、纯搜索性能压倒一切,且不需要元数据管理的时候。
Milvus:当知识库扩张到几百万甚至上亿条,需要分布式、生产级高可用时。
6.5 代码一览
builder.py第 11-16 行:get_chroma_client()返回PersistentClient,数据持久化到文件第 19-54 行:
build_knowledge_base()一次性写入 ids、documents、embeddings、metadatas第 57-60 行:
get_collection()只读打开集合retriever.py第 11 行:collection.query()一次查询,向量、原文、元数据同时返回
