ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

从零搭建朴素RAG系统:检索增强生成的核心原理与工程实践

从零搭建朴素RAG系统:检索增强生成的核心原理与工程实践 1. 项目概述从“玩具”到“基石”的朴素RAG如果你最近在折腾大语言模型应用尤其是想让它回答你公司内部文档里的问题或者让它基于你的私人知识库跟你聊天那你大概率绕不开一个词RAG。RAG检索增强生成听起来挺高大上但它的核心思想其实朴素得惊人当模型不知道答案时别让它瞎编让它先去你的资料库里翻翻书找到相关段落后再组织语言回答。这就像你考试时允许开卷但只能翻指定的几本教材。而“Naive RAG”也就是朴素RAG就是这个思想最直接、最不加修饰的实现。它没有复杂的召回策略没有精巧的重排序就是“切块-存向量-搜向量-塞给模型”一条龙。很多人觉得它简单、效果不稳定是“玩具级”方案。但在我实际搭建和优化了十几个RAG系统后我的体会是Naive RAG不是终点但它是所有高级RAG的起点和试金石。你不彻底搞懂它后面所有的优化都像是空中楼阁。这篇文章我就带你亲手搭一个Naive RAG把每一步的“为什么”和“坑”都掰开揉碎了讲清楚。无论你是想快速验证一个想法还是为后续的工程化优化打基础这个“朴素”的开始都至关重要。2. Naive RAG 核心架构与设计思路拆解2.1 为什么是“检索增强生成”要理解Naive RAG得先明白LLM大语言模型的固有缺陷。现在的LLM比如GPT-4、Claude或者开源的Qwen、Llama本质上是基于海量互联网文本训练出的“概率预测大师”。它们擅长生成流畅、合乎语法的文本但存在两个致命问题知识滞后性和幻觉。模型训练数据有截止日期无法获取最新信息同时对于训练数据中不存在或权重很低的“长尾知识”比如你公司的内部流程文档模型要么说不知道更危险的是它会基于语义关联“自信地”编造一个看似合理但完全错误的答案这就是幻觉。RAG的提出就是为了给模型外接一个“实时、可靠的外部记忆体”。它的工作流可以概括为一个循环用户提问 - 系统将问题转化为查询如向量 - 在知识库中检索相关文档片段 - 将片段和问题一起交给LLM - LLM生成基于这些片段的答案。这样一来答案的实时性和事实准确性不再完全依赖于LLM的原始训练数据而是由你提供的、可控的知识库来保障。2.2 Naive RAG 的“朴素”体现在哪里所谓“Naive”就是指它采用了这个工作流中最基础、最直接的实现方式没有添加额外的优化层。我们可以用一个简单的管道图来理解[原始文档] - (文本切分) - [文本块] - (向量化编码) - [向量] - (存入向量数据库) | v [用户问题] - (向量化编码) - [查询向量] - (向量相似度检索) - [Top-K相关文本块] - (拼接成上下文) - [LLM] - [最终答案]它的“朴素”具体体现在以下几个关键设计点上检索方式单一通常只使用稠密向量检索Dense Retrieval。即把文本和问题都通过一个嵌入模型Embedding Model转换成高维向量然后计算余弦相似度找出最相似的几个文本块。它完全依赖嵌入模型对语义的理解能力。检索后处理简单检索到Top-K个片段比如5个后直接按相似度分数顺序拼接起来前面加上“请根据以下上下文回答问题”的指令就扔给LLM了。没有对检索结果进行重排序Re-ranking也没有进行去重或信息融合。上下文构建直接把检索到的所有文本块简单连接可能很快会耗尽LLM的上下文窗口并且可能包含冗余或矛盾信息。这种设计的优势是简单、快速、易于实现非常适合原型验证和小规模知识库。但劣势也很明显检索精度完全依赖于嵌入模型和切分质量如果Top-K设置不当可能漏掉关键信息或引入噪声没有重排序LLM可能被相似度高但相关性不强的片段带偏。3. 核心组件详解与工具选型要搭建一个可用的Naive RAG你需要四个核心组件文档加载与切分器、嵌入模型、向量数据库、大语言模型。每个组件的选型都直接影响到最终效果。3.1 文档加载与切分知识库的“第一道加工”文档加载器负责读取各种格式的文件PDF、Word、Markdown、HTML等。在Python生态中LangChain和LlamaIndex都提供了丰富的文档加载器。对于新手我推荐从LangChain开始它的抽象更直接。注意不要小看文件解析。PDF中的表格、扫描件中的文字需要OCR、网页中的广告噪音都是常见的坑。对于生产环境你需要为每种文件类型准备备选的解析方案。文本切分是RAG系统中最容易被低估却对效果影响最大的环节。Naive RAG通常采用固定大小的重叠滑动窗口切分法。为什么需要切分LLM和嵌入模型都有输入长度限制。长文档必须切碎才能处理。为什么需要重叠为了避免一个完整的句子或关键信息被恰好切在两个块的边界而破坏其语义相邻块之间保留一部分重叠文本如100个字符可以保证上下文的连贯性。如何选择块大小和重叠大小块大小这需要权衡。块太小如128字符可能无法包含完整的上下文信息比如一个问题的答案需要前后两段才能说明白块太大如1024字符可能包含过多无关信息稀释关键内容且检索精度下降。一个常见的起点是512字符这个长度对大多数嵌入模型如text-embedding-3-small比较友好也能容纳一段完整的内容。重叠大小通常设置为块大小的10%-20%。例如块大小为512重叠可以设为50或100。# 使用 LangChain 进行文本切分的示例 from langchain.text_splitter import RecursiveCharacterTextSplitter text_splitter RecursiveCharacterTextSplitter( chunk_size512, # 每个文本块的最大字符数 chunk_overlap100, # 相邻块之间的重叠字符数 length_functionlen, separators[\n\n, \n, 。, , , , , , ] # 按此优先级分割 ) documents text_splitter.split_documents(loaded_docs) # loaded_docs 是加载后的文档对象列表实操心得RecursiveCharacterTextSplitter是通用性较好的选择它会递归地尝试用不同的分隔符来切分尽量保证块的完整性。但对于代码、Markdown等高度结构化的文本可能需要专门的切分器。3.2 嵌入模型将文本映射为“语义空间”的尺子嵌入模型负责将文本转换为固定长度的向量一组数字。在向量空间中语义相似的文本其向量也相似。Naive RAG的效果很大程度上取决于这把“尺子”准不准。选型考量维度常见的有384维、768维、1024维、1536维等。更高的维度通常能容纳更细粒度的语义信息但计算和存储成本也更高。OpenAI的text-embedding-3-small是1536维是一个性能与成本平衡的选择。上下文长度模型能处理的最大文本长度。确保它大于你设置的文本块大小。性能与开销云端API如OpenAI, Cohere开箱即用效果稳定但会产生持续费用且有网络延迟和数据隐私考量。本地开源模型如BAAI/bge-small-zh-v1.5,thenlper/gte-base数据隐私性好无网络延迟但需要本地GPU资源且效果可能略逊于顶级商用模型。对于中文场景我强烈推荐智源开源的BAAI/bge系列模型它在中文语义相似度任务上表现优异且提供了多种尺寸的版本。# 使用 HuggingFace 本地嵌入模型的示例 from langchain.embeddings import HuggingFaceEmbeddings embed_model HuggingFaceEmbeddings( model_nameBAAI/bge-small-zh-v1.5, model_kwargs{device: cpu}, # 或 cuda encode_kwargs{normalize_embeddings: True} # 归一化方便计算余弦相似度 )3.3 向量数据库海量向量的“高速索引器”当你有成千上万个文本块时暴力计算用户问题与每个块的相似度是不现实的。向量数据库专门为此优化能快速进行近似最近邻搜索。主流选择Chroma轻量级易于上手适合原型和中小项目。纯内存或持久化皆可。FAISSFacebook开源的库性能极高但更像一个算法库需要自己处理元数据存储。PGVectorPostgreSQL的扩展如果你的技术栈重度依赖PostgreSQL这是个自然的选择。它支持完整的SQL操作和向量检索。Qdrant / Weaviate / Milvus功能更全面的专业向量数据库支持过滤、标量向量混合搜索等高级特性。对于Naive RAG入门Chroma是阻力最小的路径。# 使用 LangChain Chroma 的示例 from langchain.vectorstores import Chroma # 创建向量库并存储文档 vectorstore Chroma.from_documents( documentsdocuments, # 切分后的文档 embeddingembed_model, # 嵌入模型 persist_directory./chroma_db # 持久化目录 ) # 检索 retriever vectorstore.as_retriever(search_kwargs{k: 5}) # 检索最相似的5个块 relevant_docs retriever.get_relevant_documents(你的问题是什么)3.4 大语言模型最终的“答题者”LLM负责根据检索到的上下文和用户问题生成最终答案。在Naive RAG中我们通常采用“上下文注入”的模式。提示词模板示例请严格根据以下提供的上下文信息来回答问题。如果上下文中的信息不足以回答问题请直接说“根据已知信息无法回答该问题”不要编造信息。 上下文 {context} 问题{question} 请用中文回答LLM选型云端APIGPT-4/3.5-Turbo, Claude, DeepSeek等。效果最好成本透明但需考虑合规与数据出境风险。本地部署Qwen、ChatGLM、Llama等开源模型。数据完全私有但需要较强的硬件GPU和一定的模型优化知识。对于快速验证可以使用OpenAI API对于内部数据敏感的项目本地部署Qwen2-7B-Instruct这类模型是更稳妥的选择。4. 手把手实现一个Naive RAG流水线下面我们用一个具体的例子实现一个基于本地模型和Chroma的Naive RAG问答系统。假设我们的知识库是一些关于人工智能的Markdown文档。4.1 环境准备与依赖安装首先创建一个新的Python环境并安装核心库。# 创建并激活虚拟环境可选 python -m venv rag_env source rag_env/bin/activate # Linux/Mac # rag_env\Scripts\activate # Windows # 安装依赖 pip install langchain langchain-community langchain-chroma # LangChain核心及Chroma集成 pip install sentence-transformers # 用于本地嵌入模型 pip install chromadb # Chroma向量数据库客户端 pip install pypdf python-docx markdown # 文档加载器支持 pip install unstructured[md] # 增强的Markdown解析 # 如果需要本地LLM例如使用Ollama # pip install ollama # 或者使用vLLM等高性能推理框架4.2 步骤一文档加载与处理我们创建一个docs文件夹里面放几篇Markdown文档。然后编写加载代码。# load_docs.py import os from langchain_community.document_loaders import DirectoryLoader, UnstructuredMarkdownLoader from langchain.text_splitter import RecursiveCharacterTextSplitter # 1. 加载文档 documents_path ./docs loader DirectoryLoader( documents_path, glob**/*.md, loader_clsUnstructuredMarkdownLoader, show_progressTrue ) raw_documents loader.load() print(f共加载了 {len(raw_documents)} 个文档) # 2. 文本切分 text_splitter RecursiveCharacterTextSplitter( chunk_size512, chunk_overlap100, length_functionlen, separators[\n\n, \n, 。, , , , , , ] ) all_splits text_splitter.split_documents(raw_documents) print(f切分后得到 {len(all_splits)} 个文本块) print(第一个文本块预览, all_splits[0].page_content[:200])4.3 步骤二向量化与存储接下来我们使用BGE模型将文本块向量化并存入Chroma数据库。# create_vectorstore.py from langchain.embeddings import HuggingFaceEmbeddings from langchain.vectorstores import Chroma # 1. 初始化嵌入模型 embedding_model HuggingFaceEmbeddings( model_nameBAAI/bge-small-zh-v1.5, model_kwargs{device: cpu}, encode_kwargs{normalize_embeddings: True} ) # 2. 创建并持久化向量存储 persist_directory ./chroma_db_naive vectorstore Chroma.from_documents( documentsall_splits, embeddingembedding_model, persist_directorypersist_directory ) print(f向量库已创建并保存至 {persist_directory} 包含 {vectorstore._collection.count()} 条记录。)4.4 步骤三构建检索与生成链现在我们连接检索器和LLM构建完整的问答链。这里为了演示我们使用一个简单的模拟LLM实际应用中替换为真实的LLM调用。# rag_chain.py from langchain.vectorstores import Chroma from langchain.embeddings import HuggingFaceEmbeddings from langchain.prompts import ChatPromptTemplate from langchain.schema.runnable import RunnablePassthrough from langchain.schema.output_parser import StrOutputParser # 假设我们使用一个本地运行的Ollama模型 from langchain_community.llms import Ollama # 1. 加载已有的向量库 persist_directory ./chroma_db_naive embedding_model HuggingFaceEmbeddings( model_nameBAAI/bge-small-zh-v1.5, model_kwargs{device: cpu}, encode_kwargs{normalize_embeddings: True} ) vectorstore Chroma( persist_directorypersist_directory, embedding_functionembedding_model ) # 2. 定义检索器 retriever vectorstore.as_retriever(search_kwargs{k: 4}) # 检索4个相关块 # 3. 定义提示词模板 template 请严格根据以下提供的上下文信息来回答问题。如果上下文中的信息不足以回答问题请直接说“根据已知信息无法回答该问题”不要编造信息。 上下文 {context} 问题{question} 请用中文回答 prompt ChatPromptTemplate.from_template(template) # 4. 初始化LLM (这里用Ollama的Qwen2-7B模型示例确保你已用ollama pull qwen2:7b拉取模型) llm Ollama(modelqwen2:7b, temperature0.1) # temperature调低让答案更确定 # 5. 构建RAG链 def format_docs(docs): return \n\n.join(doc.page_content for doc in docs) rag_chain ( {context: retriever | format_docs, question: RunnablePassthrough()} | prompt | llm | StrOutputParser() ) # 6. 提问测试 question 什么是机器学习 answer rag_chain.invoke(question) print(f问题{question}) print(f答案{answer}) print(- * 50) # 我们可以查看检索到的上下文 retrieved_docs retriever.get_relevant_documents(question) print(f检索到的上下文片段共{len(retrieved_docs)}个) for i, doc in enumerate(retrieved_docs): print(f\n[片段 {i1}] (相似度分数可查看metadata):) print(doc.page_content[:300] ...)运行这个脚本你就能看到一个最基本的Naive RAG系统开始工作了。它会从你的docs文件夹下的Markdown文件中寻找关于“机器学习”的内容并组织成答案。5. Naive RAG 的典型问题与实战调优技巧一个能跑起来的Naive RAG只是第一步。在实际使用中你会立刻遇到各种问题。下面是我在项目中踩过的坑和总结的调优经验。5.1 检索失败问题与答案“擦肩而过”这是最常见的问题。用户问“如何训练一个神经网络”但你的知识库里存储的片段是“神经网络的训练步骤包括1. 前向传播 2. 计算损失 3. 反向传播 4. 参数更新”。虽然内容高度相关但两者的表述方式不同导致向量相似度不高。解决方案与技巧查询扩展在将用户问题向量化前先对问题进行扩展。例如使用LLM生成问题的同义句、相关实体或更详细的描述。# 一个简单的查询扩展示例伪代码 expansion_prompt f“请生成以下问题的几个同义或更详细的表述{question}” expanded_queries llm.generate(expansion_prompt) # 得到多个查询变体 # 将所有扩展查询的向量进行平均或分别检索后合并结果优化嵌入模型尝试不同的嵌入模型。对于中文BAAI/bge-large-zh-v1.5比small版本效果显著更好但计算更慢。也可以尝试moka-ai/m3e-base。调整文本块大小和重叠如果答案分散在多个块中增大块大小或重叠可能有助于将关键信息包含在一个块内。反之如果块内噪声多可以减小块大小。5.2 答案质量不佳噪声干扰与信息缺失即使检索到了相关文档生成的答案也可能啰嗦、跑偏或遗漏重点。这通常是因为噪声干扰检索到的Top-K个片段中混入了相关性不强的片段。信息缺失关键的答案信息被切分到了不同的块且没有被同时检索到。LLM指令遵循问题LLM没有严格遵守“只基于上下文回答”的指令。解决方案与技巧引入重排序器这是从Naive RAG迈向进阶RAG的关键一步。使用一个更精细的交叉编码器模型如BAAI/bge-reranker-base对初步检索到的Top-N例如20个结果进行重新打分和排序只保留Top-K例如3个最相关的给LLM。重排序器计算的是“问题-文档对”的相关性比嵌入模型的“文档-文档”语义相似度更精准。# 使用 FlagEmbedding 库进行重排序的示例 from FlagEmbedding import FlagReranker reranker FlagReranker(BAAI/bge-reranker-base, use_fp16True) # 使用fp16加速 retrieved_docs retriever.get_relevant_documents(question, k20) # 先多召回一些 # 计算重排序分数 pairs [(question, doc.page_content) for doc in retrieved_docs] scores reranker.compute_score(pairs) # 根据分数重新排序并选取前3个 ranked_docs [doc for _, doc in sorted(zip(scores, retrieved_docs), reverseTrue)][:3]优化提示词工程在提示词中更明确地约束LLM。强调精确性“你的回答必须严格、一字不差地基于提供的上下文。不要引入上下文以外的知识。”指定格式“如果上下文包含步骤请用列表形式回答。”要求引用“在回答的末尾注明你的答案来源于哪个上下文片段用编号表示。” 这不仅能提高答案可信度还能帮你反向验证检索质量。后处理与验证对LLM生成的答案进行事实一致性检查或者让LLM自己评估答案是否完全基于上下文。5.3 效率与成本问题当知识库很大时向量检索和LLM调用都可能成为瓶颈。解决方案与技巧分层索引与过滤在向量检索前先使用关键词、元数据如文档类型、创建日期进行过滤缩小检索范围。混合检索结合稠密检索向量和稀疏检索如BM25。BM25基于关键词匹配对精确术语如产品型号、错误代码的召回非常有效且计算速度快。将两者的结果融合能兼顾语义和字面匹配。# 伪代码混合检索 from rank_bm25 import BM25Okapi # 1. 构建BM25索引基于文本块的词条 bm25_index BM25Okapi([doc.page_content.split() for doc in all_splits]) # 2. 分别进行BM25检索和向量检索 bm25_scores bm25_index.get_scores(question.split()) vector_scores vectorstore.similarity_search_with_score(question, k10) # 3. 分数融合如加权平均 combined_results fusion(bm25_scores, vector_scores)缓存对常见的查询及其检索结果进行缓存可以极大提升响应速度。5.4 评估如何知道你的RAG系统好不好搭建好系统后你需要一套评估方法来衡量其效果而不是凭感觉。评估主要看三个方面检索质量检索到的文档是否相关常用指标是召回率K在Top-K个结果中有多少比例包含了正确答案所需的文档。生成质量事实一致性答案中的事实是否与检索到的上下文一致是否出现幻觉答案相关性答案是否直接回答了问题信息完整性答案是否涵盖了上下文中所有关键信息端到端质量直接让人或LLM作为裁判对“问题-答案”对进行打分。对于Naive RAG你可以手动构建一个测试集QA对运行系统并检查检索结果和生成答案这是最直接的方法。也可以使用像RAGAS、TruLens这样的自动化评估框架。6. 从Naive RAG出发下一步可以做什么当你熟练掌握了Naive RAG的搭建和调优后你会发现它的天花板很明显。这时你可以考虑以下几个进阶方向这也是当前RAG领域的热点高级检索策略多路召回与融合同时使用向量检索、关键词检索、甚至基于知识图谱的检索然后融合结果。递归检索与查询转换将复杂问题拆解成多个子问题分别检索后再综合。Agentic RAG让LLM作为智能体主动决定何时检索、检索什么、如何迭代优化查询。智能文本切分与索引语义切分不再固定大小而是根据语义边界如段落、主题进行切分。小到大检索先检索小的、精确的片段如果信息不足再扩大检索范围。添加摘要为每个文本块生成一个简洁的摘要先检索摘要再定位到原文。答案生成优化Map-Reduce对检索到的多个文档分别生成答案再合并总结。Refine迭代式生成基于前一个答案和新的上下文进行优化。引用与溯源让答案明确指向来源文档和具体位置。工程化与部署流水线监控监控检索命中率、LLM延迟、token消耗等指标。版本化管理知识库更新后如何增量更新向量索引多租户与安全如何为不同用户或组织隔离数据Naive RAG就像一辆组装好的自行车它能带你上路让你理解RAG的基本原理和所有部件是如何协同工作的。当你骑着它遇到上坡检索不准、颠簸答案幻觉时你就会明白为什么需要更高级的变速器重排序、减震器查询扩展和更强大的发动机更优的模型。从这个“朴素”的起点开始一步步解决实际问题才是掌握RAG技术的最佳路径。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进