ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

LangChain+FAISS 构建知识库问答机器人:RAG 原理与实战避坑

LangChain+FAISS 构建知识库问答机器人:RAG 原理与实战避坑

简介:这是一套使用LangChain构建基于RAG知识库的智能聊天机器人项目,适合具备一定Python基础、希望快速上手大模型应用开发的工程师。项目整合OpenAI模型、FastAPI后端与HTML+JS+CSS前端,附带问题QA知识库和图文教程,可完整演示从知识库构建、向量检索到前后端联调回答的RAG实现链路。压缩包共9个文件,具体包括2个Markdown文档、Python服务脚本、HTML/CSS/JS前端文件、环境变量配置、依赖清单、Git忽略文件与说明文档等,体积仅6KB,结构轻量清晰,便于本地部署与二次开发。目前已有965人学习下载,适合快速入门与项目二次开发。通过对照教程与源码,读者能掌握LangChain调用OpenAI构建RAG问答的核心方法,并可直接扩展至客服助手、个人助理、教育辅导等真实场景,是一份覆盖环境搭建、后端服务、前端交互和知识库组织的紧凑而完整的实战参考。

1. 先弄清楚:RAG 聊天机器人到底在解决什么问题

如果你手头有一堆内部文档、产品手册或历史工单,想让大模型基于这些内容做问答,会发现一个很尴尬的事实:模型肚子里根本没有这些资料,你问它它只能胡编。微调模型成本高、迭代慢,而且今天改十页文档,明天就得重新训一次。RAG(Retrieval-Augmented Generation,检索增强生成)就是这一问题的标准解法:把文档切片、向量化后存进知识库,用户提问时先检索出最相关的片段,再把这些片段拼进提示词交给大模型生成答案。LangChain 是目前组织这套链路最成熟的框架,它把文档加载、文本切分、向量存储、检索、对话记忆和生成串成一条可维护的流水线。这篇文章面向两类读者:一类是想快速搭出基于私有知识库的问答机器人,但不想从零写检索逻辑的开发者;另一类是已经跑通过简单 Demo,却在实际数据上频繁翻车、想搞清楚参数和排查思路的工程师。我会按从原理到实现、再到避坑和进阶的顺序,把整条链路讲透。

2. 拆解 RAG 链路:为什么检索决定答案质量的上限

2.1 RAG 的核心思想:让模型学会“开卷考试”

大模型本身是一个闭卷考生,知识停留在训练数据截止的那一刻,而且对私有内容一无所知。RAG 做的事情很简单:每次考试前,先从资料库里翻出相关段落,夹在试卷里让模型照着答。这个思路的好处在于,你不必改变模型的权重,只需要改变它每次回答问题时的上下文。检索结果的质量直接决定生成答案的质量——如果检索回来的片段本身不相关,再强的生成模型也只能基于垃圾输入输出垃圾结果。这一点被很多人忽略,他们把大量精力花在调提示词上,却不愿意花时间优化检索环节。

用我自己的经历来说,某次做一个设备维修知识库问答,用户问“更换液压油滤芯后需要排气吗”,检索链路返回的片段是“滤芯型号对照表”,答案自然答非所问。根因不是模型不行,而是文本切分把上下文割裂了。理解了「检索是上限、生成是下限」这句话,后面所有优化方向都会变得清晰。

2.2 一个完整 RAG 管线的五个必经环节

一条典型的 LangChain RAG 管线包括五个环节:文档加载、文本切分、向量化存储、相似度检索、生成回答。文档加载解决的是“把 PDF、Word、Markdown、网页变成纯文本”的问题;文本切分解决的是“把长文档切成适合检索和模型上下文的小块”的问题;向量化存储解决的是“把文本变成数字向量并支持快速查找”的问题;相似度检索解决的是“找出与问题最相关的若干片段”的问题;生成回答解决的是“把片段和问题打包成提示词,让模型给出有依据的输出”的问题。

这五个环节里,文本切分和相似度检索是最容易被低估的两环。很多人以为只要把 PDF 丢进去就能得到好答案,实际上一份 200 页的手册如果不做结构化切分,检索回来的片段可能是半截表格、半个章节,模型根本读不出有效信息。向量化的选型同样关键,嵌入模型决定了文本在向量空间里的分布方式,选错了模型,相似文档的向量距离可能会很远,导致检索召回率为零。

