先说明一下我为什么非要写这个 003 号项目。做问答应用的人大概都有同一种痛:大模型生成能力强,但它的"知识"停留在训练时的那一刻。你问它团队内部制度、某份产品手册的具体条款、某个系统的配置要求,它能答,但答案不可信——它没真正见过这些资料,只能凭"语感"去猜。我遇到过最离谱的一次,模型把一份文档里写的"推荐保留 7 天"答成了"建议保留 90 天",理由是"90 天更符合常规备份周期"。那一刻我就确定,光靠模型本身做知识问答是行不通的,得给它配一个可以随时翻阅的资料库。于是就有了这套 LangChain + 通义千问 的 RAG 知识库问答项目。它的思路是:先把本地文档切片、向量化、存进向量库,用户提问时先从知识库里召回最相关的片段,再把这些片段连同问题一起交给通义千问生成回答。模型不再是"闭卷考试靠猜",而是"开卷考试有据可查"。这篇内容适合想把团队文档、个人笔记、产品手册变成可对话问答机器人的开发者,也适合正在学 LangChain 和 RAG、想找一个能直接跑通的完整示例的朋友。代码放在文章末尾,中间我会把每一段的关键逻辑、为什么这么写、可能踩的坑都讲清楚。1. 这个 003 号项目:RAG 到底解决了什么问题先说个背景。我习惯把迭代中的小项目按编号存下来,003 就是第三版的知识库问答 Demo。前两版分别是"直接让模型看全文回答"和"简单关键词匹配后拼接 Prompt",都失败了。失败原因其实很典型,值得先讲一下。第一版直接传全文,问题在于:文档一长,输入就超 token 上限;即便截断,模型注意力分散,回答容易跑偏。第二版用关键词匹配,效果更差——用户问"报销时限"时候选文档里写的是"费用报销需在业务发生后 30 天内提交",关键词完全对不上,语义不通。直到第三版换成真正的向量检索,才把"语义相近"和"关键词相同"区分开,问答效果才终于可用。1.1 RAG 的核心链路:检索在前,生成在后RAG 全称 Retrieval-Augmented Generation,检索增强生成。它的链路可以拆成离线准备和在线问答两个阶段。离线阶段做的事情是:把知识文档读进来,切成一个个有语义边界的小块,每个小块调用嵌入模型转成一个向量,最后把所有向量存进向量库。在线阶段做的事情是:把用户的问题也转成向量,去向量库里找出最相似的几个片段,再把"问题 + 这几个片段"拼成 Prompt 交给大模型。这里有个关键区别:嵌入模型负责"找资料",大模型负责"读资料并组织答案"。两者各司其职,不要混为一谈。我见过有人试图让大模型自己从一堆文档里找答案,结果就是又慢又贵又容易幻觉。RAG 的正确姿势永远是:机器先筛,模型后读。1.2 为什么不用微调来解决很多人第一反应是:效果不好就微调模型啊。但实际做知识问答,微调有四个硬伤。第一,数据更新太频繁。知识库里的文档每周都可能变,每改一次就微调一次,成本和时间都扛不住。第二,微调需要一定量的高质量标注数据,普通团队根本攒不够。第三,微调后的模型仍然可能产生幻觉,它只是"见过了"这些资料,不等于"会准确引用"——本质上还是背题,不是翻书。第四,成本高。一次完整的参数微调加上验证,开销远超调一次 API。RAG 的优势在于:文档更新只需要重新向量化对应文件,不需要动模型;回答时还天然带"出处",用户能核对答案是不是真的来自某段资料。对知识库问答这个场景,RAG 几乎是当前性价比最高的方案。1.3 这套代码能做到什么程度003 号项目跑通之后的效果大概是这样的:你把几份 txt 文档扔进 knowledge 目录,程序会建立索引;然后你问"报销时限是多少天""某个接口的默认超时时间",它能从资料里找到对应片段并给出有依据的回答。如果资料里确实没有相关内容,它会明确说"知识库中没有找到相关信息",而不是硬编一个答案。当然它也有边界:文档质量差、扫描版 PDF、多轮追问、需要跨文档推理的复杂问题,这版代码并不能完美处理。先把基础链路跑通,后面再逐步加功能,是更务实的路径。2. 动手前必须想清楚的三个决策:切片、嵌入模型、向量库RAG 看起来就两步——检索和生成,但真正决定效果的是三个前置决策。我在 003 号项目里都踩过坑,先说结论,再讲为什么。2.1 文档切片的粒度直接决定回答质量很多人第一次写 RAG,文档是整篇往里塞的,结果要么 token 超限,要么检索时召回一大段无关内容。正确做法是先切片:把长文档切成有语义边界的小块,每一块作为独立的检索单位。切入点的两个参数是 chunk_size 和 chunk_overlap。003 号项目用 500 字符、重叠 80 字符。为什么是 500?如果设成 2000,一次召回 3 块就是 6000 字符,上下文太长,既费 token,又会因为每块内容太多,相关性得分被无关段落稀释。如果设成 100,片段太碎,一个完整知识点被切断,模型拼不出逻辑。500 对中文技术文档和制度手册是比较稳的中间值。chunk_overlap 的作用是防止关键句子恰好卡在切片边界。前一块结尾和后一块开头有 80 个字符交叠,知识点就不会"正好断在缝里"。这个值建议在 50 到 100 之间调,看你的文档句式长度。切片工具我用 RecursiveCharacterTextSplitter,它比固定长度硬切聪明:会优先按分隔符(换行、句号、逗号)切,实在没有分隔符才按字符长度兜底。对中文来说,最好把分隔符列表改成 ["\n\n", "\n", "。", "!", "?"],这样切出来的块基本不会从句子中间断开。这个细节网上很多教程没有强调,但影响很大。2.2 嵌入模型选型:直接上 text-embedding-v3,别纠结切片之后,要把每个文本块转成向量——一串能表达语义的数字。两个文本语义越接近,向量在空间里就越靠近。这一步用的模型叫嵌入模型,也叫文本向量化模型。003 号项目直接用了通义千问官方的 text-embedding-v3。原因很简单:和对话模型同一个平台的 API,密钥统一,调用方式一致,不需要维护两套鉴权体系。v3 相比早期 v1/v2,中文长文本的语义精度提升明显,支持自定义向量维度,默认 1024 维,文档量不大时成本可以忽略。也想提醒一句:如果你有强隐私需求,可以换本地开源嵌入模型,比如 300M 参数级别的中文向量模型,CPU 也能跑。但本地模型和 API 模型的效果差异是实打实的,尤其面对垂直领域术语时,召回精度差会直接拖累最终答案质量。我的建议是:先跑通流程用 API 模型,确认链路没问题,再根据隐私要求决定要不要换。2.3 向量库选型:FAISS 够用,Chroma 更方便向量库负责两件事:存向量、做相似度检索。003 号项目选的是 FAISS,一个 C++ 实现的相似度搜索库,Python 包名 faiss-cpu。它没有独立服务进程,直接以本地文件形式保存索引,非常适合单机 Demo 和小型知识库。如果你图省事,也可以用 Chroma,它会自动持久化,不用手动 save/load。但我还是推荐先学 FAISS,因为它的检索逻辑更透明,你清楚索引存在哪个文件、怎么加载、什么时候失效。等 FAISS 玩明白了,换 Chroma 只是几行代码的事。向量检索最常见的指标是余弦相似度,直观理解就是两个向量方向的夹角度数,夹角越小越相似。FAISS 底层支持内积和 L2 距离,LangChain 封装后默认做余弦相似度归一化,日常使用你只需要关心"召回前几个片段",也就是 k 值。003 号项目里 k=3,意思是每次找最相关的 3 个片段给模型。k 太大会引入噪音,k 太小会漏掉关键信息,我试下来 3 到 5 是比较稳的范围。3. 环境准备:版本号这个东西,卡了我一整天老实说,这个项目 80% 的调试时间不是花在写逻辑上,而是在对齐版本。LangChain 这两年 API 变动很大,网上教程大多写于不同时期,照着敲完直接报 ImportError 是常事。下面这套依赖组合是实测能跑的,建议先按这个来,跑通之后再考虑升级。3.1 Python 版本与核心依赖清单建议 Python 3.10 或 3.11。我试过 3.9,某些新版本依赖会直接报 "requires Python >=3.10"。核心依赖如下表:包名推荐版本用途langchain0.2.17框架主包,提供链条与 Prompt 管理langchain-community0.2.19文档加载器、FAISS、模型适配器langchain-core0.2.43核心抽象,必须与主包同大版本faiss-cpu1.8.0向量索引与相似度检索dashscope1.20+通义千问官方 SDK,嵌入调用依赖它安装命令:pip install langchain==0.2.17 langchain-community==0.2.19 langchain-core==0.2.43 faiss-cpu==1.8.0 dashscope这里必须提醒:不要直接 pip install langchain 之后再装一个最新版 langchain-community,两个包版本必须配套,否则会出现各种 "ModuleNotFoundError: langchain_community.embeddings" 之类的诡异报错。如果你是从旧项目升级,先卸载干净再装指定版本:pip uninstall langchain langchain-community langchain-core -y pip install langchain==0.2.17 langchain-community==0.2.19 langchain-core==0.2.43 faiss-cpu==1.8.0 dashscope我把版本写死,不是保守,而是这类项目最怕"昨天能跑、今天报错"。把版本锁进 requirements.txt,是项目可复现的基本习惯。3.2 获取 API Key 的正确姿势调用通义千问模型,需要去官方控制台开通模型服务,创建 API Key。注意:对话模型和 Embedding 模型最好都开通,缺一个后面就会报 "model not found" 或 "permission denied"。拿到 Key 之后,不建议硬编码在代码里。虽然教程里都这么写,但代码一旦提交到 Git 仓库,Key 等于泄露了。正确做法是设置环境变量:# macOS / Linux export DASHSCOPE_API_KEY="sk-你的Key" # Windows PowerShell $env:DASHSCOPE_API_KEY="sk-你的Key"代码里用 os.environ.get("DASHSCOPE_API_KEY") 读取。如果你已经在某个项目里把 Key 写死并提交过,赶紧去控制台重置,不要抱侥幸心理。3.3 FAISS 安装的跨平台差异faiss-cpu 在大多数平台有预编译轮子,pip 直接装就行。但 Windows 上偶尔会遇到 import faiss 时报 DLL 加载失败,多半是 Python 位数或与 numpy 版本冲突。解决顺序是:先升级 numpy,再强制重装 faiss:pip install --upgrade numpy pip install --force-reinstall faiss-cpu如果还不行,检查 Python 是不是 64 位。32 位 Python 装不了现代版本的 faiss。4. 代码逐段拆解:从文档到答案的完整链路下面按三段讲核心链路:加载与切片、向量化入库、检索问答。我会把每一段代码的"为什么这么写"讲清楚,而不是只贴代码让你复制。4.1 加载文档:DirectoryLoader 与编码陷阱加载文档用的是 DirectoryLoader 加 TextLoader 组合。它扫描指定目录下所有匹配文件,逐个加载成 Document 对象。有个容易被忽略的坑:中文文本文件如果不是 UTF-8 编码,TextLoader 默认直接报 UnicodeDecodeError。解决方式是显式传 encoding="utf-8";如果你的文件是 GBK 编码,就传 encoding="gbk"。目录结构建议这样:project/ ├── knowledge/ # 放知识文档 │ ├── 产品手册.txt │ └── 制度规范.md ├── qa_demo.py # 主代码 └── vector_index/ # FAISS 索引保存目录(自动创建)如果你想加载 PDF,可以把 loader_cls 换成 PyPDFLoader。但扫描件需要先做 OCR,否则提取出来的全是乱码或空白,这一步没办法靠 LangChain 自动解决。4.2 切片与简单清洗:脏文本比想象中多加载进来的文档,先过一遍切片器。我在代码里对 RecursiveCharacterTextSplitter 做了两个定制:分隔符列表按中文习惯调整,chunk_overlap 设为 80。另外一个容易忽略的细节是:很多文档里有连续空行和多余空格,会干扰切片边界。我在切片前加了一步处理:把连续空行压缩成单个换行,去掉行首行尾多余空格。切片之后建议打印每个片段的前 50 个字符,确认边界没有把句子拦腰截断。这个检查成本极低,但能立刻暴露 chunk_size 是否合适。4.3 向量化入库与本地持久化切片完成,接下来对每个片段调用嵌入模型生成向量,构建 FAISS 索引。核心代码只有几行:vectorstore = FAISS.from_documents(chunks, embeddings) vectorstore.save_local("./vector_index")from_documents 内部会循环调用嵌入模型。这里有两个注意点。第一,嵌入模型是 API 调用,文档多的时候会比较慢,甚至触发并发限制。几千段落级别以内不用担心,但如果知识库有几十万片段,就必须考虑分批处理或加限速。第二,一定要 save_local。如果不保存,每次运行都要重新向量化全部文档,既烧钱又费时间。第二次运行直接用 load_local 秒开:vectorstore = FAISS.load_local( "./vector_index", embeddings, allow_dangerous_deserialization=True )这里 allow_dangerous_deserialization 是 LangChain 新版本的安全要求。FAISS 索引文件本质是 pickle 序列化,加载不可信来源的索引文件有代码执行风险。索引是自己生成的可以放心打开;从别处下载的,千万别开。4.4 检索问答:Prompt 模板才是真正的灵魂最后一步是检索加问答。LangChain 里有 RetrievalQA 这种封装好的链条,但我强烈建议你至少看一眼它怎么把"检索结果 + 问题"拼成 Prompt,因为最终回答质量一半由检索决定,另一半由 Prompt 决定。003 号项目的 Prompt 模板:你是知识库问答助手。请严格按照下面的资料回答问题。 如果资料中没有相关内容,请明确回答"知识库中没有找到相关信息",不要编造。 资料: {context} 问题:{question} 回答:这个模板里有三个关键约定:明确告诉模型"只根据资料回答",压住幻觉。强制模型在资料不足时承认不知道,而不是硬答。不要求模型输出华丽表达,保证答案紧凑、易核对。温度参数我设为 0.3。温度控制生成随机性:0 最保守但显得机械,0.7 以上容易发挥过头。RAG 场景要的是稳定准确,0.3 是个平衡点。另外,RetrievalQA 的 chain_type 我用的 "stuff",意思是把所有检索片段一次性塞进上下文。k=3 时这点 token 开销完全可接受。如果你的 k 很大或片段很长,就要考虑 "map_reduce" 或 "refine" 模式来分摊,否则会遇到上下文超限报错。5. 实测效果与三次典型翻车记录代码能跑只是第一步,能稳定跑出高质量答案才算结束。下面分享我调试过程中最典型的三个问题,顺序基本就是新手会遇到的顺序。5.1 翻车一:API Key 环境变量没生效第一次运行,代码报错信息大概是 "InvalidApiKey: Invalid API-key provided"。我第一反应是 Key 复制错了,反复重试仍然报错。最后发现,Key 没问题,问题是环境变量设置在我的 shell 会话里,但我在 IDE 里运行 Python,IDE 进程根本没继承 shell 里 export 的变量。排查思路很简单:在代码开头加一行打印,确认环境变量是否读得到:print("DASHSCOPE_API_KEY:", os.environ.get("DASHSCOPE_API_KEY")[:8] + "****")如果打印出来是 None,说明环境变量没进 Python 进程。解法有两种:把 export 写进 .bashrc 或 .zshrc 然后重启 shell;或者在 IDE 的运行配置里手动加环境变量。我个人推荐第二种,只影响当前项目,不会污染全局。5.2 翻车二:LangChain 版本迁移导致的导入路径失效换个环境复现 003 号项目时,第一行导入就报错。网上搜到的代码很多是from langchain.embeddings import DashScopeEmbeddings,但新版 LangChain 把这块移到了 langchain_community 下。正确写法是:from langchain_community.embeddings import DashScopeEmbeddings from langchain_community.llms import Tongyi只有 langchain 0.0.x 老版本才用from langchain.embeddings。这个迁移让很多人对着旧教程改到怀疑人生。应对方法就是我在第 3 节说的:依赖版本写死,升级前先看 changelog。5.3 翻车三:检索结果和问题完全不相关有一次我问"请假流程需要提前几天申请",它答非所问,把毫不相干的制度背了一段给我。我立刻做了两件事:第一,把 k 从 3 调到 5,确认召回片段确实多了;第二,把检索到的片段内容打印出来,发现命中的片段标题相关,但内容被 chunk_size 切得太碎,关键信息残缺。根因在切片参数。知识库里有一篇很长的制度文档,每条制度原本是完整段落,但文本超过 500 字符被切开,关键句恰好落在两块边界,重叠的 80 字符又不够覆盖。解决办法是把 chunk_overlap 提到 100,同时按二级标题对长文档二次切分。这也引出一个重要原则:RAG 排错,永远先看"检索到了什么",再看"模型回答了什么"。检索环节错了,后面调 Prompt 都是白费功夫。6. 完整可运行代码与使用说明终于到代码了。下面是完整的单文件版本,整合了前面所有内容,保存为 qa_demo.py,按注释修改路径和 Key 就能跑。6.1 代码全文# -*- coding: utf-8 -*- """ LangChain + 通义千问 RAG 知识库问答 Demo(003号项目) 功能: 1. 读取 knowledge 目录下的 txt/md 文档 2. 切片、向量化、构建 FAISS 索引并保存 3. 交互式问答:从知识库检索相关片段,交给通义千问生成回答 依赖: pip install langchain==0.2.17 langchain-community==0.2.19 langchain-core==0.2.43 faiss-cpu==1.8.0 dashscope 目录结构: project/ ├── qa_demo.py ├── knowledge/ # 放你的知识文档(.txt / .md) └── vector_index/ # 索引保存目录(自动创建) 环境变量: DASHSCOPE_API_KEY=你的密钥 """ import os import re from langchain_community.document_loaders import DirectoryLoader, TextLoader from langchain.text_splitter import RecursiveCharacterTextSplitter from langchain_community.embeddings import DashScopeEmbeddings from langchain_community.vectorstores import FAISS from langchain_community.llms import Tongyi from langchain.chains import RetrievalQA from langchain.prompts import PromptTemplate API_KEY = os.environ.get("DASHSCOPE_API_KEY") if not API_KEY: raise RuntimeError("请先设置环境变量 DASHSCOPE_API_KEY") KNOWLEDGE_DIR = "./knowledge" INDEX_DIR = "./vector_index" def clean_text(text: str) -> str: """简单清洗:压缩连续空行、去掉行首行尾多余空格。""" text = re.sub(r"\n{3,}", "\n\n", text) lines = [line.strip() for line in text.splitlines()] return "\n".join(lines) def load_and_split(): """加载文档并切片。""" loader = DirectoryLoader( KNOWLEDGE_DIR, glob="**/*.txt", loader_cls=TextLoader, loader_kwargs={"encoding": "utf-8"}, ) docs = loader.load() if not docs: raise RuntimeError(f"{KNOWLEDGE_DIR} 目录下没有找到 .txt 文档") splitter = RecursiveCharacterTextSplitter( chunk_size=500, chunk_overlap=80, separators=["\n\n", "\n", "。", "!", "?"], ) chunks = [] for doc in docs: cleaned = clean_text(doc.page_content) doc.page_content = cleaned chunks.extend(splitter.split_text(cleaned)) print(f"文档加载完成:{len(docs)} 个文件,切片 {len(chunks)} 段") return chunks def build_index(chunks: list): """构建向量索引并保存到本地。""" embeddings = DashScopeEmbeddings(model="text-embedding-v3") if os.path.exists(INDEX_DIR): print("检测到已有索引,重新构建前先删除旧索引,避免版本不一致") import shutil shutil.rmtree(INDEX_DIR) vectorstore = FAISS.from_texts(chunks, embeddings) vectorstore.save_local(INDEX_DIR) print(f"索引已保存到 {INDEX_DIR}") def create_qa(): """加载索引并创建问答链。""" embeddings = DashScopeEmbeddings(model="text-embedding-v3") if not os.path.exists(INDEX_DIR): raise RuntimeError("索引不存在,请先运行 build_index()") vectorstore = FAISS.load_local( INDEX_DIR, embeddings, allow_dangerous_deserialization=True, ) llm = Tongyi( model="qwen-plus", temperature=0.3, ) template = """你是知识库问答助手。请严格按照下面的资料回答问题。 如果资料中没有相关内容,请明确回答"知识库中没有找到相关信息",不要编造。 资料: {context} 问题:{question} 回答:""" prompt = PromptTemplate( template=template, input_variables=["context", "question"], ) qa_chain = RetrievalQA.from_chain_type( llm=llm, retriever=vectorstore.as_retriever(search_kwargs={"k": 3}), chain_type="stuff", chain_type_kwargs={"prompt": prompt}, ) return qa_chain if __name__ == "__main__": # 首次运行:构建索引 if not os.path.exists(INDEX_DIR): data = load_and_split() build_index(data) qa = create_qa() print("知识库问答已就绪,输入问题开始提问,输入 exit 退出。") while True: question = input("\n问题:").strip() if not question: continue if question.lower() == "exit": break answer = qa.invoke({"query": question}) print("回答:", answer["result"])6.2 使用步骤与调参建议使用步骤很简单:把知识文档放进 knowledge 目录,编码统一为 UTF-8 的 txt 文件。安装依赖,设置好 DASHSCOPE_API_KEY 环境变量。首次运行 python qa_demo.py,会自动加载文档、构建索引。之后每次运行,索引已存在,直接进入问答循环。跑通之后,我强烈建议做两件事。一是把 k 值和 chunk_size 各调一版做对比,找到最适合你这个知识库的参数。二是建立一个小型测试集,把历史问题、期望答案、实际输出记下来,以后换模型、调参数都有客观依据。我自己的体会是,RAG 项目的真正工作量从来不在"跑通",而在"调准"。文档切片粒度、检索召回数量、Prompt 措辞,这三个点每一个都值得反复试。003 号项目只是一个起点,你把代码跑起来之后,大概率会遇到和我不一样的文档、不一样的业务场景,但排错的思路是通用的:先确认数据加载正确,再确认检索结果合理,最后才去调整模型输出。按这个顺序走,问题基本都能定位到具体环节。
先说明一下我为什么非要写这个 003 号项目。做问答应用的人大概都有同一种痛:大模型生成能力强,但它的"知识"停留在训练时的那一刻。你问它团队内部制度、某份产品手册的具体条款、某个系统的配置要求,它能答,但答案不可信——它没真正见过这些资料,只能凭"语感"去猜。我遇到过最离谱的一次,模型把一份文档里写的"推荐保留 7 天"答成了"建议保留 90 天",理由是"90 天更符合常规备份周期"。那一刻我就确定,光靠模型本身做知识问答是行不通的,得给它配一个可以随时翻阅的资料库。于是就有了这套 LangChain + 通义千问 的 RAG 知识库问答项目。它的思路是:先把本地文档切片、向量化、存进向量库,用户提问时先从知识库里召回最相关的片段,再把这些片段连同问题一起交给通义千问生成回答。模型不再是"闭卷考试靠猜",而是"开卷考试有据可查"。这篇内容适合想把团队文档、个人笔记、产品手册变成可对话问答机器人的开发者,也适合正在学 LangChain 和 RAG、想找一个能直接跑通的完整示例的朋友。代码放在文章末尾,中间我会把每一段的关键逻辑、为什么这么写、可能踩的坑都讲清楚。1. 这个 003 号项目:RAG 到底解决了什么问题先说个背景。我习惯把迭代中的小项目按编号存下来,003 就是第三版的知识库问答 Demo。前两版分别是"直接让模型看全文回答"和"简单关键词匹配后拼接 Prompt",都失败了。失败原因其实很典型,值得先讲一下。第一版直接传全文,问题在于:文档一长,输入就超 token 上限;即便截断,模型注意力分散,回答容易跑偏。第二版用关键词匹配,效果更差——用户问"报销时限"时候选文档里写的是"费用报销需在业务发生后 30 天内提交",关键词完全对不上,语义不通。直到第三版换成真正的向量检索,才把"语义相近"和"关键词相同"区分开,问答效果才终于可用。1.1 RAG 的核心链路:检索在前,生成在后RAG 全称 Retrieval-Augmented Generation,检索增强生成。它的链路可以拆成离线准备和在线问答两个阶段。离线阶段做的事情是:把知识文档读进来,切成一个个有语义边界的小块,每个小块调用嵌入模型转成一个向量,最后把所有向量存进向量库。在线阶段做的事情是:把用户的问题也转成向量,去向量库里找出最相似的几个片段,再把"问题 + 这几个片段"拼成 Prompt 交给大模型。这里有个关键区别:嵌入模型负责"找资料",大模型负责"读资料并组织答案"。两者各司其职,不要混为一谈。我见过有人试图让大模型自己从一堆文档里找答案,结果就是又慢又贵又容易幻觉。RAG 的正确姿势永远是:机器先筛,模型后读。1.2 为什么不用微调来解决很多人第一反应是:效果不好就微调模型啊。但实际做知识问答,微调有四个硬伤。第一,数据更新太频繁。知识库里的文档每周都可能变,每改一次就微调一次,成本和时间都扛不住。第二,微调需要一定量的高质量标注数据,普通团队根本攒不够。第三,微调后的模型仍然可能产生幻觉,它只是"见过了"这些资料,不等于"会准确引用"——本质上还是背题,不是翻书。第四,成本高。一次完整的参数微调加上验证,开销远超调一次 API。RAG 的优势在于:文档更新只需要重新向量化对应文件,不需要动模型;回答时还天然带"出处",用户能核对答案是不是真的来自某段资料。对知识库问答这个场景,RAG 几乎是当前性价比最高的方案。1.3 这套代码能做到什么程度003 号项目跑通之后的效果大概是这样的:你把几份 txt 文档扔进 knowledge 目录,程序会建立索引;然后你问"报销时限是多少天""某个接口的默认超时时间",它能从资料里找到对应片段并给出有依据的回答。如果资料里确实没有相关内容,它会明确说"知识库中没有找到相关信息",而不是硬编一个答案。当然它也有边界:文档质量差、扫描版 PDF、多轮追问、需要跨文档推理的复杂问题,这版代码并不能完美处理。先把基础链路跑通,后面再逐步加功能,是更务实的路径。2. 动手前必须想清楚的三个决策:切片、嵌入模型、向量库RAG 看起来就两步——检索和生成,但真正决定效果的是三个前置决策。我在 003 号项目里都踩过坑,先说结论,再讲为什么。2.1 文档切片的粒度直接决定回答质量很多人第一次写 RAG,文档是整篇往里塞的,结果要么 token 超限,要么检索时召回一大段无关内容。正确做法是先切片:把长文档切成有语义边界的小块,每一块作为独立的检索单位。切入点的两个参数是 chunk_size 和 chunk_overlap。003 号项目用 500 字符、重叠 80 字符。为什么是 500?如果设成 2000,一次召回 3 块就是 6000 字符,上下文太长,既费 token,又会因为每块内容太多,相关性得分被无关段落稀释。如果设成 100,片段太碎,一个完整知识点被切断,模型拼不出逻辑。500 对中文技术文档和制度手册是比较稳的中间值。chunk_overlap 的作用是防止关键句子恰好卡在切片边界。前一块结尾和后一块开头有 80 个字符交叠,知识点就不会"正好断在缝里"。这个值建议在 50 到 100 之间调,看你的文档句式长度。切片工具我用 RecursiveCharacterTextSplitter,它比固定长度硬切聪明:会优先按分隔符(换行、句号、逗号)切,实在没有分隔符才按字符长度兜底。对中文来说,最好把分隔符列表改成 ["\n\n", "\n", "。", "!", "?"],这样切出来的块基本不会从句子中间断开。这个细节网上很多教程没有强调,但影响很大。2.2 嵌入模型选型:直接上 text-embedding-v3,别纠结切片之后,要把每个文本块转成向量——一串能表达语义的数字。两个文本语义越接近,向量在空间里就越靠近。这一步用的模型叫嵌入模型,也叫文本向量化模型。003 号项目直接用了通义千问官方的 text-embedding-v3。原因很简单:和对话模型同一个平台的 API,密钥统一,调用方式一致,不需要维护两套鉴权体系。v3 相比早期 v1/v2,中文长文本的语义精度提升明显,支持自定义向量维度,默认 1024 维,文档量不大时成本可以忽略。也想提醒一句:如果你有强隐私需求,可以换本地开源嵌入模型,比如 300M 参数级别的中文向量模型,CPU 也能跑。但本地模型和 API 模型的效果差异是实打实的,尤其面对垂直领域术语时,召回精度差会直接拖累最终答案质量。我的建议是:先跑通流程用 API 模型,确认链路没问题,再根据隐私要求决定要不要换。2.3 向量库选型:FAISS 够用,Chroma 更方便向量库负责两件事:存向量、做相似度检索。003 号项目选的是 FAISS,一个 C++ 实现的相似度搜索库,Python 包名 faiss-cpu。它没有独立服务进程,直接以本地文件形式保存索引,非常适合单机 Demo 和小型知识库。如果你图省事,也可以用 Chroma,它会自动持久化,不用手动 save/load。但我还是推荐先学 FAISS,因为它的检索逻辑更透明,你清楚索引存在哪个文件、怎么加载、什么时候失效。等 FAISS 玩明白了,换 Chroma 只是几行代码的事。向量检索最常见的指标是余弦相似度,直观理解就是两个向量方向的夹角度数,夹角越小越相似。FAISS 底层支持内积和 L2 距离,LangChain 封装后默认做余弦相似度归一化,日常使用你只需要关心"召回前几个片段",也就是 k 值。003 号项目里 k=3,意思是每次找最相关的 3 个片段给模型。k 太大会引入噪音,k 太小会漏掉关键信息,我试下来 3 到 5 是比较稳的范围。3. 环境准备:版本号这个东西,卡了我一整天老实说,这个项目 80% 的调试时间不是花在写逻辑上,而是在对齐版本。LangChain 这两年 API 变动很大,网上教程大多写于不同时期,照着敲完直接报 ImportError 是常事。下面这套依赖组合是实测能跑的,建议先按这个来,跑通之后再考虑升级。3.1 Python 版本与核心依赖清单建议 Python 3.10 或 3.11。我试过 3.9,某些新版本依赖会直接报 "requires Python >=3.10"。核心依赖如下表:包名推荐版本用途langchain0.2.17框架主包,提供链条与 Prompt 管理langchain-community0.2.19文档加载器、FAISS、模型适配器langchain-core0.2.43核心抽象,必须与主包同大版本faiss-cpu1.8.0向量索引与相似度检索dashscope1.20+通义千问官方 SDK,嵌入调用依赖它安装命令:pip install langchain==0.2.17 langchain-community==0.2.19 langchain-core==0.2.43 faiss-cpu==1.8.0 dashscope这里必须提醒:不要直接 pip install langchain 之后再装一个最新版 langchain-community,两个包版本必须配套,否则会出现各种 "ModuleNotFoundError: langchain_community.embeddings" 之类的诡异报错。如果你是从旧项目升级,先卸载干净再装指定版本:pip uninstall langchain langchain-community langchain-core -y pip install langchain==0.2.17 langchain-community==0.2.19 langchain-core==0.2.43 faiss-cpu==1.8.0 dashscope我把版本写死,不是保守,而是这类项目最怕"昨天能跑、今天报错"。把版本锁进 requirements.txt,是项目可复现的基本习惯。3.2 获取 API Key 的正确姿势调用通义千问模型,需要去官方控制台开通模型服务,创建 API Key。注意:对话模型和 Embedding 模型最好都开通,缺一个后面就会报 "model not found" 或 "permission denied"。拿到 Key 之后,不建议硬编码在代码里。虽然教程里都这么写,但代码一旦提交到 Git 仓库,Key 等于泄露了。正确做法是设置环境变量:# macOS / Linux export DASHSCOPE_API_KEY="sk-你的Key" # Windows PowerShell $env:DASHSCOPE_API_KEY="sk-你的Key"代码里用 os.environ.get("DASHSCOPE_API_KEY") 读取。如果你已经在某个项目里把 Key 写死并提交过,赶紧去控制台重置,不要抱侥幸心理。3.3 FAISS 安装的跨平台差异faiss-cpu 在大多数平台有预编译轮子,pip 直接装就行。但 Windows 上偶尔会遇到 import faiss 时报 DLL 加载失败,多半是 Python 位数或与 numpy 版本冲突。解决顺序是:先升级 numpy,再强制重装 faiss:pip install --upgrade numpy pip install --force-reinstall faiss-cpu如果还不行,检查 Python 是不是 64 位。32 位 Python 装不了现代版本的 faiss。4. 代码逐段拆解:从文档到答案的完整链路下面按三段讲核心链路:加载与切片、向量化入库、检索问答。我会把每一段代码的"为什么这么写"讲清楚,而不是只贴代码让你复制。4.1 加载文档:DirectoryLoader 与编码陷阱加载文档用的是 DirectoryLoader 加 TextLoader 组合。它扫描指定目录下所有匹配文件,逐个加载成 Document 对象。有个容易被忽略的坑:中文文本文件如果不是 UTF-8 编码,TextLoader 默认直接报 UnicodeDecodeError。解决方式是显式传 encoding="utf-8";如果你的文件是 GBK 编码,就传 encoding="gbk"。目录结构建议这样:project/ ├── knowledge/ # 放知识文档 │ ├── 产品手册.txt │ └── 制度规范.md ├── qa_demo.py # 主代码 └── vector_index/ # FAISS 索引保存目录(自动创建)如果你想加载 PDF,可以把 loader_cls 换成 PyPDFLoader。但扫描件需要先做 OCR,否则提取出来的全是乱码或空白,这一步没办法靠 LangChain 自动解决。4.2 切片与简单清洗:脏文本比想象中多加载进来的文档,先过一遍切片器。我在代码里对 RecursiveCharacterTextSplitter 做了两个定制:分隔符列表按中文习惯调整,chunk_overlap 设为 80。另外一个容易忽略的细节是:很多文档里有连续空行和多余空格,会干扰切片边界。我在切片前加了一步处理:把连续空行压缩成单个换行,去掉行首行尾多余空格。切片之后建议打印每个片段的前 50 个字符,确认边界没有把句子拦腰截断。这个检查成本极低,但能立刻暴露 chunk_size 是否合适。4.3 向量化入库与本地持久化切片完成,接下来对每个片段调用嵌入模型生成向量,构建 FAISS 索引。核心代码只有几行:vectorstore = FAISS.from_documents(chunks, embeddings) vectorstore.save_local("./vector_index")from_documents 内部会循环调用嵌入模型。这里有两个注意点。第一,嵌入模型是 API 调用,文档多的时候会比较慢,甚至触发并发限制。几千段落级别以内不用担心,但如果知识库有几十万片段,就必须考虑分批处理或加限速。第二,一定要 save_local。如果不保存,每次运行都要重新向量化全部文档,既烧钱又费时间。第二次运行直接用 load_local 秒开:vectorstore = FAISS.load_local( "./vector_index", embeddings, allow_dangerous_deserialization=True )这里 allow_dangerous_deserialization 是 LangChain 新版本的安全要求。FAISS 索引文件本质是 pickle 序列化,加载不可信来源的索引文件有代码执行风险。索引是自己生成的可以放心打开;从别处下载的,千万别开。4.4 检索问答:Prompt 模板才是真正的灵魂最后一步是检索加问答。LangChain 里有 RetrievalQA 这种封装好的链条,但我强烈建议你至少看一眼它怎么把"检索结果 + 问题"拼成 Prompt,因为最终回答质量一半由检索决定,另一半由 Prompt 决定。003 号项目的 Prompt 模板:你是知识库问答助手。请严格按照下面的资料回答问题。 如果资料中没有相关内容,请明确回答"知识库中没有找到相关信息",不要编造。 资料: {context} 问题:{question} 回答:这个模板里有三个关键约定:明确告诉模型"只根据资料回答",压住幻觉。强制模型在资料不足时承认不知道,而不是硬答。不要求模型输出华丽表达,保证答案紧凑、易核对。温度参数我设为 0.3。温度控制生成随机性:0 最保守但显得机械,0.7 以上容易发挥过头。RAG 场景要的是稳定准确,0.3 是个平衡点。另外,RetrievalQA 的 chain_type 我用的 "stuff",意思是把所有检索片段一次性塞进上下文。k=3 时这点 token 开销完全可接受。如果你的 k 很大或片段很长,就要考虑 "map_reduce" 或 "refine" 模式来分摊,否则会遇到上下文超限报错。5. 实测效果与三次典型翻车记录代码能跑只是第一步,能稳定跑出高质量答案才算结束。下面分享我调试过程中最典型的三个问题,顺序基本就是新手会遇到的顺序。5.1 翻车一:API Key 环境变量没生效第一次运行,代码报错信息大概是 "InvalidApiKey: Invalid API-key provided"。我第一反应是 Key 复制错了,反复重试仍然报错。最后发现,Key 没问题,问题是环境变量设置在我的 shell 会话里,但我在 IDE 里运行 Python,IDE 进程根本没继承 shell 里 export 的变量。排查思路很简单:在代码开头加一行打印,确认环境变量是否读得到:print("DASHSCOPE_API_KEY:", os.environ.get("DASHSCOPE_API_KEY")[:8] + "****")如果打印出来是 None,说明环境变量没进 Python 进程。解法有两种:把 export 写进 .bashrc 或 .zshrc 然后重启 shell;或者在 IDE 的运行配置里手动加环境变量。我个人推荐第二种,只影响当前项目,不会污染全局。5.2 翻车二:LangChain 版本迁移导致的导入路径失效换个环境复现 003 号项目时,第一行导入就报错。网上搜到的代码很多是from langchain.embeddings import DashScopeEmbeddings,但新版 LangChain 把这块移到了 langchain_community 下。正确写法是:from langchain_community.embeddings import DashScopeEmbeddings from langchain_community.llms import Tongyi只有 langchain 0.0.x 老版本才用from langchain.embeddings。这个迁移让很多人对着旧教程改到怀疑人生。应对方法就是我在第 3 节说的:依赖版本写死,升级前先看 changelog。5.3 翻车三:检索结果和问题完全不相关有一次我问"请假流程需要提前几天申请",它答非所问,把毫不相干的制度背了一段给我。我立刻做了两件事:第一,把 k 从 3 调到 5,确认召回片段确实多了;第二,把检索到的片段内容打印出来,发现命中的片段标题相关,但内容被 chunk_size 切得太碎,关键信息残缺。根因在切片参数。知识库里有一篇很长的制度文档,每条制度原本是完整段落,但文本超过 500 字符被切开,关键句恰好落在两块边界,重叠的 80 字符又不够覆盖。解决办法是把 chunk_overlap 提到 100,同时按二级标题对长文档二次切分。这也引出一个重要原则:RAG 排错,永远先看"检索到了什么",再看"模型回答了什么"。检索环节错了,后面调 Prompt 都是白费功夫。6. 完整可运行代码与使用说明终于到代码了。下面是完整的单文件版本,整合了前面所有内容,保存为 qa_demo.py,按注释修改路径和 Key 就能跑。6.1 代码全文# -*- coding: utf-8 -*- """ LangChain + 通义千问 RAG 知识库问答 Demo(003号项目) 功能: 1. 读取 knowledge 目录下的 txt/md 文档 2. 切片、向量化、构建 FAISS 索引并保存 3. 交互式问答:从知识库检索相关片段,交给通义千问生成回答 依赖: pip install langchain==0.2.17 langchain-community==0.2.19 langchain-core==0.2.43 faiss-cpu==1.8.0 dashscope 目录结构: project/ ├── qa_demo.py ├── knowledge/ # 放你的知识文档(.txt / .md) └── vector_index/ # 索引保存目录(自动创建) 环境变量: DASHSCOPE_API_KEY=你的密钥 """ import os import re from langchain_community.document_loaders import DirectoryLoader, TextLoader from langchain.text_splitter import RecursiveCharacterTextSplitter from langchain_community.embeddings import DashScopeEmbeddings from langchain_community.vectorstores import FAISS from langchain_community.llms import Tongyi from langchain.chains import RetrievalQA from langchain.prompts import PromptTemplate API_KEY = os.environ.get("DASHSCOPE_API_KEY") if not API_KEY: raise RuntimeError("请先设置环境变量 DASHSCOPE_API_KEY") KNOWLEDGE_DIR = "./knowledge" INDEX_DIR = "./vector_index" def clean_text(text: str) -> str: """简单清洗:压缩连续空行、去掉行首行尾多余空格。""" text = re.sub(r"\n{3,}", "\n\n", text) lines = [line.strip() for line in text.splitlines()] return "\n".join(lines) def load_and_split(): """加载文档并切片。""" loader = DirectoryLoader( KNOWLEDGE_DIR, glob="**/*.txt", loader_cls=TextLoader, loader_kwargs={"encoding": "utf-8"}, ) docs = loader.load() if not docs: raise RuntimeError(f"{KNOWLEDGE_DIR} 目录下没有找到 .txt 文档") splitter = RecursiveCharacterTextSplitter( chunk_size=500, chunk_overlap=80, separators=["\n\n", "\n", "。", "!", "?"], ) chunks = [] for doc in docs: cleaned = clean_text(doc.page_content) doc.page_content = cleaned chunks.extend(splitter.split_text(cleaned)) print(f"文档加载完成:{len(docs)} 个文件,切片 {len(chunks)} 段") return chunks def build_index(chunks: list): """构建向量索引并保存到本地。""" embeddings = DashScopeEmbeddings(model="text-embedding-v3") if os.path.exists(INDEX_DIR): print("检测到已有索引,重新构建前先删除旧索引,避免版本不一致") import shutil shutil.rmtree(INDEX_DIR) vectorstore = FAISS.from_texts(chunks, embeddings) vectorstore.save_local(INDEX_DIR) print(f"索引已保存到 {INDEX_DIR}") def create_qa(): """加载索引并创建问答链。""" embeddings = DashScopeEmbeddings(model="text-embedding-v3") if not os.path.exists(INDEX_DIR): raise RuntimeError("索引不存在,请先运行 build_index()") vectorstore = FAISS.load_local( INDEX_DIR, embeddings, allow_dangerous_deserialization=True, ) llm = Tongyi( model="qwen-plus", temperature=0.3, ) template = """你是知识库问答助手。请严格按照下面的资料回答问题。 如果资料中没有相关内容,请明确回答"知识库中没有找到相关信息",不要编造。 资料: {context} 问题:{question} 回答:""" prompt = PromptTemplate( template=template, input_variables=["context", "question"], ) qa_chain = RetrievalQA.from_chain_type( llm=llm, retriever=vectorstore.as_retriever(search_kwargs={"k": 3}), chain_type="stuff", chain_type_kwargs={"prompt": prompt}, ) return qa_chain if __name__ == "__main__": # 首次运行:构建索引 if not os.path.exists(INDEX_DIR): data = load_and_split() build_index(data) qa = create_qa() print("知识库问答已就绪,输入问题开始提问,输入 exit 退出。") while True: question = input("\n问题:").strip() if not question: continue if question.lower() == "exit": break answer = qa.invoke({"query": question}) print("回答:", answer["result"])6.2 使用步骤与调参建议使用步骤很简单:把知识文档放进 knowledge 目录,编码统一为 UTF-8 的 txt 文件。安装依赖,设置好 DASHSCOPE_API_KEY 环境变量。首次运行 python qa_demo.py,会自动加载文档、构建索引。之后每次运行,索引已存在,直接进入问答循环。跑通之后,我强烈建议做两件事。一是把 k 值和 chunk_size 各调一版做对比,找到最适合你这个知识库的参数。二是建立一个小型测试集,把历史问题、期望答案、实际输出记下来,以后换模型、调参数都有客观依据。我自己的体会是,RAG 项目的真正工作量从来不在"跑通",而在"调准"。文档切片粒度、检索召回数量、Prompt 措辞,这三个点每一个都值得反复试。003 号项目只是一个起点,你把代码跑起来之后,大概率会遇到和我不一样的文档、不一样的业务场景,但排错的思路是通用的:先确认数据加载正确,再确认检索结果合理,最后才去调整模型输出。按这个顺序走,问题基本都能定位到具体环节。