资讯动态

基于LangChain搭建RAG问答库:FAISS向量检索与文本分割实战

发布时间:2026/10/6 11:01:56 来源:尧图企业网站定制
用 LangChain 快速搭建一个开箱即用的 RAG 问答库langchain-rag-chat最近有个私活项目需要做一个内部知识库问答系统客户要求不高能上传文档、能提问、答案别编得太离谱就行。但就是这种“要求不高”的项目最坑网上现成的 RAG 教程不是太老就是太碎照着抄基本跑不通。我索性自己攒了一个叫langchain-rag-chat的小项目把 LangChain 的文档加载、文本分割、向量检索、大模型生成整个链路串了起来做完之后顺手整理成这套实操笔记希望能让想上 RAG 但又没人带的新手少走点弯路。这个项目能解决的问题很实际你有几十份 PDF、Word、Markdown 或者网页内容扔进去之后可以用自然语言提问系统会先从这些文档里检索相关片段再交给大模型组织答案而不是让模型凭空瞎编。它适合三类人看一是刚接触 LangChain 想弄懂 RAG 完整流程的开发者二是要在公司内部快速搭一个私有知识库的运维或工程同学三是想自己动手做个本地问答工具的技术爱好者。整个项目跑起来只需要一台普通电脑不需要 GPU也不需要额外部署向量数据库真正的开箱即用。1. 整体设计思路为什么 RAG 这条路值得走1.1 RAG 到底解决了什么问题大模型本身的知识截止时间固定也没有办法看到你公司的内部文档、产品手册、个人笔记。如果你直接拿一个问题去问 ChatGPT它能答对一半就算运气好另一半全靠编。RAGRetrieval-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负责读取 PDFpython-docx负责 Wordbeautifulsoup4负责 HTML。缺了哪个对应格式的文档就会解析失败。LangChain 版本更新很快命名空间和接口经常变。我测试时的版本是 0.3.x如果你的版本比这新遇到导入报错优先检查是不是某个模块被移到了langchain_community。2.2 环境变量配置OpenAI 接口的钥匙放哪里如果你打算用 OpenAI 的模型和 embedding需要配置 API Key。本地开发的时候我建议在项目根目录创建一个.env文件OPENAI_API_KEYsk-xxxxxxxxxxxxxxxxxxxxxxxx OPENAI_API_BASEhttps://api.openai.com/v1然后在代码里用dotenv加载from dotenv import load_dotenv load_dotenv()我踩过这个坑之前把 API Key 直接写在代码里结果一不小心推到了公开仓库几分钟内就被别人盗刷了几十块钱。从那以后我养成了习惯所有密钥只放.env并且把.env加进.gitignore。如果你不想用 OpenAI也可以改成本地模型比如 OllamaLangChain 有完整的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), encodingutf-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_size500, chunk_overlap50): splitter RecursiveCharacterTextSplitter( chunk_sizechunk_size, chunk_overlapchunk_overlap, separators[\n\n, \n, 。, , , ., !, ?, , ,, , ], length_functionlen, ) 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(modeltext-embedding-3-small)但如果你的场景不允许把公司文档发到外部 API就改用本地模型from langchain_huggingface import HuggingFaceEmbeddings def get_embeddings_local(): return HuggingFaceEmbeddings( model_namesentence-transformers/all-MiniLM-L6-v2, encode_kwargs{normalize_embeddings: True}, )all-MiniLM-L6-v2是本地 embedding 模型里非常流行的一个只有 80MB 左右生成的向量维度是 384CPU 上跑检索也很快。这个模型对英文效果好中文效果要差一些如果知识库以中文为主可以考虑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_deserializationTrue ) def get_retriever(): embeddings get_embeddings() vector_store load_vector_store(embeddings) return vector_store.as_retriever( search_typesimilarity, search_kwargs{k: 4} )注意load_local里的allow_dangerous_deserializationTrue这个参数在 LangChain 0.3 之后强制要求。FAISS 索引文件是 pickle 格式加载时会执行反序列化如果你加载的是别人给你的索引文件理论上存在安全风险。自己生成自己加载没太大问题但从网上下载的索引文件就要警惕了。search_kwargs{k: 4}表示每次检索取回 4 个最相关的片段。这个数字我试过很多次太少比如 1 个模型没有足够上下文太多比如 8 个上下文过长并且容易混入噪声。4 个是当前配置下的最佳平衡点。3.5 问答链组装LangChain 的 LCEL 语法和 Prompt 设计检索器拿到了接下来就是组装问答链。LangChain 0.3 时代推荐用 LCELLangChain 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(modelgpt-4o-mini, temperature0.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(fLoading {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(titlelangchain-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_modelQueryResponse) def ask(request: QueryRequest): if not request.question.strip(): raise HTTPException(status_code400, detail问题不能为空) answer chain_instance.invoke(request.question) return QueryResponse(answeranswer)启动服务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 或结构化文本再灌进知识库。另外一个常见原因是k4太小。当文档内容比较杂时最相似的 4 个片段可能都无法回答提问。临时调高到k6或k8试试代价只是生成时会多消耗一些 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-textLangChain 里换两个对象就能跑通全本地化。一台 16G 内存的 Mac Mini 跑 7B 模型速度可以接受敏感数据完全不出内网。零基础的同学先把文本拆解工具选好比如上面提到的pypdf、paddleocr再把本地模型拉到本地跑一遍整个过程一两个小时就能看到结果。我自己的体会是RAG 项目的核心难点其实不在模型而在数据清洗和检索调优。Garbage in, garbage out这句话在 RAG 领域体现得淋漓尽致。建库之前多花十分钟检查文档质量检索阶段多跑几个 k 值对比效果比你换更强的大模型回报高得多。把 LangChain 这条链路彻底吃透再往 LangGraph、Agent 方向延伸你会发现自己对“怎么让 AI 真的下地干活”这件事的理解会比别人深一个层次。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