2.3 LangChain 在这条链路里扮演什么角色

LangChain 不提供大模型、不提供向量数据库,它提供的是把这些组件拼装在一起的抽象层。它的价值主要体现在三方面:第一是统一的接口,无论你用的是哪家 Embedding 模型、哪个向量库、哪家大模型 API,LangChain 都封装成相近的调用方式,切换供应商时不用重写业务代码;第二是预制组件,文档加载器有几十种格式适配,文本切分器有多种策略,检索器有向量库检索、多路检索、带重排序的检索等,开箱即用;第三是链式组装,把检索和生成串成一条可复用的链对象,后续做流式输出、回调监控、对话记忆都在这条链上做扩展。

不过也要说一句公道话:LangChain 的版本迭代比较快,API 变动频繁,网上很多教程跑不通,根源往往是版本不匹配。我在后文会给出经过验证的版本组合和完整代码,你照着跑就能通。

2.4 嵌入模型与向量库的选择逻辑

嵌入模型负责把文本转换成一串浮点数,常见选项有 OpenAI 的 text-embedding-ada-002、智源的 BGE 系列、阿里的通义文本向量模型等。选型时看三个指标:检索效果、维度大小、语言适配度。如果你处理的是中文知识库,BGE 系列或通义向量模型通常会比通用英文模型效果好;如果数据以英文为主,OpenAI 嵌入模型依然是很稳的选择。

向量库方面,FAISS 适合中小规模知识库(几十万条以内)的单机部署;Milvus 适合大规模数据和分布式场景;Chroma 和 Qdrant 则胜在轻量和易用。我一般建议项目初期用 FAISS 把链路跑通,验证检索质量后再决定是否迁移到更重的向量库。迁移成本不高,因为 LangChain 把向量库操作封装成了统一的接口,切换只需要改几行初始化代码。

3. 从零搭建 LangChain RAG 聊天机器人:最小可复现实现

3.1 环境准备与版本选型

动手之前先确认环境。Python 版本建议 3.10 或 3.11,过低或过高都可能遇到依赖编译问题。下面这组版本组合是我实际验证过的,能避免大量踩坑:

langchain==0.1.20 langchain-community==0.1.17 langchain-openai==0.1.14 faiss-cpu==1.8.0 pypdf==4.2.0

如果你用的是 OpenAI 系列模型,还需要配置 OPENAI_API_KEY。我一般习惯把密钥放在项目根目录的 .env 文件里,用 python-dotenv 加载,这比硬编码在代码里安全得多。安装命令如下:

pip install langchain==0.1.20 langchain-community==0.1.17 langchain-openai==0.1.14 faiss-cpu==1.8.0 pypdf==4.2.0 python-dotenv==1.0.1

建议在虚拟环境里安装,避免污染全局 Python 环境。装完验证一下版本是否一致,不一致就按上面对齐,LangChain 各子包的版本兼容问题是新手最常见的翻车点。

3.2 加载文档:从 PDF 和 Markdown 开始

文档加载是 RAG 链路的起点。LangChain 的 langchain-community 包里提供了丰富的加载器,最常用的是 DirectoryLoader 和 PyPDFLoader。前者可以批量加载目录下的所有文件,后者针对单个 PDF 做解析。我建议先拿几份干净的 Markdown 或 PDF 做测试,不要一上来就处理扫描件或复杂表格。

from langchain_community.document_loaders import DirectoryLoader, PyPDFLoader # 批量加载 markdown 文件 md_loader = DirectoryLoader( path="./docs/md", glob="**/*.md", show_progress=True, use_multithreading=True, ) md_docs = md_loader.load() # 加载单个 pdf 文件 pdf_loader = PyPDFLoader(file_path="./docs/manual.pdf") pdf_docs = pdf_loader.load() # 合并文档列表 all_docs = md_docs + pdf_docs print(f"共加载 {len(all_docs)} 个文档块")

