
做RAG检索增强生成项目绕不开的就是“查得出来”这步。我接过不少类似需求模型接了、Prompt也写了最后效果还是飘问题多半出在检索链路没做扎实文档没切好、向量化不准、向量库存取混乱、相似度算出来也不知道怎么用。这篇内容我打算用一套可以直接跑的代码把向量、余弦相似度、ChromaDB、阿里云Embedding这几个RAG核心组件串起来跑通“文档进得去、查得出、排得对”的最小闭环。适合刚接触RAG的开发者也适合想快速验证检索方案的业务项目参考。1. 先拆散再组装RAG检索链路里的四个角色1.1 大模型的短板和RAG的思路大模型本身对私有文档几乎一无所知。你辛辛苦苦整理的内部FAQ、产品资料、竞品分析如果直接丢给模型它只能凭训练时见过的泛化知识回答答错、编造、答非所问都太常见了。这就是所谓的知识截止和幻觉问题也是RAG存在的根本原因。RAG的思路不是去重新训练模型而是在生成之前先把“答案素材”找出来。用户问一个问题系统先去知识库里检索和这个问题最相关的几段内容再把这几段拼进Prompt一起发给大模型。模型看到的是“资料问题”的组合而不是光秃秃的题目输出自然稳得多。这个思路的关键就落在检索质量上——查不到相关内容后面生成再强也没用。所以一个最小可用的RAG系统核心就是把四个东西串起来Embedding负责把文字变成向量向量库负责存和查相似度算法负责判断哪些内容最相关最后交给大模型生成答案。四个环节各有各的坑下面逐个说清楚。1.2 四个组件到底各干什么先说Embedding。一个Embedding服务可以把一段文本转换成一串固定长度的浮点数比如1024维的向量。这串数字不是随机生成的它把文本的语义信息压缩到了向量空间里语义相近的文本向量距离就近语义无关的向量距离就远。这个“语义距离”是RAG的基石。再说余弦相似度。两个向量之间怎么算“近”最常用的指标是余弦相似度公式是cosine_similarity A·B / (|A| × |B|)也就是两个向量的点积除以各自的模长。结果范围在-1到1之间越接近1表示方向越一致。用在语义检索里就是越接近1越相关。ChromaDB内部默认用的就是余弦距离它返回的distance实际等于1 - cosine_similarity所以返回的distance越小相似度反而越高这个很多人初次接触会搞反。然后说ChromaDB。这是一个轻量级的向量数据库支持本地持久化不需要单独起服务写几行代码就能把向量和原始文本一起存进去。它解决的是“索引和管理”问题你不可能每次查询都把所有向量拉出来挨个算相似度向量库通过内置的ANN索引近似最近邻搜索大幅度缩短检索时间。最后是阿里云Embedding这个具体服务。我这次选它主要原因是中文效果好、接口简单、开通就能用。尤其在中文业务场景里本地跑一个小模型往往效果打折用云服务可以直接拿到质量比较稳定的向量化结果减少一个变量。这四个角色合起来就是一条完整的检索链路文档切分→逐段Embedding→存入ChromaDB→查询时把问题也Embedding→从ChromaDB里用余弦距离检索Top-K→拼接上下文交给LLM。接下来我先说准备阶段的事再一步步把代码写明白。2. 实操前的准备环境、密钥和向量库模式2.1 依赖安装与Python版本这套链路其实没有特别重的依赖我用的是Python 3.10后面运行一直很稳。核心只需要两个包chromadb和dashscope。前者是向量库后者是阿里云Embedding的SDK。命令很简单pip install chromadb dashscope如果是国内网络环境可以加清华镜像源加速pip install chromadb dashscope -i https://pypi.tuna.tsinghua.edu.cn/simple如果你后面还想顺手做文本切分可以再装一个langchain-text-splitters只用它里面的RecursiveCharacterTextSplitter。不过我先说明这个不是必须的后面我会用一段不依赖LangChain的简单切分逻辑先把链路跑起来再说。注意chromadb在安装时会带一些依赖包括numpy、onnxruntime等如果机器比较旧建议装完先执行一下chromadb --version确认安装成功。2.2 开通阿里云Embedding并拿到密钥阿里云的Embedding模型目前挂在百炼平台上DashScope是它的服务模型名你需要先注册并开通百炼服务然后在控制台里创建一个API Key。拿到Key之后我建议直接写成环境变量不要在代码里硬编码避免仓库提交时泄漏export DASHSCOPE_API_KEY你的API_KEYWindows下对应的写法是set DASHSCOPE_API_KEY你的API_KEY。模型名我用的text-embedding-v3默认输出维度是1024维也可按需改成512或2048向量维度越高通常语义表达越细腻但存储和计算开销也会上去。对大多数知识库检索场景1024维已经够用。这里有个细节同一个Collection里所有向量的维度必须一致。也就是说你如果中途改了dimension参数之前存进去的向量就不能继续用了需要重新生成或新开一个Collection。2.3 ChromaDB两种模式怎么选ChromaDB有两种使用方式一个是以PersistentClient方式嵌入到你的应用进程里数据落盘到本地目录另一个是以HttpClient连接一个独立的Chroma服务端。我推荐本地优先。原因很简单省事、快、不容易出网络问题。对一个几十万条数据级别的内部知识库本地PersistentClient完全扛得住。等到数据量大到需要多机共享或跨服务访问时再换成服务端模式也不迟。import chromadb client chromadb.PersistentClient(path./chroma_db) collection client.get_or_create_collection( nameknowledge_base, metadata{hnsw:space: cosine} )path是数据持久化目录首次运行会自动创建。metadata{hnsw:space: cosine}这行很多人会漏掉它显式指定索引的距离算法为余弦距离。如果你不写Chroma默认是l2欧氏距离虽然也能用但和余弦相似度表达的意义有偏差我还是建议一开始就明确指定。另一个容易踩的坑如果你在创建Collection时不传入embedding_functionChroma会尝试用自己内置的默认Embedding模型那个模型首次运行会联网下载网络受限时直接报错。我们既然用阿里云的Embedding就应该在后续add和query时显式传入向量绕开默认模型。这一点后面代码里会体现。3. 从文本到向量的完整入库流程3.1 文本切分先解决“粒度”问题向量化不能把整本几十页的手册直接算成一个向量那样信息都糊在一起了。检索之前必须把长文档切成一段段适合检索的文本块专业说法是chunk。为什么切分两个原因。第一是Embedding模型对输入长度有限制text-embedding-v3单次输入长度虽然能到几千个token但太长时尾部信息很容易被稀释掉。第二是检索粒度问题用户问一个具体问题你希望召回的是“某一段相关描述”而不是一整章泛泛的介绍。我的切分策略分两种情况。如果文本本身有清晰的结构比如Markdown标题、列表、PC的段落标记那就按标题和段落切开每个标题下的一段内容作为一条。如果没有结构就用固定窗口长度切比如每500个字符一块块与块之间重叠50个字符避免一句话被拦腰斩断在中间导致语义残缺。简单实现一个切分函数def split_text_simple(text, chunk_size500, overlap50): step chunk_size - overlap chunks [] for i in range(0, len(text), step): chunks.append(text[i:i chunk_size]) return chunks这段代码适合快速验证。真实项目里建议升级成按结构切分或者用RecursiveCharacterTextSplitter它能根据分隔符优先级递归去切尽量保持段落完整。我实际项目里踩过纯字符切分的坑一个“根据《合同法》第54条”的句子被切成两半检索时后半截没有前文送到模型那里根本看不懂。所以有结构的文本一定要先按结构切不要图省事。3.2 调用Embedding接口生成向量切好文本块之后每块都要调一次Embedding接口。我是用dashscopeSDK调用的代码很直接import os from dashscope import TextEmbedding def get_embeddings(texts): resp TextEmbedding.call( modeltext-embedding-v3, inputtexts, dimension1024, api_keyos.getenv(DASHSCOPE_API_KEY) ) if resp.status_code ! 200: raise RuntimeError(fEmbedding调用失败: {resp.code} {resp.message}) return [item[embedding] for item in resp.output[embeddings]]这里注意几个细节。input参数接收的是一个字符串列表SDK会一次把多条文本一起发出去。text-embedding-v3对单次请求的文本条数有限制我习惯一次传5条以内控制请求体的体积降低超时概率。如果文档量大就分批循环处理。批量处理时我还会加一点延迟避免瞬间请求太多被限流。虽然DashScope的限流阈值对普通项目来说不低但处理几千个chunk时还是稳一点好import time def embed_batch(texts, batch_size5): all_vectors [] for i in range(0, len(texts), batch_size): batch texts[i:i batch_size] vectors get_embeddings(batch) all_vectors.extend(vectors) time.sleep(0.1) print(f已处理 {min(i batch_size, len(texts))}/{len(texts)}) return all_vectors这里有个性能和可靠性的权衡点每次调用有网络开销批量太小会慢批量太大会超时。5条一批是我多次试验下来比较均衡的值你可以按你的网络情况微调。如果某一次调用失败最简单的做法是抛异常后从断点重试因为我们会在落库前把向量和文本对应关系先准备好重试不会造成脏数据。3.3 写入ChromaDB并持久化向量生成完之后就是入库。这里必须是“文本、向量、元数据”三样一起写不然后面查出来只有向量没有原文也没法拼给大模型。我的入库逻辑是这样的def build_knowledge_base(chunks, metadatas, collection): ids [fchunk_{i} for i in range(len(chunks))] embeddings embed_batch(chunks) collection.add( idsids, documentschunks, embeddingsembeddings, metadatasmetadatas )ids必须是非空字符串而且不能重复。metadatas建议至少包含两个字段来源文件名和块序号。这样一来后面如果发现某条检索结果质量有问题能直接定位到是哪个文件的第几段排查效率会高很多。metadatas [ {source: 产品手册.md, chunk_index: i} for i in range(len(chunks)) ]写库的时候有个隐藏问题如果你在一个长期运行的项目里重复执行build_knowledge_base同一个chunk_id会被重复添加Chroma会直接报ID already exists。所以增量更新时优先用collection.upsert而不是addcollection.upsert( idsids, documentschunks, embeddingsembeddings, metadatasmetadatas )upsert的存在就是用来处理这个场景的id不存在就插入存在就覆盖。我后来更新的知识库小工具里默认都用upsert。还有一点Collection一旦创建它内部保存的向量维度就被固定了。你如果第一批文本用1024维向量写入第二批突然换成512维Chroma会报维度不一致。这个错误信息挺明显但如果没排查到维度这一步可能还会怀疑是数据问题。入库前统一好维度能省很多时间。4. 查询相似度计算与Top-K检索4.1 一条query如何变成可检索的向量入库之后就是查询。查询的流程和入库其实是镜像关系用户输入一个问题先调用同一个Embedding模型把问题转成向量然后用这个向量去ChromaDB做相似度检索。def search(query_text, collection, top_k5): query_vector get_embeddings([query_text])[0] results collection.query( query_embeddings[query_vector], n_resultstop_k, include[documents, distances, metadatas] ) return results注意这里查询也必须用同一个模型。如果你入库用text-embedding-v3查询却换成别的模型向量空间的语义对齐就乱了检索结果会完全不可信。这点看似基础但我真见过因为换Embedding模型导致检索质量暴跌的案例。collection.query返回的results是个字典其中documents是命中的文本列表distances是距离分数列表metadatas是对应的元数据列表。distances就是我们之前说的余弦距离值越小越相似。4.2 余弦相似度的结果怎么看ChromaDB在显式指定hnsw:space: cosine后返回的distance就等于1 - cosine_similarity。所以两个向量完全同方向相似度为1distance为0。两个向量完全无关相似度接近0distance接近1。两个向量方向相反相似度为-1distance为2。实际跑出来的分数不会那么极端一般相同的领域文本相似度在0.75以上无关内容可能只有0.3左右。我常用的换算方式similarities [1 - d for d in results[distances][0]]然后就可以直接看相似度去设置阈值。比如我习惯把阈值设在0.65左右低于这个分数就不拼进Prompt宁可告诉用户“知识库里没有相关内容”也不要硬凑一段不相关的内容让模型瞎编。如果你不放心Chroma返回的distance可以自己拿向量手动算一遍余弦相似度做验证。拿numpy来实现import numpy as np def cosine_similarity(vec_a, vec_b): a np.array(vec_a, dtypefloat) b np.array(vec_b, dtypefloat) return float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b)))我调试阶段经常用这个方法比对结果能确认Chroma的索引和距离计算没有偏差。这个步骤在项目初期很有价值因为如果连“相似度计算”这个底层环节都没验证过后面叠加LangChain、Prompt模板、生成逻辑之后出问题你都不知道从哪里查起。4.3 把检索结果拼给大模型检索只是前半场后半场是把结果拼成上下文交给大模型。我自己习惯用一个标准模板把多条文档拼接起来def build_prompt(query, docs): context \n\n.join( f[{meta.get(source, 未知来源)}] {doc} for doc, meta in zip(docs[documents][0], docs[metadatas][0]) ) prompt f请根据以下资料回答问题。 资料 {context} 问题{query} 要求 1. 优先使用资料中的事实回答。 2. 如果资料中没有答案直接回答“知识库中没有找到相关内容”。 3. 不要编造资料中不存在的信息。 return promptPrompt模板里我刻意写了“如果资料中没有答案直接回答不知道”。如果不加这个约束即使检索结果不相关模型也会强行根据模糊印象作答幻觉立刻回来。加了这个约束虽然不能百分百杜绝但至少给了模型一个明确的兜底出口。拼好Prompt之后就可以接任意大模型了。我用得最多的是阿里云百炼上的qwen-plus当然你也可以用任何OpenAI兼容接口from openai import OpenAI client OpenAI( api_keyos.getenv(DASHSCOPE_API_KEY), base_urlhttps://dashscope.aliyuncs.com/compatible-mode/v1 ) resp client.chat.completions.create( modelqwen-plus, messages[{role: user, content: build_prompt(query, results)}] ) print(resp.choices[0].message.content)到这里一条完整的RAG链路就跑通了文档切分、向量化、入库、检索、拼Prompt、生成答案。很多初学者把注意力全放在生成那一步但我的经验是前面检索环节的调试价值远高于后面。只要检索质量过关Prompt稍微写写效果都不会差。5. 高频踩坑与排查实录5.1 常见问题速查表以下是我在实际使用中遇到过的典型问题按现象整理成了速查表。现象可能原因解决办法ID already exists异常重复add相同id的chunk改用collection.upsert查询结果明显不相关入库和查询用的Embedding模型不一致统一为同一个模型和dimension首次导入时报下载失败没传embeddingsChroma想下载默认Embedding模型调用add/query时显式传入向量dimension mismatch错误新旧数据向量维度不一致统一dimension或新建Collection重新入库中文长文本被截断固定字符切分把句子切开了按结构切分或使用RecursiveCharacterTextSplitterAPI调用频繁超时单次请求文本条数太多一次传5条以内增加sleep间隔检索分数普遍偏低chunk_size太小单条信息量不足适当放大chunk_size加入重叠窗口答案生成仍胡说没有在Prompt里要求“资料中没有就不要答”增加兜底约束这个表格看着简单但每一条背后都是实际跑偏过才总结出来的。拿“Chroma想下载内置模型”那条来说我第一次跑的时候完全没注意结果没有网络的环境里直接卡死后来才发现需要显式传向量。这些坑文档里都有写但不踩过就是记不住。5.2 检索质量调优的几个方向如果你的RAG系统检索质量不够好先别急着换大模型按下面顺序排查。第一个是chunk大小。很多项目把文档切得很碎每块只有一两句话结果单个向量语义过于单薄检索时分数普遍上不去。我一般做中文场景初始值设在400到800字之间然后看具体业务再微调。如果问题偏向“答案都能找到但不够精确”就调小chunk并增加重叠如果偏向“经常找不到”就调大chunk让语义更完整。第二个是top_k选择。top_k3和top_k5的差异不只是数量还会影响大模型的注意力分配。我多数项目用5如果答案总是不准降到3试试让模型聚焦在最相关的段落上。别把top_k设到10以上Retrieval结果太多会把真正相关的内容淹没掉。第三个是阈值过滤。很多项目把Top-K的结果无条件拼进Prompt完全不看相似度分数。我的建议是设置阈值比如similarity 0.65才使用低于阈值直接不检索。这个做法看似简单却能显著减少无关上下文对模型的干扰。第四个是metadata过滤。如果你的知识库包含多类文档比如服务条款、操作指南、FAQ可以在query时用where参数限定来源collection.query( query_embeddings[query_vector], n_results5, where{source: 操作指南.md} )这样做的好处是精确命中不用靠相似度去区分不同主题的文档检索准确率能明显提升。5.3 最后的调试心得调试这套RAG链路我的顺序永远是先验证Embedding、再验证检索、最后再优化生成。具体做法是先准备几条测试文本把它们的向量打出来确认不是全零向量再手动计算余弦相似度和Chroma返回的distance比对确保底层逻辑一致。然后构造一个“正确答案明显在知识库里”的问题看能不能查出来。如果这一步过了系统的地基就算稳了再往后接Prompt和生成才有意义。我还会把每次查询的query、命中的chunk、相似度分数、最终答案完整打日志。一旦线上回答有问题回溯日志就能立刻定位是检索没查到、排序不对、还是模型生成飘了。这个习惯帮我在一个文档检索项目里快速定位到问题根源那次就是用户输入的提问方式太口语化documents里优势词不多Embedding结果偏离了预期。日志里看到相似度分数全在0.3以下一下就判断出是检索问题而不是生成问题。另外一个小技巧知识库更新时不要把所有chunk重新向量化一遍那样成本太高。只需要对新增和修改的文档做切分、Embedding然后调用upsert覆盖对应的id。ChromaDB会保留旧的索引结构你只需要保证id策略唯一且稳定。我自己做完这套实操下来最大的体会是RAG一点都不玄乎本质上就是一个“先查对资料再让模型回答”的工程问题。只要把切分粒度、向量质量、相似度阈值这几个关键参数调试到位即使不接复杂的RAG框架效果也足够稳。下一步如果你想继续扩展可以往混合检索、结果重排、多路召回方向走但那都是在这个地基上做加法了。