手里有几份产品手册、技术文档,想让大模型基于这些资料回答问题,但模型总是“一本正经地胡说八道”——这是我一开始接触LangChain时的真实场景。后来我把RAG(Retrieval-Augmented Generation,检索增强生成)跑通之后才发现,问题不在于模型不够聪明,而在于我没让它“先查资料再说话”。这篇博文就是我从零学习LangChain、完整实现一个RAG增强检索项目的全过程记录,包括思路拆解、代码实现、参数调试和踩坑经验,适合刚入门RAG、想快速搭建一个可用Demo的开发者参考。
1. 项目概览与核心思路
1.1 这个项目到底在解决什么问题
先还原一个真实需求场景。假设你手上有一批内部文档,比如产品操作手册、售后FAQ、测试报告,你想让大模型成为一个“懂这些资料的客服助手”。如果直接把所有文档塞进提示词里,很快会遇到两个问题:一是上下文长度根本装不下,二是无关信息会严重干扰模型判断,让它给出泛泛而谈甚至错误的内容。
RAG的思路很简单:用户提问之后,先去一个“外部知识库”里检索与问题最相关的几个片段,再把提问和这些片段一起交给大模型生成回答。这样模型不需要记住全部资料,只需要基于检索到的片段做推理和归纳。
所以这个项目的核心目标是:利用LangChain框架,快速搭建一条“文档加载 -> 文本切分 -> 向量化 -> 向量检索 -> 生成回答”的完整链路。它解决的不是“模型会不会聊天”的问题,而是“怎么让模型用你给它的资料来聊天”的问题。对于企业内部知识库问答、开源文档助手、个人资料库检索等场景,这套方案非常通用。
1.2 为什么选择LangChain + RAG这条技术路线
LangChain是目前编排大模型应用最成熟的框架之一。它能用统一接口对接不同的大模型供应商、向量数据库、Embedding模型,省去大量胶水代码。RAG本身是一种架构模式,并不是某个库的专属功能,但LangChain把RAG链路中的每个环节都抽象成了可替换的组件,这让学习和迭代都变得很舒服。
我最初也考虑过完全手写:用某个Embedding模型生成向量,用某个向量数据库做相似度检索,再直接调用大模型的API。听起来也不算复杂,但真做起来会发现细节很多:文本切分不合适导致检索不准、向量维度不匹配、检索结果和生成环节之间需要拼接模板、还要处理历史对话和多轮上下文……这些功能LangChain都有现成的封装,先基于它跑通,再去理解内部原理,学习曲线会平滑很多。
另外,RAG本身是一种“模块化”的架构,后续想换成更好的Embedding模型、更多样的检索策略、或者加一个重排序环节,都只需要改动一个组件,不用推翻重来。这也是我推荐用它作为学习入口的原因。
2. 环境准备与技术选型
2.1 基础环境搭建
如果你用的Python版本是3.9及以上,安装LangChain就可以直接用pip完成。我是用虚拟环境来隔离依赖的,避免不同项目之间的包版本冲突。
# 创建并激活虚拟环境 python -m venv rag_env source rag_env/bin/activate # 安装核心依赖 pip install langchain pip install langchain-community pip install langchain-openai pip install chromadb pip install tiktoken这里有几个细节需要说明一下。第一,LangChain现在的版本拆分得比较细,核心包、社区集成包、不同厂商的适配包是分开的,如果只装一个langchain,某些组件会提示找不到。第二,我选择Chroma作为向量数据库,是因为它在本地运行、零配置,适合学习和快速验证;生产环境可以换成Weaviate、Qdrant或者云上的向量数据库。第三,tiktoken用来做Token计数,方便后面控制提示词长度和估算成本,属于必备工具。
2.2 向量数据库与Embedding模型的选型考量
向量数据库的选择,直接决定了检索的性能和运维成本。为了便于对照,我把几个常见选项整理成了表格:
| 方案 | 部署方式 | 优点 | 适合场景 |
|---|---|---|---|
| Chroma | 本地/嵌入式 | 零配置、API简单、支持持久化 | 学习Demo、小规模知识库 |
| FAISS | 本地库 | 检索效率高、内存索引快 | 单机中等规模检索 |
| Qdrant | 本地/服务 | 支持过滤、扩展性好 | 生产环境起步 |
| Weaviate | 服务 | Schema管理完善、多租户支持 | 企业级知识库 |
| Milvus | 服务 | 分布式、海量向量 | 大规模高并发场景 |
我的建议是:如果只是为了跑通流程、理解RAG机制,直接选Chroma。它不需要单独启动服务,直接在进程内运行,持久化到一个文件夹即可,特别适合前期调试。等项目做到后期,需要多人共享、大量并发的时候,再迁移到独立的向量数据库服务。
Embedding模型的选择同样关键。我第一版用的是OpenAI的text-embedding-ada-002,效果稳定,但每次调用都要走API,批量处理文档时耗时和成本都比较高。后来换成开源的BAAI/bge-large-zh-v1.5,用本地推理生成向量,中文场景下效果也足够好。关键在于:Embedding模型决定了文本在向量空间里的语义分布,不同模型的“理解能力”差异很大,建议至少对比两个模型再定下来。
3. RAG流程拆解与关键实现
3.1 文档加载与切分:预处理决定了检索质量
很多人会忽略这个环节,但我实测下来,预处理对RAG效果的影响绝不亚于模型选择。文档加载的常见格式有PDF、Word、Markdown、HTML等,LangChain提供了对应的Loader,比如PyPDFLoader、TextLoader、MarkdownTextLoader。
文档加载之后,最重要的步骤是切分。切分策略如果太粗,一个块里包含太多主题,检索时容易把不相关的内容一起带出来;如果太细,语义上下文被切碎,模型看不懂片段在说什么。
我用的切分器是RecursiveCharacterTextSplitter,它会按优先级依次尝试分隔符:先按段落切,再按句子切,最后按字符切,尽量保持语义完整。参数配置如下:
from langchain_text_splitters import RecursiveCharacterTextSplitter text_splitter = RecursiveCharacterTextSplitter( chunk_size=500, # 每个块的最大字符数 chunk_overlap=50, # 相邻块之间的重叠字符数 separators=["\n\n", "\n", "。", "!", "?", ";", " ", ""], )chunk_size和chunk_overlap这两个参数需要根据文档的实际长度和语言特点调整。中文文档我建议以500字符左右为起点,太小了语义不完整,太大了容易混入多个主题。重叠部分的作用是避免因为切点正好落在关键句子中间而丢失上下文。
切分完之后,建议把每个块里的连续空白字符、多余换行清理一下,否则向量化时这些噪声也会参与计算,影响检索精度。
3.2 向量化与索引构建:Embedding直接影响召回效果
向量化是RAG中“检索”环节的数据基础。每一段文本经过Embedding模型,变成一个固定维度的向量,比如1024维,然后存入向量数据库。用户提问时,将问题也转换为向量,再用余弦相似度或点积等方式在库里找最相近的向量。
我使用的本地模型是BAAI/bge-large-zh-v1.5,它输出1024维向量,中文效果不错。加载和调用代码如下:
from sentence_transformers import SentenceTransformer embedding_model = SentenceTransformer("BAAI/bge-large-zh-v1.5") # 单个文本向量化 vector = embedding_model.encode("什么是RAG检索增强生成")在LangChain中使用这个模型,需要写一个适配器,把SentenceTransformer包装成LangChain的Embeddings接口:
from langchain_core.embeddings import Embeddings class LocalEmbeddings(Embeddings): def __init__(self, model_name): from sentence_transformers import SentenceTransformer self.model = SentenceTransformer(model_name) def embed_documents(self, texts): return self.model.encode(texts, normalize_embeddings=True).tolist() def embed_query(self, text): return self.model.encode(text, normalize_embeddings=True).tolist()需要留意的是normalize_embeddings=True这个参数。归一化后,向量点积等价于余弦相似度,能提升检索的稳定性和性能。如果你用的模型本身没有归一化,后续查询时也建议手动做一次归一化。
索引构建的逻辑很简单:把切分好的所有文档块逐条向量化,然后写入Chroma。Chroma会自动创建集合、存储向量和原始文本,方便后续按ID删除或更新。
from langchain_chroma import Chroma vectorstore = Chroma.from_documents( documents=split_docs, embedding=LocalEmbeddings("BAAI/bge-large-zh-v1.5"), persist_directory="./chroma_db", )这一步实际执行时,文档量如果比较大,建议分批编码,避免一次性加载太多内容导致内存溢出。我处理过一批200页左右的PDF,分批编码后大约多花了半分钟,但内存占用一直很平稳。
3.3 检索与重排序:让最相关的片段排在最前面
向量检索本身能返回TopK个相似片段,但“相似”不一定是“相关”。很多情况下,用户提问中的关键词在文档里出现次数不多,但语义上很相近;或者某个片段里包含了问题的答案,但被几个无关片段挤到了后面。这时候就需要重排序(Rerank)环节。
我使用的检索器直接基于向量库构建,先取TopK=20的候选片段,再用一个交叉编码器(Cross-Encoder)逐对计算“问题-片段”的相关性分数,最后取分数最高的TopK=4个片段。代码如下:
from langchain.retrievers import ContextualCompressionRetriever from langchain.retrievers.document_compressors import CrossEncoderReranker from sentence_transformers import CrossEncoder reranker = CrossEncoder("BAAI/bge-reranker-large") compressor = CrossEncoderReranker( model=reranker, top_n=4, batch_size=8, ) compression_retriever = ContextualCompressionRetriever( base_compressor=compressor, base_retriever=vectorstore.as_retriever(search_kwargs={"k": 20}), )为什么要先取20个再压缩到4个?因为向量检索的速度很快,但精度不够高。用较宽的召回范围先把可能的候选都捞出来,再用计算量更大的重排序模型做精排,这是工业界RAG的标准做法。代价是每个查询会增加一次交叉编码器推理,耗时大约几十毫秒,完全可接受。
3.4 生成增强:提示词模板与LLM调用的实战配置
检索到相关片段之后,最后一步是把片段和用户问题组合成提示词,交给大模型生成回答。这里最关键的是提示词模板的设计。好的模板要明确告诉模型三件事:一是基于哪些资料回答,二是资料不足时该怎么处理,三是回答的格式要求。
from langchain.prompts import ChatPromptTemplate prompt_template = ChatPromptTemplate.from_messages([ ("system", "你是一个严谨的文档问答助手。请仅基于以下资料回答用户问题,不要编造资料中不存在的信息。\n\n资料:\n{context}"), ("human", "问题:{question}"), ]) from langchain_openai import ChatOpenAI llm = ChatOpenAI( model="qwen-plus", api_key="your-api-key", base_url="your-base-url", temperature=0.1, )我用的不是OpenAI官方接口,而是某个兼容OpenAI协议的国内大模型API,所以需要设置base_url。这里的重点是temperature=0.1:问答任务需要确定性高、忠实于资料,温度过高容易发挥过头,温度过低则可能显得机械。0.1-0.3是比较合适的区间。
接下来把检索器和提示词模板组合成一个完整的RAG链路。LangChain提供了create_retrieval_chain之类的便捷方法,也可以手动串联:
from langchain_core.runnables import RunnablePassthrough def format_docs(docs): return "\n\n".join(doc.page_content for doc in docs) rag_chain = ( {"context": compression_retriever | format_docs, "question": RunnablePassthrough()} | prompt_template | llm )这个链路的意思非常清晰:先把问题传给检索器,得到文档片段,格式化为字符串;与此同时问题原样传入;然后填充提示词模板;最后交给LLM生成回答。调试时可以把每个环节单独打印出来,检查画面是否正常。
4. 实操过程与踩坑记录
4.1 第一版Demo:用最小代码跑通流程
我第一次搭建时,没有加重排序,也没用本地Embedding,全程用OpenAI的接口,代码大约80行左右就能跑通。核心步骤就三步:加载并切分PDF、向量化并存储到Chroma、构建检索链并提问。
那轮实验我用的资料是某款产品的用户手册,问题问了“设备的网络参数怎么配置”。结果回答基本正确,但引用的内容很散,常常把安卓版和iOS版的说明混在一起。原因也很明显:没有元数据过滤,检索器并不知道用户问的是哪个平台。
这个阶段虽然效果一般,但它让我对RAG的全链路有了整体认识。对于第一次上手的朋友,我的建议是不要一上来就追求完美,先跑通最小闭环,再逐项优化。只有先看到完整的输入输出,才知道后续该从哪里下手。
4.2 效果调优:从“答非所问”到“精准引用”
当我把本地Embedding、重排序、更好的提示词一步步加进去之后,回答质量有了明显提升。但中间也遇到一个特别典型的坑:切分粒度太粗。
我一开始把chunk_size设成了1000,认为这样上下文更完整。结果发现,一旦一页文档里包含多个小节,检索器返回的片段可能跨越主题,回答就会把不相关内容混在一起。后来我把chunk_size降到500,同时增加了chunk_overlap到80,效果立刻改善。
另一个调优点是用元数据过滤。我在切分文档片段时,给每个片段打上了来源文件名、章节标题、所属平台等元数据。然后在检索时,如果用户明确提到了“安卓版”,我就通过元数据过滤器把范围缩小到相关平台,大幅减少干扰片段。
from langchain.retrievers import EnsembleRetriever不过实测下来,简单的元数据过滤比复杂的集成检索更容易见效,也更容易调试。我建议先做好这一步,再考虑混合检索等进阶方案。
4.3 性能优化:并发、缓存与异步
项目跑通之后,我开始考虑两个现实问题:批量文档入库慢、多用户查询响应慢。
入库慢的根源在于Embedding生成是逐条调用的。解决方法是启用批处理编码,把批量文本一次性交给模型推理。对于SentenceTransformer,可以通过encode的batch_size参数控制;对于API型Embedding,则可以通过并发请求加速。
查询响应慢的根源在于每个问题都要重新检索、重新调用LLM。对于重复问题或者相似问题,可以引入缓存层。LangChain自带InMemoryCache和SQLiteCache,我最终选择了SQLite缓存:
from langchain.globals import set_llm_cache from langchain.cache import SQLiteCache set_llm_cache(SQLiteCache(database_path=".langchain_cache.db"))加上缓存之后,相同或相似问题的响应速度几乎降为毫秒级。另外,LangChain的Runnable原生支持异步,如果服务是FastAPI实现的,推荐使用ainvoke来处理高并发场景,避免阻塞事件循环。
5. 常见问题速查与调试心得
5.1 检索结果杂乱,怎么排查
这是RAG项目里最容易遇到、也最让人头大的问题。当回答不靠谱时,首先要做的不是改提示词,而是去看检索器到底返回了什么片段。我习惯的做法是打印检索结果的前几百个字,人工判断片段内容是否和问题相关。
如果片段本身就不相关,说明问题出在Embedding或切分环节。可以尝试换一个更强的Embedding模型,或者调整切分参数。如果片段相关但回答得不对,问题出在LLM或提示词模板,需要加强提示词对“仅基于资料回答”的约束。
另外,注意检索相似度分数。如果最相似的片段分数只有0.6以下,往往表示语料库中根本没有相关答案,这时候应该让模型明确回答“资料中未提供相关信息”,而不是强行编造。
5.2 Token超限与成本控制
当文档片段数量多、BlockSize大、历史对话内容长时,很容易超过大模型的上下文长度。解决思路有三个:减少送入LLM的片段数量;做片段摘要,用摘要替代原文;对大模型上下文做滑动窗口管理。
成本方面,第一次跑批量文档时我用API版Embedding,处理几百个片段花了十几块钱,肉疼。后面切换到本地模型后,成本趋近于零,除了LLM的调用费用以外,几乎没有其他开销。如果是对成本敏感的项目,强烈建议把Embedding模型本地化部署。
5.3 上下文污染问题
上下文污染是我自己取的名字,指检索返回的片段中包含大量无关的导航栏、页眉页脚、表格噪声,导致模型被“带偏”。PDF转文本时尤其容易出现这种情况,比如文本里混入了页码和页眉。
解决办法是清洗文档。我写了一段简单的清理逻辑:去掉只包含数字的纯页码行,去掉长度过短的杂物行,去掉重复出现的页眉页脚。如果文档是表格数据,建议在预处理时转换成Markdown表格,或者直接按表格区域切分,而不是当作连续文本处理。
6. 经验总结与后续扩展
6.1 我事后复盘的一些体会
整个项目从零到跑通,我最大的体会是:RAG并不难,难的是“认真调细节”。很多人跑完一个Demo就觉得完事了,实际上生产级RAG要考虑的事远不止“检索+生成”这么简单。文档切分是否合理、Embedding是否贴合领域、检索结果是否干净、提示词是否约束住了幻觉、缓存和并发是否到位,这些细节每一项都会影响体验。
我在实际调试中发现,一个特别实用的调试技巧是:把RAG链路的中间结果全部日志化。每处理一个问题,就记录检索到的片段ID、相似度分数、最终生成的回答。这样当用户反馈回答不对时,可以回放整个链路,快速定位是检索问题还是生成问题。这个小习惯帮我省了大量排查时间。
6.2 下一步可以尝试的方向
如果想让这套系统更完善,我有几个建议方向。一是引入“多查询检索”:用户问题先被拆解成多个子查询,分别检索后合并结果,能提高复杂问题的召回率。二是增加“上下文压缩”:把检索到的多轮历史对话详情压缩成总结,保留重点信息,减少Token开销。三是加入“引文溯源”:要求模型在生成回答时标注引用的片段编号,方便用户核查。四是针对中文场景做更细致的分词和检索策略,比如在向量检索之外叠加BM25词法检索,用加权方式融合结果。
RAG项目的核心是持续迭代:资料更新后要及时重建向量索引,新问题出现后要根据失败案例补充语料。只要能把这个循环跑起来,这套系统就会越用越准。这也是我认为学习LangChain最有价值的地方:框架本身只解决工程效率问题,真正决定上限的,是你对业务和数据的理解。