在RAG还没火的时候,做智能问答基本就是两条路:要么拿模型硬刚,把全部资料塞进上下文——又贵又容易超长截断;要么微调模型,让它记住你的私有知识——费时费力,改知识还得重新训。RAG(检索增强生成)把这俩问题都解决了,知识更新只需换库,模型能力交给大模型,成本低、效果好、落地快。
这篇文章我用LangChain加通义千问,从零开始给你搭一套完整的RAG智能问答系统,覆盖文档加载、文本切割、向量化存储、检索问答全流程,代码能直接抄,参数怎么调、坑在哪儿都会说清楚。不管你是刚接触大模型应用开发的初级开发者,还是想在业务里做私有知识库问答的后端工程师,这套方案都能让你少踩三个月的坑。
说明:本篇使用通义千问(qwen-plus)作为LLM,embeddings用文本向量模型,向量库选ChromaDB,框架用LangChain。所有代码均用Python编写,基于LangChain 0.2.x版本,API密钥请自行准备。
1. 从零到一:RAG应用的整体设计与技术选型
1.1 RAG工作原理:为什么它能解决大模型的“记忆力”问题
先花两分钟搞明白RAG到底在做什么。大模型训练完以后知识就冻结了,你问它训练时没见过的内部文档,它只能一本正经地胡说八道。RAG的思路很简单:在模型回答问题前,先从你的知识库里检索出相关内容,把检索结果作为参考上下文一起发给大模型,让模型基于这些资料生成答案。
流程分三步:
- 离线索引:把文档切碎成小块,每块向量化,存入向量数据库。
- 在线检索:用户提问后,把问题向量化,在向量库里找最相似的TopK个文档片段。
- 生成回答:把问题+检索片段拼成Prompt,交给大模型生成答案。
这个过程把“知识存储”和“知识推理”解耦了。知识变了只需要重建索引,不需要重新训练模型。这也是为什么RAG能在企业知识库、客服问答、文档助手等场景快速落地。
1.2 框架选型对比:LangChain、LlamaIndex还是自己写
主流的RAG开发框架有三条路:LangChain、LlamaIndex和完全自定义。
LangChain胜在生态全,文档加载器、文本分割器、向量库封装、Prompt模板全都有,社区资料多,踩坑容易搜到解决方案。LlamaIndex更适合做以索引为中心的场景,查询引擎和知识库管理更专业,但通用性不如LangChain。自己写的话控制力最强,但工作量大,光处理各种文档格式就能消耗大量时间。
我选LangChain的原因很直接:它在多步Agent和工具调用上的扩展性更好。RAG做完以后,你很可能会想加多轮对话记忆、加SQL查询工具、加插件能力,LangChain这套体系可以平滑升级,不至于推倒重来。
另外一点,LangChain的LCEL语法写起来非常顺手,管道式的表达式让整个链路一目了然,调试也很直观。
1.3 通义千问+LangChain:大模型接入的正确姿势
通义千问是阿里云百炼平台提供的模型服务,兼容OpenAI接口风格,通过LangChain的langchain-openai包里的ChatOpenAI类就能直接对接,只是需要修改base_url和api_key。
选qwen-plus这个模型级别的原因有二:一是它在中文理解和长文本处理上表现扎实,二是价格合理、推理速度快,适合做问答系统的默认模型。如果追求更高精度,可以换qwen-max;如果成本敏感,qwen-turbo也能跑。
在建联的时候注意,LangChain 0.2.x里ChatOpenAI初始化需要两个关键参数:
- base_url:指向DashScope兼容地址
- api_key:你的DashScope API密钥
后面写代码的时候会给你完整的初始化方法。这里先要建立一个认知:LangChain接入国内模型有成熟的路径,不要在模型接入这一步卡住,重点精力应该放在数据准备和检索优化上。
2. 文档加载与切割:知识库的“地基工程”决定上限
2.1 文档加载器选型与配置
LangChain有几十种文档加载器,对应PDF、Word、Markdown、HTML、纯文本等格式。实际项目里最常用的是这几类:
- TextLoader:读纯文本和Markdown。
- PyPDFLoader:读PDF文件。
- Docx2txtLoader:读Word文档。
我用的是TextLoader加载Markdown格式的产品文档,文件本身是内部整理好的FAQ合集。如果你的资料是PDF,建议先看PDF是文字版还是扫描件。文字版直接用PyPDFLoader,扫描件得先过OCR,不然检索效果会很差。
加载代码:
from langchain_community.document_loaders import TextLoader loader = TextLoader("knowledge_base/faq.md", encoding="utf-8") docs = loader.load() print(len(docs)) # 输出文档数量这里有个容易忽略的点:encoding一定要指定utf-8,不然Windows下默认gbk编码会直接报错。加载后的docs是一个Document对象列表,每个Document包含page_content(正文)和metadata(元数据)。
2.2 文本切割:Chunk大小和重叠的真正算法
文档加载进来以后不能直接向量化,因为:
- 大模型有上下文长度限制,整篇文档塞进Prompt不现实。
- 向量检索需要语义聚焦,一整篇文档的向量会稀释局部语义,导致检索不精准。
所以必须切块。LangChain的RecursiveCharacterTextSplitter是默认首选,它按优先级依次尝试多个分隔符(从双换行、单换行到空格字符),尽可能保持在段落边界切割。
参数设置有两个关键:chunk_size和chunk_overlap。
text_splitter = RecursiveCharacterTextSplitter( chunk_size=500, chunk_overlap=100, length_function=len, separators=["\n\n", "\n", "。", "!", "?", ",", " ", ""] )chunk_size=500意味着每个块最大500个字符(这里用的是字符数,不是token数)。chunk_overlap=100表示相邻块间重叠100个字符。
为什么需要重叠?引用我常打的一个比方:你把一句话从中间剪开,前后两段各自单看,语义就丢了。重叠就是为了让被切断的上下文在相邻块里保留残余信息,检索的时候命中率更高。
chunk_size到底选多大?没有一个万能值,取决于内容的语义粒度和模型的上下文窗口。我的经验值:
- 问答型文档(FAQ):300到500,答案本身就短小,切大了反而不聚焦。
- 技术文档/方案文档:500到800,每块能包含足够的背景信息。
- 长文/书籍:800到1200,按章节语义切分。
切完后会得到一堆Document,每一段的metadata里会自动记录chunk在原文中的位置信息,这对溯源很有帮助。
2.3 Embedding模型选择:bge还是通用文本向量
向量化是RAG的语义核心。你选什么样的Embedding模型,直接决定检索效果的地板。
目前中文场景我用的是文本向量模型text2vec-large-chinese,当然在LangChain里接入通义百炼的text-embedding-v1/v2也有很多人用。综合考虑成本和效果,dashscope的embedding模型是开箱即用、不需要额外部署的。
调用方式:
from langchain_community.embeddings import DashScopeEmbeddings embeddings = DashScopeEmbeddings( model="text-embedding-v2", dashscope_api_key="你的API密钥" )Embedding模型决定了“语义距离”的计算方式。同一个问题,不同模型算出来的相似度排名可能不同。如果后续发现检索质量不够,优先考虑换一个更强的Embedding模型,比调切割参数见效更快。
3. 向量存储与检索链路:从ChromaDB到相似度计算
3.1 选ChromaDB当向量库的理由
向量数据库的选择范围很广:ChromaDB、FAISS、Milvus、Qdrant、Pinecone。对于快速验证和中小规模的RAG应用,我强烈建议从ChromaDB入手。
它的优势是简单、本地文件即存即用、不需要单独起服务器。直接LangChain里实例化就能跑,数据落盘到本地目录,对学习和中小规模部署非常友好。
FAISS也很轻量,但它是纯粹的索引库,没有ChromaDB那种集合管理能力。Milvus是生产级选择,支持分布式和海量数据,但部署复杂度高,前期验证阶段没必要。
如果知识库规模超过百万级向量,再迁到Milvus或者Qdrant也来得及,LangChain的接口抽象让迁移成本很低。
3.2 构建知识库索引的完整代码
先看完整流程代码,再逐步拆解。
from langchain_community.vectorstores import Chroma # 切分后的文档块 chunks = text_splitter.split_documents(docs) # 入库向量化 vectorstore = Chroma.from_documents( documents=chunks, embedding=embeddings, persist_directory="./chroma_db" )这行代码做了三件事:把每个chunk用Embedding模型转成向量、建立向量索引、持久化到本地。索引构建以后,以后不需要重新跑这一步,直接从persist_directory加载即可。
vectorstore = Chroma( embedding_function=embeddings, persist_directory="./chroma_db" )注意:加载已有库的时候不需要传documents参数,只需要embedding_function和persist_directory,且embedding_function必须和当初建库时用的是同一个模型,否则向量维度不同,检索结果会完全错乱。
3.3 检索器参数:TopK与相似度阈值的调优
有了向量库,下一步配置检索器。这里有两个参数关系重大:search_kwargs里的k和score_threshold。
retriever = vectorstore.as_retriever( search_type="similarity_score_threshold", search_kwargs={ "k": 5, "score_threshold": 0.15 } )- k:召回数量。数量太少容易漏答案,太多会混入噪音、浪费上下文窗口。默认4到6比较均衡,如果知识库质量高,可以适当提高到8。
- score_threshold:相似度阈值。Chroma返回的score是距离,越小越相似。我实测下来,text2vec和dashscope embedding在阈值0.15到0.25之间表现比较稳定。设太严会召回为零,设太松会又拉回一堆不相关内容。
这两个参数要一起调。k决定了上限,score_threshold决定了下限,配合使用才能获得高质量检索结果。
3.4 检索质量的验证方法
检索器配好以后,先别急着接大模型,直接把检索结果打印出来看一眼。
question = "退货流程是什么?" retrieved_docs = retriever.invoke(question) for doc in retrieved_docs: print(doc.page_content[:200]) print("---")这一步叫“裸检”。如果这一步返回的片段和问题相关,后面生成阶段基本没问题;如果这一步就是答非所问,那换什么Prompt都救不回来。
我见过很多人跳过这一步直接看最终答案,发现质量差就盲目调Prompt,这方向完全错了。检索是上游,生成是下游,上游污染了下游永远干净不了。
4. 问答链路实现:LangChain表达式构建最终应用
4.1 Prompt模板设计:让模型学会引用你的知识
检索到了相关文档,接下来把文档和问题一起送给大模型。Prompt模板的设计决定了模型输出质量。
from langchain.prompts import PromptTemplate prompt_template = """你是智能客服助手。请根据以下已知信息,用中文简明、准确地回答用户问题。 已知信息: {context} 用户问题: {question} 要求: 1. 如果已知信息中没有答案,请直接说明“根据现有知识无法回答”,不要编造。 2. 如果答案涉及多个要点,请分点列出。 3. 引用信息中的内容时,请用原文表述,不要过度发挥。 回答:""" prompt = PromptTemplate( template=prompt_template, input_variables=["context", "question"] )这个模板有三个设计考虑:
一是强制约束模型不要编造。这是企业内部问答的底线,宁可答不上来,不能瞎编。二是要求分条目,方便用户阅读。三是明确引用原文,保证答案可溯源。
4.2 构建完整问答链路的LCEL代码
LangChain 0.2.x推荐使用LCEL(LangChain Expression Language)来组装链路,代码简洁、可读性强、调试也容易。
from langchain_openai import ChatOpenAI from langchain.schema.runnable import RunnablePassthrough from langchain.schema.output_parser import StrOutputParser # 通义千问接入 llm = ChatOpenAI( model="qwen-plus", api_key="你的API密钥", base_url="https://dashscope.aliyuncs.com/compatible-mode/v1", temperature=0.3 ) # LCEL装配 rag_chain = ( { "context": retriever, "question": RunnablePassthrough() } | prompt | llm | StrOutputParser() )这条管道的工作流程:输入用户问题,同时把它发给检索引擎得到context,然后把context和question填进prompt,交给llm生成,最后StrOutputParser把模型输出的实际内容取出来。
RunnablePassthrough()在这里就是透传的意思,问题原样保留,不经过任何处理。
4.3 流式输出与对话历史的扩展
实际用户不满足于一个问题。真实场景里用户会追问:“那怎么退款呢?”需要对话历史来保持上下文。
LCEL的RunnableWithMessageHistory可以自动管理会话记忆:
from langchain_community.chat_message_histories import ChatMessageHistory from langchain_core.runnables.history import RunnableWithMessageHistory chain_with_history = RunnableWithMessageHistory( rag_chain, get_session_history=lambda session_id: ChatMessageHistory(), input_messages_key="question", history_messages_key="chat_history" )如果要做得更完善,可以引入LangChain的history模块,或者用Redis存会话状态。但在早期版本里,先保证单轮问答效果扎实,再接多轮,不然会话一多检索质量会下降。
4.4 完整调用示例
result = rag_chain.invoke("订单已发货,几天能到?") print(result)输出示例:
根据已知信息,平台默认快递为普通快递,一般在发货后3-5个工作日内送达。如果需要更快,可以在下单时选择顺丰快递,一般2-3天送达。这一步如果实现,基础版RAG应用就齐活了。剩下的是持续调优的问题。
5. 关键参数调优:让答案从“能答”变“答得好”
5.1 温度与采样参数的影响
temperature是控制模型随机性的核心参数。问答场景里,temperature不建议高于0.3。原因很简单:
- 问答需要严谨、事实准确,低温度可以让模型更倾向于选择高概率的输出路径。
- 温度过高会引入随机性,使模型在事实性问题上跑偏。
如果做创意文案、头脑风暴,temperature可以调到0.8以上,但问答场景务必低温。
还有一个参数是top_p,控制采样时的候选范围,可以理解成“只从前百分之多少概率的词里选”。LangChain里默认不传,按需调整。一般temperature和top_p二选一调整即可,不要同时猛调。
5.2 知识库更新与索引重建策略
企业知识是不断更新的。文档改了以后,旧向量还在向量库里,检索的时候可能同时命中新旧版本内容,导致答案不统一。
三种更新策略:
- 全部删库重建。知识量小、构建时间短的情况下最简单靠谱。
- 增量更新。只更新变更文档涉及的chunk,需要记录文档到chunk的映射关系。
- 定时全量重建。适合每天晚上低频跑批。
在ChromaDB里删掉旧数据再重建的代码:
vectorstore.delete_collection() # 或者删除本地持久化目录后重新构建对于早期的应用,建议直接用全量重建,省心,不用维护脏数据。以后规模大了再设计增量方案。
5.3 上下文窗口与答案质量的博弈
检索出来的文档块越多,prompt越长,模型能看到的信息越丰富,但同时冗余也越多。我实测下来:
- 5块x500字符,大约2500字符的上下文,配合qwen-plus的上下文窗口绰绰有余。
- 如果文档块内容本身很长(800以上),适当减少k的数量,避免超过模型的有效处理范围。
超长上下文还有一个隐藏问题:模型对中间内容的注意力会下降。这也是为什么不是“塞得越多越好”的原因。检索的终极目标是少而精,不是大而全。
6. 常见问题排查:本地实测遇到的典型坑
6.1 检索结果为空或完全无关
症状:问什么都返回“根据现有知识无法回答”,或者答非所问。
排查顺序:
- 直接打印检索到的文档内容,看检索器本身有没有返回东西。
- 如果检索结果为空,检查score_threshold,调低(放宽)阈值。
- 检查Embedding模型是否和建库时一致。
- 检查chunk切割:如果每块内容太短(比如小于50字符),语义信息不足,embedding效果会很差。
实测遇到过的是,某个业务术语“退款单”在文档里写作“退单”,检索问题时embedding语义有偏差,召回率低。解决办法是给检索器加同义词扩展,或者直接在文档里补充别名。
6.2 答案完整但引用了错误信息
症状:答案读起来很顺,但内容完全是另一回事,和用户的问题没关系。
这是RAG里最危险的问题,模型一本正经地把不相关的内容讲得很有条理。
根因通常是:检索阶段把低相关度的chunk也返回了,模型误以为这些内容就是回答依据。解决方式:提高score_threshold,收紧检索范围,同时Prompt里明确要求“如果已知信息与问题无关,请直接拒绝回答”。
6.3 多轮对话中的上下文污染
症状:第一轮问答正常,第二轮再问一个问题,答案开始混合第一轮的内容,甚至完全围绕第一轮展开。
这个问题的根源是直接把所有历史轮次一股脑拼进Prompt,导致上下文窗口被历史占满,当前问题在长上下文中被稀释。
解决方式:
- 只保留最近两轮对话,更早的截断。
- 把历史记录和当前问题分开传入,分别限定长度。
- 必要的时候做意图识别——如果用户换了话题,清空历史重新开始。
6.4 LangChain版本兼容性问题
LangChain升级到0.2.x以后,很多API位置变了。最典型的几个变化:
- 老版本langchain.llms.OpenAI,新版变为langchain_openai.ChatOpenAI。
- 部分文档加载器移到了langchain_community包。
- vectorstores的from_documents方法也有细节变化。
如果安装时直接pip install langchain没带版本号,可能会出现代码和包版本不匹配导致大量报错。建议用固定版本:pip install langchain==0.2.x langchain-openai langchain-community chromadb,保证依赖一致性。我在实际搭建过程中因为版本问题卡了至少一小时,提前锁定版本能省很多时间。
7. 进阶优化方向:从能用到好用还差这几步
7.1 混合检索:向量+关键词的双保险
向量检索擅长语义相似,但不擅长精确匹配。比如用户问“订单号12345”,这种高精度的数字标识,向量检索可能匹配不到完全一致的内容。BM25关键词检索可以弥补这个盲区。
方案是把向量检索和BM25检索的结果做融合(Reciprocal Rank Fusion),再统一排序,能显著提升长尾查询的准确率。LangChain里可以用EnsembleRetriever实现:
from langchain.retrievers import EnsembleRetriever from langchain.retrievers import BM25Retriever bm25_retriever = BM25Retriever.from_documents(chunks) bm25_retriever.k = 3 ensemble_retriever = EnsembleRetriever( retrievers=[bm25_retriever, retriever], weights=[0.3, 0.7] )权重设置上,我习惯偏重向量检索,因为大部分业务问题还是语义匹配为主。但如果你的文档里代码、编号很多,BM25的权重可以提高到0.4。
7.2 重排模型:让最相关内容排在前面
向量检索TopK返回的结果里,可能前三条无关、第四条才是正确答案。用重排模型(Cross-Encoder)对TopK结果重新精排,可以显著提升最终效果。
常见的重排模型有bge-reranker,LangChain接入通过HuggingFaceCrossEncoder实现。它的原理是同时把问题和文档块拼起来输入模型,计算相关性打分,比向量相似度更精确,但速度慢,不能处理全量数据,所以只对Top50左右的结果重排。
7.3 引用溯源:回答里给出信息来源
企业内部问答,谁也不想模型给个结论但没有依据。在LangChain里可以在生成环节把文档块的metadata带出来,在最终回答后面附上“参考文档”。
实现方式:不直接用StrOutputParser,改成自定义输出,把检索结果里的source字段一并返回。这样用户可以回溯验证答案的正确性,也方便管理员发现知识库里的过时内容。
这些进阶方向不是必须一次到位的,先把基础版跑通,再按业务反馈逐项升级。
8. 个人实操总结与下一步建议
整套方案从零搭起来,核心链路并不长,真正花时间的地方在于数据清洗、切割参数和检索调优。我在实际踩坑过程中最大的体会是:RAG的成败在检索不在生成。很多人默认只要大模型聪明就能答得好,但实际上,你喂给它什么,它就只能吃什么。把文档切好、把向量库建稳、把检索器调准,比换更强的模型回报更明显。
最后分享一个我在调试时的独门习惯:每次改动参数前,先固定一组测试问题集(10到20个问题,包含常见问题、长尾问题和知识边界问题),每次改动后用同一组问题回归验证。没有评估集的RAG调优就是无头苍蝇,有了测试集,你才能量化知道每次调整是变好了还是变差了。
如果你的项目文档量更大、并发要求更高,后续可以考虑引入Milvus承载向量库、接入Agent工具扩展能力,或者用Docker容器化部署。在这个基础上扩展,路线已经很清晰了。