LangChain+RAG基础版:从本地文档到智能问答的完整实现
我最初做这套“RAG基础版”起因特别朴素本地攒了一堆项目文档、会议纪要和历史方案真要查点东西的时候大模型虽然聊得头头是道但一涉及具体业务细节就开始胡编。当时也试过直接把整份文档塞进上下文几千字的材料勉强能跑等到几万字的时候就彻底没戏了。后来接触到RAGRetrieval-Augmented Generation检索增强生成的思路配合LangChain框架把链路搭起来才算是找到一条能落地的路子。这篇文章就把我搭这个基础版的完整过程写出来包括核心原理、关键代码、选型逻辑以及几次差点劝退我的坑。适合刚接触RAG、想用LangChain快速跑通一套本地问答系统的开发者参考。1. 这套基础版到底在解决什么问题1.1 大模型的幻觉与私域知识的矛盾先说说我为什么需要RAG而不直接用微调。微调一个模型需要准备大量的指令数据、标注样本训练还要调显卡、等迭代对大多数小团队来说成本并不划算。而RAG的思路是完全不同的模型本身不做任何参数更新它只是多了一个开卷考试的机会。大模型本质上是一个参数化的知识存储结构你问它问题它靠的是训练阶段见过的内容在参数里形成的概率分布来回答。一旦问题涉及某个具体组织的内部知识比如某公司今年的数据报表、某个系统的接口设计文档模型在训练时就没见过自然只能靠猜。RAG的核心动作就两个先把私域文档切碎、变成向量存起来用户提问时先从库里检索出最相关的片段再把片段拼接成上下文一起丢给模型。模型看的是你给的文档而不是凭空想生成内容的准确性就能提高不少。1.2 RAG基础版的链路全貌一套最小可用的RAG系统按阶段拆开其实不复杂索引阶段加载文档、拆分文本、向量化、写入向量库检索阶段把用户问题向量化从库里做相似度搜索召回TopK片段生成阶段把召回片段和原始问题组装进Prompt交给模型生成答案用LangChain来做最大的好处是这些步骤都有封装好的组件不用自己从头写文本切分器、不用手工维护向量库连接甚至Prompt模板也有标准范式。对于基础版来说重点是把链路跑通理解每一层在干什么而不是一上来就追各种花哨的高级技巧。2. 动手前的环境准备与选型思路2.1 依赖安装的三个关键点我在搭这套环境的时候用的是Python 3.10以上的版本太老的版本在依赖兼容上会吃不少苦头。核心依赖分三层向量存储层需要一个本地可跑的向量数据库来存embedding框架层LangChain以及对应的社区集成包模型层大模型API的SDK、Embedding模型的调用库安装的时候我建议直接用一个虚拟环境不要把全局Python搞乱。基础依赖大概长这样pip install langchain pip install langchain-community pip install langchain-core pip install langchain-text-splitters这里有个容易踩的坑LangChain更新非常频繁老版本和新版本之间API变动很大。早期版本的load_qa_chain、VectorstoreIndexCreator这些接口现在都变了位置很多网上的代码直接复制会报ModuleNotFoundError。建议装完之后用pip freeze锁一下版本至少保证项目可复现。2.2 LLM与向量库的选型逻辑大模型的部分基础版可以直接用市面上兼容统一调用格式的商业API也可以通过本地框架把私有化模型包装成兼容接口。关键点在于你选的LangChain模型类需要支持标准的对话接口。向量库的选择我一开始纠结过一段时间。FAISS这类本地库胜在轻量、不需要单独起服务适合个人项目和原型验证而生产环境往往需要支持高并发和动态写入那时候再考虑真正的向量数据库服务也不迟。基础版我建议先用轻量级方案把流程跑通再说。Embedding模型同样不能随便选。它决定了你的文档在向量空间里长什么样检索效果好不好有一半看它。这里提醒一句你用的Embedding模型必须对你的中文内容有足够好的支持我实测过部分偏英文优化的模型在中文技术文档上的检索效果很飘。注意向量化模型和问答模型是两码事。很多新手会误以为Embedding也走同一个大模型API实际LangChain里它们是分别配置、分别调用的。3. 索引构建把本地文档变成可检索的向量库3.1 文档加载与清洗注意事项LangChain的文档加载器家族很庞大最常用的是纯文本加载和PDF加载。我自己的文档大多是Markdown和TXT所以直接用文本加载器就够了。from langchain_community.document_loaders import TextLoader loader TextLoader(project_notes.md, encodingutf-8) documents loader.load()这里有一个我当时忽略的细节文档加载进来后不是马上就能切片的。一份文档里往往充斥着目录、重复小标题、无关的导航信息如果不做清洗这些噪音会直接进入向量库将来检索的时候把它们当上下文喂给模型反而会干扰回答。我就是从第二版才开始认真做清洗的效果立刻不一样。清洗其实不复杂正则把多余空白、特殊字符、明显的页眉页脚去掉就够了。对基础版来说不要在清洗上花太多时间但基本的去噪一定要做。3.2 文本切分的参数博弈文本切分是整个RAG链条里最值得花时间调的一步。切得太小语义上下文被截断检索到的片段残缺不全切得太大向量化精度降低、检索噪声也多而且塞给模型的时候浪费上下文窗口。LangChain提供了几种切分器最常用的是按字符数和重叠区间来切的。from langchain_text_splitters import RecursiveCharacterTextSplitter text_splitter RecursiveCharacterTextSplitter( chunk_size500, chunk_overlap80, separators[\n\n, \n, 。, , , , ], ) chunks text_splitter.split_documents(documents)chunk_size代表每块字符数上限chunk_overlap代表相邻两块的共享字符数。重叠的意义在于如果一句话恰好横跨两块边界没有重叠的话语义就会被硬生生切断有重叠两头都能保留上下文。你可能会问到底设多少合适我的经验是技术文档建议chunk_size500左右聊天记录或短句类内容可以更小。这个值没有绝对标准但有一个判断依据——切出来的块要尽量是一个语义完整的单元而不是机械的数字切分。观察几个切分结果发现句子被腰斩、问题描述被切成两半就该调大chunk_overlap或调整分隔符优先级。3.3 向量化存储从embedding到写入切分完成的文档块要转成向量。我用的Embedding模型支持中文效果尚可调用方式在LangChain里有对应封装。from langchain_community.embeddings import SomeEmbedding from langchain_community.vectorstores import FAISS embedding_model SomeEmbedding(api_keyyour-key) vectorstore FAISS.from_documents(chunks, embedding_model) vectorstore.save_local(faiss_index)这段代码做了三件事把每一块文本转成向量、把所有向量连同原文一起建索引、把索引落盘。好处是一次构建以后直接读盘使用不用每次启动都重新处理文档。vectorstore FAISS.load_local(faiss_index, embedding_model, allow_dangerous_deserializationTrue)加载的时候如果不传allow_dangerous_deserialization参数新版本LangChain会直接抛异常。这个参数是因为从本地文件反序列化存在潜在安全风险如果你能保证索引文件是自己生成的可以放心打开。4. 检索与生成写一个能跑起来的问答闭环4.1 相似度检索的关键动作索引建好之后真正回答问题的阶段反而是简单的。用户输入问题后系统先把问题转成向量然后在库里做相似度搜索。LangChain把这一层也封装了。retriever vectorstore.as_retriever( search_typesimilarity, search_kwargs{k: 5} )这里的k代表召回多少个片段我建议从5开始调。如果你想跳过按向量相似度召回也可以试试关键词召回两者效果差异很大检索方式优势劣势适用场景向量相似度语义理解强能召回说法不同但意思相近的内容对实体名称、编号等精确匹配不敏感技术文档、问答库、概念型内容关键词BM25对专有名词和编号匹配精准无法处理同义改写运维手册、代码报错、产品名检索混合检索两者并集或加权融合实现略复杂需要调权重生产级系统推荐我记得第一次跑检索时看到一个特别离谱的结果用户问支付超时怎么排查召回的前两条文档写的是如何配置支付回调超时重试机制说明虽然词面相关但完全不是直接答案。原因就是单纯靠向量召回语义接近但不精确的片段容易混进来。后来加了重排序逻辑才明显好转基础版可以先用similarity跑通后续再考虑混合检索。4.2 Prompt模板与回答增强检索回来的片段不会自动变成答案中间需要一个组织Prompt的过程。LangChain的PromptTemplate就是干这个的。from langchain_core.prompts import PromptTemplate prompt_template PromptTemplate.from_template( 你是企业内部知识助手请根据下面提供的资料回答问题。 资料 {context} 问题 {question} 请优先依据资料回答资料中没有的信息明确说明不知道。 ) prompt prompt_template.format( context\n\n.join([doc.page_content for doc in retrieved_docs]), questionuser_question )我特别建议在Prompt里加一句资料中没有的信息要明确说不知道这能显著减少模型的胡编概率。别觉得这是废话实际对比过不加这句的时候模型会自信地给出各种幻觉答案。4.3 端到端问答函数把上面几步串起来整个问答就闭环了。我习惯封装成一个函数。def ask_question(question: str) - str: docs retriever.get_relevant_documents(question) context \n\n.join([doc.page_content for doc in docs]) prompt prompt_template.format(contextcontext, questionquestion) response chat_model.invoke(prompt) return response.content到这里一个基础版的RAG问答系统就能跑起来了。用户输入任何问题系统自动走检索、拼装、生成三步返回答案。我在企业内部资料上实测原本需要翻半天文档的问题现在基本几秒内能给到一个有依据的回答。5. 跑通之后我踩过的几个影响效果的关键点5.1 相关性阈值与不知道的判断基础版跑通之后你会发现一个尴尬场景明明文档里没有答案系统还是会想办法凑一个出来。这时候需要在检索环节加一个阈值判断只有最高相似度分数达到设定值的片段才被采纳。不同Embedding模型的相似度分数分布差异很大有的普遍在0.8以上有的在0.5左右不建议直接套用别人的阈值。先把一批已知有答案和已知没答案的问题跑一遍观察分数分布再定阈值。实测下来这一步能让回答质量稳定不少。5.2 chunk参数、模型选择对效果的直观影响我做过几次控制变量测试对比了不同参数下的回答准确性配置召回片段是否完整回答准确性备注chunk_size200, overlap20频繁截断语句偏低小块丢失上下文chunk_size500, overlap80句子完整良好技术文档推荐chunk_size1000, overlap200片段过宽中等检索噪声高模型层面的影响更直观。我一开始用的是通用对话模型回答虽然流畅但经常脱离给定资料自由发挥后来换了指令遵循能力更强的版本回答明显老实了。在RAG链路里模型最重要的不是知识储备而是按给定资料作答的服从性。5.3 基础版后面能升级的方向基础版跑通之后有几个值得尝试的进阶方向换更好的Embedding模型和中转重排序这是提效果见效最快的把similarity检索换成混合检索关键词和向量双通道召回再合并根据问题类型做路由简单问题直接答复杂问题才检索对历史问答缓存减少重复调用大模型的次数省钱又提速我自己在基础版稳定跑了两周之后才陆续把重排序和问题改写加进去。不要一上来就追求大而全的架构先把基础链路吃透再一步步往上加排查问题的时候心里才有底。最后分享一个我调参时的小技巧在检索结果里把每段文本的相似度分数打印出来连同最终答案一起看。只要瞄一眼分数分布你就知道是检索没召回对的文档还是模型没好好用文档定位问题会快很多。这套基础版花了我一个周末从零搭起来核心代码不到两百行但它解决的问题是实打实的。如果手头也有文档攒了一堆、问啥啥不知道的困扰照着这个链路走一遍应该很快能看到效果。