ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

LangChain+RAG基础版:从本地文档到智能问答的完整实现

LangChain+RAG基础版:从本地文档到智能问答的完整实现

我最初做这套“RAG基础版”,起因特别朴素:本地攒了一堆项目文档、会议纪要和历史方案,真要查点东西的时候,大模型虽然聊得头头是道,但一涉及具体业务细节就开始胡编。

当时也试过直接把整份文档塞进上下文,几千字的材料勉强能跑,等到几万字的时候就彻底没戏了。后来接触到RAG(Retrieval-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", encoding="utf-8") documents = loader.load()

这里有一个我当时忽略的细节:文档加载进来后不是马上就能切片的。一份文档里往往充斥着目录、重复小标题、无关的导航信息,如果不做清洗,这些噪音会直接进入向量库,将来检索的时候把它们当上下文喂给模型,反而会干扰回答。

我就是从第二版才开始认真做清洗的,效果立刻不一样。清洗其实不复杂,正则把多余空白、特殊字符、明显的页眉页脚去掉就够了。对基础版来说,不要在清洗上花太多时间,但基本的去噪一定要做。

3.2 文本切分的参数博弈

文本切分是整个RAG链条里最值得花时间调的一步。切得太小,语义上下文被截断,检索到的片段残缺不全;切得太大,向量化精度降低、检索噪声也多,而且塞给模型的时候浪费上下文窗口。

LangChain提供了几种切分器,最常用的是按字符数和重叠区间来切的。

from langchain_text_splitters import RecursiveCharacterTextSplitter text_splitter = RecursiveCharacterTextSplitter( chunk_size=500, chunk_overlap=80, separators=["\n\n", "\n", "。", "!", "?", ";", ","], ) chunks = text_splitter.split_documents(documents)

chunk_size代表每块字符数上限,chunk_overlap代表相邻两块的共享字符数。重叠的意义在于:如果一句话恰好横跨两块边界,没有重叠的话语义就会被硬生生切断;有重叠,两头都能保留上下文。

你可能会问,到底设多少合适?我的经验是:技术文档建议chunk_size=500左右,聊天记录或短句类内容可以更小。这个值没有绝对标准,但有一个判断依据——切出来的块要尽量是一个语义完整的单元,而不是机械的数字切分。观察几个切分结果,发现句子被腰斩、问题描述被切成两半,就该调大chunk_overlap或调整分隔符优先级。

3.3 向量化存储:从embedding到写入

切分完成的文档块要转成向量。我用的Embedding模型支持中文效果尚可,调用方式在LangChain里有对应封装。

from langchain_community.embeddings import SomeEmbedding from langchain_community.vectorstores import FAISS embedding_model = SomeEmbedding(api_key="your-key") vectorstore = FAISS.from_documents(chunks, embedding_model) vectorstore.save_local("faiss_index")

这段代码做了三件事:把每一块文本转成向量、把所有向量连同原文一起建索引、把索引落盘。好处是一次构建,以后直接读盘使用,不用每次启动都重新处理文档。

vectorstore = FAISS.load_local("faiss_index", embedding_model, allow_dangerous_deserialization=True)

加载的时候如果不传allow_dangerous_deserialization参数,新版本LangChain会直接抛异常。这个参数是因为从本地文件反序列化存在潜在安全风险,如果你能保证索引文件是自己生成的,可以放心打开。

4. 检索与生成:写一个能跑起来的问答闭环

4.1 相似度检索的关键动作

索引建好之后,真正回答问题的阶段反而是简单的。用户输入问题后,系统先把问题转成向量,然后在库里做相似度搜索。LangChain把这一层也封装了。

retriever = vectorstore.as_retriever( search_type="similarity", 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]), question=user_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(context=context, question=question) response = chat_model.invoke(prompt) return response.content

到这里,一个基础版的RAG问答系统就能跑起来了。用户输入任何问题,系统自动走检索、拼装、生成三步,返回答案。我在企业内部资料上实测,原本需要翻半天文档的问题,现在基本几秒内能给到一个有依据的回答。

5. 跑通之后,我踩过的几个影响效果的关键点

5.1 相关性阈值与"不知道"的判断

基础版跑通之后,你会发现一个尴尬场景:明明文档里没有答案,系统还是会想办法凑一个出来。这时候需要在检索环节加一个阈值判断,只有最高相似度分数达到设定值的片段才被采纳。

不同Embedding模型的相似度分数分布差异很大,有的普遍在0.8以上,有的在0.5左右,不建议直接套用别人的阈值。先把一批已知有答案和已知没答案的问题跑一遍,观察分数分布,再定阈值。实测下来这一步能让回答质量稳定不少。

5.2 chunk参数、模型选择对效果的直观影响

我做过几次控制变量测试,对比了不同参数下的回答准确性:

配置召回片段是否完整回答准确性备注
chunk_size=200, overlap=20频繁截断语句偏低小块丢失上下文
chunk_size=500, overlap=80句子完整良好技术文档推荐
chunk_size=1000, overlap=200片段过宽中等检索噪声高

模型层面的影响更直观。我一开始用的是通用对话模型,回答虽然流畅但经常脱离给定资料自由发挥;后来换了指令遵循能力更强的版本,回答明显老实了。在RAG链路里,模型最重要的不是知识储备,而是"按给定资料作答"的服从性。

5.3 基础版后面能升级的方向

基础版跑通之后,有几个值得尝试的进阶方向:

  1. 换更好的Embedding模型和中转重排序,这是提效果见效最快的
  2. 把similarity检索换成混合检索,关键词和向量双通道召回再合并
  3. 根据问题类型做路由,简单问题直接答,复杂问题才检索
  4. 对历史问答缓存,减少重复调用大模型的次数,省钱又提速

我自己在基础版稳定跑了两周之后,才陆续把重排序和问题改写加进去。不要一上来就追求大而全的架构,先把基础链路吃透,再一步步往上加,排查问题的时候心里才有底。

最后分享一个我调参时的小技巧:在检索结果里把每段文本的相似度分数打印出来,连同最终答案一起看。只要瞄一眼分数分布,你就知道是检索没召回对的文档,还是模型没好好用文档,定位问题会快很多。这套基础版花了我一个周末从零搭起来,核心代码不到两百行,但它解决的问题是实打实的。如果手头也有"文档攒了一堆、问啥啥不知道"的困扰,照着这个链路走一遍,应该很快能看到效果。

RELATED READING

延伸阅读

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