
1. 为什么我要从零手搓一个最小 RAG先说结论如果你已经能跑通一个上传文档→提问→得到带出处回答的闭环你对 RAG 的理解就已经超过一大半只会调 API 的人了。我这次做的这个最小 RAG核心就三样东西——Embedding 模型负责把文字变成向量Chroma 负责存和查这些向量DeepSeek 负责拿着检索结果生成最终回答。整套流程不到 200 行 Python跑在本地不依赖任何重型框架。为什么不用 LangChain 那种现成框架我试过封装太厚出问题的时候你根本不知道是检索挂了还是 prompt 拼错了。对于刚入门 RAG 的人来说先用最少的依赖把链路跑通比一上来就套框架重要得多。等你理解了每一步在干什么再去用框架就是降维打击。这个项目适合三类人想搞懂 RAG 底层原理的初学者、想给自己知识库做个问答入口的开发者、以及被各种RAG 教程绕晕想找个干净实现的人。RAG 这个词现在被炒得很热但它的本质特别朴素大模型不知道你的私有数据那就在回答问题前先把相关资料塞进它的上下文里。就这么简单。所谓检索增强生成检索是手段增强的是模型的上下文生成才是目的。理解了这句话后面所有的工程细节都是围绕怎么检索得准和怎么塞得巧展开的。我踩过的第一个坑就是一开始以为 RAG 是个很玄的东西结果拆开一看无非就是向量相似度搜索 拼 prompt。真正难的不是跑通而是跑准——检索回来的东西不相关模型再强也是胡说八道。所以这篇我会把重点放在为什么这么设计和哪里容易翻车上而不是贴一堆代码就完事。2. 整体架构设计与技术选型思路2.1 最小 RAG 的四段式链路一个能用的 RAG无论多复杂拆开都是四段文档加载与切分 → 向量化 → 存储与检索 → 生成回答。我这个最小版本对应下来就是文档加载读本地 txt/md 文件按固定长度切块向量化用 Embedding 模型把每个文本块转成向量存储检索Chroma 存向量查询时做相似度搜索生成把检索到的文本块拼进 prompt交给 DeepSeek 生成这四段里最容易被人忽视但最影响效果的是切分。很多人上来就调模型结果检索出来的块要么太长塞满上下文要么太短丢了语义。我后面会专门讲切分的门道。2.2 为什么选 Chroma 而不是 FAISS 或 Milvus选型这块我纠结过。FAISS 是 Facebook 出的向量检索库性能强但它只管索引元数据存储、持久化、增删改都得你自己搞。Milvus 功能全但部署一套下来对个人项目太重了。Chroma 的定位刚好卡在中间轻量、自带持久化、API 简单、支持元数据过滤pip 装完就能用数据默认存本地。对于最小 RAG 来说Chroma 最大的好处是零运维。你不需要起服务、配集群chromadb.PersistentClient(path./db)一行就把数据落盘了。等你的数据量涨到百万级、需要分布式的时候再换 Milvus 也不迟。过早优化是 RAG 项目里最常见的浪费。方案部署成本持久化元数据过滤适用规模FAISS低需自己实现不支持中小规模Chroma极低内置支持小到中等Milvus高内置支持大规模2.3 Embedding 模型怎么选Embedding 模型决定了检索质量的上限。热词里embedding 模型排行被搜了很多次说明大家都在纠结这个。我的建议是分场景纯英文、追求效果可以看 MTEB 排行榜上靠前的开源模型中英文混合、要本地跑选一个中文友好的多语言模型维度别太高图省事、能联网直接用 API 提供的 embedding 接口我这次用的是本地能跑的中文友好模型维度 768。为什么不追最高维度因为维度越高存储和检索成本越大而检索质量的提升往往不成正比。768 维对个人知识库完全够用。这里有个经验embedding 模型一旦选定整个库的向量就必须用同一个模型生成中途换模型等于所有数据要重新向量化这个坑我踩过血的教训。2.4 DeepSeek 在链路里的角色DeepSeek 在这里只干一件事拿到检索结果后生成自然语言回答。它不参与检索也不参与向量化。很多人误以为 RAG 的效果主要靠大模型其实检索质量占七成生成质量占三成。检索回来的东西是对的哪怕用个中等模型也能答得不错检索回来的是垃圾GPT 来了也救不了。调用 DeepSeek 的 API 时关键是把 prompt 设计好。我的模板是固定的三段式系统指令 检索到的上下文 用户问题。系统指令里必须明确要求只根据提供的上下文回答上下文没有的信息就说不知道这一句能挡掉大量幻觉。3. 核心细节拆解与实操要点3.1 文档切分RAG 效果的第一道分水岭切分看着简单其实门道最多。我一开始按固定 500 字符切结果经常把一个完整段落从中间劈开检索出来的块语义残缺。后来改成按语义边界切再控制块大小效果明显好转。具体做法是优先按段落、标题、句子这些自然边界切如果单个段落超过阈值比如 800 字符再按句子细分。块之间保留一定的重叠overlap我一般设 50 到 100 字符。重叠的作用是防止关键信息刚好卡在切分点上被割裂。提示chunk_size 和 chunk_overlap 没有万能值。文档结构规整比如 FAQ可以切小一点叙述性长文要切大一点。建议先用 500/50 试看检索效果再调。这里还有个常被问的问题RAG 知识库能存储图片吗答案是纯文本 RAG 存不了图片的语义。图片要么先用多模态模型转成文字描述再入库要么用支持多模态的向量模型。最小版本里我建议先只处理文本把链路跑通再说。3.2 向量化的批量处理与成本控制向量化是整条链路里最耗时的一步。如果你有几千个文本块一条条调 embedding 接口会慢到怀疑人生。一定要批量处理一次传几十条能快好几倍。本地模型的话批量还能吃满 GPU 或 CPU 的并行能力。我实测下来批量大小设 32 到 64 比较稳太大反而可能爆内存。另外向量化结果要缓存同一个文本块不要重复算。Chroma 在 add 的时候如果检测到相同 ID 会覆盖所以给每个块生成稳定的 ID比如用内容哈希很重要这样重复导入不会产生脏数据。3.3 Chroma 的集合设计与元数据Chroma 里数据组织成 collection类似关系库的表。建 collection 的时候有个细节距离度量方式要选对。默认是 L2 距离但很多 embedding 模型训练时用的是余弦相似度这时候应该显式设成 cosine否则检索结果会偏。元数据这块别浪费。每个块除了存文本和向量还可以存来源文件名、页码、章节标题。这样检索回来的时候你能告诉用户这段话来自哪个文件的哪一部分带出处的回答可信度直接翻倍。而且元数据还能用来做过滤比如只在某个文件范围内检索。collection client.create_collection( namemy_kb, metadata{hnsw:space: cosine} # 关键指定余弦距离 )3.4 Prompt 拼接的讲究检索回来 top-k 个块怎么拼进 prompt 也有讲究。我的做法是给每个块加上编号和来源让模型知道信息从哪来。格式大概是这样[片段1] 来源xxx.md 文本内容 [片段2] 来源yyy.md 文本内容 请根据以上片段回答{用户问题}top-k 不是越大越好。k 太大上下文里塞满不相关的块反而干扰模型判断还浪费 token。我一般设 3 到 5。如果检索质量高k3 就够如果检索召回不稳可以适当加大但要配合重排序。4. 完整实操流程与关键环节实现4.1 环境准备与依赖安装先把环境搭起来。Python 建议 3.9 以上我用的 3.10。依赖就几个核心的pip install chromadb sentence-transformers openai这里解释下为什么用openai这个库调 DeepSeekDeepSeek 的 API 兼容 OpenAI 的接口格式所以直接用 openai 的 SDK把 base_url 换成 DeepSeek 的地址就行省得再学一套 SDK。这是很多人的知识盲区以为要装专门的库。如果你本地没有 GPUsentence-transformers 跑起来会慢一点但小规模知识库完全能接受。想更快可以换成 API 版的 embedding代价是要联网。4.2 文档加载与切分实现先写加载和切分。我处理的是本地 markdown 和 txt 文件逻辑很直接import os import hashlib def load_docs(folder): docs [] for fname in os.listdir(folder): if fname.endswith((.txt, .md)): path os.path.join(folder, fname) with open(path, r, encodingutf-8) as f: docs.append({source: fname, text: f.read()}) return docs def split_text(text, chunk_size500, overlap50): chunks [] start 0 while start len(text): end start chunk_size chunks.append(text[start:end]) start end - overlap return chunks这段是简化版实际用的时候我建议按段落优先切。但作为最小实现固定切分足够让你理解链路。注意 overlap 不能大于等于 chunk_size否则会死循环这个 bug 我调试了半小时才发现。4.3 向量化与入库接下来把切好的块向量化并写进 Chromaimport chromadb from sentence_transformers import SentenceTransformer model SentenceTransformer(你的embedding模型路径) client chromadb.PersistentClient(path./chroma_db) collection client.get_or_create_collection( namekb, metadata{hnsw:space: cosine} ) def build_index(docs): ids, texts, metadatas [], [], [] for doc in docs: for i, chunk in enumerate(split_text(doc[text])): uid hashlib.md5(f{doc[source]}-{i}.encode()).hexdigest() ids.append(uid) texts.append(chunk) metadatas.append({source: doc[source], chunk: i}) embeddings model.encode(texts, batch_size32).tolist() collection.add(idsids, documentstexts, embeddingsembeddings, metadatasmetadatas)这里用内容加序号生成 MD5 作为 ID保证重复导入不会产生重复数据。embeddings 转成 list 是因为 Chroma 不直接吃 numpy 数组这个细节文档里没明说报错了才知道。4.4 检索与生成闭环查询的时候先把问题向量化再去 Chroma 查最相似的块最后拼 prompt 调 DeepSeekfrom openai import OpenAI llm OpenAI(api_key你的key, base_urlhttps://api.deepseek.com) def ask(question, top_k3): q_vec model.encode([question]).tolist() res collection.query(query_embeddingsq_vec, n_resultstop_k) contexts res[documents][0] sources [m[source] for m in res[metadatas][0]] ctx \n\n.join( f[片段{i1}] 来源{s}\n{c} for i, (c, s) in enumerate(zip(contexts, sources)) ) prompt f只根据以下片段回答问题没有的信息就说不知道。 {ctx} 问题{question} resp llm.chat.completions.create( modeldeepseek-chat, messages[{role: user, content: prompt}] ) return resp.choices[0].message.content跑通这一步你就有了一个能用的 RAG。第一次看到它准确引用你文档里的内容回答问题时那种感觉还是很爽的。4.5 参数选择背后的计算逻辑top_k 到底设多少我的算法是看你的上下文窗口和单块大小。假设单块 500 字符模型上下文能放 8000 字符那理论上能塞 16 块。但实际不能塞满因为要留空间给问题和回答而且塞太多不相关的块会稀释有效信息。所以经验值是用满上下文的 30% 到 50% 来放检索结果剩下的留给对话。按这个算500 字符的块k 取 3 到 5 比较合理。5. 常见问题与排查技巧实录5.1 检索结果不相关怎么办这是最高频的问题。排查顺序我总结成一张表现象可能原因排查方法检索全是无关内容距离度量选错检查 collection 是否设了 cosine检索漏掉关键块切分把语义切碎打印块内容人工看相似度分数都很低embedding 模型不匹配换中文友好模型结果时好时坏块大小不合理调整 chunk_size 重试我遇到最多的是距离度量没设对。默认 L2 配余弦训练的模型检索结果会莫名其妙。改成 cosine 之后立刻正常。5.2 模型回答不知道但文档里明明有这种情况八成是检索没召回不是模型的问题。先打印检索到的块看看如果里面没有答案那就是检索环节挂了。解决办法加大 top_k、优化切分、或者加一个重排序步骤。别急着怪模型RAG 的锅九成在检索。5.3 重复导入导致数据翻倍如果你反复跑入库脚本又没做去重Chroma 里会堆一堆重复块检索结果全是重复内容。用内容哈希做 ID 是标准解法add 相同 ID 会覆盖而不是新增。这个习惯一定要养成。5.4 中文乱码与编码问题读文件一定要指定encodingutf-8Windows 上默认编码经常是 gbk读中文文件直接报错。这个坑新手几乎必踩。注意如果你的文档里有大量特殊符号或公式切分时可能把公式切坏建议对这类文档单独处理。5.5 我踩过的三个真实坑第一个坑embedding 模型换了没重建索引。我中途换了个模型检索结果全乱查了半天才发现新旧向量不在一个空间里根本没法比。第二个坑chunk_overlap 设得比 chunk_size 还大脚本直接死循环。第三个坑API key 硬编码在代码里提交到仓库才发现赶紧改成环境变量。这三个坑现在写出来都是常识但当时每一个都卡了我不少时间。6. 从最小版本继续扩展的方向跑通最小版本之后你会发现瓶颈在哪。热词里rag 瓶颈被搜了很多次我自己的体会是最小 RAG 的瓶颈几乎全在检索质量上。生成那一步只要模型不太差基本都能答得像模像样。想继续提升我建议按这个顺序来先加重排序用一个专门的 rerank 模型对检索结果二次排序这一步性价比最高再优化切分策略按语义或标题层级切然后考虑混合检索把关键词检索和向量检索结合起来弥补纯向量对专有名词不敏感的短板。至于rag 知识库和结构知识库的区别简单说RAG 知识库是非结构化的、靠相似度找结构知识库比如知识图谱是有明确关系的、靠查询语言找。两者不是替代关系很多成熟系统是混着用的——先用图谱定位实体再用 RAG 补充细节。这个方向值得单独开一篇讲最小版本先把文本 RAG 吃透就够了。最后分享一个我自己的习惯每次改完检索参数都拿同一批问题跑一遍记录命中情况。RAG 调优是个反复试错的过程没有这套记录你根本不知道改动是变好还是变坏。这个笨办法比任何花哨的技巧都管用。