用 LangChain 快速搭建一个开箱即用的 RAG 问答库:langchain-rag-chat
最近有个私活项目需要做一个内部知识库问答系统,客户要求不高,能上传文档、能提问、答案别编得太离谱就行。但就是这种“要求不高”的项目最坑,网上现成的 RAG 教程不是太老就是太碎,照着抄基本跑不通。我索性自己攒了一个叫langchain-rag-chat的小项目,把 LangChain 的文档加载、文本分割、向量检索、大模型生成整个链路串了起来,做完之后顺手整理成这套实操笔记,希望能让想上 RAG 但又没人带的新手少走点弯路。
这个项目能解决的问题很实际:你有几十份 PDF、Word、Markdown 或者网页内容,扔进去之后可以用自然语言提问,系统会先从这些文档里检索相关片段,再交给大模型组织答案,而不是让模型凭空瞎编。它适合三类人看:一是刚接触 LangChain 想弄懂 RAG 完整流程的开发者,二是要在公司内部快速搭一个私有知识库的运维或工程同学,三是想自己动手做个本地问答工具的技术爱好者。整个项目跑起来只需要一台普通电脑,不需要 GPU,也不需要额外部署向量数据库,真正的开箱即用。
1. 整体设计思路:为什么 RAG 这条路值得走
1.1 RAG 到底解决了什么问题
大模型本身的知识截止时间固定,也没有办法看到你公司的内部文档、产品手册、个人笔记。如果你直接拿一个问题去问 ChatGPT,它能答对一半就算运气好,另一半全靠编。RAG(Retrieval-Augmented Generation,检索增强生成)的思路就是把“检索”和“生成”拼在一起:先从你的知识库里找出和问题最相关的几段内容,再把这些内容作为上下文塞给模型,让模型对着资料回答。我自己比较简单的理解是,RAG 相当于给大模型配了一个可以随时翻阅的资料员,问什么就先去翻资料,翻到了再开口说话。
相较于微调模型,RAG 的优势非常明显。微调需要准备大量标注数据,训练一轮的成本高,而且知识更新一次就得重新训练。RAG 呢?换一份文档进去,重新切分、灌入向量库,问题就解决了。尤其在公司内部场景,文档天天在变,你今天上传了新的产品规格书,明天就希望问答系统能答出新规格的问题,RAG 天然适合这种增量更新的需求。
1.2 方案选型:为什么固定选 LangChain 全家桶
做 RAG 不一定非要 LangChain,直接用 embedding 模型加向量库加 Prompt 拼接也能做。但我最终选择 LangChain 是因为它的抽象层次刚好卡在“够用”和“太绕”之间。langchain-rag-chat整个项目里,我用到了这样几个核心组件:
langchain_community.document_loaders:处理 PDF、Word、HTML、纯文本的加载,不需要自己写解析器。langchain_text_splitters:按照指定块大小做文本切分,同时处理好上下文重叠。langchain_openai:封装了 OpenAI 的 embedding 接口和聊天模型接口。langchain_huggingface:如果你不想调用 OpenAI 接口,也可以用它加载本地 embedding 模型。FAISS:本地向量存储,不需要单独起服务,适合中小型知识库。
这个组合最大的好处是:所有组件都是标准接口,后续想换掉任何一环都很容易。比如你嫌 OpenAI 的 embedding 贵,可以换成sentence-transformers/all-MiniLM-L6-v2这种本地模型,代码只需要改动两行。
1.3 整体架构拆解:从文档到答案的五步走
整个问答链路是我在设计时最花心思的地方,拆开来看其实就是五步:
- 加载(Load):把 PDF、Word、HTML 等原始文档读进来,转成纯文本。
- 分割(Split):把长文本切成固定的 chunk,每个 chunk 带一点重叠,防止上下文被切断。
- 向量化(Embedding):把每个 chunk 变成一个向量,维度取决于你选的 embedding 模型。
- 检索(Retrieve):用户提问时,把问题也转成向量,然后在向量库里找最相似的 chunk。
- 生成(Generate):把找到的 chunks 拼进 Prompt,交给大模型生成答案。
这个流程理解透了,后面所有代码都是为这五步服务的。我见过很多新手上来就抄代码,抄完跑不通,就是因为不理解自己手里这段代码到底在做第几步,出了问题根本无从排查。
2. 环境准备与项目初始化:开箱即用的前置条件
2.1 依赖安装:版本锁定非常重要
这一个项目在 Python 3.10 下开发测试,Python 3.12 我也跑过,但有几个依赖在 Python 3.12 下需要编译,容易出幺蛾子。保险起见,建议直接用 Python 3.10。
创建虚拟环境并安装依赖,直接贴命令:
python -m venv venv source venv/bin/activate # Windows 上用 venv\Scripts\activate pip install --upgrade pip pip install langchain langchain-community langchain-openai langchain-text-splitters pip install fastapi uvicorn faiss-cpu pypdf python-docx beautifulsoup4注意几个细节:
faiss-cpu是必须的,即使你后面用 GPU,本地开发也先用 CPU 版跑通再说。pypdf负责读取 PDF,python-docx负责 Word,beautifulsoup4负责 HTML。缺了哪个,对应格式的文档就会解析失败。- LangChain 版本更新很快,命名空间和接口经常变。我测试时的版本是 0.3.x,如果你的版本比这新,遇到导入报错,优先检查是不是某个模块被移到了
langchain_community。
2.2 环境变量配置:OpenAI 接口的钥匙放哪里
如果你打算用 OpenAI 的模型和 embedding,需要配置 API Key。本地开发的时候,我建议在项目根目录创建一个.env文件:
OPENAI_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxx OPENAI_API_BASE=https://api.openai.com/v1然后在代码里用dotenv加载:
from dotenv import load_dotenv load_dotenv()我踩过这个坑:之前把 API Key 直接写在代码里,结果一不小心推到了公开仓库,几分钟内就被别人盗刷了几十块钱。从那以后我养成了习惯,所有密钥只放.env,并且把.env加进.gitignore。
如果你不想用 OpenAI,也可以改成本地模型(比如 Ollama),LangChain 有完整的ChatOllama和OllamaEmbeddings支持。不过为了照顾大多数读者,这篇教程先以 OpenAI 接口为主线来写,本地模型我最后单独提一句。
2.3 项目目录结构:从一开始就分好工
写代码之前,先把目录搭好。这个项目中我用的结构非常简洁:
langchain-rag-chat/ ├── .env ├── requirements.txt ├── data/ │ └── 示例文档.pdf ├── app.py # FastAPI 入口 ├── rag/ │ ├── __init__.py │ ├── loader.py # 文档加载 │ ├── splitter.py # 文本分割 │ ├── embeddings.py # 向量化 │ ├── retriever.py # 检索 │ └── chain.py # 问答链 └── scripts/ └── build_db.py # 建库脚本有人会问,项目就这几个文件,有必要拆这么细吗?我的经验是:RAG 项目后期一定会调整某个环节,你不可能每次改个分割参数就把整个主文件翻一遍。而且拆开之后,每个文件都可以单独测试,排查问题效率高很多。
3. 核心代码实现:手把手把 RAG 链路写透
3.1 文档加载:PDF、Word、HTML 三件套一次搞定
加载器是loader.py的核心,它的工作就是接收文件路径,返回 LangChain 标准的Document对象列表。这里的Document就是一个page_content加上一堆 metadata 的数据结构,metadata 里通常存来源文件名、页码之类的信息。
# rag/loader.py from pathlib import Path from langchain_community.document_loaders import PyPDFLoader, Docx2txtLoader, TextLoader, BSHTMLLoader def load_document(file_path: str): path = Path(file_path) suffix = path.suffix.lower() if suffix == ".pdf": loader = PyPDFLoader(str(path)) elif suffix in (".docx", ".doc"): loader = Docx2txtLoader(str(path)) elif suffix == ".html": loader = BSHTMLLoader(str(path)) else: loader = TextLoader(str(path), encoding="utf-8") docs = loader.load() return docs这里有一个非常关键的点:很多人加载 PDF 之后直接拿去切分,结果一团糟。因为 PDF 的文本提取可能包含页眉页脚、表格错乱、换行错乱,尤其是扫描版 PDF,提取出来基本是乱码。我的建议是:先把文档转成 text 后人工抽查几段,确认质量再进下一步。在langchain-rag-chat里,我会加一个VERBOSE开关,打开时把加载好的前 500 字打印出来,方便检查。
3.2 文本分割:chunk_size 和 chunk_overlap 怎么调才不翻车
分割是 RAG 里最容易出问题的一步,也是新手最不理解的一步。chunk_size决定了每个片段有多长,chunk_overlap决定了相邻片段之间有多长的内容是重复的。为什么要有重叠?因为如果你把一段话从中间硬生生切开了,后半句话丢失了前文的主语,检索到的时候模型根本看不懂。
# rag/splitter.py from langchain_text_splitters import RecursiveCharacterTextSplitter def create_splitter(chunk_size=500, chunk_overlap=50): splitter = RecursiveCharacterTextSplitter( chunk_size=chunk_size, chunk_overlap=chunk_overlap, separators=["\n\n", "\n", "。", "!", "?", ".", "!", "?", ",", ",", " ", ""], length_function=len, ) return splitterRecursiveCharacterTextSplitter的原理是优先尝试用更长的分隔符切,如果切出来的段还是超过chunk_size,就换更短的分隔符继续切。这个separators列表的顺序有讲究,我把它理解成“切菜的时候先挑骨头,再挑筋,最后切肉”。中文文档把。!?放在英文标点前面,是为了避免中文句子被英文逗号切断。
chunk_size的选择直接影响检索效果。我测试过几种配置:
| chunk_size | 效果 |
|---|---|
| 200 | 切片太小,上下文不完整,检索到的片段信息量不足,模型回答经常跑偏 |
| 500 | 较为平衡,既保留完整语义,检索精确度也够高,适合大多数场景 |
| 1000 | 上下文完整,但多个片段拼接后容易超出模型 token 限制,回答延迟也更高 |
至于chunk_overlap,默认 10% 到 20% 都行,50 这个数对 500 的块来说就是 10%,实测下来够用。
3.3 向量化:Embedding 模型的选择与本地化思路
加载和分割之后,文本就绪。接下来要做的是把文本转成向量,这一步由 embedding 模型完成。我在项目里默认用 OpenAI 的text-embedding-3-small,维度是 1536,效果稳定。代码很简单:
# rag/embeddings.py from langchain_openai import OpenAIEmbeddings def get_embeddings(): return OpenAIEmbeddings(model="text-embedding-3-small")但如果你的场景不允许把公司文档发到外部 API,就改用本地模型:
from langchain_huggingface import HuggingFaceEmbeddings def get_embeddings_local(): return HuggingFaceEmbeddings( model_name="sentence-transformers/all-MiniLM-L6-v2", encode_kwargs={"normalize_embeddings": True}, )all-MiniLM-L6-v2是本地 embedding 模型里非常流行的一个,只有 80MB 左右,生成的向量维度是 384,CPU 上跑检索也很快。这个模型对英文效果好,中文效果要差一些,如果知识库以中文为主,可以考虑BAAI/bge-small-zh-v1.5,中文本地化表现好了不少。
向量化这里有一个常识需要提醒:你建库时用的 embedding 模型,必须和检索时用的完全一致。你要是建库时用 OpenAI,查询时换了本地模型,向量空间的分布完全不同,检索结果会变成一团乱。
3.4 向量存储与检索:FAISS 本地版就够用
向量数据库选型,很多教程一上来就推 Milvus、Weaviate,其实对一个小项目来说纯属过度设计。FAISS 是 Facebook 开源的计算库,可以把向量存在本地文件里,不需要额外启动服务,百兆级别以内的知识库跑起来毫无压力。
# rag/retriever.py import os from langchain_community.vectorstores import FAISS from rag.embeddings import get_embeddings DB_INDEX_PATH = "./vector_store" def build_vector_store(docs, embeddings): vector_store = FAISS.from_documents(docs, embeddings) vector_store.save_local(DB_INDEX_PATH) def load_vector_store(embeddings): if not os.path.exists(DB_INDEX_PATH): raise FileNotFoundError("向量库不存在,请先运行建库脚本") return FAISS.load_local( DB_INDEX_PATH, embeddings, allow_dangerous_deserialization=True ) def get_retriever(): embeddings = get_embeddings() vector_store = load_vector_store(embeddings) return vector_store.as_retriever( search_type="similarity", search_kwargs={"k": 4} )注意load_local里的allow_dangerous_deserialization=True,这个参数在 LangChain 0.3 之后强制要求。FAISS 索引文件是 pickle 格式,加载时会执行反序列化,如果你加载的是别人给你的索引文件,理论上存在安全风险。自己生成自己加载没太大问题,但从网上下载的索引文件就要警惕了。
search_kwargs={"k": 4}表示每次检索取回 4 个最相关的片段。这个数字我试过很多次,太少(比如 1 个)模型没有足够上下文,太多(比如 8 个)上下文过长并且容易混入噪声。4 个是当前配置下的最佳平衡点。
3.5 问答链组装:LangChain 的 LCEL 语法和 Prompt 设计
检索器拿到了,接下来就是组装问答链。LangChain 0.3 时代推荐用 LCEL(LangChain Expression Language)语法,可读性比老的LLMChain好很多:
# rag/chain.py from langchain_openai import ChatOpenAI from langchain.prompts import ChatPromptTemplate from langchain_core.output_parsers import StrOutputParser from langchain_core.runnables import RunnablePassthrough from rag.retriever import get_retriever PROMPT_TEMPLATE = """你是一个严谨的文档问答助手。请根据以下提供的资料片段回答问题。 资料片段: {context} 问题:{question} 要求: 1. 优先基于资料片段回答,不要编造。 2. 如果资料片段中找不到答案,直接回答“根据提供的资料无法回答这个问题”。 3. 回答时尽量引用资料中的原话,用简洁通顺的中文组织。 """ prompt = ChatPromptTemplate.from_template(PROMPT_TEMPLATE) def build_chain(): retriever = get_retriever() llm = ChatOpenAI(model="gpt-4o-mini", temperature=0.3) def format_docs(docs): return "\n\n---\n\n".join(doc.page_content for doc in docs) chain = ( {"context": retriever | format_docs, "question": RunnablePassthrough()} | prompt | llm | StrOutputParser() ) return chain这段代码就是整个 RAG 的引擎。retriever | format_docs的意思是:先把问题喂给检索器,取回 Document 列表,再把这个列表传给format_docs拼成一段长文本。RunnablePassthrough()负责把用户的原始问题传给 Prompt。
这里 Prompt 设计的要求项非常关键。我在最开始写 Prompt 时只写了一句“根据资料回答问题”,结果模型经常开始自由发挥,把资料里没有的信息也补全了。后来把“如果资料里找不到答案,直接说无法回答”加进去,效果立刻不一样。大模型很擅长编造,你必须用明确的指令把它按在资料上。
3.6 建库脚本:把加载、分割、向量化串起来
有了上面这些模块,建库就简单了。我写了一个scripts/build_db.py把整个流程串起来:
# scripts/build_db.py import os import sys sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from rag.loader import load_document from rag.splitter import create_splitter from rag.embeddings import get_embeddings from rag.retriever import build_vector_store DATA_DIR = "./data" def main(): embeddings = get_embeddings() splitter = create_splitter() all_docs = [] for file_name in os.listdir(DATA_DIR): file_path = os.path.join(DATA_DIR, file_name) if not os.path.isfile(file_path): continue print(f"Loading {file_path}") docs = load_document(file_path) chunks = splitter.split_documents(docs) print(f" -> {len(chunks)} chunks") all_docs.extend(chunks) if not all_docs: print("没有加载到任何文档,请检查 data 目录") return build_vector_store(all_docs, embeddings) print(f"向量库建立完成,共 {len(all_docs)} 个片段") if __name__ == "__main__": main()运行方式:
python scripts/build_db.py跑完以后,项目目录下会多出一个vector_store文件夹,里面有.faiss和.pkl两个文件,这就是序列化后的向量索引。以后更新知识库,重新跑一遍脚本就行,旧的索引会被覆盖。
3.7 接入 FastAPI:让问答系统可以被调用
核心链路跑通之后,我用 FastAPI 把它包了一层 HTTP 接口。为什么用 FastAPI?并发性能好,自动生成文档,而且写起来简单。以下是接口部分的代码:
# app.py from fastapi import FastAPI, HTTPException from pydantic import BaseModel from rag.chain import build_chain app = FastAPI(title="langchain-rag-chat") class QueryRequest(BaseModel): question: str class QueryResponse(BaseModel): answer: str chain_instance = None @app.on_event("startup") def load_chain(): global chain_instance chain_instance = build_chain() @app.post("/ask", response_model=QueryResponse) def ask(request: QueryRequest): if not request.question.strip(): raise HTTPException(status_code=400, detail="问题不能为空") answer = chain_instance.invoke(request.question) return QueryResponse(answer=answer)启动服务:
uvicorn app:app --host 0.0.0.0 --port 8000然后就可以用curl测试:
curl -X POST http://localhost:8000/ask \ -H "Content-Type: application/json" \ -d '{"question": "产品的保修期是多久?"}'@app.on_event("startup")里提前加载链,而不是每次请求都重建,这是有讲究的。因为build_chain()需要加载 FAISS 索引,而加载索引是比较重的 I/O 操作。如果每次invoke都重新加载一次,响应时间会从几百毫秒变成好几秒,用户体验完全不一样。
4. 常见问题与排查技巧实录
这部分是我最想聊的。代码能不能跑通是一回事,遇到问题能不能快速定位是另一回事,而后者才真正考验工程能力。
4.1 检索结果不准确
最常见的现象:你问了 A 问题,模型回答的时候引用的资料片段明显是 B 主题的内容。排查思路如下:
第一步,单独测试检索器。不要直接跑整个问答链,而是把用户问题拿去调retriever.get_relevant_documents(query),打印返回的几个片段标题,看看是不是相关。这一步能判断问题出在检索还是生成阶段。
第二步,检查 chunk_size。如果文档里每个章节大约一两千字,你把 chunk_size 设成 2000,那返回的片段就涵盖了整个章节,相关和不相关的内容混在一起,模型就容易抓错重点。这时把 chunk_size 调小到 500 会立竿见影。
第三步,检查 embedding 模型。如果是中英文混合文档,用纯英文的 embedding 模型检索中文查询,效果会很差。建议中英文混合场景直接上BAAI/bge-m3这种多语言模型。
4.2 问什么都说“根据资料无法回答”
这个问题和上一个相反,是模型太保守了。原因往往是检索出来的片段没有真正覆盖问题,或者说检索结果返回的数量太少。
我在实践中遇到过一种很隐蔽的情况:文档是表格型内容,比如人员通讯录、产品参数表,用pypdf提取出来之后表格结构完全丢失,变成了一堆散落的字符。这时候检索器哪怕拿到了片段,也没法理解里面的逻辑关系。解决方案是用 PDF 阅读器先把表格转成 CSV 或结构化文本,再灌进知识库。
另外一个常见原因是k=4太小。当文档内容比较杂时,最相似的 4 个片段可能都无法回答提问。临时调高到k=6或k=8试试,代价只是生成时会多消耗一些 token。用户问的一个问题如果涉及多个知识点,我会建议做成多级检索,先检索出 20 个候选片段,再做一次粗排,选出最相关的 4 个喂给模型。这算进阶玩法,但效果真的不一样。
4.3 能不能存图片、扫描件和复杂表格
这可能是新同学问得最多的问题。RAG 知识库能存图片吗?说实话,传统的 RAG 流程存不了视觉信息,图片本身没法被文本 embedding 模型处理。但如果你配合多模态模型,就可以走“图文混合 RAG”的路线:把图片交给视觉模型(比如 GPT-4o)生成文本描述,再把描述存进向量库,这样检索的时候就能找到图片里的信息了,只是读取的是图片的文字版说明而非图像本身。
扫描版 PDF 也类似。它本质上是图片,文字提取出来是乱码,必须先用 OCR。我常用的方案是paddleocr,对中文支持特别好,可离线运行。先 OCR 成文本,再走正常 RAG 流程。表格数据问题,可以先用camelot或pdfplumber把表格抽出来,转成 Markdown 格式再入库。这些环节属于文本切分工具选型问题,处理好了,知识库的覆盖面一下就宽了。
4.4 向量库加载报错
如果在load_local时遇到Could not deserialize或者版本不兼容的报错,大概率是 FAISS 版本和保存索引时不匹配。解决办法很简单:升级 FAISS,然后重新建库。索引文件不要跨版本保留,数据量不大的话重建成本很低。
另外,在 Windows 上faiss-cpu的安装偶尔会失败,这时候要检查 Python 是否 3.10 或 3.11,而不是最新版 3.13。很多 C 扩展库对最新版 Python 的适配总是慢半拍,这话我说过很多次,但每次都有新同学踩进去。
4.5 一个容易忽略的模型参数问题
调用 OpenAI 的ChatOpenAI时,我没有在代码里写死max_tokens,因为默认值够用。但我见过有人把max_tokens设置成 500,结果模型只能输出 500 字,长答案被截断,看起来就像“回答了一半就没了”。如果你的场景要输出长文,记得把max_tokens调整到一个合理的大值,比如 1024 或 2048。
temperature参数的设定也有讲究。问答场景,我推荐 0.2 到 0.3,太低模型只会逐字复述原文,太高模型容易放飞自我。如果你要的是创造性写作,调高没问题;但你是要“准确回答问题”,就老老实实把温度降下来。
5. 从“能跑”到“好用”:本地模型与后续扩展方向
这里分享一下我实际用下来的经验。langchain-rag-chat在本地开发机上跑通之后,我第一件事就是加了一个语义缓存层。用户问过同样的问题,直接返回上次的答案,省了一次大模型调用。缓存键我用的是问题的 embedding 向量的余弦相似度,相似度大于 0.95 就认为是一样的。这个小改动让重复提问的响应时间从 3 秒降到了 200 毫秒。
第二个建议是加一个简单的引用来源展示。把检索到的 Document 的metadata["source"]和metadata.get("page", "")一起返回给前端,让用户能看到答案出自哪份文档、哪一页。这在企业内部场景里几乎是刚需,因为你答得再对,用户也得知道依据是什么。
第三个方向,如果你不想接 OpenAI 接口,强烈建议试试 Ollama。安装之后拉一个qwen2.5:7b和nomic-embed-text,LangChain 里换两个对象就能跑通全本地化。一台 16G 内存的 Mac Mini 跑 7B 模型速度可以接受,敏感数据完全不出内网。零基础的同学先把文本拆解工具选好(比如上面提到的pypdf、paddleocr),再把本地模型拉到本地跑一遍,整个过程一两个小时就能看到结果。
我自己的体会是,RAG 项目的核心难点其实不在模型,而在数据清洗和检索调优。Garbage in, garbage out,这句话在 RAG 领域体现得淋漓尽致。建库之前多花十分钟检查文档质量,检索阶段多跑几个 k 值对比效果,比你换更强的大模型回报高得多。把 LangChain 这条链路彻底吃透,再往 LangGraph、Agent 方向延伸,你会发现自己对“怎么让 AI 真的下地干活”这件事的理解会比别人深一个层次。