做 RAG 检索链路时,很多人第一反应是直接套框架,几条 API 一调就出结果。可等你把知识库规模变大,或者检索结果开始飘的时候,就会发现问题根本没法排查。我最近在搭一套内部知识平台问答系统时,把最核心的几个组件单独拉出来练了一遍:文本向量化、余弦相似度计算、向量数据库 ChromaDB,以及 Embedding 服务的对接。这篇就把整个实操过程摊开写清楚,包括为什么选这些组件、相似度算出来怎么解读、入库和查询的代码长什么样、还有我一路踩过来的坑。如果你刚开始碰 RAG,或者用框架总觉得哪里不对,这篇应该能帮你把底层逻辑理顺。
1. 先把 RAG 核心链路拆开看
1.1 一条最小可用的检索链路长什么样
RAG 的前半段是检索,后半段才是生成。生成的质量很大程度上取决于检索出来的内容靠不靠谱。很多人误以为 RAG 就是“拿一个向量库存文档,然后搜一搜”,实际上中间还隔着切片、向量化、索引构建、相似度检索、重排好几个环节。
一条最小可用的链路大致是:先把原始文档加载进来,按照一定规则切成文本块(chunk);然后把每个文本块通过 Embedding 模型转成向量;向量写入向量数据库并建立索引;用户提问时,把问题文本也转成同一个空间的向量;随后在库里做相似度计算,返回最相关的 Top K 个文本块;最后把这些文本块拼接成上下文,连同用户问题一起交给大模型去生成答案。
在这个链路里,Embedding 负责把自然语言变成计算机能计算的数字表示,向量数据库负责存储和索引,余弦相似度负责判断两个向量在语义方向上是否一致。它们四者的关系,打个比方就是“食材、灶台、火候和厨师”:Embedding 是食材处理,ChromaLDB 是灶台,余弦是火候,大模型是最后掌勺的。任何一环掉链子,出锅的菜都不对劲。
我之所以建议你先把组件拆开练,而不是直接套一个现成框架,是因为框架把所有步骤都封装成了黑盒。一旦检索结果不理想,你根本不知道问题出在切片粒度、Embedding 模型不一致、索引默认参数、还是相似度公式的方向上。拆开练一遍,等于亲手给每个零件装上了仪表盘,这才谈得上调优。
1.2 为什么偏偏是这四个组件的组合
先看一张选型对照表,这是我当时挑组件时的真实思路:
| 组件 | 作用 | 我选它的核心原因 |
|---|---|---|
| 文本向量(Embedding) | 把文本编码成稠密向量 | 语义相似度能被量化,中文效果直接决定检索准确率 |
| 余弦相似度 | 衡量向量方向一致性 | 对文本长度、词频变化不敏感,适合做语义排序 |
| ChromaDB | 向量存储与检索 | 轻量、免服务端部署、Python 接口友好,中小知识库足够用 |
| 云上 Embedding 服务 | 批量文本转向量 | 免维护模型服务,按量付费,适合快速验证项目 |
另一个常见替代方案是自己在 GPU 服务器上部署开源向量模型,比如下载一个底座模型然后起一个推理服务。这个方案的好处是数据不出内网,长期使用边际成本低;坏处也很明显,你要维护服务和并发能力,还要处理模型版本升级,项目启动阶段非常耗精力。我这次选择云上 Embedding 服务,就是为了先把链路跑通,让业务先看到效果,后续如果量大了再考虑本地化推进。
ChromDB 这边,我也对比过更重型的向量库,它们功能更强,但要独立部署、要配集群、要处理运维问题,对一个几百到几万条文档规模的知识库来说,属于杀鸡用牛刀。ChromaDB 用 Python 包就能跑,支持持久化目录,也能指定相似度度量方式,做 Demo 和中小项目都很顺手。
1.3 拆开练的核心收益
我一直觉得,调试 RAG 的过程和外科手术有相似之处:你必须知道每个零件的正常指标是多少。比如,查询返回的 Top 1 内容不对,第一步肯定先检查文档和问题是不是用了同一个 Embedding 模型;如果检索得分全部趋同、区分度很低,再检查向量有没有做归一化或者是不是该换相似度算法;如果返回结果为空,优先怀疑集合维度不匹配。
这些判断依赖你对组件的理解,而不依赖框架。等你把核心链路亲手搭过一遍,再回去用框架,你会觉得每个黑盒都有了一面透明的墙,定位问题会快很多。
2. 核心概念与参数,动手前先聊明白
2.1 Embedding 怎么把文字变成向量
Embedding 本质上是一个映射函数,它把一段文字压缩成一个固定长度的数值数组,也就是“向量”。比如“猫”和“老虎”这两个词,在理想的向量空间里,它们的位置会比“猫”和“汽车”更接近。向量是稠密浮点数数组,每个维度并没有明确的的语义,但整体组合在一起就编码了文本的语义信息。
我用的云上文本向量模型,可以通过参数指定输出维度,以“1024 维”为例,每个文本块都会变成一个长度为 1024 的浮点数组。你可以把它理解成把一句话摊开成一条由 1024 个数字组成的“指纹”,语义越接近的指纹越相像。
这里有一个非常重要的提醒:全流程必须固定同一个维度。入库时用 1024 维,查询时必须也用 1024 维。如果中途换了另一个维度的模型,轻则写入报错,重则集合还在但查出来的结果稀碎。我在后来的项目里干脆把维度写进了配置文件,谁都不许手改。
2.2 余弦相似度的公式和它的脾气
余弦相似度衡量的是两个向量在方向上的接近程度,公式是:
cos(θ) = (A · B) / (|A| * |B|)也就是两个向量的点积除以它们模长的乘积。取值范围在 -1 到 1 之间,1 表示方向完全一致,0 表示正交,-1 表示方向完全相反。
为什么要用余弦而不是欧氏距离?举个例子,A = [1, 2, 3],B = [2, 4, 6],这两个向量方向完全一致,余弦相似度是 1;但欧氏距离却达到了 3.74。在文本场景里,同一句话换一种说法、同一个意思用更多词表达,都会导致向量长度变化,但语义方向往往是稳定的。余弦恰好在比较时忽略了长度,只看方向,所以更适合做语义排序。
但这里有一个必须提前告诉你的大坑:ChromaDB 查询结果里返回的字段叫distances,不是similarities。如果你创建集合时指定的度量方式是cosine,那么底层存的距离实际是“余弦距离”,也就是:
余弦距离 = 1 - 余弦相似度所以返回值越小,代表两个向量越相似。我看到好几个人都默认数字越大越相关,结果把查询结果排反了。这个细节,我会在后面的代码里再强调一次。
2.3 切片与集合参数,决定了检索的上限
梳理完 Embedding 和相似度,还有一个经常被低估的步骤:文本切片。我把切片看作 RAG 检索效果的上限,因为最后喂给大模型的上下文,是从切片堆里捞出来的。切片太大会带入大量无关信息,切片太小又会让语义碎片化,怎么切都不对。我的实践是先用自然段落边界作为主切片依据,段落太长再按固定窗口二次切,窗口大小在 300 到 500 个字符之间;相邻切片之间保留 50 到 100 个字符的 overlap,减少跨段上下文被切断的概率。
每一条切片入库时还要带上元数据,比如来源文件名、章节标题、切片序号。这些元数据不仅方便后续过滤,还能在检索结果中显示出处,做知识库问答时用户很看重这个。
接下来是集合(Collection)参数。ChromaDB 创建集合时可以指定向量索引的度量空间,我用的配置是:
metadata={"hnsw:space": "cosine"}如果不指定,默认是l2,也就是欧氏距离。注意,这个参数在集合创建时就定死了。如果集合已经存在,你再改metadata不会重建索引,查询时用的还是旧参数。所以调试时如果改了空间类型,先确认集合不存在,或者显式删除重建,再写数据。
2.4 用一小段代码验证余弦排序逻辑
为了让心里有底,可以先脱离数据库,用 NumPy 验证一下余弦相似度的排序逻辑:
import numpy as np def cosine_similarity(a, b): a = np.asarray(a, dtype=np.float32) b = np.asarray(b, dtype=np.float32) return float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b) + 1e-9)) vec_query = [1, 0, 0] vec_related = [1, 0.2, 0] vec_unrelated = [0, 1, 0] print(cosine_similarity(vec_query, vec_related)) # 接近 0.98 print(cosine_similarity(vec_query, vec_unrelated)) # 0.0向量越接近,相似度越接近 1;越无关,越接近 0。用这种极小的向量跑通逻辑后,再替换成真实的 Embedding 向量,你就能确定问题到底出在算法理解还是数据上。
3. 实操:从环境准备到完整检索
3.1 安装依赖并初始化 ChromaDB
先把需要的库装好:
pip install chromadb dashscope numpy然后初始化一个带持久化目录的客户端。这里我特别强调:要使用PersistentClient,而不是临时客户端。临时客户端的数据只停留在内存里,进程一退出就全部丢失。用持久化目录,你之后重启代码,集合还在,数据还在。
import chromadb client = chromadb.PersistentClient(path="./demo_chroma") collection = client.get_or_create_collection( name="router_kb", metadata={"hnsw:space": "cosine"} )get_or_create_collection这个接口很顺手,但我们刚才说过,如果集合已存在且当初的hnsw:space不是cosine,这条代码不会纠正它。所以最稳妥的方式是在调试阶段写一个重置逻辑:同名集合存在就删除,然后用新参数重建。
3.2 封装云上 Embedding 调用
我以 DashScope SDK 为例,把 Embedding 调用封装成两个函数:单条文本和批量文本。实际项目里肯定要用批量接口,因为一条条循环调用既慢又容易被限流。
import os import dashscope from http import HTTPStatus dashscope.api_key = os.getenv("DASHSCOPE_API_KEY") DIMENSION = 1024 def embed_texts(texts, dimension=DIMENSION): response = dashscope.TextEmbedding.call( model="text-embedding-v3", input=texts, dimension=dimension ) if response.status_code != HTTPStatus.OK: raise RuntimeError( f"Embedding API error: {response.code} {response.message}" ) embeddings = sorted(response.output["embeddings"], key=lambda x: x["index"]) return [item["embedding"] for item in embeddings] def embed_one(text, dimension=DIMENSION): return embed_texts([text], dimension=dimension)[0]批量接口返回的结果里带有index字段,用来标记对应原始输入的顺序。我见过因为没按index排序,导致向量和文本错位入库的案例,所以排序这一步不能省。
3.3 准备文本切片并写入向量库
接下来是数据入库。先用一个简单的字符级切片函数演示,界面上建议优先用段落边界:
def chunk_text(text, chunk_size=300, overlap=50): chunks = [] start = 0 while start < len(text): end = min(start + chunk_size, len(text)) chunks.append(text[start:end]) if end == len(text): break start = max(end - overlap, start + 1) return chunks我准备了一段虚拟的路由器使用手册文本,把它切片、向量化、写入 ChromaDB:
router_manual = """ 路由器使用说明:当设备无法上网时,请先检查光猫指示灯状态; 如果光猫信号灯持续红色,需要联系运营商确认线路故障。 Wi-Fi 信号较弱时,建议把路由器放在房屋中心,并远离金属遮挡物; 同时关闭微波炉、无线鼠标接收器等可能干扰信号的设备。 重置路由器会清除所有自定义配置,操作前请务必备份当前设置。 默认管理地址是 192.168.1.1,登录后可在无线设置中修改信道。 如果多台设备同时连接出现抢带宽现象,可以开启 QoS 按设备限速。 """ chunks = chunk_text(router_manual, chunk_size=200, overlap=40) ids = [f"chunk_{i}" for i in range(len(chunks))] metadatas = [{"source": "router_manual.md", "idx": i} for i in range(len(chunks))] embeddings = embed_texts(chunks)最后执行写入:
collection.add( ids=ids, documents=chunks, embeddings=embeddings, metadatas=metadatas )这里有个细节:我们传入了embeddings,ChromaDB 就不会再调用它内置的 Embedding 函数。如果省略embeddings,ChromaDB 会尝试用默认模型去生成向量,而那个默认模型对中文知识库的适配非常差,我第一次跑就被带偏过。
3.4 查询链路与结果解读
数据写进去以后,查询就很简单了:
question = "家里 Wi-Fi 信号很弱应该怎么办" query_vector = embed_one(question) results = collection.query( query_embeddings=[query_vector], n_results=3, include=["documents", "distances", "metadatas"] ) for i in range(3): print(results["distances"][0][i], results["documents"][0][i])输出大概是这样的:
0.182 建议把路由器放在房屋中心,并远离金属遮挡物... 0.245 如果多台设备同时连接出现抢带宽现象... 0.336 重置路由器会清除所有自定义配置...注意distances的值是 0.18、0.24 这样的小数,越小越相关。如果你创建集合时用的hnsw:space是cosine,这些值就是余弦距离(1 减去相似度),理解成相关性时要在脑内翻转一下方向。我的习惯是在结果返回后统一转成“相似度”再交给上层模块:
similarities = [1 - d for d in results["distances"][0]]这样团队里其他人使用时就不会搞混。
3.5 把检索结果拼接成上下文
RAG 的最后一步是把检索出来的文本块拼进 Prompt。这部分和检索链路没有强绑定,但为了完整性我一般会这样组织:
context = "\n".join(results["documents"][0]) prompt = f""" 请基于以下知识回答用户问题,如果知识中不包含相关信息,请明确说明不知道。 知识: {context} 用户问题: {question} """后续把prompt发送给大模型即可。这里不展开生成侧代码,因为标题这次聚焦的是检索侧核心组件。
4. 常见问题与排查技巧实录
4.1 查询结果和预期差很多,先怀疑向量空间
最典型的问题是文档和问题使用了不一致的 Embedding 模型,或者同一个模型但不同版本、不同维度。向量空间一旦不一致,入库的向量和查询向量根本不在一个语义空间里,余弦相似度算出来就没有参考意义。排查方法很简单:在日志里输出当前使用的模型名和维度,入库一次,查询一次,确认两者完全一致。
还有一种情况是集合是老的,里面存的是旧模型生成的向量。你换了新模型曲线去查询,照样歪。解决办法是加一个“构建版本”字段,每批数据都写入模型名和维度,查询脚本启动时检查版本,不一致就强制重建集合。
4.2 余弦模式下的 distances 到底该大还是该小
这个问题遇到的频率极高,值得单列一条。在 ChromaDB 中,如果指定了hnsw:space = "cosine",返回的距离是1 - cosine_similarity,所以:
- 值越小,表示向量越相似;
- 值越大,表示向量越不相似;
- 取值范围在 0 到 2 之间。
我在工程里建议统一封装一个to_similarity_list函数,把distances转成similarities,并注明取值范围。不要在业务代码里直接拿distances当相似度排序,否则迟早有人被绕晕。
4.3 Embedding 维度不一致,报错和哑弹都要处理
报错还算好的,至少能发现。麻烦的是那种不报错的哑弹:集合已经存在,里面存了 1024 维向量;你换了另一个也支持 1024 的模型,但语义分布完全不同,查询也能返回结果,只是结果没意义。所以链接测试必须既查“维度一致”,也查“模型一致”。
如果你遇到 Chroma 报维度不匹配,心里的第一反应应该是“旧集合残留了不同维度的数据”,而不是怀疑代码逻辑。处理方式是:删掉旧集合,用新维度重建,重新入库。不要尝试在集合内部直接修改维度,Chroma 没这个弹性。
4.4 API 限流、超时与费用预估
批量 Embedding 调用时,网络错误是会随机出现的,尤其一次传入几百条文本,总有一两条会碰到超时。我的做法是把批量请求拆小,每次不超过 32 条,失败后自动重试,最多三次。如果重试还失败,就把这批文本单独记到失败日志里,不阻塞整个任务。
费用方面,向量化是按调用次数和文本长度计费的。如果你在反复调试,同一批文档会被多次向量化。最简单也最有效的办法是加一层本地缓存:把文本哈希作为 key,向量作为 value,存在本地pickle或者 SQLite 里。下次请求先查缓存,命中就不调 API,既能省钱又避免限流。
4.5 检索效果始终不理想的排查速查表
我把自己遇到过的现象整理成了一个表,按“现象 - 原因 - 处理方式”排列:
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
| 返回内容完全不相关 | Embedding 模型不一致 | 检查入库与查询模型名 |
| Top 结果排序颠倒 | 把 distances 当 similarity 用 | 转成 1 - distance |
| 结果区分度差,得分都接近 | 切片过长 / 噪声太大 | 缩小切片或加 metadata 过滤 |
| 某个主题永远查不到 | 切片把关键词切断了 | 增加 overlap 或按句号分段 |
| 新增文档查询不到 | 数据没写入或索引未构建 | 检查写入日志和集合总数 |
| 查询速度突然变慢 | 数据量增长后索引效率下降 | 调大 HNSW 的 ef_search,或重建索引 |
这些排查项都是我在调试时真实遇到的。其实大多数问题在写代码前能规避掉,关键还是开头说的:不要把 RAG 当黑盒用。
5. 检索效果的后续优化与工程化扩展
5.1 查询侧增强:改写与 HyDE
基础链路跑通之后,如果想进一步提升检索效果,最经济的做法是先在查询侧动手。简单的方式是让大模型把用户问题改写成两三个等价的子问法,然后分别检索,合并结果后去重。比如“Wi-Fi 很卡”可以改写成“如何解决无线网络速度慢”“信号不稳定的排查方法”,这些改写后的句子在向量空间里可能比原始问题更贴近文档表达。
再进阶一点是 HyDE(Hypothetical Document Embeddings):先让大模型根据问题生成一个“假设性答案”,然后拿这个假设答案去检索。因为假设答案长得很像知识库里的文档内容,Embedding 之后往往更容易命中正确的切片。这个技巧我试过一些场景,确实能提升召回,但它会额外消耗大模型调用次数和延时,生产环境要权衡。
5.2 结果侧增强:重排模型
ChromaDB 初检时我一般取 Top 50,然后送给重排模型,让重排模型对这 50 条做精细打分,再取 Top 5 交给大模型。初选是海选,重排才是终面。常见的重排方案有开源本地模型和云服务,你可以根据环境选一种。
重排带来的是召回率和准确率的明显提升,尤其当切片内容很长,单纯靠向量相似度分不出来时。代价是多一次模型调用,但对知识库问答这种场景,多花这几十毫秒完全值得。
5.3 增量更新与数据分发
知识库不是一次性建完就完事的。现实业务里,文档更新很频繁。ChromaDB 支持用同一个id覆盖更新,也支持按id删除。我的建议是每条切片的id要有稳定的生成规则,比如source_file + "_" + chunk_index,这样文档更新时可以用新切片覆盖旧切片,不用先删整个集合。
数据备份直接复制PersistentClient指向的那个目录就行。如果数据量大了,可以考虑用符号链接把目录指到挂载盘上,避免塞满系统盘。运维上每次写入都记录时间戳,定期对账集合总数和源文档总数,能发现漏写故障。
5.4 落地生产的一些基础要求
我梳理过一个最小生产清单,供你参考:
- Embedding 模型名和向量维度写入统一配置,不散落在代码里;
- API Key 一律走环境变量,不硬编码;
- Embedding 请求带重试和缓存;
- 每个切片都带
source、updated_at等元数据; - 查询时按业务域过滤 metadata,避免跨域污染;
- 日志记录“查询耗时、返回条数、命中来源”;
- 定期备份向量库目录。
这套清单看起来简单,但很多小项目在第一项就栽了跟头。我在踩过几次“换模型忘了重建库”的坑之后,把第一步当成了铁律。
最后再分享一点个人心得:我实际跑下来发现,最耗时间的不是写代码,而是确认“向量空间没歪”和“相似度方向没反”这两件事。很多人一上来就堆大模型、搞复杂提示词,却忽略了底层这些核心组件的一致性。如果你也想搭 RAG,先把标题里这几个组件亲手跑通,再去套上层框架,你会发现自己对检索结果的控制力完全不一样。这个问题不解决,后面调什么都像隔靴搔痒。