RAG 系列第二篇,咱们终于可以不聊理论了。这篇直接用 LangChain + FAISS + 通义千问,把一套轻量级 RAG 检索增强生成系统从零搭出来,目标很具体:你手上有几个 PDF、Markdown 或者纯文本文件,想基于这些资料做问答,又不想部署重量级向量数据库,也不想从零写嵌入和检索逻辑,这套组合是当下最省事的方案之一。
我先说结论:我用这套架构跑通了一个内部文档问答 Demo,检索延迟大概几十毫秒,完整问答在 2 秒左右,整体代码量控制在 200 行以内,纯本地跑向量检索,只有生成阶段调用云端模型接口。对个人知识库、企业小规模文档问答、产品说明书答疑这些场景来说,性价比极高。
如果你正在纠结“RAG 到底怎么落地”“FAISS 够不够用”“国产大模型怎么接”,这篇应该能帮你在半天内跑通第一版。就算你之前没写过 LangChain 代码,照着下面的步骤来也能复现。
1. 项目整体设计与思路拆解
1.1 RAG 到底在解决什么问题
大模型本身的“知识”是训练时固化的,你问它昨天刚发布的内部流程,它大概率只能瞎编。检索增强生成就是给模型配一个外部资料库:用户提问之后,先从库里检索最相关的几段文本,再把这些文本连同问题一起交给模型生成答案。说白了,就是让大模型从“闭卷考试”变成“开卷考试”。
开卷考试需要两个前提:第一,资料库里有答案;第二,你能快速翻到相关的那几页。在 RAG 系统里,“翻书”由向量检索完成,“作答”由大模型完成。整个过程跑通了之后,知识更新也不需要重新训练模型,替换库里的文档即可,成本低到可以按天迭代。
所以 RAG 特别适合这几类事情:私有知识库问答、产品文档客服问答、论文阅读助手、代码库检索解释、企业内部制度查询。它解决的是大模型在“垂直领域内容”上的准确性和时效性问题,而不是替代大模型本身。
1.2 为什么选 LangChain + FAISS + 通义千问这个组合
技术选型没有绝对正确,只有适合场景。我把这套组合的理由摊开讲。
LangChain 解决的是“编排问题”。它把所有 RAG 步骤抽象成组件,文档加载用 Loader,文本切分用 Splitter,向量库用 VectorStore,问答链用 RetrievalQA。整套流程不需要自己写胶水代码,也方便后续替换组件。比如今天用 FAISS,明天想换别的向量库,改动很小。
FAISS 解决的是“轻量存储和检索问题”。它是 Meta 开源的向量索引库,核心功能就是把向量存进索引文件,然后在内存里做相似度搜索。对几十万条以内的场景,性能完全够用。关键是它不需要单独部署服务,直接落盘成一个文件夹,随用随加载,适合个人项目和中小团队。
通义千问这类国产大模型接口解决的是“生成问题”。通过 OpenAI 兼容协议接入,一条 API Key 就能跑通。中文理解能力强,成本比海外模型低,响应速度也不错。选它更多是图省事:不用自己部署 GPU 推理服务,也不用纠结显存和模型权重,按量付费,开箱即用。
这套组合本质上是“最小可用架构”:一个普通笔记本电脑,装 Python 环境,就能完整跑起来。数据量上来之后再考虑迁移到更重的向量数据库,初期没必要把架构搞复杂。
1.3 系统架构与数据流转
整体流程可以用下面这条链路概括:
- 加载文档:从 PDF、Markdown、TXT 中读取原始文本。
- 清洗与切分:把长文档切成固定大小的文本块。
- 向量化:将每个文本块输入 Embedding 模型,得到向量。
- 构建索引:把向量写入 FAISS 索引文件。
- 用户提问:问句经同一个 Embedding 模型转成向量。
- 相似度检索:在 FAISS 中检索与问句最相似的 Top-K 文本块。
- 拼接上下文:将检索到的文本块、问题、系统指令拼成一个 Prompt。
- 模型生成:把 Prompt 发给通义千问接口,得到最终答案。
这里最容易被忽略的一点是:提问向量必须和文档向量由同一个 Embedding 模型生成。否则向量空间不对齐,检索结果一定会“风马牛不相及”。我见过不少人栽在这个坑里,后面实操部分会专门强调。
2. 核心细节解析与实操要点
2.1 文档加载:格式决定方案
LangChain 的文档加载器很多,但真正常用的就那么几种。TXT 和 Markdown 直接用TextLoader;PDF 用PyPDFLoader;如果 PDF 是扫描件,需要接 OCR 方案,不是简单一个 Loader 能解决的。
我用一个混合目录演示,里面既有.txt也有.pdf,做法是分别写两个循环,不要依赖一个DirectoryLoader处理所有格式,因为不同格式的解析错误处理方式不一样。
from langchain_community.document_loaders import PyPDFLoader, TextLoader from langchain.schema import Document def load_documents(file_paths): docs = [] for path in file_paths: if path.endswith(".pdf"): loader = PyPDFLoader(path) elif path.endswith(".txt") or path.endswith(".md"): loader = TextLoader(path, encoding="utf-8") else: continue loaded = loader.load() docs.extend(loaded) print(f"加载文件: {path}, 页数/块数: {len(loaded)}") return docs中文文本加载最容易踩的坑是编码问题。TextLoader默认编码不一定是 UTF-8,Windows 下经常会碰到gbk编码文件。建议显式指定encoding="utf-8",如果报错再根据文件实际编码调整,不要相信默认值。
PDF 解析的坑更多。很多在线生成 PDF 的工具不会保留真正的文本层,PyPDFLoader提取出来的内容会是乱码。我处理 PDF 的通用原则是:先用pdfinfo或直接打印前几页内容检查解析质量,如果文本中夹杂大量“口口口”,就考虑换 OCR 方案。轻量级系统里,我建议优先把 PDF 转成 Markdown 或纯文本再进 RAG,解析质量会好很多。
2.2 文本切分:决定 RAG 上限的关键环节
很多人低估了文本切分的作用。切分的核心目标很简单:每个文本块要“语义完整”,不要把一个完整的知识点拦腰截断。
举例来说,一段说明书里写着“连接超时时间默认为 30 秒,修改超时时间为 60 秒时,需要重启服务”。如果切分点正好落在两句话中间,模型检索到的上下文就只有半句,回答质量自然会崩。
我常用的参数是这样:
from langchain.text_splitter import RecursiveCharacterTextSplitter text_splitter = RecursiveCharacterTextSplitter( chunk_size=500, chunk_overlap=80, separators=["\n\n", "\n", "。", "!", "?", ".", " ", ""] )chunk_size=500是经验值。太短容易丢失上下文,太长容易超出模型的上下文窗口,或者把多个主题混在一个块里,导致检索结果不精准。chunk_overlap=80保证前一个块结尾和后一个块开头有重叠,避免句意被切断。
RecursiveCharacterTextSplitter的思路是先按段落切,段落太长再按句子切,最后按字符切。它比固定字符数的CharacterTextSplitter聪明得多。对中文文档,最好把中文句号、感叹号、问号都加进separators,这样切分点更接近语义边界。
如果文档本身有清晰的标题结构,比如 Markdown 的##标题,可以先按标题切块,再做二次切分。这种“结构优先”的策略通常比纯字符切分效果好,但需要针对不同文档单独写解析逻辑,轻量级场景可以先不做,有精力再优化。
2.3 Embedding 选型:向量质量直接影响召回
Embedding 模型的作用是把文本变成一串数字向量。两个文本在语义上越接近,它们的向量距离就越近。选 Embedding 模型时,我建议关注三点:中文效果、向量维度、推理速度。
轻量级系统里,我最常用本地开源向量模型,比如基于sentence-transformers的多语言小模型。用 HuggingFace 的接口加载,跑在 CPU 上也能接受,文档量几百篇时索引构建可能慢一点,但检索阶段很快。
代码示例:
from langchain_community.embeddings import HuggingFaceEmbeddings embedding_model = HuggingFaceEmbeddings( model_name="paraphrase-multilingual-MiniLM-L12-v2", model_kwargs={"device": "cpu"}, encode_kwargs={"normalize_embeddings": True} )这里重点说normalize_embeddings。开启后向量会被归一化到单位长度,FAISS 用内积(IP)检索时等价于计算余弦相似度,效果更稳定。如果你用 L2 距离检索,归一化同样影响距离排序,建议统一开启。
如果你不想维护本地模型,也可以用通义千问平台提供的 Embedding 接口。好处是无需本地算力,坏处是每次调用有网络延迟和费用,而且离线场景下没法用。另外,云端 Embedding 接口和本地向量模型返回的维度可能不同,切换时务必要重建索引,不能直接混着用。
这里再强调一次:文档侧和查询侧必须是同一个 Embedding 模型。FAISS 索引文件不会记录“向量是用哪个模型生成的”,这个信息只能你自己管理和记录。我每次保存索引时,都会在旁边写一个embedding_model.txt,标明模型名称和版本,防止后面换模型后还拿旧索引检索。
2.4 FAISS 索引构建与持久化
FAISS 本身支持多种索引类型,LangChain 封装之后用起来很简单。构建标签为IndexFlatIP,也就是暴力精确检索,数据量不大时精度最高。
from langchain_community.vectorstores import FAISS vectorstore = FAISS.from_documents( documents=split_docs, embedding=embedding_model )这里documents是切分后的文本块列表,embedding是 2.3 节定义的模型。from_documents会循环调用 Embedding 接口,把每个文本块转成向量,然后建索引。
持久化也很简单:
vectorstore.save_local("./faiss_index")重新加载时:
vectorstore = FAISS.load_local( "./faiss_index", embedding_model, allow_dangerous_deserialization=True )这里有个需要注意的地方:LangChain 新版本对反序列化有安全限制,加载本地文件时要求显式传入allow_dangerous_deserialization=True。这个参数只在你信得过索引文件来源时才建议开启,如果索引文件来路不明,先想清楚再开。
另外,FAISS 索引文件在版本升级后不保证完全兼容。我踩过一次坑:用旧版 LangChain 保存的索引,升完级之后加载报错。建议在保存索引的目录里同时记录 FAISS 和 LangChain 的版本号,方便出问题时排查。
3. 实操过程与核心环节实现
3.1 环境准备
先创建虚拟环境,避免依赖冲突:
python -m venv rag_env source rag_env/bin/activate # Windows 下为 rag_env\Scripts\activate安装依赖:
pip install langchain langchain-community langchain-openai faiss-cpu pypdf sentence-transformers python-dotenv说明一下这些包各自的任务:
langchain:核心编排框架。langchain-community:社区版加载器、向量库封装。langchain-openai:通过 OpenAI 兼容协议调通义千问接口。faiss-cpu:FAISS 的 CPU 版本,足够轻量级项目使用。pypdf:解析 PDF 文件。sentence-transformers:本地 Embedding 模型运行环境。python-dotenv:读取环境变量,避免把 Key 硬编码进代码。
创建.env文件:
LLM_API_KEY=your_api_key_here LLM_API_BASE=your_llm_api_base_url LLM_MODEL=qwen-plus把真实 API Key 写到.env,然后在代码里用dotenv加载。不要提交到 Git 仓库,这不是技术问题,是基本安全意识。
3.2 构建文档知识库
这一步把本地文档吃进来,切成块,生成向量索引,保存到本地。
import os from dotenv import load_dotenv load_dotenv() from langchain_community.embeddings import HuggingFaceEmbeddings from langchain.text_splitter import RecursiveCharacterTextSplitter from langchain_community.vectorstores import FAISS # 1. 加载文档 file_paths = ["./docs/user_manual.md", "./docs/faq.pdf"] raw_docs = load_documents(file_paths) # 使用 2.1 节定义的方法 # 2. 切分文档 text_splitter = RecursiveCharacterTextSplitter( chunk_size=500, chunk_overlap=80, separators=["\n\n", "\n", "。", "!", "?", ".", " ", ""] ) split_docs = text_splitter.split_documents(raw_docs) print(f"切分后文本块数量: {len(split_docs)}") # 3. 向量化并构建 FAISS 索引 embedding_model = HuggingFaceEmbeddings( model_name="paraphrase-multilingual-MiniLM-L12-v2", model_kwargs={"device": "cpu"}, encode_kwargs={"normalize_embeddings": True} ) vectorstore = FAISS.from_documents(split_docs, embedding_model) # 4. 保存索引 vectorstore.save_local("./faiss_index") print("FAISS 索引已保存到 ./faiss_index")如果你只有几十个小文档,整个过程应该在一分钟内完成。上百个 PDF 时,本地 CPU 向量化可能会很慢,建议先拿一小部分文档跑通流程,再投入全量数据。
3.3 搭建检索问答链
构建好索引之后,开始接大模型。
import os from langchain_openai import ChatOpenAI from langchain.prompts import PromptTemplate from langchain.chains import RetrievalQA # 加载已有索引 embedding_model = HuggingFaceEmbeddings( model_name="paraphrase-multilingual-MiniLM-L12-v2", model_kwargs={"device": "cpu"}, encode_kwargs={"normalize_embeddings": True} ) vectorstore = FAISS.load_local( "./faiss_index", embedding_model, allow_dangerous_deserialization=True ) # 定义大模型 llm = ChatOpenAI( model=os.getenv("LLM_MODEL"), api_key=os.getenv("LLM_API_KEY"), base_url=os.getenv("LLM_API_BASE"), temperature=0.1, ) # 定义 Prompt prompt_template = """你是企业内部知识库助手,请严格基于以下资料回答用户问题。 资料: {context} 问题:{question} 要求: 1. 如果资料中没有明确答案,直接回答“资料中未找到相关信息”,不要编造。 2. 回答尽量简洁,引用资料中的关键表述。 3. 不要提及“我来自资料”“根据资料”等多余解释。 回答:""" PROMPT = PromptTemplate( template=prompt_template, input_variables=["context", "question"] ) # 构建问答链 qa_chain = RetrievalQA.from_chain_type( llm=llm, retriever=vectorstore.as_retriever(search_kwargs={"k": 4}), chain_type="stuff", chain_type_kwargs={"prompt": PROMPT} ) # 测试问答 query = "如何修改连接超时时间?" response = qa_chain.invoke(query) print(response["result"])这里几个细节要分清。
search_kwargs={"k": 4}表示检索时返回最相似的 4 个文本块。k值太小可能漏掉关键信息,太大则会把不相关内容塞进 Prompt,导致模型分心。轻量级场景先设 4 到 6,再根据实际效果调。
chain_type="stuff"表示把所有检索结果一次性塞进 Prompt。好处是简单直接,缺点是上下文内容多时会超过模型上限。如果检索结果块数多、每个块又长,建议换成map_reduce或refine,但初期不需要纠结。
temperature=0.1是为了生成答案更稳定。回答事实性问题时,温度越低越好。如果目标是头脑风暴,可以把温度调高,但 RAG 问答场景通常不需要创造性。
3.4 一个完整运行示例
我拿一份模拟产品手册做了测试,手册里有这样一句话:“连接超时时间默认 30 秒,如需修改,请在配置文件中调整timeout字段为 60,然后重启服务生效。”
用户问:“连接超时时间能改吗?”
得到的回答是:“可以。连接超时时间默认 30 秒,可在配置文件中修改timeout字段为 60,修改后需要重启服务生效。”
这个答案来自文档原文,不是模型凭记忆编的。这就是 RAG 和直接对话的区别:模型在有限上下文里只看到与问题最相关的文本,回答有据可依。
我也试过不加检索、直接问同一个问题,模型给了一段含糊的通用解释,甚至还会自己编造一个不存在的配置项。有了 RAG 之后,这种“一本正经胡说八道”的情况被明显压制住了。
4. 常见问题与排查技巧实录
4.1 问题速查表
下面这张表是我实际操作中遇到的典型问题,按“症状、原因、解法”列出来,方便你直接对照排查。
| 症状 | 可能原因 | 解决办法 |
|---|---|---|
| 回答和问题完全不相关 | 提问用了不同 Embedding 模型 | 确认文档和查询使用同一个 Embedding 模型 |
| 答案内容相似但细节错误 | 检索到上下文块不完整 | 增大chunk_overlap,或按标题结构切分 |
| 答案经常说“资料中未找到” | k值太小,关键内容没被召回到 | 调大search_kwargs的k,比如从 4 提到 8 |
| 模型明显在编造答案 | Prompt 没有强调“仅基于资料回答” | 在 Prompt 中加“资料中没有就直说,不编造” |
| 中文文本乱码 | PDF 没有文本层,或文件编码不对 | PDF 转成文本/OCR;TXT 指定 UTF-8 编码 |
| 加载 FAISS 索引报错 | faiss 或 langchain 版本不兼容 | 记录版本号;必要时重建索引 |
| 请求超时或接口报错 | base_url或api_key配置不对 | 检查.env是否加载,确认 API 服务商文档 |
| 答案太长超 token 限制 | chunk_size太大,或k值太大 | 减小块大小,降低检索块数,或换用map_reduce |
4.2 先调检索,再调生成
我踩过最多次的坑,是系统效果不好时直接去改 Prompt。其实在 RAG 里,绝大多数效果问题是出在检索环节,不是生成环节。
怎么定位?把问答链拆开,先单独打印检索结果:
retriever = vectorstore.as_retriever(search_kwargs={"k": 4}) docs = retriever.get_relevant_documents("如何修改连接超时时间?") for i, doc in enumerate(docs): print(f"--- 第 {i + 1} 块 ---") print(doc.page_content)如果打印出来的内容里没有真正和问题相关的句子,那模型怎么改都是白搭。这个时候要回去调切分、调 Embedding、调k值,而不是折腾 Prompt。
如果检索结果已经包含正确答案,但模型回答得还是不好,再考虑 Prompt 和参数。这是一个很实用的排查顺序,能帮你省掉大量无效调参时间。
4.3 避坑清单
再列几条容易忽略的实操细节。
第一,不要把 Embedding 模型和文档索引分开管理。很多人本地建索引用了一个模型,后来为了省事换了云端 Embedding,再用旧索引检索,结果就是召回结果一团糟。换 Embedding 模型必须重建全量索引。
第二,不要对大文档一次性切分后再反复检索。如果你直接加载一个 500 页 PDF,没有切分就进 RAG,检索单位是整页内容,上下文会又长又杂。查一次可能没感觉,查多了就知道问题了。
第三,不要把 API Key 硬编码在代码里。用.env文件管理环境变量,既安全又方便切换不同模型服务。
第四,不要在小范围测试阶段引入太多组件。比如一开始就上重排模型、多路召回,出了问题很难判断是哪一环造成的。先跑通最简单链路,再逐步加模块。
5. 效果评估与后续扩展方向
5.1 建一个简单评估集
很多人的 RAG 系统调来调去全凭感觉,这是不科学的。我建议花半小时建一个“黄金问答集”,规则很简单:选 10 到 20 个典型问题,标注标准答案和答案所在的源文档位置。
每次改动切分参数、Embedding 模型、检索k值、Prompt 模板之后,都跑一遍同一套问题,记录回答质量和召回命中情况。不用搞复杂的自动评估指标,先人工打分,判断“是否正确、是否基于文档、是否完整”这三项。能稳定跑完这个评估集,系统才算真正可用。
这也能帮你判断改动是变好还是变坏。比如把chunk_size从 500 改成 800,如果 20 条测评里有 3 条由对变错,那不管直觉上觉得多合理,都应该回滚。
5.2 后续可以做的扩展方向
第一版跑通之后,扩展方向很多,我挑几个性价比高的。
多轮对话记忆。现在这个系统是无状态问答,用户连续追问“那超时时间呢”“为什么不行”时,模型无法理解指代。可以在RetrievalQA外层加一个聊天记忆模块,把历史消息压进 Prompt,但要注意记忆内容占用的 token 空间。
查询改写。用户问题常常很口语化,直接拿去向量检索效果一般。可以先用大模型把问题改写成更适合检索的形式,比如“怎么改超时时间”改写成“修改连接超时时间的方法”。多一次模型调用,但召回准确率往往有提升。
混合检索。FAISS 擅长语义相似,但遇到产品编号、精确名称等关键词场景反而不如普通关键词搜索。可以同时跑字面检索和向量检索,再用规则或重排模型合并结果。轻量级场景可以先不做,等明确有需要再上。
重排序。检索返回 Top 4 可能包含冗余信息,可以接一个重排模型对候选结果重新打分,把最相关的内容放在最前面。这能有效提高最终答案的准确率,但会增加一到两个模型调用。
本地推理。如果不想依赖云端接口,可以把生成模型换成本地量化模型。但这需要 GPU 资源,而且小参数量模型的中文能力通常不如云端大模型。个人项目可以先接云端 API,成本可控,效果也更好。
就我自己的使用体验来说,RAG 系统真正难的不是把链路跑通,而是把“召回质量”调好。模型生成效果普通一点,靠更好的上下文也能救回来;但检索结果如果一直捞不到正确答案,模型再强也发挥不出来。所以如果你只想在这一套系统里投入一点时间去优化,我建议优先花在文档切分和检索测试上,不要一上来就沉迷换 Prompt 模板。先把资料库处理干净,再谈模型调优,这个顺序能让你的 RAG 项目少走很多弯路。