上周有个朋友问我公司里攒了几百份产品文档、报销制度、设备操作手册散落在各个文件夹里新同事想问点东西只能靠人肉搜索。他想做一个“文档问答机器人”但一搜教程全是讲微调大模型、租GPU训练的那种看起来就劝退。我直接给他推了另一条路LangChain 加通义千问做一套 RAG 知识库问答系统半小时就能把链路跑通。这篇文章就是把当时那套可运行的代码完整拆开讲清楚末尾附了完整版复制下来换个 API Key 就能用。这篇文章适合两类人一是想给自己的资料库做智能问答、但不想碰模型训练的开发者二是刚接触 RAG、想找一个最小可运行 Demo 来理解全流程的学习者。我不会只丢代码给你我会把文档加载、文本切分、向量化、检索、Prompt 设计这些环节的“为什么”都讲明白。看完你会发现RAG 这套东西原理上就是一层窗户纸真正的坑全在细节里。1. RAG到底在解决什么问题1.1 大模型天生回答不了私有知识先想一个场景你直接问一个通用大模型“我们公司的请假审批流程是什么”它能答吗大概率是答不了的。因为模型在训练时只见过公开的互联网语料它根本没见过你们公司的内部制度。这时候它又不会主动说“我不知道”而是会根据自己理解过的类似内容编造一套“看起来很像回事”的流程给你。这就是行业里常说的“幻觉”。这个问题不是换一个更大的模型就能解决的。你再大的模型它的知识截止时间也是固定的你们的文档今天更新了它不可能今天就学会。私有知识、企业内部资料、最新的产品变更说明这些东西天然不在模型的训练范围里。所以大模型落地到具体业务场景时第一个要解决的问题不是“模型不够聪明”而是“模型看不到该看的数据”。1.2 RAG的三段式流水线RAG 的全称是 Retrieval-Augmented Generation检索增强生成。这个名字很学术但背后逻辑特别朴素——与其强迫模型记住所有知识不如在它回答问题之前先把相关的资料找出来递给它让它“看着资料回答”。整个过程分三段第一段是索引构建。把散落在 txt、docx、pdf 里的文档全部读取进来切成一个个几百字的文本块再用 Embedding 模型把每个文本块变成一组向量最后存进向量数据库。这一步是离线完成的跑一次就行。第二段是检索。用户输入一个问题时系统先把问题用同一个 Embedding 模型变成向量然后在向量库里做相似度搜索找出和这个问题最相关的 3 到 5 个文本块。这一步是实时的要求快。第三段是生成。把检索到的文本块和用户问题拼成一个 Prompt交给大模型让它基于这些检索到的资料作答。这一步很像开卷考试模型不用背全部内容但它手边有一份参考资料回答必须从资料里找依据。如果你想一句话记牢RAG 就是用“开卷考试”的方式替代“闭卷硬背”的传统问答。普通 LLM 问答是闭卷RAG 是让模型带着参考资料进考场。1.3 为什么我选择RAG而不是微调很多第一次接触知识库问答的人会问为什么不微调模型微调确实可以把特定知识“训练进”模型参数里但对大多数知识库场景来说RAG 的性价比远高于微调。我拿常用的几个维度做个对比对比项微调RAG数据准备需要标注好的“问题-答案”训练数据只需要原始文档零标注硬件成本需要 GPU 训练周期以天计普通 CPU 就能完成索引分钟级知识更新文档一变重新训练一版换文档重跑一次索引即可可解释性黑盒答案无法溯源能返回命中的原文片段对幻觉的抑制依然可能编造配合提示词约束大幅降低编造你可以把微调和 RAG 的关系理解为“背教材”和“查教材”。如果你要的是学生开口就是专业术语、语气完全贴合你的团队风格那微调合适。但如果你本质上是想解决“内部资料太多了大家找不到答案”的问题那就不要去训练模型而是把检索做到位。甚至很多成熟项目是先用 RAG 把知识检索链路跑通后续再考虑用微调优化生成风格两者并不是互斥的关系。2. 技术选型为什么是LangChain、通义千问、FAISS2.1 LangChain到底帮你省了什么没有 LangChain 的时候做一个 RAG 系统要自己处理的事情非常多写代码遍历目录加载文档处理不同文件格式的解析逻辑自己实现文本切分算法对接向量数据库的 API还要写 Prompt 拼接和结果解析。每一步都不难但堆在一起就很烦。LangChain 的核心价值是流程编排和组件标准化。它把 RAG 拆成一个个标准组件文档加载器负责读取文件、文本分割器负责切分、Embedding 封装负责调向量模型、向量存储封装负责读写向量库、链Chain负责把检索结果拼进 Prompt 再调大模型。你要做的事情从“写全部代码”变成了“选组件、配参数”。当然LangChain 也是被吐槽最多的框架之一因为它更新频繁、抽象层级多、版本之间破坏性改动不少。但即便如此我依然推荐在起步阶段用它。原因很简单RAG 的链路很固定你没必要自己造每一块轮子。LangChain 帮你把边界和接口定义好了等以后你真要上生产再针对瓶颈替换具体实现也不迟。2.2 通义千问作为大模型接口大模型选型我选的是通义千问代码里用的是 ChatTongyi 这个封装。选它的理由主要有三个。第一中文效果好。知识库问答场景里检索到的资料和用户的问题大部分是中文模型对中文语义的理解能力直接决定输出质量。通义千问的中文能力在实测中表现稳定尤其是指令跟随和长文本归纳这两块。第二接入成本低。LangChain 社区里已经有现成的 ChatTongyi 类填一个 API Key 和模型名就能用不需要自己写 SDK 调用逻辑。如果你以后想换别的国产模型也可以看 LangChain 是否支持对应的 Chat 类接口风格基本一致。第三费用可控。它有不同档位的模型日常测试用轻量型号跑通了再升级到更强型号切换成本几乎为零。对我这种经常要写 Demo 验证想法的人来说这个体验很重要。2.3 Embedding模型与向量库的选择RAG 里有两个“向量”相关的东西容易被混为一谈一个是把文本变成向量的 Embedding 模型一个是存向量的向量数据库。两者都影响效果但 Embedding 模型的影响更大——检索找不到相关内容后面生成再好也是巧妇难为无米之炊。Embedding 我这里用的是 text-embedding-v3通义千问配套的中文向量模型。中文场景下用专门优化过中文语料的 Embedding 模型比直接用英文模型的效果好很多。这个差别在短文本检索时特别明显。向量库我选的是 FAISS而不是直接上 Milvus 或 Elasticsearch。原因很实在FAISS 是一个本地计算库pip install faiss-cpu就能用不需要单独启动服务不需要运维特别适合个人项目和小规模知识库。你的知识库如果只是几千个文本块FAISS 的检索性能绰绰有余。真到了百万级数据量再迁移到专用向量数据库LangChain 的接口层基本不用动这点也是框架带来的好处。3. 环境准备先把地基打好3.1 用虚拟环境隔离项目依赖开始写代码之前我强烈建议先建一个独立的 Python 虚拟环境。LangChain 的依赖树比一般库要复杂它底层依赖 pydantic而 pydantic 对版本要求非常严格。如果你把 LangChain 直接装进全局环境大概率会跟其它项目里固定版本的 pydantic 起冲突到时候满屏报错查得头疼。创建命令很简单把这段拿过去用就行python -m venv rag_env # Windows 激活 rag_env\Scripts\activate # macOS / Linux 激活 source rag_env/bin/activatePython 版本建议用 3.10 或 3.11。3.8 太旧个别新版依赖已经不再支持3.12 也能跑但有些包在 Windows 上可能没有预编译的 wheel装起来麻烦。我自己的项目环境是 Python 3.11实测最稳。3.2 核心依赖安装清单这是一个最小可运行方案不需要装一大堆用不上的包。我在项目里实际安装的依赖就这几个pip install langchain langchain-community langchain-text-splitters pip install faiss-cpu pip install dashscope python-dotenv逐个说下它们是干嘛的。langchain主框架提供链、Prompt 模板这些核心能力。langchain-community第三方集成组件的家。我们后面要用的文档加载器、FAISS 向量库、ChatTongyi、DashScopeEmbeddings 都在这个包里。新版 LangChain 把社区集成从主包里拆出来了所以必须单独装。langchain-text-splitters文本切分工具。这个也被拆成了独立包专门放 RecursiveCharacterTextSplitter。faiss-cpuFAISS 的 CPU 版本。个人和小型知识库场景下CPU 版本和 GPU 版本在检索速度上的差距你几乎感知不到装 CPU 版最省事。dashscope通义千问模型的官方 SDK。LangChain 的 ChatTongyi 内部会调用它所以这也是隐性依赖。python-dotenv用来读取 .env 文件里的环境变量方便管理 API Key。如果你的知识文档里有 PDF再额外装一个pip install pypdf。这是 PyPDFLoader 依赖的后端不装的话加载 PDF 会直接报错。3.3 API Key 配置的正确姿势通义千问的模型服务是走 API 调用的你需要先去模型服务控制台开通服务然后创建一个 API Key。过程不复杂重点是拿到 Key 之后怎么管理。不要在代码里硬编码 API Key。很多人图快直接把 Key 写在 main.py 里然后整个文件夹传到 Git 仓库Key 就泄露了。正确做法是在项目根目录建一个 .env 文件DASHSCOPE_API_KEY你的key代码里通过load_dotenv()自动读取然后os.getenv(DASHSCOPE_API_KEY)拿到值。.env 文件记得加进 .gitignore永远不要提交到远程仓库。代码里还有一个健壮性细节如果 API Key 为空直接抛异常快速失败而不是让程序跑到一半才因为认证失败报错。这个小习惯能省很多调试时间。4. 核心代码拆解一条链看懂 RAG 全流程4.1 文档加载把磁盘上的文件变成 DocumentRAG 的第一步是把文档读进来。我用的加载器是 DirectoryLoader 加 TextLoader 的组合loader DirectoryLoader( ./knowledge, glob**/*.txt, loader_clsTextLoader, loader_kwargs{encoding: utf-8}, ) documents loader.load()DirectoryLoader 负责遍历一个目录glob**/*.txt表示匹配目录下所有子文件夹里的 txt 文件。loader_clsTextLoader指定具体用 TextLoader 去解析文件内容。load() 返回的是一个 Document 对象列表。每个 Document 有两个核心字段page_content是正文文本metadata是文件信息比如文件路径。这个 metadata 后面会当答案来源展示用非常关键。单独说明一下编码问题。TextLoader 默认按 utf-8 读取文件但很多人电脑上的 txt 是 GBK 编码结果一运行就抛 UnicodeDecodeError。所以我这里用 loader_kwargs 显式传了encodingutf-8。如果你手上的文档是 GBK就把值改成gbk。最省心的做法是统一把知识库文档转成 utf-8之后就没这个烦恼了。4.2 文本切分控制每个片段的信息量文档加载进来后不能直接向量化因为整篇文档太长了。一篇 3000 字的说明书如果整体变成一个向量向量里各个主题的信息会互相干扰检索时相关性会被稀释。所以要先切块。text_splitter RecursiveCharacterTextSplitter( chunk_size500, chunk_overlap50, ) docs text_splitter.split_documents(documents)这里有两个参数需要重点理解chunk_size 和 chunk_overlap。chunk_size500 表示每个文本块大约 500 个字符。为什么选 500因为中文场景下一个汉字差不多对应一个 token500 个字符就是大概 500 个 token 的文本既能承载一个相对完整的知识点又不会占用太多大模型上下文窗口。如果设成 2000一个块里塞了好几个主题检索出来的相关性就不精确如果设成 100每段内容太短模型拿不到完整上下文回答容易断章取义。chunk_overlap50 表示相邻两个块之间保留 50 个字符的重叠。这 50 个字符是为了防止切分边界恰好把一段完整的话拦腰截断。比如上一块结尾在讲某个概念的定义下一块开头也是这个概念重叠可以让两块各自都保留完整信息。RecursiveCharacterTextSplitter 的切分逻辑不是简单地按长度硬切而是优先按换行符、句号、空格这样的天然边界递归切分。切出来都是相对完整的句子比无脑按字符切要智能一点。4.3 向量化与入库构建一次反复使用文本切好后下一步就是向量化并存入向量库。这一步在我的代码里是两行embeddings DashScopeEmbeddings(modeltext-embedding-v3) vectorstore FAISS.from_documents(docs, embeddings) vectorstore.save_local(./faiss_index)第一行创建 Embedding 模型实例。第二行对每个文本块调用模型接口生成向量并写入 FAISS 索引。第三行把索引持久化到本地磁盘。FAISS.from_documents 是同步流程文档多的时候这里会明显卡几十秒是正常的它在挨个调用 Embedding API。有一个经验值得记下来如果只是改动 Prompt 模板不需要重建索引如果你改了切分策略、换了 Embedding 模型或者新增了大量文档就建议重新 build 一次索引。因为索引里存的是文本块的向量Prompt 改了不影响向量内容。4.4 检索器与Prompt设计决定回答质量的两只手索引构建好之后要把它包装成检索器retriever vectorstore.as_retriever( search_typesimilarity, search_kwargs{k: 4}, )search_typesimilarity表示用向量余弦相似度找最相关的文本块。k4表示取最相关的 4 块。这个数字不是拍脑袋定的太小了可能漏掉关键信息太大了又可能把一堆不太相关的内容也塞进上下文反而干扰模型。4 这个值在大多数场景下是个不错的起点你可以在测试时调到 3 或 5 对比效果。Prompt 模板是整个系统里最直接的“指挥棒”我用的模板是prompt_template 请基于以下给定的资料用简洁、专业、准确的中文回答用户的问题。 如果给定的资料中没有相关内容请直接回答根据现有知识无法回答该问题。禁止编造或臆测。 给定资料 {context} 用户问题 {question} 你的回答这个模板的重点在于“禁止编造”四个字。RAG 系统的幻觉问题不只在检索环节如果 Prompt 没有明确约束模型会把检索到的资料和自己记忆里的旧知识混在一起作答。给它设置一个“不知道就直说”的兜底是抑制幻觉最简单有效的一招。模板里的{context}和{question}是占位符组装时会分别替换成检索到的文本块和用户问题。4.5 组装问答链并调用组装用的是 RetrievalQA 这个便捷链qa_chain RetrievalQA.from_chain_type( llmllm, chain_typestuff, retrieverretriever, return_source_documentsTrue, chain_type_kwargs{prompt: QA_CHAIN_PROMPT}, ) result qa_chain.invoke({query: 报销流程是什么})chain_typestuff表示把检索到的所有文本块直接拼接后塞进 Prompt。这是最简单也最推荐的方式因为它把全文都给了模型信息损失最小。只有当你检索结果特别多、超过模型上下文限制时才需要考虑 map_reduce 或 refine 这样的复杂策略。调用时输入的是一个字典key 是query。这也是 RetrievalQA 隐式的约定很多人第一次用会惯性写成question结果跑起来直接报错。输出 result 是一个字典result[result]是模型生成的最终回答result[source_documents]是命中的文本块列表遍历它就能拿到每个块的原文和文件路径。调试时这个字段特别重要它能帮你看到模型到底依据了哪些资料。5. 实测中的高频坑与排查思路5.1 中文编码引发 UnicodeDecodeError这是最常见的启动报错。TextLoader 默认用 utf-8 读文件但你的 txt 可能是 GBK 编码一加载就抛 UnicodeDecodeError。我的处理方式是在 DirectoryLoader 里显式传loader_kwargs{encoding: utf-8}然后把知识库原始文档统一用记事本或脚本转成 UTF-8。手动改文件编码太累了可以在代码里加个预处理或者干脆要求团队上传文档时统一格式。如果你坚持读 GBK就把 encoding 改成gbk。5.2 FAISS加载报错allow_dangerous_deserialization新版本 LangChain 在加载本地 FAISS 索引时强制要求你显式声明允许反序列化否则会报一堆安全提示。这是 because FAISS 的本地索引是用 pickle 序列化的反序列化恶意文件可能有安全风险。解决办法很简单加载时加上参数vectorstore FAISS.load_local( ./faiss_index, embeddings, allow_dangerous_deserializationTrue, )这个参数看着吓人但只要你加载的是自己生成的索引文件在本地可信环境里跑没有实际问题。5.3 模型调用失败限流、超时与Key配置如果你在循环测试时连续提问可能会碰到服务端返回限流错误或超时。第一反应先检查几个点API Key 是否配置在环境变量里DASHSCOPE_API_KEY 是否真的读到了模型名是否拼写正确网络环境是否稳定。排查完了如果还报限流最简单的方案是把代码里的模型从效果更强的型号换成轻量型号。日常功能调试阶段完全够用等确认链路没问题了再换大模型。另外可以在循环里加一个 sleep避免一股脑发请求。5.4 回答不对检索内容驴头不对马嘴遇到最多的是这个问题代码跑通了回答也生成了但内容完全不在点上。第一步先看 source_documents。把命中的几个文本块打印出来看看模型依据的资料是不是真的和问题相关。如果检索出来的就不相关那是索引或检索参数的问题调整 chunk_size、换 Embedding 模型、增加 k 值。如果检索出来是相关的但回答还是不对那就是 Prompt 的问题检查模板里有没有明确告诉模型“只能依据给定资料作答”。还有一个容易被忽略的细节测试时用文档里的原话问先确认链路是通的再改成同义表达验证检索器的泛化能力。很多人一上来就用自己的大白话提问检索不到内容就以为是系统坏了其实在文档里原话是有明确答案的。5.5 LangChain版本变动导致的import报错版本兼容问题我之前被恶心过。LangChain 从 0.1 到 0.3 经历了好几次大重构很多组件改了导入路径。网上大量教程还是旧版写法直接复制就会 ModuleNotFoundError。新版的核心路径写在下面RecursiveCharacterTextSplitter 从langchain_text_splitters导入DirectoryLoader、TextLoader、ChatTongyi、DashScopeEmbeddings、FAISS 从langchain_community子模块导入RetrievalQA、PromptTemplate 从langchain导入解决版本问题有一个笨办法但很有效遇到 ModuleNotFoundError优先检查 import 路径是否符合上面这些新路径而不是急着去 pip 重装。同时建议在项目 requirements.txt 里把 langchain 版本范围写死比如langchain0.3,0.4避免哪天长版本大版本升级后旧代码突然跑不了。6. 可直接运行的完整代码文章末尾6.1 项目目录结构先把目录结构摆出来照着建就行rag_demo/ ├── .env # 存放 DASHSCOPE_API_KEY ├── knowledge/ # 把你想让模型学习的文档放这里 │ └── product_manual.txt └── main.py # 本文完整代码首次运行 main.py 会自动在项目根目录创建 faiss_index 文件夹用来存放向量索引。第二次再运行如果检测到索引已存在就不会重新构建直接加载本地索引。6.2 main.py 完整代码下面这段代码就是完整可运行的版本。你只需要做三件事把 .env 文件里的 Key 换成自己的往 knowledge 目录里放几个 txt 文档然后执行python main.py。import os from dotenv import load_dotenv load_dotenv() from langchain_community.document_loaders import DirectoryLoader, TextLoader from langchain_community.embeddings import DashScopeEmbeddings from langchain_community.vectorstores import FAISS from langchain_community.chat_models import ChatTongyi from langchain_text_splitters import RecursiveCharacterTextSplitter from langchain.chains import RetrievalQA from langchain.prompts import PromptTemplate DASHSCOPE_API_KEY os.getenv(DASHSCOPE_API_KEY) if not DASHSCOPE_API_KEY: raise ValueError(请先在 .env 文件中配置 DASHSCOPE_API_KEY) KNOWLEDGE_DIR ./knowledge VECTOR_INDEX_DIR ./faiss_index def build_index(): 读取知识库文档切分向量化保存FAISS索引。 loader DirectoryLoader( KNOWLEDGE_DIR, glob**/*.txt, loader_clsTextLoader, loader_kwargs{encoding: utf-8}, ) documents loader.load() if not documents: raise ValueError(知识库目录下没有找到任何 txt 文档请先往 knowledge 目录放文件。) text_splitter RecursiveCharacterTextSplitter( chunk_size500, chunk_overlap50, ) docs text_splitter.split_documents(documents) embeddings DashScopeEmbeddings(modeltext-embedding-v3) vectorstore FAISS.from_documents(docs, embeddings) vectorstore.save_local(VECTOR_INDEX_DIR) print(f索引构建完成共切分 {len(docs)} 个文本块已保存到 {VECTOR_INDEX_DIR}) def load_index(): 加载本地FAISS索引。 embeddings DashScopeEmbeddings(modeltext-embedding-v3) vectorstore FAISS.load_local( VECTOR_INDEX_DIR, embeddings, allow_dangerous_deserializationTrue, ) return vectorstore def create_qa_chain(): 构建检索问答链。 if not os.path.exists(VECTOR_INDEX_DIR): build_index() vectorstore load_index() retriever vectorstore.as_retriever( search_typesimilarity, search_kwargs{k: 4}, ) llm ChatTongyi( modelqwen-plus, temperature0.1, ) prompt_template 请基于以下给定的资料用简洁、专业、准确的中文回答用户的问题。 如果给定的资料中没有相关内容请直接回答根据现有知识无法回答该问题。禁止编造或臆测。 给定资料 {context} 用户问题 {question} 你的回答 QA_CHAIN_PROMPT PromptTemplate( templateprompt_template, input_variables[context, question], ) qa_chain RetrievalQA.from_chain_type( llmllm, chain_typestuff, retrieverretriever, return_source_documentsTrue, chain_type_kwargs{prompt: QA_CHAIN_PROMPT}, ) return qa_chain def main(): qa_chain create_qa_chain() while True: question input(\n请输入问题输入 exit 或 quit 退出).strip() if question.lower() in (exit, quit): break if not question: continue result qa_chain.invoke({query: question}) print(\n回答) print(result[result]) print(\n参考来源) for i, doc in enumerate(result[source_documents], 1): source doc.metadata.get(source, 未知来源) content doc.page_content[:120].replace(\n, ) print(f{i}. {source} | {content}) print(已退出。) if __name__ __main__: main()6.3 运行验证与后续优化按顺序执行在项目根目录创建 .env 文件写入DASHSCOPE_API_KEY你的key。在 knowledge 目录下放几个 txt 文档比如产品说明、FAQ、操作手册。运行python main.py。在交互提示符后输入问题比如“备份策略是什么”回车就能看到回答和来源。输入 exit 退出程序。第一次运行会先构建索引等你第二次输入问题时检索和回答就已经是实时完成的。我实际测试下来从索引构建到拿到第一个回答整个过程通常不超过一分钟而且大部分时间是花在文档切分和向量化上。这个版本跑通之后还有几个后续值得做的方向如果想支持多轮对话可以在链路里加一个记忆模块把历史对话一并传给模型如果知识库文档经常更新可以在现有索引上调用 add_documents 增量添加而不是每次全量重建如果想摆脱在线 API 的成本也可以把 ChatTongyi 换成本地部署的开源模型LangChain 的接口层基本不用动。这套代码我实际跑过很多轮第一次跑通时最大的感受是RAG 真的没有想象中复杂真正的复杂度全藏在细节里——切分多大、命中几个块、Prompt 怎么写、Key 怎么配。你把这篇文章的代码复制下来换成自己的文档跑一圈很多“教程里为什么这么写”的问题就自然懂了。以后要做生产级系统再把权限、日志、效果评估这些补上但第一步永远是把链路跑通。
阅读完成 · 觉得有帮助?