基于LangChain与Chroma的RAG系统实战:从零搭建智能文档问答应用

📅 2026/7/9 10:47:52
基于LangChain与Chroma的RAG系统实战:从零搭建智能文档问答应用
1. 项目概述与核心价值最近在折腾AI应用开发发现一个挺普遍的问题直接拿大语言模型LLM去回答一些需要特定知识库的问题比如公司内部文档、最新的技术报告或者某个垂直领域的资料模型要么“一本正经地胡说八道”幻觉要么给出的信息是过时的。为了解决这个问题检索增强生成RAG技术成了当前构建可靠AI应用的主流选择。简单来说RAG就是给大模型配一个“外接硬盘”和“搜索引擎”让它能实时查询外部知识库再结合查询结果来生成答案这样既利用了模型的强大理解和生成能力又保证了信息的准确性和时效性。这篇文章我就以最流行的LangChain框架和轻量级的Chroma向量数据库为例带你从零开始手把手搭建一个能跑起来的简单RAG系统。整个过程会覆盖从原始文档处理、向量化存储到智能检索和答案生成的完整链路。无论你是想做一个智能客服、一个文档问答机器人还是想理解RAG背后的工程逻辑这篇实践指南都能给你提供清晰的路径和可复现的代码。我们不止讲“怎么做”更会深入聊聊每个环节“为什么这么做”以及我在实操中踩过的那些坑。2. 技术栈选型与核心组件解析2.1 为什么选择LangChain Chroma这个组合搭建RAG系统框架和数据库的选择是第一步。市面上工具很多我选择LangChain和Chroma是基于以下几个核心考量LangChain的核心价值在于“编排”。它不是一个单一的库而是一个将LLM、数据加载器、文本处理器、向量存储、检索器等组件连接起来的“胶水”框架。自己从头写这些组件的交互逻辑非常繁琐LangChain帮你抽象好了标准接口和链Chain让你能像搭积木一样快速构建应用。它的另一个巨大优势是生态支持几乎所有主流的LLMOpenAI、Anthropic、本地模型等和向量数据库Chroma, Pinecone, Weaviate等避免了被单一供应商锁定的风险。Chroma的定位是轻量、易用和开发者友好。它是一个开源的向量数据库设计目标就是让嵌入Embedding和检索变得简单。对于入门和中小型项目来说它无需复杂部署可以纯内存或本地持久化API简洁并且与LangChain集成得非常好。相比一些云原生的向量数据库服务Chroma让你在本地就能完成全部开发和测试成本为零这对于学习和原型验证阶段至关重要。这个组合的典型工作流是用LangChain提供的文档加载器读取你的知识源PDF、TXT、网页等然后经过文本分割Text Splitter处理接着调用嵌入模型Embedding Model将文本块转化为向量最后存入Chroma。当用户提问时系统将问题也转化为向量在Chroma中执行相似性搜索找到最相关的几个文本块将它们和问题一起组装成“提示词”Prompt发送给LLM生成最终答案。2.2 核心组件深度拆解一个RAG系统远不止“检索”和“生成”两个动作其效能高度依赖于中间的几个关键组件。文档加载器Document Loaders这是数据入口。LangChain支持数十种加载器你需要根据知识源格式选择。例如PyPDFLoader用于PDFUnstructuredFileLoader能处理多种格式WebBaseLoader用于抓取网页内容。这里的一个坑是文档质量扫描版PDF图片格式需要先做OCR网页内容可能包含大量导航栏、广告等噪音需要在加载后或分割前进行清洗。文本分割器Text Splitters这是影响检索质量的核心环节之一。你不能把整本书扔给模型也不能切得太碎丢失上下文。常用的RecursiveCharacterTextSplitter会尝试按字符如“\n\n”, “\n”, “ ”, “”递归分割尽量保持段落和句子的完整性。这里的关键参数是chunk_size块大小和chunk_overlap块间重叠。块大小通常设置在500-1000个字符或token取决于嵌入模型和LLM的上下文窗口。重叠则保证了上下文连贯性避免一个完整的句子或概念被硬生生切断。我一般会从500字符和50字符重叠开始实验。嵌入模型Embedding Model它负责将文本转化为计算机能理解的“语义向量”。这个向量的质量直接决定了检索的准确性。对于入门OpenAI的text-embedding-ada-002是一个强大且省心的选择。如果你追求本地化和零成本可以选用开源的sentence-transformers模型比如all-MiniLM-L6-v2它在效果和速度之间取得了很好的平衡。选择时需要考虑向量维度影响存储和计算、语义理解能力以及对中文的支持度。向量数据库Vector Database它的核心功能是高效存储向量并能快速进行相似性搜索如余弦相似度。Chroma会自动为你创建索引。你需要关注的是检索策略。最基础的是“相似性搜索”Similarity Search返回与问题向量最相似的K个文本块。更高级的可以用“最大边际相关性”MMR在保证相关性的同时兼顾结果多样性避免返回内容高度重复的块。大语言模型LLM这是最终的“大脑”。它接收由“检索到的上下文”和“用户问题”组成的提示词生成答案。提示词的设计Prompt Engineering在这里至关重要。一个典型的RAG提示词模板会包含指令“请根据以下上下文回答问题”、上下文检索到的文本块和问题。清晰的指令能引导模型严格依据上下文作答减少幻觉。3. 从零开始的完整搭建流程下面我们进入实战环节。请确保你的Python环境在3.8以上然后我们一步步来。3.1 环境准备与依赖安装首先创建一个新的项目目录并初始化虚拟环境这是一个保持环境干净的好习惯。mkdir simple-rag-project cd simple-rag-project python -m venv venv # Windows: venv\Scripts\activate # macOS/Linux: source venv/bin/activate接下来安装核心依赖。这里我列出了最小依赖集并附上了简要说明。pip install langchain langchain-community langchain-openai chromadb sentence-transformers pypdflangchain: LangChain核心框架。langchain-community: 社区维护的第三方集成组件如一些文档加载器。langchain-openai: OpenAI模型的官方LangChain集成。chromadb: Chroma向量数据库客户端。sentence-transformers: 用于使用开源嵌入模型。pypdf: 用于读取PDF文件。注意如果你打算使用OpenAI的API还需要在环境变量中设置你的API密钥export OPENAI_API_KEYyour-key。我们后续会同时演示使用OpenAI和本地开源模型两种方式让你有更多选择。3.2 构建知识库文档加载与处理假设我们有一个名为knowledge_base.pdf的PDF文件作为知识源。我们把它放在项目根目录下。# document_processor.py from langchain_community.document_loaders import PyPDFLoader from langchain.text_splitter import RecursiveCharacterTextSplitter # 1. 加载文档 loader PyPDFLoader(./knowledge_base.pdf) documents loader.load() print(f成功加载了 {len(documents)} 页文档。) # 2. 分割文本 text_splitter RecursiveCharacterTextSplitter( chunk_size500, # 每个文本块的最大字符数 chunk_overlap50, # 块之间的重叠字符数 length_functionlen, # 计算长度的函数 separators[\n\n, \n, , ] # 分割优先级 ) chunks text_splitter.split_documents(documents) print(f文档被分割成 {len(chunks)} 个文本块。) # 查看第一个块的内容和元数据如来源页码 print(f第一个块的前200字符{chunks[0].page_content[:200]}...) print(f元数据{chunks[0].metadata})关键操作解析loader.load()返回一个Document对象列表每个对象包含page_content文本和metadata如来源页码。分割后chunks也是一个Document列表每个块都继承了原始文档的元数据。chunk_overlap的设置是为了防止一个完整的句子被割裂在两个块中丢失关键信息。例如一个句子正好在500字符处结束没有重叠的话下一个块的开头就是一个新句子可能与前文语义断裂。3.3 向量化存储嵌入模型与Chroma数据库文本分割好后需要将它们转化为向量并存入数据库。这里我演示两种嵌入模型的使用方式。方案一使用OpenAI的嵌入模型需API Key# vector_store_openai.py from langchain_openai import OpenAIEmbeddings from langchain.vectorstores import Chroma # 初始化OpenAI嵌入模型 embeddings OpenAIEmbeddings(modeltext-embedding-ada-002) # 将文本块向量化并存入Chroma # persist_directory 指定持久化目录否则数据仅保存在内存中 vector_store Chroma.from_documents( documentschunks, embeddingembeddings, persist_directory./chroma_db # 数据将保存到本地目录 ) print(向量知识库已创建并持久化到 ./chroma_db。)方案二使用本地开源嵌入模型零成本# vector_store_local.py from langchain.embeddings.sentence_transformer import SentenceTransformerEmbeddings from langchain.vectorstores import Chroma # 初始化本地嵌入模型 embeddings SentenceTransformerEmbeddings(model_nameall-MiniLM-L6-v2) vector_store Chroma.from_documents( documentschunks, embeddingembeddings, persist_directory./chroma_db_local ) print(使用本地模型创建的向量知识库已保存。)核心要点与避坑指南嵌入模型的一致性绝对重要存入向量和后续查询时必须使用同一个嵌入模型。用A模型存用B模型查得到的向量空间不一致检索结果将毫无意义。因此persist_directory最好以模型名称命名以示区分。持久化指定persist_directory后Chroma会将索引和数据保存在本地磁盘。下次启动应用时可以直接加载无需重新计算向量极大节省时间和资源。加载已有数据库当应用重启时你可以这样加载已存在的数据库vector_store Chroma( persist_directory./chroma_db, embedding_functionembeddings # 必须和创建时一致 )3.4 实现检索与生成链知识库准备好了现在来组装最关键的RAG链Chain。链是LangChain的核心抽象它定义了组件的执行顺序。# rag_chain.py from langchain_openai import ChatOpenAI from langchain.chains import RetrievalQA from langchain.prompts import PromptTemplate # 1. 加载已有的向量数据库假设使用OpenAI嵌入模型创建的 from langchain_openai import OpenAIEmbeddings embeddings OpenAIEmbeddings() vector_store Chroma(persist_directory./chroma_db, embedding_functionembeddings) # 2. 将向量数据库转换为检索器Retriever # search_kwargs 中的 k 参数决定返回多少个最相关的文本块 retriever vector_store.as_retriever(search_kwargs{k: 3}) # 3. 定义LLM # 这里使用ChatOpenAI你也可以替换为其他LangChain支持的LLM如ChatAnthropic或本地模型 llm ChatOpenAI(model_namegpt-3.5-turbo, temperature0) # temperature0 使输出更确定、更专注于上下文适合问答任务。 # 4. 自定义提示词模板这是提升效果的关键 prompt_template 请严格根据以下提供的上下文信息来回答问题。如果你在上下文中找不到明确答案请直接说“根据已知信息无法回答该问题”不要编造信息。 上下文 {context} 问题{question} 请根据上下文给出答案 PROMPT PromptTemplate( templateprompt_template, input_variables[context, question] ) # 5. 创建检索问答链 qa_chain RetrievalQA.from_chain_type( llmllm, chain_typestuff, # “stuff”策略将所有检索到的上下文塞进提示词简单直接。 retrieverretriever, chain_type_kwargs{prompt: PROMPT}, # 使用我们自定义的提示词 return_source_documentsTrue # 返回源文档便于调试和溯源 ) # 6. 进行问答测试 query 什么是RAG技术它的主要优势是什么 result qa_chain.invoke({query: query}) print(f问题{query}) print(f答案{result[result]}) print(\n--- 来源文档 ---) for i, doc in enumerate(result[source_documents]): print(f[片段{i1}] {doc.page_content[:150]}...) print(f 来源{doc.metadata}\n)链类型chain_type解析stuff最简单的方式将所有检索到的上下文文本拼接后一次性输入给LLM。优点是信息完整缺点是受限于LLM的上下文窗口长度。map_reduce先将每个检索到的文本块单独提问LLM得到子答案再将这些子答案汇总起来进行最终总结。适合处理大量文档但调用LLM次数多成本高、速度慢。refine迭代式处理用第一个文档生成初始答案然后用后续文档不断修正和精炼这个答案。效果可能更好但同样复杂且耗时。map_rerank对每个文档块进行评分和排序只选用分数最高的。对于初学者stuff策略在上下文窗口允许的情况下是最直观有效的。4. 效果优化与高级技巧基础系统搭建完成后你会发现效果可能不尽如人意。答案可能不准确、啰嗦或者还是存在幻觉。别急RAG的调优是一个系统工程这里分享几个立竿见影的优化方向。4.1 检索环节的优化策略检索是RAG的“源头”源头不准后续生成再好也白搭。1. 调整文本分割策略chunk_size和chunk_overlap是黄金参数。如果答案总是支离破碎尝试增大chunk_size如800或1000以包含更完整的上下文。如果发现检索到的片段总是错过关键信息的开头或结尾适当增加chunk_overlap如100到150。一个实用的技巧是根据你的文档类型技术手册、法律条文、对话记录和句子平均长度来调整。2. 尝试不同的检索方法除了基础的相似性搜索LangChain和Chroma支持更高级的检索器。MMR最大边际相关性检索器在保证相关性的同时增加结果的多样性。避免返回三个几乎一样的句子。retriever vector_store.as_retriever( search_typemmr, search_kwargs{k: 4, fetch_k: 10} # 先取10个再从中选4个最 diverse 的 )自查询检索器Self-Query Retriever如果你的文档元数据metadata丰富如日期、作者、类别可以让LLM自动从问题中提取过滤条件。例如用户问“上个月发布的关于安全的规定”它能自动解析出“日期上个月”和“主题安全”进行过滤。这需要预先定义好元数据字段。3. 重排序Re-ranking这是提升精度的大杀器。先用向量检索召回一批候选文档比如20个再用一个专门的、更精细的交叉编码器Cross-Encoder模型对这些候选文档和问题进行相关性重打分和排序只取Top K个比如3个给LLM。sentence-transformers库就提供了这样的模型。虽然增加了计算开销但对最终答案质量提升显著。4.2 提示词工程与后处理检索到优质上下文后如何让LLM用好它们提示词是关键。1. 设计强约束性提示词就像上面的例子在提示词中明确指令“严格根据上下文”并设定“无法回答”的兜底条款能有效抑制幻觉。你还可以增加角色扮演如“你是一个严谨的技术助理”。2. 添加上下文标识当传入多个上下文片段时给每个片段加上序号或来源标识并在答案中要求模型引用来源。这不仅能提高答案的可信度也便于后期溯源。上下文1[来自《产品手册》第5页] ... 上下文2[来自《更新日志v2.1》] ... 问题... 请根据以上上下文回答并在答案中注明引用来源例如【上下文1】。3. 答案后处理对LLM生成的答案进行校验。例如可以检查答案中是否包含“根据已知信息无法回答”这类预设短语。更高级的做法是用另一个轻量级模型或规则判断答案是否与检索到的上下文在事实上存在冲突。4.3 系统评估与迭代如何知道你的优化是否有效你需要一个评估机制。1. 构建测试集手动整理一批“问题-标准答案”对覆盖你知识库的核心内容。答案最好是基于文档片段能直接推导出的。2. 定义评估指标检索相关性检索到的Top K个文档中有多少个包含了正确答案所需的信息这是检索模块的KPI。答案忠实度模型生成的答案是否严格源自提供的上下文没有添加或编造外部信息这对抗幻觉很重要。答案准确性对比模型答案和标准答案在事实层面是否一致可以用字符串匹配、关键词覆盖或更复杂的语义相似度如用嵌入模型计算答案向量相似度来衡量。3. 迭代循环基于评估结果回头调整分割参数、尝试不同的嵌入模型、优化提示词然后再次评估。这是一个数据驱动的闭环过程。对于生产系统可以考虑自动化部分评估流程。5. 常见问题与实战排坑记录在实际开发中你肯定会遇到各种各样的问题。下面是我总结的一些典型“坑”及其解决方案。5.1 检索效果不佳总是找不到相关文档可能原因1文本分割不合理。块太大包含了太多无关信息稀释了核心语义块太小关键信息被割裂。解决调整chunk_size和chunk_overlap。尝试按语义分割如使用SemanticChunker而不是单纯按字符长度。可能原因2嵌入模型不匹配或不适合领域。通用嵌入模型对某些专业领域如生物医学、法律术语的语义捕捉能力弱。解决确保存和查使用同一模型。对于专业领域考虑使用在该领域语料上微调过的嵌入模型。可能原因3问题表述与文档表述差异大。用户问“怎么开机”文档里写的是“设备启动步骤”。解决对用户问题进行“查询重写”或“查询扩展”。例如用LLM将问题“怎么开机”改写成“设备的启动步骤是什么”再进行检索。这能提升召回率。5.2 LLM的答案存在幻觉或忽略上下文可能原因1提示词约束力不够。解决强化提示词指令如“你必须且只能使用以下上下文中的信息来回答问题”。使用“少样本提示”Few-shot Prompting在提示词中给出几个正确遵循上下文的示例。可能原因2检索到的上下文过多或噪声大。LLM的注意力被无关信息干扰。解决减少检索返回的数量k。引入重排序Re-ranking模块只给LLM最精炼的1-2个片段。在提示词中明确“请重点关注上下文X和Y”。可能原因3LLM本身能力或温度参数问题。解决尝试换用更强大的模型如GPT-4。将temperature参数设为0或接近0使输出更确定、更遵从上下文。5.3 系统响应速度慢可能原因1嵌入模型计算慢。特别是使用大型本地模型时。解决考虑使用更轻量的嵌入模型如all-MiniLM-L6-v2只有80MB左右。对于生产环境可以使用嵌入模型API服务或部署在GPU上。可能原因2检索的文档块k值太多或块太大。解决在效果可接受的范围内尽量减少k值和chunk_size。使用map_reduce或refine链类型会比stuff更慢。可能原因3未使用持久化向量库。每次启动都重新计算嵌入。解决务必使用persist_directory参数保存向量索引后续直接加载。5.4 Chroma数据库使用问题问题更换嵌入模型后加载旧数据库报错或检索结果混乱。解决不同嵌入模型生成的向量维度可能不同绝对不兼容为每个嵌入模型创建独立的持久化目录。迁移数据时需要读取原始文本用新模型重新生成向量并存储。问题如何增量添加新文档到已有知识库解决使用vector_store.add_documents(new_chunks)方法。注意新文档的嵌入模型必须与创建库时一致。构建RAG系统就像搭积木每个环节的选择和参数调整都会影响最终效果。没有一劳永逸的“最佳配置”只有最适合你具体场景和数据的方案。我的建议是从一个最小可行产品MVP开始用本文的代码快速跑通流程然后围绕“检索精度”和“生成质量”这两个核心指标结合上面提到的优化策略一步步迭代调优。记住高质量的输入数据文档和清晰的业务问题定义往往比模型和参数的选择更重要。