如果你手头有一堆产品说明书、内部培训文档或者论文资料想让模型直接回答“这个参数在哪一章有说明”“这种报错该怎么办”把问题丢给通用大模型得到的基本就是一段逻辑通顺但内容可疑的“编造”——因为模型压根没见过你的资料。RAG检索增强生成就是专门解决这个问题的它让模型在回答之前先从你自己的文档库里检索相关片段把片段塞进上下文再结合问题生成答案。这篇文章我会用LangChain作为应用框架通义千问作为生成模型从零搭一套能针对本地文档问答的RAG系统覆盖原理、环境配置、完整代码、实际踩坑和上线前的优化方向适合刚接触大模型应用开发的读者也适合Demo跑通了但效果总不对劲的开发。1. RAG为什么有用先搞清楚检索和生成合起来的逻辑1.1 用“开卷考试”理解RAG的工作流程RAG的全称是Retrieval-Augmented Generation拆开看就两部分检索Retrieval 生成Generation。检索这一步先从一个叫“向量数据库”的地方把与问题相关的文档片段找出来生成这一步再把这些片段和用户问题一起交给大模型让它组织成自然语言回复。用开卷考试来类比最直白直接问大模型相当于闭卷考试它只能吃脑子里已经学会的知识不知道你手头这份文档里写了什么微调相当于把整本教材背下来再考试成本高而且教材一变又要重背RAG则是开卷考试你先把教材里相关的段落找出来摆在桌上模型照着这些段落组织答案答案自然更贴近你的材料、也更容易求证出处。这个流程落到代码层面大致是文档加载、文本切块、向量化、入库、检索、生成六个环节。文档加载和文本切块负责把PDF、TXT、Markdown变成可检索的“段落”向量化把段落变成一串数字入库是把数字存进向量数据库用户提问时系统先做相似度检索把跟问题最相关的几个段落捞出来最后生成环节才轮到通义千问登场。1.2 三种方案取舍直接问、微调、RAG很多教程一上来就讲代码反而把“为什么一定要用RAG”这个问题忽略了。我把三种常见方案放在一起对比一下这样你后续做技术选型时心里更有底。方案对私有知识的掌握能力实现成本知识更新成本适合场景直接问大模型无只能靠模型预训练知识极低无需更新通用问答、闲聊、代码讲解微调模型强但可能固化、学歪高需要训练环境、数据清洗每次重训特定风格、固定格式输出、特定领域语言习惯RAG强且答案可控、可溯源中主要花在文档处理换库重新索引即可产品文档问答、企业内部知识库、论文阅读助手我实际测下来的感受是如果目标是“告诉我这段文档里关于某个机制的描述”RAG明显优于微调。微调更适合让模型“学会某种说话方式”或“学会某种特定输出结构”不适合让它“记住一大堆随时在更新的业务资料”。1.3 为什么选LangChain 通义千问这个组合先说LangChain。市面上做大模型应用的框架不少LangChain胜在生态成熟、文档全、社区案例多。它把“外部文档加载”“文本切分”“向量化”“检索”“生成”这一套原子操作都封装成了模块新手照着文档就能把链路串起来调试时也能把每个环节的数据单独拿出来看。通义千问作为生成模型优势在于中文理解能力扎实、API响应稳定并且提供了可以直接调用的Embedding模型省去了自己搭向量化模型的麻烦。对于中文业务文档场景这个组合的落地阻力比“本地小模型自己训练Embedding”低很多而且通义千问还提供了OpenAI兼容的调用方式哪天你想把生成端换成其他模型代码改动也很小。2. 环境准备装库、配API、挑向量库2.1 Python虚拟环境与核心依赖清单RAG应用涉及好几个包不同包的依赖还可能互相冲突尤其LangChain在0.2之后把许多组件拆到了独立的库里面直接pip install langchain已经不够了。我建议每个项目都建独立的虚拟环境避免把全局Python环境搞乱。python -m venv .venv source .venv/bin/activate然后安装以下依赖版本我在实际使用中验证过可跑通pip install langchain pip install langchain-community pip install langchain-chroma pip install langchain-openai pip install langchain-text-splitters pip install dashscope pip install pypdf这里重点说明一下为什么需要这几个包langchain是框架本体langchain-community里放着各类第三方集成组件langchain-chroma是向量数据库Chroma的官方适配langchain-openai用于通过OpenAI兼容协议调用通义千问langchain-text-splitters提供文本切分器dashscope是通义千问API的官方SDK主要用于Embeddingpypdf用来解析PDF文档。2.2 通义千问API接入官方SDK和OpenAI兼容接口通义千问的接入方式有两种我建议你两种都了解因为它们在LangChain里的接入位置不一样。第一种是用dashscope官方SDK配合LangChain中的DashScopeEmbeddings类来做文本向量化。Embedding的调用方式长这样from langchain_community.embeddings import DashScopeEmbeddings embeddings DashScopeEmbeddings( modeltext-embedding-v3, dashscope_api_key你的API Key )第二种是走OpenAI兼容协议。通义千问提供了与OpenAI格式兼容的接口这样你就可以用langchain-openai里的ChatOpenAI类来调用它作为生成模型只需改一下base_url和模型名from langchain_openai import ChatOpenAI llm ChatOpenAI( modelqwen-plus, api_key你的API Key, base_urlhttps://dashscope.aliyuncs.com/compatible-mode/v1 )这里有个容易踩的坑用ChatOpenAI方式连接通义千问时模型列表里能填的通常是qwen-turbo、qwen-plus、qwen-max这几个型号我在实测中发现qwen-plus在文档总结和复杂推理上比qwen-turbo稳不少但速度和成本不如qwen-turbo。如果只是跑通流程选qwen-turbo就够了生产场景再上qwen-plus。2.3 向量数据库选型Chroma适合起步FAISS适合轻量场景向量数据库是整个RAG系统里存储“语义索引”的地方。它的作用不是存原文而是存文本向量并在查询时做相似度计算。当前主流选择有Chroma、FAISS、Milvus、Qdrant等但第一批跑通方案我强烈建议只用Chroma或者FAISS。Chroma的好处是“零额外服务”。你不需要启动一个单独的数据库服务它可以把数据持久化到本地磁盘目录代码里创建一次就能反复读取。对于几千到几万条文档块级别的数据量Chroma的性能完全足够。FAISS是向量检索算法库严格来说不算数据库但它的内存索引速度很快适合一次性加载全部向量、跑完就丢的批量任务。FAISS的缺点是持久化方案需要自己管文件工程上不如Chroma省心。我的建议个人项目、知识库Demo、中小规模文档问答直接选Chroma。我后续的代码也都基于Chroma来写。2.4 API Key的安全管理别写死在代码里API Key写死到代码里是新手最常见的坏习惯。这里提供一个简单的做法把Key放到项目根目录的.env文件里用python-dotenv加载。这样代码可以随手分享Key不会泄露。pip install python-dotenv# .env DASHSCOPE_API_KEY你的API Keyimport os from dotenv import load_dotenv load_dotenv() api_key os.getenv(DASHSCOPE_API_KEY)说句实在话很多人第一次跑RAG就是死在这种小细节上代码写了、Key也填了但因为格式问题读取失败或者把Key直接提交到了代码仓库。多花两分钟用环境变量管理后面省事很多。3. 手把手搭建从文档到可问答的完整链路3.1 文档加载PDF、TXT、Markdown的处理差异文档加载是RAG链路的起点。LangChain的Loader体系把文件读取和文本抽取封装成了统一接口但不同格式的解析质量差别很大你不能无脑套同一个Loader。TXT和Markdown最简单直接读文件内容即可PDF则要分情况扫描版PDF需要OCR文字版PDF用PyPDFLoader通常够用但遇到复杂排版、表格、多栏布局时解析出来可能是错乱的片段。我实际测试过一个带表格的中文PDF直接解析后表格内容完全被打散检索这个问题根本搜不到相关内容。from langchain_community.document_loaders import PyPDFLoader from langchain_community.document_loaders import TextLoader # PDF加载 pdf_loader PyPDFLoader(产品说明书.pdf) pdf_docs pdf_loader.load() # TXT/Markdown加载 txt_loader TextLoader(内部FAQ.txt, encodingutf-8) txt_docs txt_loader.load()如果是带复杂表格的文档我建议先在PDF工具里转成Markdown或者结构化文档再喂给Loader解析质量会高很多。这一步看起来不起眼但对最终检索效果的影响可能是决定性的。3.2 文本分块chunk_size和overlap到底怎么设加载出来的文档动辄几万字直接把整篇文档塞进上下文既不现实也不科学。需要把文档切成固定大小的“块”chunk每个块就是一个独立的检索单元。LangChain提供了多种切分器最常用的是RecursiveCharacterTextSplitter。它的底层逻辑是递归切分优先按段落、再按句子、再按字符尽量保证每个块语义完整。参数上重点看两个chunk_size和chunk_overlap。from langchain_text_splitters import RecursiveCharacterTextSplitter text_splitter RecursiveCharacterTextSplitter( chunk_size300, chunk_overlap50, separators[\n\n, \n, 。, , , , , ] )chunk_size决定一个块包含多少字符chunk_overlap决定相邻块之间有多少字符重叠。为什么需要重叠因为如果切分点刚好落在一句话中间这句话的语义就被切断了检索时可能漏掉关键信息。重叠部分相当于给切分边界加了一层缓冲。中文场景下我习惯把separators里显式加上中文标点“。”因为RecursiveCharacterTextSplitter默认没有中文标点分隔符遇到长句可能会等字符级回退才切分导致块边界不理想。chunk_size建议从200到500之间开始调试太小了块内容不够检索召回的内容碎片化严重太大了向量平均化失去局部语义。我的经验是中文技术文档300字左右的块在召回精度和上下文完整性上比较平衡。3.3 Embedding与向量入库把文本变成“坐标点”分块完成后就进入向量化环节。Embedding模型会把每一块文本映射成一组浮点数向量语义相近的文本在向量空间里的距离也越近。这就是RAG能做“语义检索”而不是“关键词检索”的核心。用通义千问的Embedding模型来向量化from langchain_community.embeddings import DashScopeEmbeddings from langchain_chroma import Chroma embeddings DashScopeEmbeddings( modeltext-embedding-v3, dashscope_api_keyapi_key ) vectorstore Chroma.from_documents( documentssplit_docs, embeddingembeddings, persist_directory./chroma_db )这一步跑完本地会生成一个chroma_db目录里面就是向量索引。之后再做问答时不需要重新处理文档直接加载这个目录即可。向量化是耗时大户几百页的文档每次调用Embedding API都需要时间我实测过一千块文本大概要几分钟具体取决于API并发限制。所以入库一次、之后复用是必须养成的习惯。加载已有向量库也很简单vectorstore Chroma( persist_directory./chroma_db, embedding_functionembeddings )3.4 检索与生成让通义千问学会“照着材料回答”向量库建好以后就是整条链路的最后一个环节检索并生成答案。先写检索retriever vectorstore.as_retriever( search_typesimilarity, search_kwargs{k: 4} )这里的k表示每次检索最多召回多少个文档块。k值太大会引入无关内容太小又可能漏掉关键信息。我习惯先用4起步如果答案不完整或引用不足再往上调到6到8。生成环节有两种写法。一种是使用LangChain传统的RetrievalQA链另一种是使用新版LCEL表达式的写法。前者简单直接适合快速验证后者更灵活适合定制Prompt和流式输出。先看RetrievalQAfrom langchain.chains import RetrievalQA from langchain_openai import ChatOpenAI llm ChatOpenAI( modelqwen-plus, api_keyapi_key, base_urlhttps://dashscope.aliyuncs.com/compatible-mode/v1, temperature0.1 ) qa_chain RetrievalQA.from_chain_type( llmllm, retrieverretriever, return_source_documentsTrue ) result qa_chain.invoke({query: 这个产品的保修政策是什么}) print(result[result])这里把temperature设置为0.1是为了让模型尽可能忠实于检索到的文档内容减少自由发挥。如果你发现答案经常“自己编”先检查两件事一是temperature是不是设得过高二是检索召回的相关片段是不是真的覆盖了问题。很多“幻觉”问题根子不在生成模型而在检索质量。我更推荐用LCEL写法因为可以精细控制Prompt。把检索结果拼成上下文再让千问基于上下文回答from langchain_core.prompts import ChatPromptTemplate from langchain_core.output_parsers import StrOutputParser prompt ChatPromptTemplate.from_template( 你是一个严谨的文档问答助手。请仅依据以下资料片段回答问题。 如果资料中没有相关内容请直接说明“资料中未找到相关信息”不要编造。 资料片段 {context} 问题 {question} 请给出答案 ) def format_docs(docs): return \n\n.join([d.page_content for d in docs]) rag_chain ( {context: retriever | format_docs, question: lambda x: x[question]} | prompt | llm | StrOutputParser() ) answer rag_chain.invoke({question: 这个产品的保修政策是什么}) print(answer)这套写法把“检索”和“生成”拆成了两个清晰的环节后续加日志、加重排序、加流式输出都很方便。4. 实测坑点中文、相似度、幻觉一个一个排4.1 中文文本切分不当导致检索命中差我最初跑通流程用的是默认的RecursiveCharacterTextSplitter没传separators结果检索效果很差。主要原因就是中文文档常量整句由连续汉字组成默认的英文空格分隔符几乎不起作用文本会被切成一整块或者切在莫名其妙的位置。举个实际例子一段“本产品保修期为一年。保修期内如出现非人为损坏可免费维修。人为损坏除外。”如果没有中文标点分隔符切分器可能把“本产品保修期为一年。保修期内如出现非人为损坏”切在一起把后半段切到下一块。检索“人为损坏怎么处理”时可能召回的块里偏偏缺了“人为损坏除外”这句关键内容。解决办法就是我上面提到的把“。”这些中文标点加进separators同时用chunk_overlap做缓冲。改完之后同样的检索Query命中准确率明显提升。4.2 相似度分数别搞反Chroma默认是距离排查检索问题时光看“有没有召回”不够还要看分数是否合理。Chroma默认返回的是L2距离欧式距离数值越小表示向量距离越近、语义越接近。很多同学第一次看结果发现分数出来是2、3这种数误以为“分数越高越好”结果把问题和答案完全弄反了。docs vectorstore.similarity_search_with_score(这个产品的保修政策是什么, k4) for doc, score in docs: print(score, doc.page_content[:50])想用余弦相似度也可以在创建Chroma时指定collection_metadata的hnsw空间类型{hnsw:space: cosine}。余弦相似度越大代表越相似。但无论用哪种距离度量都要先确认自己用的哪个指标再判断结果的好坏。4.3 模型幻觉和上下文长度的冲突RAG的一个常见问题是“上下文越长幻觉越低但模型输入限制越顶”。通义千问各个模型的上下文长度不同qwen-plus一般在较长的上下文窗口下表现良好但如果你喂给它的文档块太多、加上历史对话很容易触发超限报错。我的处理策略一是控制召回的块数不要贪多二是对召回的块做内容压缩只保留与问题最相关的句子而不是整块塞给模型三是把用户的旧对话历史做摘要再拼进上下文避免历史无限膨胀。控制Prompt中“资料片段”的规模本质上是一个“尽力给模型提供高信噪比材料”的过程。材料越聚焦编造空间越小超限风险也越小。我实际测试中把召回块数从8降到4配合更精确的Prompt模板答案质量反而提升了。4.4 API限流与网络抖动的重试机制通义千问的API是有并发限制的。批量向量化一千条文本时如果并发开得太大很容易收到限流错误。我的建议是写一个带重试和退避的封装处理这类偶发失败。import time from tenacity import retry, wait_exponential, stop_after_attempt retry(waitwait_exponential(multiplier1, min2, max10), stopstop_after_attempt(5)) def embed_text_with_retry(text): return embeddings.embed_query(text)另外批量调用Embedding API时建议自己控制并发比如用线程池限制同时处理的请求数在8到16之间。这个值取决于你实际账户的限流情况保守起步、逐步调高是稳妥路线。5. 提升可用性的几个进阶操作5.1 检索后重排序让最相关的片段排到最前面向量检索召回的前几个结果里偶尔会混入看起来相似、实际无关的内容。这时重排序Rerank就很关键先用向量粗召回几十个候选块再用一个更强的排序模型对候选块和问题做精细相关度打分最后只取最高分的前几个块喂给大模型。LangChain里可以挂一个重排序器配合通义千问之外的专用排序模型使用。排序模型通常是“交叉编码器”结构把问题和文档块同时输入模型得到这个组合的相关性分数比向量余弦相似度更准确。from langchain.retrievers import ContextualCompressionRetriever from langchain.retrievers.document_compressors import CrossEncoderReranker # 以具体的reranker模型为例LangChain社区集成中可按需替换 reranker CrossEncoderReranker( model_namebge-reranker-base, top_n3 ) compression_retriever ContextualCompressionRetriever( base_compressorreranker, base_retrieverretriever )代价是每次查询多了一步排序计算响应时间会变长。我的建议Demo阶段不必上重排序当你发现检索结果里“总是有一两块无关内容干扰答案”时再上重排序这时候收益最明显。5.2 多轮对话带着历史去检索问题才不“断片”单轮问答很简单一把Query丢进检索器就完事。但用户连续提问时第二个问题往往是“那它呢”“有什么注意事项”如果没有历史上下文检索器根本不知道“它”指的是谁。这个问题需要做“历史感知的检索”先把历史对话和当前问题合并成一个完整问题再去检索。from langchain.chains import create_history_aware_retriever, create_retrieval_chain from langchain.chains.combine_documents import create_stuff_documents_chain # 用LLM将多轮对话改写成独立的检索Query history_aware_retriever create_history_aware_retriever( llm, retriever, promptChatPromptTemplate.from_template( 结合历史对话将用户最后的问题改写成一个可以独立检索的查询。 历史对话{chat_history} 用户最新问题{input} 改写后的查询 ) ) qa_prompt ChatPromptTemplate.from_template( 根据以下文档内容回答用户问题。若资料中没有请明确说明未找到。 {context} 问题{input} ) doc_chain create_stuff_documents_chain(llm, qa_prompt) rag_chain create_retrieval_chain(history_aware_retriever, doc_chain) result rag_chain.invoke({ input: 那它有注意事项吗, chat_history: [(这个产品支持无线连接吗, 支持的。)] })这种设计在实际使用中价值很大。没有历史感知的问答系统稍微多聊几句就会“失忆”整体体验会大打折扣。5.3 让体验变好的最后一公里流式输出与缓存RAG系统的后端逻辑跑通后最后还要解决两个体验问题响应太慢和重复计算。LangChain的LCEL写法天然支持流式输出async for chunk in rag_chain.astream({question: 这个产品怎么保修}): print(chunk, end, flushTrue)前端收到流式返回就能像官方大模型应用那样逐字显示效果而不是等十几秒才一次性出来。缓存方面最常见的策略是完全相同的Query直接返回上次答案。可以用Redis或者简单的内存缓存实现Key是哈希后的QueryValue是答案。这样高频重复问题响应速度可以降到毫秒级。还有一个比较隐蔽的性能问题每次提问Embedding API都要重新对Query做一次向量化。如果请求量上来了这也会成为瓶颈。可以考虑在本地缓存Query的Embedding向量语义相同或相似的Query直接命中减少API调用次数。结语说说我实际跑下来的几点体会从零搭完这套RAG应用我的最大感受是RAG的入门门槛不高真正拉开差距的是细节。同样一个模型、同样的文档切分方式不同、召回数不同、Prompt模板里“只依据资料回答”这句话写不写效果可能差很多。调试时建议把链路拆成几个独立节点分别检查先看文档加载出来是不是完整的再看切分后每块是否语义通顺然后拿到检索结果确认内容和问题确实相关最后才去调生成参数。不要一上来就怪模型“幻觉”多数时候问题出在更前面的检索环节。最后再分享一个小技巧碰到回答质量不理想先把问题和召回到的文档块打印出来人工看一眼。很多时候答案错误不是因为模型笨而是检索回来的材料本身就牛头不对马嘴。看清楚这一层RAG优化就不再是玄学了。