最近在做内部文档问答系统的时候,我把这套基于 LangChain 的 RAG 问答库完整搭了一遍,跑通之后发现整个流程比想象中顺手不少。这个项目叫 langchain-rag-chat,核心就是用 LangChain 快速拼装一套开箱即用的 RAG 问答库:你丢给它一堆文档,它能自动切分、向量化、建索引,然后你就能像聊天一样问它问题,回答内容基于你给的文档,而不是模型瞎编。
说实话,RAG 这个思路听起来简单——不就是"检索增强生成"嘛——但真正落地的时候,文档怎么切、向量怎么存、检索怎么调、上下文怎么塞,每一步都有坑。这篇博文我就把这套方案从零到一完整拆开讲清楚,包括代码实现、参数选择理由、踩过的坑和调优思路,适合刚入门 LangChain 的开发者、想快速验证 RAG 效果的产品经理,以及准备做知识库问答但没有太多时间抠细节的团队。
1. 整体设计思路与技术选型
1.1 为什么用 LangChain 而不是自己手写
RAG 的本质流程并不复杂:加载文档 -> 切分 -> 向量化 -> 存库 -> 检索 -> 拼 Prompt -> 调 LLM 生成回答。这套东西自己写也不难,难的是生态和工程细节。LangChain 的价值在于把这些步骤抽象成标准组件,文档加载有几十种 Loader,切分器有四五种策略,向量库适配了 FAISS、Chroma、Milvus、Elasticsearch 等主流方案,你只需要写很薄的胶水代码就能串起来。
我见过很多人纠结"要不要用 LangChain,会不会过度封装"。我的判断是:如果只是做个 Demo,自己写没问题;但如果你希望后续能快速切换向量库、换 Embedding 模型、加记忆、接 Agent,LangChain 的抽象层能帮你省掉大量重构时间。langchain-rag-chat 这个项目实际跑起来之后,换个向量库只改一行配置,这种便利性自己手写很难做到。
1.2 开箱即用的设计目标
"开箱即用"这四个字是我对这套方案的核心要求:克隆代码、装依赖、填 API Key、跑起来,整个过程不应该超过 15 分钟。为了实现这个目标,我需要做到三件事。
第一,配置驱动。所有可变的参数——模型名称、向量库路径、切分大小、检索 TopK——全部放在配置文件里,不散落在代码各处。第二,默认值合理。我不要求用户第一次跑就去调参,默认配置是基于常见文档规模(几十页到几百页)验证过的,能直接出结果。第三,启动脚本把整个流程串起来,一条命令完成文档入库,一条命令启动问答服务。
这意味着我在设计代码结构时,把"入库"和"问答"两条链路分开。入库是离线任务,跑一次就行;问答是在线服务,需要响应快。两者共享同一个向量库文件,这个设计在后面排查问题的时候帮了大忙。
1.3 技术选型背后的考量
向量数据库我选了 FAISS,没有用 Chroma 或 Milvus。原因很简单:对于个人知识库、小型团队内部文档这种规模(几千到几万条向量),FAISS 完全够用,而且是本地文件存储,不需要额外起服务。Milvus 适合百万级以上的生产场景,但那套运维成本对一个刚起步的 RAG 项目来说太重了。Chroma 虽然也很轻,但 FAISS 的检索性能在同等规模下表现更稳,而且 LangChain 对 FAISS 的封装最成熟。
Embedding 模型这块,我留了两个接口:一个走 OpenAI 的 text-embedding-3-small,适合有 API 额度的情况;另一个走 Ollama 拉本地模型,比如 bge-m3 或者 nomic-embed-text,适合数据敏感或者不想付费的场景。两者在代码里通过配置切换,完全不影响上层逻辑。LLM 那边同理,OpenAI 的 gpt-4o-mini 负责高质量回答,Ollama 上的 qwen2.5 或 llama3.1 负责本地离线跑。
2. 核心细节解析与实操要点
2.1 文档加载:格式兼容是第一个坑
RAG 的第一步是把文档变成纯文本。但现实世界里文档格式五花八门:PDF、Word、Markdown、TXT、甚至扫描件。LangChain 提供了统一的 BaseLoader 接口,我用的是 DirectoryLoader 加多格式 loader 映射,代码写起来像这样:
from langchain_community.document_loaders import ( PyPDFLoader, Docx2txtLoader, TextLoader, UnstructuredMarkdownLoader, ) from langchain_community.document_loaders import DirectoryLoader loaders = { ".pdf": PyPDFLoader, ".docx": Docx2txtLoader, ".txt": TextLoader, ".md": UnstructuredMarkdownLoader, }每一个格式的 Loader 背后都有不同的解析库,踩坑最狠的是 PDF。PyPDFLoader 遇到的问题通常是两种情况:一种是扫描版 PDF——整页就是一张图,直接加载出来是空文本;另一种是排版复杂的 PDF,文字顺序错乱。前者的解法是先用 OCR 工具(比如 PaddleOCR)把图片里的文字提出来,再进入 RAG 流程;后者的解法是换解析库,实测下来 Unstructured 对复杂排版的容忍度比 PyPDF 高不少,但速度更慢,需要按需取舍。
还有个容易忽略的点:编码问题。TextLoader 默认按 UTF-8 读取,遇到 GBK 编码的中文 txt 直接报错,我在 loader 里统一加了 encoding="utf-8" 的兜底,同时捕获异常跳过坏文件。做知识库的人一定要记住,线上数据永远比你想象的脏,加载阶段多做容错,后面能少哭很多次。
2.2 文本切分:chunk_size 不是越大越好
切分策略直接决定 RAG 的上限。切太碎,语义不完整;切太大,向量检索的精度下降,而且塞进 Prompt 的 token 会爆。我用的默认策略是 RecursiveCharacterTextSplitter,按字符递归切分,chunk_size=500,chunk_overlap=80。
为什么是 500?这个数字不是拍脑袋拍的。OpenAI 的 text-embedding-3-small 对 512 token 以内的文本编码效果最稳定,中文场景下 500 个字符大约对应 300~400 token,既能保留一个完整段落的语义,又不会超出 Embedding 模型的舒适区。chunk_overlap=80 的作用是让相邻切块之间保留上下文连续性,避免一个完整句子被拦腰截断丢失信息。
对于 Markdown 或 HTML 这类带结构的文档,我强烈建议换成 MarkdownHeaderTextSplitter 或者 HTMLHeaderTextSplitter。它们的逻辑是先从标题层级入手,把文档切成语义完整的题块,再在题块内部按长度二次切分。实测这个策略对技术文档、操作手册的效果提升非常明显。如果你手里的文档是标准化的接口文档,这个改动值得做。
from langchain_text_splitters import RecursiveCharacterTextSplitter text_splitter = RecursiveCharacterTextSplitter( chunk_size=500, chunk_overlap=80, length_function=len, separators=["\n\n", "\n", "。", "!", "?", ";", " ", ""], )注意最后那个 separators 参数——我把中文句末标点加了进去。默认的分隔符列表里面没有中文标点,这意味着英文场景下按空格切得好好的,中文文档却可能在一个句号后面硬切。我见过有人整套流程跑下来效果一直差,最后把向量库里随机抽了几条文本才发现,切出来的块全是支离破碎的短语。如果你做中文知识库,这个细节务必改掉。
2.3 Embedding 模型:本地还是 API
Embedding 模型的选择直接影响检索质量,但很多人忽视这一点,觉得反正都是算向量,用哪个差别不大。实际差别大了。英文场景下 OpenAI 的 embedding 一骑绝尘,但中文场景下,尤其是垂直领域的专业文档,国产开源模型比如 bge-m3、bge-large-zh 的表现完全不输,甚至在一些领域术语上更好。
langchain-rag-chat 里我做了抽象,方便随时切换。用 OpenAI 的代码大家都熟:
from langchain_openai import OpenAIEmbeddings embeddings = OpenAIEmbeddings(model="text-embedding-3-small")本地方案我推荐两条路。一条是 Ollama 跑 nomic-embed-text,资源占用极小,4G 内存的机器都能跑;另一条是 modelscope 或 HuggingFace 拉 bge-m3,中文效果更好,但需要 Python 环境安装 sentence-transformers,首次加载模型要下载几百 MB 权重。两个方案 LangChain 都原生支持:
from langchain_community.embeddings import OllamaEmbeddings embeddings = OllamaEmbeddings(model="nomic-embed-text")这里有个实操提醒:同一个向量库只能对应一种 Embedding 模型。如果你先用 OpenAI 的 embedding 建了索引,后面换成本地模型,必须清空向量库重新入库,否则检索的时候维度都对不上。我在配置文件里特意加了 embedding_model 字段并写入向量库元数据,加载时做个校验,不一致就直接报错提醒,省得用户稀里糊涂跑出错误结果。
2.4 向量存储与检索策略:从最相似到最相关
向量库存进去之后,检索策略决定了"找到的内容是否真的有用"。很多 RAG 项目检索效果差,不是因为向量库不行,而是检索策略没调好。最简单的是按相似度取 TopK:FAISS 默认返回最相似的 K 条。这个策略的问题是容易扎堆——十条结果高度相似,等于只覆盖了一个方面的信息。
实践之后我的结论是:TopK 设置 4~6 之间最稳,同时配合一个相关性阈值。相似度低于阈值的千万别硬塞给 LLM。LangChain 里可以直接用 similarity_score_threshold 检索器:
from langchain_community.vectorstores import FAISS vectorstore = FAISS.from_documents(docs, embeddings) retriever = vectorstore.as_retriever( search_type="similarity_score_threshold", search_kwargs={"score_threshold": 0.5, "k": 4} )如果文档之间内容差异不大,想要结果更多样,可以换 MMR 检索。MMR 算法在保证相关性的同时,会主动拉开结果之间的相似度,让返回的几个片段尽量覆盖不同方面。代价是首次计算慢一点,但在小规模知识库上基本无感。实测 MMR 模式在 FAQ 类知识库上提升明显,在长文档问答上不如纯 TopK 稳定,需要根据场景选择。
再往深了走,就是混合检索和重排的思路了。混合检索是向量检索和关键词检索(BM25)并行跑,合并结果;重排是拿 rera 等模型对召回结果做二次打分。这些进阶手段效果好,但引入的组件和运维复杂度也上来了,适合 base 版本跑通之后再做优化,不要第一步就上重武器。
3. 实操过程与核心环节实现
3.1 环境准备:Mac 和 Linux 上的注意事项
首先是环境依赖。这套代码在 Python 3.10+ 上跑,建议用虚拟环境隔离依赖。依赖安装命令如下:
pip install langchain langchain-community langchain-openai langchain-text-splitters faiss-cpu pypdf python-docx这里有个 Mac 上的经典坑:faiss-cpu 在部分 Apple Silicon 机器上安装没问题,但有些版本会编译失败。如果你遇到这个问题,两条路可以选。一条是装 prebuilt 版本:pip install faiss-cpu --no-cache-dir,大概率能解决;另一条是彻底绕开,用 Chroma 替代 FAISS,LangChain 里两者接口几乎一致。
Ollama 本地模型的启动也值得多说一句。首次启动要拉模型,看网速可能要等一会儿,之后模型常驻内存。Ollama 默认监听 11434 端口,LangChain 连接没问题。但注意 Ollama 模型列表里要把 embedding 模型和 chat 模型分开,别拿同一个模型干两件事。有人图省事直接用 qwen2.5 当 embedding 模型,效果很差,因为 chat 模型生成的向量跟专门的 embedding 模型在空间分布上差异很大,硬用会把检索质量拉低一截。
3.2 入库流程:从文档到向量库的完整实现
入库流程是最核心的环节,我把它封装成一个文件 ingest.py,逻辑分四步:加载文档、切分文本、向量化、存库。核心代码如下:
import config from pathlib import Path from langchain_community.document_loaders import DirectoryLoader from langchain_text_splitters import RecursiveCharacterTextSplitter from langchain_community.vectorstores import FAISS def ingest(): loader = DirectoryLoader( config.DOCS_DIR, glob="**/*.md", loader_cls=TextLoader, loader_kwargs={"encoding": "utf-8"}, ) docs = loader.load() print(f"Loaded {len(docs)} documents") splitter = RecursiveCharacterTextSplitter( chunk_size=config.CHUNK_SIZE, chunk_overlap=config.CHUNK_OVERLAP, ) chunks = splitter.split_documents(docs) print(f"Split into {len(chunks)} chunks") embeddings = config.get_embeddings() vectorstore = FAISS.from_documents(chunks, embeddings) vectorstore.save_local(config.VECTOR_STORE_PATH) print(f"Saved vector store to {config.VECTOR_STORE_PATH}") if __name__ == "__main__": ingest()这里 DirectoryLoader 的 glob 参数我一开始没加,结果把临时文件、图片文件全吞进来,直接报错。后来改成glob="**/*.md",只加载指定格式,干净利落。如果你有新格式文件,在 glob 里加后缀,或者换成前面说的多格式 loader 映射。
FAISS 的本地持久化用的 save_local 方法,序列化到磁盘一个目录。这个目录里包含两个文件:index.faiss 和 index.pkl。前者是向量索引,后者是文档内容的 pickle。加载的时候要带上 embeddings 参数,因为 FAISS 反序列化需要知道向量的维度:
vectorstore = FAISS.load_local( config.VECTOR_STORE_PATH, embeddings, allow_dangerous_deserialization=True, )注意这个 allow_dangerous_deserialization 参数,LangChain 从某个版本开始加了安全校验,默认不允许加载本地 pickle 文件,必须显式打开。这看起来烦人,但是是合理的——pickle 反序列化有远程代码执行的风险,如果你加载的是别人分享的向量库文件,这个开关一定要谨慎。自己本地用没问题,生产环境要对加载来源做严格校验。
3.3 检索问答:把聊天接口串起来
入库做完了,问答链路就是检索加生成。我用的 LangChain 的 LCEL 表达式把组件串成链,这个写法的好处是每个环节都能看到中间结果,方便调试,改成 streaming 输出也容易:
from langchain.prompts import ChatPromptTemplate from langchain_openai import ChatOpenAI from langchain.schema.runnable import RunnablePassthrough from langchain.schema.output_parser import StrOutputParser prompt = ChatPromptTemplate.from_template(""" 你是一个知识库问答助手,请依据以下资料回答问题。 如果资料中没有相关信息,直接说你不知道,不要编造。 资料: {context} 问题:{question} """) def format_docs(docs): return "\n\n".join(f"[来源{i+1}] {doc.page_content}" for i, doc in enumerate(docs)) chain = ( {"context": retriever | format_docs, "question": RunnablePassthrough()} | prompt | llm | StrOutputParser() )这段代码的键值关系我要重点解释。retriever 实例本身就是 Runnable,retriever | format_docs的意思是:先检索,然后把检索到的文档列表传给 format_docs 函数格式化成字符串,最后放进 context 变量。question 则原样传给 LLM。整个链路的执行顺序是:用户提问 -> 检索 -> 格式化上下文 -> 拼 Prompt -> 调用 LLM -> 输出字符串。
Prompt 模板里那句"如果你不知道,就说不知道"非常重要。RAG 最令人反感的行为就是瞎编。加了这个限定之后,当检索结果和问题不相关时,模型会倾向于承认不知道,而不是强行编一个答案。这个在专业知识库场景下尤其关键,宁可回答"暂未找到相关信息",也不能给错误答案误导用户。
3.4 多轮对话的记忆处理
纯问答模式好用,但用户更习惯对话。多轮对话意味着:用户问"它的参数是多少",需要知道"它"指代上一轮提到的产品。实现方式是把历史消息塞进 Prompt,让模型结合上下文理解当前问题。
LangChain 处理这种场景的常见做法是把整段对话历史传给 LLM,由模型决定回答。但这里有个性能陷阱:如果每轮对话都把全部历史塞进去,token 消耗会越来越大,回答延迟也越来越高。我的方案是只保留最近 N 轮对话,并且干脆让模型先对用户问题做独立化改写——把"它的参数是多少"改写成"某某产品的参数是多少",然后再用改写后的独立问题去检索。这个技巧在 RAG 对话场景里非常管用,检索质量提升明显。
from langchain.memory import ConversationBufferWindowMemory memory = ConversationBufferWindowMemory(k=3, return_messages=True)k=3 的意思是保留最近三轮对话。这个值我建议不要调太大,过期的上下文对当前问题帮助有限,反而稀释相关性。
3.5 配置文件:把参数集中管理
整个项目保持开箱即用的体验,关键在设计了一个干净的 config.py。所有参数塞在一个文件里,新用户跑起来只需要改最上面的配置项:
# config.py import os # 文档目录与向量库路径 DOCS_DIR = "./docs" VECTOR_STORE_PATH = "./vector_store" # 文本切分参数 CHUNK_SIZE = 500 CHUNK_OVERLAP = 80 # 模型配置 EMBEDDING_PROVIDER = "openai" # 或 "ollama" LLM_PROVIDER = "openai" # 或 "ollama" OPENAI_API_KEY = os.getenv("OPENAI_API_KEY", "") # RAG 参数 TOP_K = 4 SCORE_THRESHOLD = 0.5用环境变量读 API Key 而不是写死在代码里,这个习惯能避免代码提交到仓库时泄露密钥。如果你用 Ollama 的本地模型,把对应 provider 改成 ollama 并填上模型名,其他不用动。我实测从 OpenAI 切到 Ollama 整个流程大概五分钟,这也是组件化设计的好处。
4. 常见问题与排查技巧实录
4.1 问题速查表
我在跑这个项目的过程中遇到不少问题,也帮朋友排查过一些,整理成速查表,方便你对照排查。
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 中文文档切出乱码文本 | 文本编码不是 UTF-8 | loader 加载时指定 encoding="utf-8",必要时统一转码 |
| 检索结果为空 | 相似度阈值设太高 | 先把 score_threshold 降到 0.3 再逐步调高 |
| 加载向量库报 pickle 警告 | LangChain 反序列化安全限制 | 确认文件来源可信后,加 allow_dangerous_deserialization=True |
| PDF 加载出来是空的 | 扫描版 PDF,无内置文字层 | 先用 OCR(PaddleOCR/Tesseract)提取文字 |
| Mac 上 faiss-cpu 安装失败 | 架构不匹配或缓存冲突 | pip install faiss-cpu --no-cache-dir 重装 |
| 回答内容跟文档对不上 | 检索到不相关片段 | 降低阈值、加大 TopK,或检查 chunk_size 是否过小 |
| 切换 embedding 模型后报维度错 | 向量库仍用旧模型索引 | 清空向量库目录,重新运行入库脚本 |
| Ollama 连接失败 | 服务未启动或端口不对 | 运行 ollama serve,确认 11434 端口可访问 |
里面最有价值的是第一行和第七行。编码问题基本是中文知识库的入门必踩;切换 embedding 模型导致维度错,这个问题出现的频率远比你想象的高,因为大家一开始都是东试一个模型西试一个模型。
4.2 检索效果不好:先别急着换模型
我问过好几个朋友,RAG 效果不好第一反应是换更强的 LLM 或者换更贵的 embedding。但根据我自己的调参经验,大多数情况下问题出在切分和检索参数上,而这两个环节是最容易被忽视的。
第一步要看的,是检索回来的片段到底跟问题相关不相关。把 chain 里的 retriever 单独拿出来,输入一个测试问题,直接打印返回的文档内容。如果片段本身就不相关,后面 LLM 再强也没用。第二步才是调整 chunk_size 和 TopK。文本切分过细,信息被切碎,检索容易漏;切分过大,单条向量包含太多主题,检索命中但不精准。理想状态是每个 chunk 只讲一个完整主题。
第三步是检查文档预处理。我遇到过一份从网上爬来的 HTML 转成的文本,里面全是导航栏、广告、页脚信息,噪音比正文还多。这种文档不经过清洗直接入库,检索效果必然差。RAG 领域有个说法:Garbage in, garbage out。文档清洗花的每一分钟,都能在检索质量上得到回报。
还有一个很实用的小技巧:在入库前对文档做一次粗粒度去重。如果知识库里存在两份高度重复的文档,检索结果会被重复内容占满,导致多样性下降。我在 ingest.py 里加了基于文本哈希的简单去重,效果立竿见影。
4.3 RAG 的边界:什么时候需要换方案
RAG 不是万能的。当你遇到以下情况,说明 plain RAG 已经到瓶颈了,需要考虑架构升级。
第一种情况是知识之间强关联、多层次。典型的例子是产品文档:一个功能涉及接口、配置、权限、错误码四条链路,纯 RAG 切出来的片段彼此割裂,模型很难把它们串成完整答案。这个场景更适合转向 GraphRAG,把实体和关系抽出来存进知识图谱,回答问题时先沿着关系路径推理再拼接证据。
第二种情况是大量结构化数据。表格、数据库、API 接口这些内容,用纯文本切分入库非常浪费,精准度也差。这时应该用结构化知识库——把数据查询能力(比如 SQL)接入问答链路,问题来了先转成查询语句,查到结果再生成回答。我在热词里看到"rag知识库和结构知识库区分以及应用场景",其实关键判断标准就一条:你的数据是文本还是结构化记录。文本用 RAG,记录用查询,两者也能混合用,没有谁替代谁的关系。
第三种情况是任务太复杂,需要多步推理。单轮 RAG 只能做"检索-生成"的直线流程,当问题需要先查 A 再根据 A 的结果查 B,就需要 Agent 来动态编排。LangChain 的 Agent 框架、Dify、CrewAI 都可以做这件事,但它们解决的问题层级不同。LangChain 是底层框架,灵活度高但上手成本也高;Dify 是平台型产品,拖拽界面快速出活;CrewAI 更偏向多角色协作场景。选哪个取决于你的团队规模和项目性质,没有绝对的好坏,个人项目的判断标准很简单:能让你把问题解决掉的就是合适的。
4.4 关于图片与多模态内容的处理
热词里有人问"rag知识库能存储图片嘛",这里展开说一下。传统的文本 Embedding 模型只能处理文字,你直接把图片传进去是没法向量化的。但有两种思路可以解决。
第一种是 OCR 思路:把图片里的文字提取出来,再作为文本入库。对截图、扫描件、含文字的图表有效。第二种是多模态 Embedding 思路:用 CLIP 这类模型把图片整体编码成向量,检索的时候可以做到"用文字描述找图片",或者"用图片找相似图片"。LangChain 社区里有对应的多模态 Loader 和 Embedding 封装,但工程成熟度不如文本方案。
如果你的知识库主要面向图文混排的文档,我的建议是:文档里嵌入的图片,用 OCR 提取文字进 RAG;独立图片素材库,单独走多模态向量检索。两者并行,各管一摊,不要试图用一套流程通吃。
4.5 从文档问答到产品化:还需要做什么
跑通 langchain-rag-chat 只是第一步,真要产品化还有几件事要补。
第一是回答的引用溯源。现在的 Prompt 模板里给了来源编号,但输出没强制带来源。生产环境我强烈建议要求模型在回答末尾列出引用片段编号,方便用户核对,也让回答可信任。这个改动成本极低,价值极高。
第二是流式输出。对话场景下用户等待超过两秒就会焦虑,流式输出能把首字延迟压缩到几百毫秒。LangChain 的 chain.stream() 方法一行就能拿到流式输出,再借助 FastAPI 的 SSE 推到前端即可。
第三是反馈闭环。用户给回答点赞或点踩,数据要留存下来,作为后续优化检索质量的评估集。这一步很多团队跳过,等到效果变差时才后悔没有历史数据可以分析。
写在最后:调试 RAG 的一点心得
我给不少人调过 RAG 项目,最大的心得是:RAG 的效果不是靠单个环节的一招鲜,而是靠所有环节的叠加。文档清洗数据、切分策略合理、检索参数得当、Prompt 指令清晰,每一点提升一点,最后的效果差距会非常明显。你先别急着追求花哨的 Agent 编排和 GraphRAG,把基础链路调到稳定好用,再考虑升级。
最后再分享一个小技巧:给你自己的知识库准备一个固定的评测集——挑 20 个有标准答案的问题,每次调整参数后都跑一遍这 20 个问题,比较回答质量的差异。这个习惯能让你从"感觉好像变好了"进化到"确实变好了",调试效率完全不在一个量级上。