ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

最小RAG实战:用Embedding + Chroma + DeepSeek从零搭建知识库问答

最小RAG实战:用Embedding + Chroma + DeepSeek从零搭建知识库问答 1. 项目概述这个“最小 RAG”到底在做什么先交代背景。我这段时间在系统性地补大模型应用开发的基本功Day 8 这天的目标很明确不依赖任何重型框架纯手工拼一个能用的 RAG 流水线。用到的三样东西分别是 Embedding 模型、Chroma 向量库、DeepSeek 大模型接口。三者的分工非常清晰Embedding 负责把文本变成计算机能懂的数字向量Chroma 负责把这些向量存起来、按相似度捞出来DeepSeek 负责根据捞出来的内容生成最终回答。很多初学者一听到 RAG 就头大因为网上的教程往往一上来就堆 LangChain、LlamaIndex、向量数据库集群、Rerank 模型看完直接劝退。但这个项目想传达一个朴素的观点RAG 的本质就是“先查资料再写回答”把这句话落到代码上三样东西足够了。你不需要几百 MB 的大模型不需要 GPU也不需要复杂的中间件一台普通电脑加上一个能上网的 Python 环境就能跑通整个流程。这套方案适合谁适合已经把 Python 基础语法过完、想接触大模型应用但不知道从哪下手的人也适合那些被 LangChain 封装搞晕、想直接看底层逻辑的开发者。读完这篇文章你能得到一个完整可运行的脚本更重要的是你能理解 RAG 每一个环节“为什么这么做”而不是只会复制粘贴。另外提一句这套流程虽然是“最小实现”但我尽量把生产环境里常见的问题也一并讲清楚比如重复入库怎么避免、上下文怎么截断、检索命中率低怎么排查。咱们从零开始但不做玩具。2. 整体设计拆解为什么是 Embedding Chroma DeepSeek 这个组合2.1 RAG 的核心链路一次提问走完的四步在动手写代码之前得先把 RAG 的流程在脑子里过一遍。一次完整的 RAG 问答其实就是这么四步把知识文档切成小段chunk每一段文本用 Embedding 模型转成向量。把向量连同原始文本一起存入向量数据库比如 Chroma。用户提问时把问题转成同样的向量去向量库里做相似度搜索找出最相关的几个文本片段。把这几个片段和用户问题组装成 Prompt发给 DeepSeek让它“基于给定的资料”生成回答。这个流程听起来平淡无奇但每一步都有坑。比如文本怎么切切大了检索不精准切小了上下文不完整向量模型怎么选选错了中文效果拉胯Prompt 怎么组织组织不好模型就胡说八道。后面我会一步步展开讲。之所以强调“最小实现”是为了把每一步都摊开让你看清楚。等你理解了这个链路再去用 LangChain 之类的框架你会发现那些 API 背后其实就是这几件事的封装。2.2 为什么选 Chroma 而不是 FAISS 或 Milvus网上聊向量数据库动不动就是 Pinecone、Milvus、Weaviate看着很高端但对个人项目和入门学习来说真没必要。我做技术选型就一个原则最小可用、能拆则拆。Chroma 在这三者里胜出的原因是纯 Python 库pip 安装即用不需要单独部署服务自带持久化能力数据能存到本地磁盘重启不丢API 设计简单得不像向量数据库add、query、delete三个方法走天下还支持 metadata 过滤这个功能在做文档管理时非常实用。FAISS 也不是不能用它查得快但默认不负责持久化需要自己操心索引的保存和加载对新手不太友好。Milvus 功能强大但部署成本高直接引入 Docker 和分布式概念明显超出了“最小实现”的范畴。Chroma 就像工具箱里的瑞士军刀不是最锋利的但刚好够用且不添乱。2.3 DeepSeek 在这次实战里的角色定位DeepSeek 在这个项目里承担的只是“生成”这一环而不是整个 RAG 本身。RAG 的价值核心在“检索”大模型只是一个把检索结果用自然语言组织起来的出口。选 DeepSeek 有两个实际原因一是它提供的 API 兼容 OpenAI 的调用格式代码写起来非常顺滑二是它的定价对个人开发者非常友好实测下来一个完整问答流程的 token 消耗极低几乎可以忽略不计。还有一点值得说DeepSeek 的上下文窗口足够大这给 RAG 留出了操作空间。你不用担心把几千字的检索片段塞进 Prompt 就把窗口塞爆对于最小场景来说完全够用。当然DeepSeek 只是一个选项这套代码里的 LLM 调用部分你完全可以换成 OpenAI、Ollama 本地模型、Kimi 等接口概念都是共通的。3. Embedding 环节详解文本向量化的原理与模型选型3.1 什么是文本向量化给文字找个“数学位置”Embedding 这个名字听起来很高深其实可以类比成给每个句子找一个“数学坐标”。想象你有一张世界地图每句话都是一个城市语义相近的句子在地图上的位置也相近。“今天天气真好”和“阳光明媚的日子”这两个句子在语义空间里离得很近而“今天天气真好”和“一元二次方程的解法”就隔了十万八千里。向量化做的就是这件事把文本映射成一串几百上千维的浮点数数组然后通过计算向量之间的距离通常是余弦相似度来判断两段文字在语义上像不像。RAG 检索的本质就是把你手里的“问题向量”去和库里成千上万个“文本块向量”做距离比较取最近的几个返回。有意思的是向量模型并不理解文字它只是从海量语料里学到了一套映射规律。这也是为什么不同 Embedding 模型的效果差异很大——训练语料、模型结构、目标语言都会影响最终向量的质量。3.2 Embedding 模型怎么选从模型排行榜说起现在很多人问“embedding模型排行”其实看排行榜不如看场景匹配度。当前中文场景下口碑比较好的几个开源模型包括BAAI 的 bge-m3、bge-large-zh-v1.5、智源的 text2vec 系列以及 OpenAI 的 text-embedding-3-small 这类闭源模型。我自己实际测试下来给普通开发者一个相对省心的建议默认选 bge-m3。原因有三第一中英文效果好200 多种语言覆盖不用担心代码库里的英文文档处理不好第二最长支持 8192 token 的输入切分文本时不用太焦虑 chunk 长度第三768 维向量存储占用和检索速度的平衡做得不错。如果是纯中文场景bge-large-zh-v1.5 也很能打但对显存和推理时间的要求更高一些。OpenAI 的 text-embedding-3-small 胜在稳定和 API 托管但国内网络环境访问毕竟麻烦而且数据要出网很多场景不合适。3.3 向量维度与存储成本的关系一个容易忽略但重要的参数是向量维度。常用的文本向量维度一般在 384、512、768、1024、3072 这几个档位。维度越高理论上表达语义的能力越强但存储空间和计算耗时也随之上涨。比如一条 200 字的文本块如果用 768 维向量存储仅向量部分就要占 768 个浮点数1 万条文本块就是 768 万个浮点数在 Chroma 里一般用 float32 存储也就是大约 30 MB 的数据量。听起来不大但当文档数量上到几十万条时维度差异带来的存储和检索成本就很可观了。所以我建议初期不用追求“最强模型”先用 bge-m3 或者 bge-small-zh 这些轻量级模型把流程跑通后续再根据实际效果决定要不要换更强的大模型。RAG 项目的复杂度是逐步增加的别在第一步就给自己上强度。4. Chroma 向量库实操初始化、持久化、检索与清理4.1 安装与初始化一条命令的事Chroma 的安装没有任何黑魔法一条 pip 命令就完了pip install chromadb需要说明的是Chroma 默认的临时客户端chromadb.Client()数据是挂在内存里的退出程序就没了实践中基本都会用PersistentClient指定持久化目录。初始化时有两件事必须提前想好存储路径和 collection 名称。import chromadb # 持久化客户端数据会写到 ./my_knowledge_base 目录 client chromadb.PersistentClient(path./my_knowledge_base) # 创建或获取一个 collection类似传统数据库里的“表” collection client.get_or_create_collection( namedemo_kb, metadata{hnsw:space: cosine} )hnsw:space这个参数容易被人忽略它决定相似度计算方式。RAG 场景我推荐用cosine余弦相似度它只关注向量方向的一致性不受向量长度影响语义匹配稳定。默认的l2是欧氏距离在很多场景下效果差不太多但既然能显式指定就选更适合语义检索的那一个。我习惯把PersistentClient的路径单独提出来配置方便换库或者备份不建议随手写死一个临时目录。4.2 数据写入ids、documents、embeddings 必须同步Chroma 的add接口看起来很简单但有一个必须记住的对应关系每一条记录必须有id、文档内容、向量或 embedding 函数的引用三者一一对应。举个例子collection.add( ids[doc1_chunk1, doc1_chunk2], documents[第一段文本……, 第二段文本……], embeddings[[0.1, 0.2, ...], [0.3, 0.4, ...]], # 必须是768维 metadatas[{source: guide.md}, {source: guide.md}] )踩过一个典型的坑如果不传embeddingsChroma 会试图调用内置的默认 embedding 函数比如 all-MiniLM-L6-v2自动生成向量。这个模型对中文的支持很一般而且每次都要联网下载生成出来的向量质量也差。所以正确做法是自己在外部生成好 embeddings再传进去底层的 Embedding 模型完全由自己掌控。另一个值得注意的点是id的设计。生产环境里 id 建议带语义比如source_filename_chunk_index这样后续想删掉某篇文档的数据时只需按 id 前缀匹配就能清掉不用把所有数据翻出来逐条找。我在项目里就养成了“文档 块序号”作为 id 的习惯。4.3 检索查询n_results 与 where 过滤查询是 RAG 里最高频的操作Chroma 的query方法长这样results collection.query( query_embeddings[question_vector], n_results3, where{source: guide.md}, # 可选只搜某个来源 include[documents, distances, metadatas] )这里的n_results是检索几个最相似的文本块。我建议从 3 到 5 开始太少容易漏信息太多会让最终的 Prompt 臃肿、模型分不清重点。include参数控制返回什么字段通常至少要documents和distancesdistances可以用来判断检索质量——如果最小距离都大于 1余弦距离范围是 0~2大概率是没找到相关内容这时候别硬答让模型直接说“资料里没有”。还需要提一下 metadata 过滤的使用场景。假设你的知识库里有多个文档用户只想查某一篇的范围或者你想测试特定文档的命中情况where就能帮你快速限定范围。这个能力在调试阶段非常好用我经常用它来验证“这篇文档到底进没进库”。4.4 清理与防污染重复写入是最大的坑初学阶段最快遇到的恶心问题就是同一个文档跑了两遍脚本向量库里出现了两份内容导致检索结果被重复项淹没。Chroma 的add方法遇到相同id默认会跳过还是覆盖实测下来相同 id 再次 add 一般不会像你想象中“更新”而是会报错或者产生意外行为最好的实践是显式管理。我自己的做法是每次写入前先按来源清空旧数据。collection.delete(where{source: guide.md})然后重新写入整个文档的块。这种“先删后写”的策略在文档更新时尤其好用能避免旧版本文档残留污染检索结果。如果你想要增量更新也一定要保证 id 的确定性——同一个语义块在每次切分时生成的 id 应该一样否则重复执行必然造成数据膨胀。5. DeepSeek 接口接入从 API Key 到多轮对话模板5.1 获取 API Key 与环境变量配置DeepSeek 的 API Key 需要在 DeepSeek 开放平台注册后创建然后在代码里通过环境变量读取。之所以强调环境变量而不是把 Key 硬编码在代码里是为了防止源码泄露到 GitHub 上翻车。基本配置代码如下import os api_key os.environ.get(DEEPSEEK_API_KEY) if not api_key: raise ValueError(请先设置环境变量 DEEPSEEK_API_KEY)在本地跑的时候可以直接在终端里导出环境变量再运行脚本在服务器上则可以用.env文件配合python-dotenv读取道理一样都是把密钥和代码隔离。5.2 OpenAI 兼容格式官方 SDK 还是 requestsDeepSeek 的 API 和 OpenAI 的聊天补全接口格式兼容这意味着你可以使用 openai Python SDK只要把base_url替换成 DeepSeek 的地址。官方也提供了 deepseek-sdk但我更建议直接用 openai SDK因为它的生态更完善示例多踩坑少。from openai import OpenAI client OpenAI( api_keyapi_key, base_urlhttps://api.deepseek.com ) resp client.chat.completions.create( modeldeepseek-chat, messages[ {role: system, content: system_prompt}, {role: user, content: user_prompt} ], temperature0.1 )这里有两个经验点model参数用deepseek-chat是最标准的对话模型文本生成任务基本都够用temperature我习惯设成 0.1~0.3RAG 场景下要的是“忠实于资料”的回答不是“放飞自我”的创作温度越高模型越容易自己发挥出现幻觉的概率也更大。5.3 RAG 的 Prompt 模板设计Prompt 模板是整个 RAG 的“灵魂”。同样一个检索结果模板写得好回答就准确模板写得烂模型就开始脑补。我实际使用的模板可以归纳成三个层次身份设定、检索内容、输出约束。system_prompt 你是一个严谨的知识库问答助手请严格基于提供的资料内容进行回答。 user_prompt f 请根据以下检索到的资料回答问题。 [资料开始] {context_str} [资料结束] 问题{question} 约束 1. 如果资料中没有相关信息请直接回答“资料中未找到相关信息”不要编造。 2. 不要复述与问题无关的资料内容。 3. 回答需要简洁但关键信息要完整。 为什么把资料放在问题前面因为模型对越靠后的内容注意力往往越集中把资料先给模型“读”再把问题抛给它回答更容易扣题。另外检索片段之间建议用空行分隔并加上编号这样模型在引用时可以像写论文一样说“根据资料2”逻辑更清晰。5.4 上下文长度控制截断与压缩策略DeepSeek 的上下文窗口虽然够大但也不是无限大。如果你的文本块有几十个、每个块 500 字一次性全塞进去不仅浪费 token还会稀释模型对重点信息的注意力。我的建议是检索结果数量限制在 3~5 条每个文本块在拼入 Prompt 前做一个简单的长度检查比如超过 800 字就截断到 800 字如果文本块的语义跨度很大可以在拼接时给每个块加一个小标题前缀例如【来源1】【来源2】。这个“先粗筛、再压缩”的思路等系统复杂度提升之后还能进一步升级为 re-ranking 和摘要压缩没必要在 Day 8 就背上这些体重。6. 完整代码实现一次性跑通最小 RAG 全流程6.1 文本文档的加载与切分先说切分。文档切分看起来简单但它是检索精度的基石。切得太粗一块里面有多个主题检索时容易牛头不对马嘴切得太细语义破碎模型看到的内容不完整。我刚开始就是简单的按长度切100 个字一刀切结果检索出来的片段经常是一个句子的一半根本没法用。后来我总结出一个相对可用的最小策略先按段落\n\n拆出自然语义块每个语义块如果超过 300 字再按句子进一步拆分每个 chunk 尽量保持语义完整长度控制在 200~500 字之间。实现代码不需要写得很复杂可以用langchain-text-splitters里的RecursiveCharacterTextSplitter也可以用正则自己做。为了控制依赖我在这个项目里直接写了手切逻辑def split_text(text: str, chunk_size: int 300, overlap: int 30) - list[str]: paragraphs [p.strip() for p in text.split(\n\n) if p.strip()] chunks [] buffer for para in paragraphs: if len(buffer) len(para) chunk_size and buffer: chunks.append(buffer) buffer para else: buffer buffer \n para if buffer else para # 给每个 chunk 加一点 overlap把上个 chunk 的尾巴拼到下个头 result [] for i, c in enumerate(chunks): if i 0: prev_tail chunks[i-1][-overlap:] c prev_tail c result.append(c) return resultoverlap重叠是个容易忽略但很关键的细节。一句话被切到上一块的末尾和下一块的开头时如果不做重叠检索时就可能因为“差几个字”而搜不到。加上 30~50 字的重叠能显著提升召回率。6.2 实现 Embedding 调用与 Chroma 入库在代码实现中我把 Embedding 模型封装成一个函数用 SentenceTransformer 加载 bge-m3 模型pip install sentence-transformersfrom sentence_transformers import SentenceTransformer model SentenceTransformer(BAAI/bge-m3) def embed_texts(texts: list[str]) - list[list[float]]: embeddings model.encode(texts, normalize_embeddingsTrue) return embeddings.tolist()这里一个小小的细节是normalize_embeddingsTrue将向量归一化成单位向量配合 Chroma 里的 cosine 距离效果最好。另外第一次运行时会去 HuggingFace 下载模型文件网络状况不好的话可能会失败可以考虑从 ModelScope 下载后本地加载或者直接用镜像源。然后我们把切分好的 chunks 一次性写入 Chromadoc_name deepseek_guide.md chunks split_text(raw_text) # 采用先删后写避免重复数据堆积 collection.delete(where{source: doc_name}) embeddings embed_texts(chunks) collection.add( ids[f{doc_name}_chunk_{i} for i in range(len(chunks))], documentschunks, embeddingsembeddings, metadatas[{source: doc_name, chunk_index: i} for i in range(len(chunks))] )一个容易出错的点如果chunks长度是 0比如空文档embeddings也是空列表此时调add会报错。所以代码里需要加一个防御判断至少保证文档有实际内容才入库。6.3 检索链路问题向量化 相似度查询当用户输入问题时我们做的是def search_knowledge(question: str, n_results: int 3): q_vec embed_texts([question]) results collection.query( query_embeddingsq_vec, n_resultsn_results, include[documents, distances, metadatas] ) docs results[documents][0] distances results[distances][0] return docs, distances实际操作时我会加一个小逻辑检查距离值cosine distance。当距离接近 1.5 甚至更高时说明检索到的信息相关性极低我会直接告诉用户“知识库中没有相关内容”而不是把这些低质量片段硬塞给大模型。6.4 组装 Prompt 并调用 DeepSeek 生成回答最后把检索结果拼进 Prompt调用 DeepSeekdef rag_answer(question: str): docs, distances search_knowledge(question) if not docs: return 知识库中没有检索到相关内容。 context_str \n\n.join( [f[资料{i1}] {doc} for i, doc in enumerate(docs)] ) user_prompt f 请根据以下检索到的资料回答问题。 [资料开始] {context_str} [资料结束] 问题{question} 约束 1. 如果资料中没有相关信息请直接回答“资料中未找到相关信息”不要编造。 2. 不要复述与问题无关的资料内容。 3. 回答需要简洁但关键信息要完整。 resp client.chat.completions.create( modeldeepseek-chat, messages[ {role: system, content: 你是一个严谨的知识库问答助手。}, {role: user, content: user_prompt} ], temperature0.1 ) return resp.choices[0].message.content到这里一个能跑通的最小 RAG 就算完成了。你给它一个问题它会先检索知识库再把检索到的相关资料交给 DeepSeek 做总结回答。整个流程不到 200 行代码五脏俱全。7. 预判你的下一个问题RAG 常见瓶颈与周边工具盘点7.1 RAG 的瓶颈到底在哪检索命中率小于 1.0 是常态很多人把 RAG 跑通之后第一反应是“回答挺准啊”然后过了几天换一个文档测试发现经常答非所问于是跑来问“是不是 DeepSeek 不行”。其实多数情况不是大模型的锅而是检索环节崩了。RAG 的上限由检索决定大模型只是把检索到的资料“念出来”而已。资料没检索对再强的模型也只能编。这时就涉及到一个热词rag hit rate也就是检索命中率。简单理解就是对于一批预设的“问题→答案来源”测试集检索系统能不能把包含答案的那个文本块搜出来。我自己的经验是最小实现的 RAG 在老板说“随便来一个文档试试”时命中率 60%~70% 都很正常千万别期待一次搜索就百分之百命中。真要提升可以从换更强的 embedding 模型、改切分策略、添加 Rerank 环节这三个方向入手这些就留给 Day 9 之后了。7.2 图片能不能进知识库先说结论再讲办法“rag知识库能存储图片嘛”这个问题在社区里很常见。直接回答能但要区分两种方式。一种是把图片作为原样放进 Chroma 里比如存文件的二进制内容或下载 URL然后在认知层让多模态大模型看图这是偏工程的做法复杂度高不少。另一种是把图片先过 OCR比如 PaddleOCR把文字抽出来按文本方式入库这是目前最常用、成本最低的做法。对最小 RAG 项目而言我不建议一开始就让图片直连多模态模型。把文字抽出来和文本知识放一起走同一个流程带来的复杂度最低效果也还过得去。等到后面真的需要识别图表、流程图再考虑多模态方案也不迟。7.3 本地 RAG 可行吗从 Ollama 到文本拆解工具有朋友在热搜里问“ollama 简易本地 rag 知识库”以及“有没有本地的rag文本拆解工具”这说明很多人对数据出网有担忧希望全流程本地化。这个方向完全可行。Embedding 模型可以用跑在本地 CPU 上的 bge-small-zh向量库用 Chroma生成模型用 Ollama 跑 Qwen 或 deepseek-r1 的 7B 量化版。整条链路没有一条数据出本地机器适合内网环境。文本拆解工具方面轻量方案可以用pdfminer.six、pypandoc等读取文档再用刚才提到的RecursiveCharacterTextSplitter切块。重量级方案可以看看 RAGFlow它内置了完整的文档解析和版式分析能力对 PDF 和表格的支持更好但对“最小实现”来说有点超纲了。7.4 RAG 和 Wiki、Ontology 的关系别神话 RAG最后顺便聊聊几个常被混淆的概念。Wiki 是一个知识组织形态RAG 是一种检索增强方法两者并不互斥。你可以把一套 Wiki 文档作为 RAG 的语料来源也可以单独用 Wiki 做人工检索。Ontology本体则是更结构化的知识表达方式通常是定义实体、属性和关系图。RAG 最大的价值在于“把非结构化的文本变成可问答的服务”但它并不会真正“理解”知识之间的逻辑关系。如果你要做的场景强烈依赖严格的关系推理比如风控规则、多层依赖查询比起硬上 RAG不如先考虑知识图谱或者规则引擎。认清工具边界比会用工具更重要。6. 最后补一句我的使用体会这套最小 RAG 我前前后后折腾了两天才调顺。最大的感悟是别迷信框架也别迷信“加一个模块就能解决所有问题”。跑通以后折腾空间反而比想象中大得多——同一个文档换一种切分方式检索质量就像换了个系统换一个 embedding 模型相似的文本找出来的结果也千差万别。现在这套代码已经变成了我日常试新想法的基础工具箱后续如果再往里加东西我打算先加一个简单的 rerank 逻辑再试试把 DeepSeek 换成流式输出体验会再上一个台阶。
返回列表