资讯动态

RAG检索链路四大组件实操:向量化、余弦相似度与ChromaDB

发布时间:2026/10/22 13:44:50 来源:尧图企业网站定制
做 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, dtypenp.float32) b np.asarray(b, dtypenp.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( namerouter_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, dimensionDIMENSION): response dashscope.TextEmbedding.call( modeltext-embedding-v3, inputtexts, dimensiondimension ) if response.status_code ! HTTPStatus.OK: raise RuntimeError( fEmbedding API error: {response.code} {response.message} ) embeddings sorted(response.output[embeddings], keylambda x: x[index]) return [item[embedding] for item in embeddings] def embed_one(text, dimensionDIMENSION): return embed_texts([text], dimensiondimension)[0]批量接口返回的结果里带有index字段用来标记对应原始输入的顺序。我见过因为没按index排序导致向量和文本错位入库的案例所以排序这一步不能省。3.3 准备文本切片并写入向量库接下来是数据入库。先用一个简单的字符级切片函数演示界面上建议优先用段落边界def chunk_text(text, chunk_size300, overlap50): 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我准备了一段虚拟的路由器使用手册文本把它切片、向量化、写入 ChromaDBrouter_manual 路由器使用说明当设备无法上网时请先检查光猫指示灯状态 如果光猫信号灯持续红色需要联系运营商确认线路故障。 Wi-Fi 信号较弱时建议把路由器放在房屋中心并远离金属遮挡物 同时关闭微波炉、无线鼠标接收器等可能干扰信号的设备。 重置路由器会清除所有自定义配置操作前请务必备份当前设置。 默认管理地址是 192.168.1.1登录后可在无线设置中修改信道。 如果多台设备同时连接出现抢带宽现象可以开启 QoS 按设备限速。 chunks chunk_text(router_manual, chunk_size200, overlap40) ids [fchunk_{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( idsids, documentschunks, embeddingsembeddings, metadatasmetadatas )这里有个细节我们传入了embeddingsChromaDB 就不会再调用它内置的 Embedding 函数。如果省略embeddingsChromaDB 会尝试用默认模型去生成向量而那个默认模型对中文知识库的适配非常差我第一次跑就被带偏过。3.4 查询链路与结果解读数据写进去以后查询就很简单了question 家里 Wi-Fi 信号很弱应该怎么办 query_vector embed_one(question) results collection.query( query_embeddings[query_vector], n_results3, 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 很卡”可以改写成“如何解决无线网络速度慢”“信号不稳定的排查方法”这些改写后的句子在向量空间里可能比原始问题更贴近文档表达。再进阶一点是 HyDEHypothetical 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先把标题里这几个组件亲手跑通再去套上层框架你会发现自己对检索结果的控制力完全不一样。这个问题不解决后面调什么都像隔靴搔痒。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