news 2026/10/19 3:50:40

从零搭建RAG智能问答系统:LangChain+通义千问+ChromaDB实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
从零搭建RAG智能问答系统:LangChain+通义千问+ChromaDB实战

在RAG还没火的时候,做智能问答基本就是两条路:要么拿模型硬刚,把全部资料塞进上下文——又贵又容易超长截断;要么微调模型,让它记住你的私有知识——费时费力,改知识还得重新训。RAG(检索增强生成)把这俩问题都解决了,知识更新只需换库,模型能力交给大模型,成本低、效果好、落地快。

这篇文章我用LangChain加通义千问,从零开始给你搭一套完整的RAG智能问答系统,覆盖文档加载、文本切割、向量化存储、检索问答全流程,代码能直接抄,参数怎么调、坑在哪儿都会说清楚。不管你是刚接触大模型应用开发的初级开发者,还是想在业务里做私有知识库问答的后端工程师,这套方案都能让你少踩三个月的坑。

说明:本篇使用通义千问(qwen-plus)作为LLM,embeddings用文本向量模型,向量库选ChromaDB,框架用LangChain。所有代码均用Python编写,基于LangChain 0.2.x版本,API密钥请自行准备。

1. 从零到一:RAG应用的整体设计与技术选型

1.1 RAG工作原理:为什么它能解决大模型的“记忆力”问题

先花两分钟搞明白RAG到底在做什么。大模型训练完以后知识就冻结了,你问它训练时没见过的内部文档,它只能一本正经地胡说八道。RAG的思路很简单:在模型回答问题前,先从你的知识库里检索出相关内容,把检索结果作为参考上下文一起发给大模型,让模型基于这些资料生成答案。

流程分三步:

  1. 离线索引:把文档切碎成小块,每块向量化,存入向量数据库。
  2. 在线检索:用户提问后,把问题向量化,在向量库里找最相似的TopK个文档片段。
  3. 生成回答:把问题+检索片段拼成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 知识库更新与索引重建策略

企业知识是不断更新的。文档改了以后,旧向量还在向量库里,检索的时候可能同时命中新旧版本内容,导致答案不统一。

三种更新策略:

  1. 全部删库重建。知识量小、构建时间短的情况下最简单靠谱。
  2. 增量更新。只更新变更文档涉及的chunk,需要记录文档到chunk的映射关系。
  3. 定时全量重建。适合每天晚上低频跑批。

在ChromaDB里删掉旧数据再重建的代码:

vectorstore.delete_collection() # 或者删除本地持久化目录后重新构建

对于早期的应用,建议直接用全量重建,省心,不用维护脏数据。以后规模大了再设计增量方案。

5.3 上下文窗口与答案质量的博弈

检索出来的文档块越多,prompt越长,模型能看到的信息越丰富,但同时冗余也越多。我实测下来:

  • 5块x500字符,大约2500字符的上下文,配合qwen-plus的上下文窗口绰绰有余。
  • 如果文档块内容本身很长(800以上),适当减少k的数量,避免超过模型的有效处理范围。

超长上下文还有一个隐藏问题:模型对中间内容的注意力会下降。这也是为什么不是“塞得越多越好”的原因。检索的终极目标是少而精,不是大而全。

6. 常见问题排查:本地实测遇到的典型坑

6.1 检索结果为空或完全无关

症状:问什么都返回“根据现有知识无法回答”,或者答非所问。

排查顺序:

  1. 直接打印检索到的文档内容,看检索器本身有没有返回东西。
  2. 如果检索结果为空,检查score_threshold,调低(放宽)阈值。
  3. 检查Embedding模型是否和建库时一致。
  4. 检查chunk切割:如果每块内容太短(比如小于50字符),语义信息不足,embedding效果会很差。

实测遇到过的是,某个业务术语“退款单”在文档里写作“退单”,检索问题时embedding语义有偏差,召回率低。解决办法是给检索器加同义词扩展,或者直接在文档里补充别名。

6.2 答案完整但引用了错误信息

症状:答案读起来很顺,但内容完全是另一回事,和用户的问题没关系。

这是RAG里最危险的问题,模型一本正经地把不相关的内容讲得很有条理。

根因通常是:检索阶段把低相关度的chunk也返回了,模型误以为这些内容就是回答依据。解决方式:提高score_threshold,收紧检索范围,同时Prompt里明确要求“如果已知信息与问题无关,请直接拒绝回答”。

6.3 多轮对话中的上下文污染

症状:第一轮问答正常,第二轮再问一个问题,答案开始混合第一轮的内容,甚至完全围绕第一轮展开。

这个问题的根源是直接把所有历史轮次一股脑拼进Prompt,导致上下文窗口被历史占满,当前问题在长上下文中被稀释。

解决方式:

  1. 只保留最近两轮对话,更早的截断。
  2. 把历史记录和当前问题分开传入,分别限定长度。
  3. 必要的时候做意图识别——如果用户换了话题,清空历史重新开始。

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容器化部署。在这个基础上扩展,路线已经很清晰了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/19 3:49:55

开源免费AI论文工具千笔AI:从选题到查重降重实战指南

论文季一到,后台就会收到一大堆追问:专科生写论文,有没有不用花钱的办法?我的回答永远是三个关键词:开源、免费、AI。今天要聊的这款工具,叫千笔AI,属于“学霸同款”那类能把论文从选题一直推到…

作者头像 李华
网站建设 2026/10/19 3:49:15

open-code-review 实战:让 Code Review 从形式主义走向自动化

先说一件让我下决心换工具的事。上个月我们有个支付状态更新的接口出了事故,根因是新增代码里把一个布尔判断写反了。代码过了人工 Review,也过了单元测试,但没有任何一层拦住它。我翻了一下当天那条 PR,评审意见里只有一句 "…

作者头像 李华
网站建设 2026/10/19 3:49:14

SpringBoot+Vue个性化图书推荐系统全栈实战解析

如果你正在准备毕业设计,或者刚学完 Java 和 Vue 想找一个完整的全栈项目练手,SpringBootVue 的个性化图书推荐系统管理平台这类源码确实值得好好研究。它几乎是“毕设常青树”——技术栈主流、功能边界清晰、前后端分离的工程结构完整,还带着…

作者头像 李华
网站建设 2026/10/19 3:49:12

AI任务跑完自动通知:Deepseek Harness插件dsh-notify-push实践

打工人的AI外挂:牛马的Deepseek Harness插件dsh-notify-push实践笔记先说说憋了很久的话。作为一名日常被需求、评审、临时变更反复拉扯的搬砖打工仔,我一直在琢磨怎么用AI帮自己省点力气。试过各种AI工具,也搭过个人知识库,但有个…

作者头像 李华
网站建设 2026/10/19 3:48:07

AI一键生成文献综述:从文献荒到学术地图的实践指南

做学术研究的人,几乎都经历过这种时刻:题目定了,关键词摆在眼前,但打开数据库一搜,要么看到几千篇论文不知道从哪读起,要么搜来搜去都是同一批文章,要么读完三十篇摘要依然理不出一个清晰的脉络…

作者头像 李华
网站建设 2026/10/19 3:47:52

STM32F1 OLED驱动文件详解:SSD1306 IIC移植与避坑指南

简介:一套面向STM32F1系列微控制器的OLED显示驱动源码包,围绕IIC通信接口提供完整驱动逻辑,适用于嵌入式入门开发者或需要在项目中快速点亮OLED屏的工程师。压缩包共4个文件,包括3个头文件与1个C源文件:.h中声明了函数…

作者头像 李华