这里的 use_multithreading=True 适合多文件场景,能明显加快加载速度;glob 参数控制匹配模式,**/*.md 表示递归匹配所有子目录下的 md 文件。加载完成后每个 Document 对象包含 page_content(正文文本)和 metadata(来源路径、页码等元信息)。需要特别注意,PDF 加载器的解析质量取决于 PDF 本身的排版,多栏布局和扫描件是重灾区,后文会讲应对方法。

3.3 文本切分的参数详解:chunk_size 和 chunk_overlap 怎么配

文本切分是 RAG 里最影响检索质量的环节,没有之一。LangChain 默认推荐的是 RecursiveCharacterTextSplitter,它按一组分隔符递归切分,优先保留语义完整的段落。核心参数有两个:chunk_size 和 chunk_overlap。chunk_size 控制每一块文本的字符数(不是 token 数),chunk_overlap 控制相邻块之间重叠的字符数,目的是避免一句话在切分点上被截断,导致检索时语义残缺。

from langchain_text_splitters import RecursiveCharacterTextSplitter text_splitter = RecursiveCharacterTextSplitter( chunk_size=500, chunk_overlap=100, length_function=len, separators=["\n\n", "\n", "。", "!", "?", ",", " ", ""], ) split_docs = text_splitter.split_documents(all_docs) print(f"切分为 {len(split_docs)} 个文本块")

这里的 separators 参数很重要,我按中文标点做了扩展,让它优先在段落、句号、问号处切分,尽可能保持语义完整。length_function 用 len 统计字符数,对中文知识库来说,500 字符大致对应 300~400 个 token,比较适合大多数开源模型的上下文窗口。如果你的知识库是技术参数表这类高度结构化的内容,可以把 chunk_size 适当调小到 300 左右;如果是长篇幅的叙述性文档,调到 800 也问题不大。chunk_overlap 一般是 chunk_size 的 15%~30%,100 对 500 是一个比较稳的起点。

很多从没做过 RAG 的人会犯一个错误:认为切得越细检索越精准。实际上切得太细会丢失上下文,切得太粗会把多个主题混在一块。我建议做一次快速实验:随机挑 5 个问题,手动检查检索回来的 chunk 内容与问题的相关性,如果发现上下文断裂或主题混杂,就调整 chunk_size 重新切。

3.4 向量化与存储:构建可检索的知识库索引

文本切分完成后,下一步是把每一块文本转成向量并存入向量数据库。这里有两个关键决策:嵌入模型用什么、向量库用什么。我以 OpenAI 的 text-embedding-ada-002 和 FAISS 为例演示,这套组合对中小规模知识库非常友好。

from langchain_openai import OpenAIEmbeddings from langchain_community.vectorstores import FAISS # 初始化嵌入模型 embeddings = OpenAIEmbeddings( model="text-embedding-ada-002", chunk_size=1000, # 批量请求的文本条数 ) # 构建向量索引并持久化 vectorstore = FAISS.from_documents( documents=split_docs, embedding=embeddings, ) # 保存到本地,下次直接加载,避免重新向量化 vectorstore.save_local("./faiss_index") print(f"向量索引已构建,共 {vectorstore.index.ntotal} 条向量")

embedding 的 batch_size 控制单次 API 请求处理的文本条数,条数过多会触发请求体超限,建议保持在 64~1000 之间。向量索引构建完成后一定要 save_local 持久化,否则每次启动程序都要重新把全部文档向量化,耗时且费钱。加载本地索引的代码是 FAISS.load_local("./faiss_index", embeddings),后续会把完整链路串起来。

需要注意一个细节:如果你切换了嵌入模型,本地索引必须重建,因为不同模型产出的向量不在同一语义空间内,拿着 A 模型的向量去 B 模型的索引里检索,结果没有任何意义。

3.5 检索策略:相似度搜索背后还有哪些可调旋钮

向量检索的核心是找到与用户问题最相似的文本块。LangChain 默认的 similarity_search 是基于余弦距离或内积的暴力检索,数据量大时可以用 FAISS 的 IVF 索引加速,但初始阶段没这个必要。除了基础检索,还有三个旋钮值得调:fetch_k、score_threshold 和 k。k 是最终返回给模型的文本块数,fetch_k 是先粗召回多少候选,score_threshold 是过滤掉相似度低于阈值的块。

# 基础相似度检索 retriever = vectorstore.as_retriever( search_type="similarity", search_kwargs={"k": 4} ) # 带相关性分数过滤的检索 retriever_filtered = vectorstore.as_retriever( search_type="similarity_score_threshold", search_kwargs={"k": 4, "score_threshold": 0.6} ) # 手动检索,查看返回结果 results = retriever.invoke("更换液压油滤芯后需要排气吗") for i, doc in enumerate(results): print(f"第 {i+1} 个结果,来源: {doc.metadata}") print(doc.page_content[:150]) print("---")

k 的取值影响答案的上下文完整度,也占用模型输入 token,一般设置 3~6 比较合适。score_threshold 需要先实测你的嵌入模型对相关和无关文本的分数分布,再设定阈值。我自己的经验是阈值设置在 0.5~0.7 之间能过滤掉大部分无关片段,但不同嵌入模型差异很大,不能照搬,必须拿你的数据先做一轮探查。手动打印检索结果这一步值得认真做,它能直观暴露切分和向量化的问题,是调试 RAG 的第一手段。

3.6 生成链路:把检索结果和对话记忆接入大模型

检索做完了,接下来是把相关片段、系统提示词、用户问题和历史对话打包交给大模型。LangChain 最常用的方式是 RetrievalQA 链,但实际项目中我建议用更灵活的 LCEL(LangChain Expression Language)写法,它支持流式输出和更细粒度的控制。

from langchain_openai import ChatOpenAI from langchain_core.prompts import ChatPromptTemplate from langchain_core.runnables import RunnablePassthrough # 定义提示词模板 prompt_template = """ 你是一个基于知识库回答问题的助手。请只根据以下资料回答问题,如果资料中没有相关内容,请明确回答“知识库中没有找到相关信息”,不要编造。 【相关资料】 {context} 【历史对话】 {chat_history} 【用户问题】 {question} """ prompt = ChatPromptTemplate.from_template(prompt_template) # 初始化大模型 llm = ChatOpenAI( model="gpt-4o-mini", temperature=0.2, ) # 定义格式化函数:把检索到的文档拼成上下文 def format_docs(docs): return "\n\n".join(doc.page_content for doc in docs) # 构建 LCEL 链 rag_chain = ( { "context": retriever | format_docs, "question": RunnablePassthrough(), "chat_history": RunnablePassthrough(), } | prompt | llm ) # 单轮问答测试 response = rag_chain.invoke("更换液压油滤芯后需要排气吗") print(response.content)

这里用 LCEL 把几个 Runnable 组件用管道符串起来:先检索并格式化上下文,同时把用户问题透传给提示词模板,然后交给模型生成。temperature 设到 0.2 是为了在稳定性和少量多样性之间取得平衡,如果是严肃的运维问答场景,直接设 0 也合理。提示词里“知识库中没有找到相关信息”这句兜底很重要,它能在检索结果不相关时让模型明确说不知道,而不是硬编一个答案出来。

3.7 多轮对话记忆:一个必须提前规划的环节

单轮问答跑通后,用户自然会问“多轮对话怎么办”。如果每次只把当前问题交给模型,聊天机器人就完全没有上下文记忆,用户说“那换成夏天用的型号呢”这种指代性问题时,模型根本不知道“那”指什么。LangChain 处理对话记忆的思路是把历史对话也拼进提示词,可以用 ConversationBufferMemory 或更轻量的 RunnableWithMessageHistory。

from langchain_core.chat_history import InMemoryChatMessageHistory from langchain_core.runnables.history import RunnableWithMessageHistory # 历史消息存储,生产环境建议换成 Redis 等持久化方案 session_store = {} def get_session_history(session_id: str): if session_id not in session_store: session_store[session_id] = InMemoryChatMessageHistory() return session_store[session_id] # 带历史记录的对话链 rag_with_history = RunnableWithMessageHistory( rag_chain, get_session_history, input_messages_key="question", history_messages_key="chat_history", ) # 多轮对话测试 response = rag_with_history.invoke( {"question": "更换液压油滤芯后需要排气吗"}, config={"configurable": {"session_id": "test-session"}}, ) print("第一轮回答:", response.content) response2 = rag_with_history.invoke( {"question": "那滤芯型号是哪种?"}, config={"configurable": {"session_id": "test-session"}}, ) print("第二轮回答:", response2.content)

session_id 是区分不同用户会话的标识符,实际项目中一般用登录用户 ID 或前端生成的会话 ID。上面这段代码把历史消息存在内存里,服务一重启就丢了,生产环境建议把历史消息存到 Redis 这类持久化存储中。多轮对话有一个潜在隐患:历史消息越长,占用的上下文窗口越多,检索出的相关资料块能塞进去的空间就越小。我一般习惯在提示词里只保留最近 3~5 轮对话,或者用摘要压缩历史,避免对话过长时模型“看不清”知识库内容。

4. 常见问题排查:5 个让我熬夜最多的坑

4.1 中文文本切分把完整段落切得七零八落

现象是检索结果总在句子中间断掉,生成的答案前言不搭后语。原因在于默认的 RecursiveCharacterTextSplitter 对中文支持不友好,按英文句点和换行切分,导致中文文本的切分点落在不恰当位置。解决方法是像前文那样显式指定中文标点作为分隔符,把 separators 里的分隔符顺序调整为["\n\n", "\n", "。", "!", "?"],并且务必调整顺序,让完整的语义块优先保留。更深层的做法是改用按 Markdown 标题结构切分的 MarkdownHeaderTextSplitter,如果你知识库里的文档有清晰的标题层级,这种切分器效果明显更好。

4.2 答案里充满幻觉,压根没用到检索内容

一套链路跑通了,但问它知识库之外的问题,它依然一本正经地编答案。这个坑往往出在提示词不够强硬。修复方式是三步:提示词里明确写“只根据资料回答,资料中没有就明确说不知道”;把 temperature 降到 0.2 以下,减少模型的自由发挥空间;设置 score_threshold 过滤无关检索结果。三步都做了还编,就检查检索回来的上下文里是否混入了无关文本——有可能你的 k 值太大,不相关的内容把模型带偏了。

4.3 相同文本重复出现,答案变得冗长啰嗦

检索回来的 4 个结果里有 3 个是同一段内容的重复变体,原因是切分时 chunk_overlap 过大,或者同一份文档被多次加载进了索引。排查思路是先打印检索结果的来源 metadata,看是否来自同一份文档的相邻片段,如果是,把 chunk_overlap 调小。如果是文档重复加载导致索引里有重复向量,解决办法是构建索引前先对文档做去重,或者用向量库的去重机制。这个问题不解决,模型会因为上下文里反复出现相同内容而输出大量冗余表述。

4.4 PDF 表格和扫描件检索质量极差

PDF 加载器遇到复杂表格和扫描件时,解析出来的文本是乱的,检索自然不准。这是 PDF 结构化解析的物理限制,不是 RAG 参数的问题。常见做法是对这些文档单独做 OCR 处理,常见方案是先用 OCR 工具把扫描件转成文本再用 LangChain 加载。表格类内容我一般会考虑先转成 Markdown 表格再入库。如果你知识库里这两种内容占比高,建议把它当作前置预处理任务来做,而不是指望 LangChain 的加载器一步到位。

4.5 LangChain 版本升级后代码突然跑不通

最经典的是 LangChain 0.1.x 里from langchain.vectorstores import FAISS还能用,升级 0.2.x 或 0.3.x 之后报 ImportError。原因是 LangChain 把大量与核心包解耦,社区贡献的组件全部移到 langchain-community。规避办法:用虚拟环境锁定依赖版本,不要在项目中途随意升级;迁移到新版本时先跑一遍线上链路的冒烟测试。我的习惯是每次升级先把 requirements.txt 里所有包固定版本备份,升级后逐个版本比对 API 差异,而不是一把梭升级。

5. 进阶优化:重排序、流式输出与效果评估方法

5.1 重排序:让检索结果从“相关”到“精准”

基础向量检索的局限性在于嵌入模型只理解语义层面的相似度,对关键词精确匹配和细微主题区分不够敏感。比如知识库里同时有“更换滤芯步骤”和“滤芯故障分析”两份文档,问“怎么换滤芯”时,前者确实排在前面,但当文档数量增加后,top-k 里很容易混入主题相近但答案无关的内容。解决方案是加入重排序(Reranker):先用较小的 k(比如 20)召回一批粗相关文档,再用专门的重排序模型按相关性精排,只取前 3~5 个去生成答案。LangChain 里可以做一条自定义检索链:

from langchain.retrievers import ContextualCompressionRetriever from langchain.retrievers.document_compressors import CrossEncoderReranker from langchain_community.cross_encoders import HuggingFaceCrossEncoder # 初始化交叉编码器重排序模型 reranker_model = HuggingFaceCrossEncoder( model_name="BAAI/bge-reranker-v2-m3", ) # 构建重排序压缩器 compressor = CrossEncoderReranker( model=reranker_model, top_n=3, ) # 包裹原检索器 compression_retriever = ContextualCompressionRetriever( base_compressor=compressor, base_retriever=retriever, ) # 使用重排序后的检索器 rag_chain_rerank = ( { "context": compression_retriever | format_docs, "question": RunnablePassthrough(), "chat_history": RunnablePassthrough(), } | prompt | llm )

top_n 是重排序后保留的文档数,一般取 3~5。重排序会引入额外计算延迟,每轮问答大约增加几十到几百毫秒,但对答案质量的提升是实打实的。如果你做的是知识密集型问答场景,这一步投资回报很高。

5.2 流式输出:体验优化的关键一环

聊天机器人如果等全部答案生成完才一次性吐出,用户会感觉响应很慢。流式输出让模型边生成边输出,用户首字延迟大幅降低。LangChain 的 LCEL 链天然支持.stream()方法,改造成本极低:

# 流式输出整个人类可读的答案 async for chunk in rag_chain.astream( {"question": "更换液压油滤芯后需要排气吗"} ): print(chunk.content, end="", flush=True)

如果你用的是带历史的链,同样传入 session_id 配置即可。前后端对接时,一般走 SSE(Server-Sent Events)协议,后端把流式拼接好的 token 逐个推给前端,前端一边接收一边渲染 Markdown。这个优化非常推荐在第一个版本就做,因为改造成本小,但对体验的改善极其明显。

5.3 快速评估:不依赖人工逐条验证的评测方法

评估 RAG 效果不能只靠“我感觉还行”,需要有可量化的指标。最省钱的做法是准备一组 20~50 条(问题、标准答案、相关文档片段)的评测集,然后让大模型扮演裁判,逐条给生成结果打分,最后统计平均分和失败案例。

eval_prompt = """ 请从准确性、相关性、完整性三个维度,给助手回答打分(1-5分)。 问题:{question} 标准答案:{reference} 助手回答:{answer} 请返回 JSON 格式:{{"accuracy": 分, "relevance": 分, "completeness": 分, "reason": "简要说明"}} """ # 评测循环 def evaluate_rag(question, reference, answer): prompt = eval_prompt.format( question=question, reference=reference, answer=answer, ) eval_result = eval_llm.invoke(prompt) return eval_result.content

eval_llm 可以用更强的模型(比如 gpt-4o),让它在你的业务语境下做裁判,比通用指标贴近实际。这套评测体系的价值在于:每次修改切分参数、嵌入模型或提示词后,你能跑一遍评测集看到分数的变化,而不是靠记忆感知“好像好了一点”。这也是我把项目交付给同事或客户时必做的一件事,有了分数基线,后续优化才不会沦为玄学。

最后说一点我自己的教训:做 RAG 聊天机器人,前三次迭代的重心一定要放在检索质量上,而不是调模型或调提示词。我见过很多同行花了两周在提示词工程上精雕细琢,最后发现问题的根源是文本切分把章节标题和正文拆开了,导致检索返回的全是孤立的正文片段,模型根本不知道这段内容属于哪个主题。先拿 10 个有代表性的问题跑一遍检索,打印中间结果,亲眼看看召回内容与问题的匹配度,再决定下一步优化方向——这是我一直保持的习惯,也是这条链路上性价比最高的一个动作。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

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