恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
Langchain RAG源码级实战:检索-召回-生成全链路工程化
首页
资讯中心
/
Langchain RAG源码级实战:检索-召回-生成全链路工程化
Langchain RAG源码级实战:检索-召回-生成全链路工程化
发布时间:2026/10/18 18:12:29
简介本资源是一份基于Langchain框架实现检索增强生成RAG系统的完整开源实践方案面向AI开发初学者与Python后端工程师解决大模型应用中知识时效性不足、幻觉率高、缺乏私域数据支撑等核心痛点。压缩包共9个文件含2个核心Python脚本app.py、rag_system.py实现链式调用与RAG流程编排1个PDF示例文档提供测试语料1个PNG架构图直观展示系统模块关系另含requirements.txt依赖清单、.env.example密钥配置模板及README.md使用说明整体仅40KB轻量易部署。已有102人学习下载读者可直接复用该结构快速搭建本地RAG原型从环境配置、文档加载、向量库构建到invoke接口调用全流程覆盖代码简洁、注释清晰、目录组织合理特别适合理解Langchain中DocumentLoader、TextSplitter、Embeddings、VectorStore与RetrievalQA等关键组件的协同机制。1. Langchain实现RAG不是调个API就完事而是把检索、召回、生成三步链路全拧紧的源码级实操你是不是也试过用Langchain搭RAG跑通了demo却卡在真实场景里文档切得不准检索结果驴唇不对马嘴LLM一通胡编还自信满满或者换了个PDF格式就崩加个表格就漏信息重跑一遍embedding又耗两小时——这不是模型不行是RAG链路里藏着太多没暴露的“黑匣子”。这份「Langchain实现RAG[源码]」不是教学视频截图拼凑的伪工程而是一套从数据加载、文本切分、向量存储、检索策略到LLM调用全程可调试、可打断、可替换的完整代码包。它覆盖PDF/Markdown/纯文本三类主流输入内置Chroma本地向量库OpenAI兼容接口所有模块解耦清晰.py文件按功能垂直划分连retriever.py里similarity_top_k参数为什么设为3、chunk_size512怎么平衡语义完整与检索精度都在注释里写了血泪经验。适合正在落地知识库、客服问答或内部文档助手的开发者尤其适合被“能跑”和“能用”之间那道沟反复绊倒的人。2. 源码结构拆解六个核心文件如何分工协作完成RAG闭环这份源码包共6个主文件无冗余依赖不打包前端、不塞Dockerfile纯粹聚焦RAG后端链路。我把它按数据流向重新组织成三层输入层数据准备→ 检索层向量构建与查询→ 生成层上下文注入与响应。每个文件职责明确改一处不影响全局这才是工程化RAG该有的样子。2.1 data_loader.py支持PDF/Markdown/Text的统一入口但PDF解析逻辑藏了玄机# data_loader.py from langchain.document_loaders import PyPDFLoader, UnstructuredMarkdownLoader, TextLoader from langchain.text_splitter import RecursiveCharacterTextSplitter def load_documents(file_path: str) - list: if file_path.endswith(.pdf): loader PyPDFLoader(file_path) # 关键点PDF加载后强制启用page_contentTrue避免元数据丢失 docs loader.load_and_split() # 补充处理对含表格的PDF额外调用tabula提取结构化内容需安装tabula-py # 实际项目中我们注释掉了这行因为tabula在中文PDF上常抽风改用pdfplumber做二次校验 elif file_path.endswith(.md): loader UnstructuredMarkdownLoader(file_path) docs loader.load() else: loader TextLoader(file_path, encodingutf-8) docs loader.load() # 统一分割RecursiveCharacterTextSplitter比CharacterTextSplitter更抗格式干扰 text_splitter RecursiveCharacterTextSplitter( chunk_size512, chunk_overlap64, separators[\n\n, \n, 。, , , , , ] ) return text_splitter.split_documents(docs)这段代码表面看是常规加载但三个细节决定成败PyPDFLoader.load_and_split()调用后必须检查docs[0].metadata是否含page字段缺失说明PDF是扫描件OCR未启用需提前拦截报错separators列表按中文标点优先级排序把\n\n放最前确保段落不被粗暴切开chunk_overlap64不是拍脑袋定的——实测512长度下64重叠能覆盖92%的跨chunk指代如“该方法”“上述步骤”再大则向量库冗余暴涨。提示UnstructuredMarkdownLoader依赖unstructured包安装时务必加[pdf,md]extras否则.md文件加载会静默失败只返回空列表。2.2 vector_store.pyChroma本地向量库初始化与持久化拒绝每次重算embedding# vector_store.py from langchain.vectorstores import Chroma from langchain.embeddings import OpenAIEmbeddings import os def init_vectorstore(documents: list, persist_dir: str ./chroma_db) - Chroma: # 关键参数embedding_function必须显式传入不能依赖Langchain默认 embedding OpenAIEmbeddings( modeltext-embedding-ada-002, # 注意不是text-embedding-3-small后者不兼容旧版Chroma openai_api_keyos.getenv(OPENAI_API_KEY), # 避坑不设chunk_size让embedding函数自己处理否则Langchain会二次切分 ) # persist_directory存在时Chroma自动加载不存在则新建 vectorstore Chroma.from_documents( documentsdocuments, embeddingembedding, persist_directorypersist_dir, # collection_name可选但建议固定避免同目录下多collection混用 ) vectorstore.persist() # 强制落盘防止进程退出丢数据 return vectorstore这里埋着两个易忽略的硬约束text-embedding-ada-002是当前Chroma 0.4.x系列唯一完全兼容的embedding模型换成新版text-embedding-3-small会导致score字段解析异常检索结果全乱vectorstore.persist()必须显式调用——Langchain文档说“自动持久化”实测在Jupyter里不调用就只存内存关内核即丢失。2.3 retriever.py不只是top_k而是带重排序的混合检索器# retriever.py from langchain.retrievers import ContextualCompressionRetriever from langchain.retrievers.document_compressors import LLMChainExtractor from langchain.llms import OpenAI def build_retriever(vectorstore: Chroma, llm: OpenAI) - ContextualCompressionRetriever: # 基础向量检索器 base_retriever vectorstore.as_retriever( search_typesimilarity, search_kwargs{k: 3} # 先取3个最相似chunk ) # LLM压缩器用LLM判断哪些chunk真相关过滤噪声 compressor LLMChainExtractor.from_llm(llm) # 组合成压缩检索器 compression_retriever ContextualCompressionRetriever( base_compressorcompressor, base_retrieverbase_retriever ) return compression_retriever别被ContextualCompressionRetriever名字唬住——它本质是“先向量查再LLM筛”。实测发现当search_kwargs{k: 5}时LLM压缩器耗时翻倍但准确率仅提升3%性价比极低LLMChainExtractor对中文支持弱遇到专业术语易误判我们后来替换成基于关键词的EmbeddingFilter代码包里filter.py有实现这个检索器必须和后续llm_chain用同一个LLM实例否则API key或温度值不一致会导致行为漂移。2.4 prompt_template.py把“请根据以下内容回答”写进prompt不如写清“不要编造、不确定就答不知道”# prompt_template.py from langchain.prompts import PromptTemplate RAG_PROMPT PromptTemplate( input_variables[context, question], template你是一个严谨的技术文档助手。请严格基于以下【参考内容】回答问题规则如下 1. 若【参考内容】中明确提及答案直接引用原文关键句不增删 2. 若【参考内容】未提及或信息不足必须回答“未在提供的资料中找到相关信息” 3. 禁止推测、联想、补充背景知识 4. 回答语言与问题语言一致问中文答中文问英文答英文。 【参考内容】 {context} 【问题】 {question} 【回答】 ) # 注意template末尾的【回答】是强提示词让LLM明确输出起始位置减少格式污染这个prompt设计反直觉没写“你很专业”反而强调“不准编造”。原因很实在——在某高校知识库项目里我们发现LLM对“不确定”类问题的幻觉率高达67%加了第2、3条约束后压到11%。【回答】这个标记更是关键测试中去掉它GPT-3.5-turbo有23%概率在答案前加“好的根据您提供的内容…”这类废话导致下游解析失败。2.5 llm_chain.py链式调用不是炫技而是让错误可定位# llm_chain.py from langchain.chains import RetrievalQA from langchain.llms import OpenAI def build_qa_chain(retriever, llm: OpenAI): # 关键return_source_documentsTrue否则debug时不知道答案从哪来 qa_chain RetrievalQA.from_chain_type( llmllm, chain_typestuff, # 最简模式适合单轮问答若需多轮换refine retrieverretriever, return_source_documentsTrue, # 必开这是RAG可解释性的命脉 chain_type_kwargs{ prompt: RAG_PROMPT, # 复用上面定义的prompt verbose: True # 开启后打印每步耗时定位瓶颈 } ) return qa_chainreturn_source_documentsTrue是生死线。没有它你永远不知道是检索错了source_documents为空还是LLM读歪了source_documents有内容但答案不符或者prompt没生效答案格式不对verboseTrue打印的日志里Loading prompt、Running retriever、Generating response三段耗时一目了然某次线上延迟高就是Running retriever卡了8秒顺藤摸瓜发现Chroma没建索引。2.6 main.py把所有模块串起来但留了三个可插拔接口# main.py import os from data_loader import load_documents from vector_store import init_vectorstore from retriever import build_retriever from llm_chain import build_qa_chain from langchain.llms import OpenAI if __name__ __main__: # 1. 加载文档支持单文件/文件夹 docs load_documents(./docs/manual.pdf) # 2. 构建向量库若已存在则跳过 vectorstore init_vectorstore(docs, persist_dir./chroma_db) # 3. 构建检索器可替换为自定义retriever llm OpenAI( temperature0, # RAG必须0有随机性就不可控 openai_api_keyos.getenv(OPENAI_API_KEY) ) retriever build_retriever(vectorstore, llm) # 4. 构建问答链 qa_chain build_qa_chain(retriever, llm) # 5. 执行查询此处可接入Flask/FastAPI result qa_chain({query: 系统默认超时时间是多少}) print(答案, result[result]) print(来源页码, [doc.metadata.get(page, N/A) for doc in result[source_documents]])这个main.py刻意没封装成class因为实际部署时load_documents可能换成数据库读取init_vectorstore可能换成FAISS或Weaviatebuild_retriever可能换成HyDE假设性文档嵌入增强版。留着函数接口比写死一个RAGSystem类灵活十倍。3. 避坑指南五个让RAG从“能跑”变成“敢上线”的血泪排查记录RAG链路长、环节多一个参数错、一个包版本不匹配就可能表现为“答案离谱”“响应超时”“根本没结果”。以下是我在三个真实项目中踩出的硬坑每一条都附带复现方式和验证命令。3.1 现象检索返回空列表result[source_documents]为[]原因Chroma向量库初始化时persist_directory路径权限不足或磁盘满导致写入失败但Chroma.from_documents()不抛异常静默降级为内存模式。下次运行时因无持久化数据检索自然为空。解决运行前检查路径可写ls -ld ./chroma_db df -h .初始化后立即验证python -c from langchain.vectorstores import Chroma; vChroma(persist_directory./chroma_db); print(len(v._collection.get()[ids]))输出应为文档chunk总数若为0则说明持久化失败。3.2 现象PDF加载后docs[0].page_content为空字符串但metadata有source字段原因PDF是扫描件图片型PDFPyPDFLoader无法OCR只能提取元数据。常见于用户上传的手机拍照PDF。解决加载后强制检查docs loader.load_and_split() if not docs[0].page_content.strip(): raise ValueError(fPDF {file_path} 可能为扫描件请先OCR处理)生产环境应前置OCR服务如Tesseract本代码包utils/ocr_pdf.py提供最小可行方案。3.3 现象qa_chain({query: ...})报错openai.error.RateLimitError但OPENAI_API_KEY正确且额度充足原因langchain0.1.0与openai1.0.0的异步请求冲突RetrievalQA内部并发调用embedding和LLM触发OpenAI的突发限流即使QPS3。解决降级OpenAI SDKpip install openai0.28.1Langchain 0.1.x 官方兼容版本或升级Langchainpip install langchain0.1.16已修复此问题但需同步检查Chroma版本是否匹配。3.4 现象中文问题回答中混入大量英文单词如“timeout设置为30s”而原文是“超时时间为30秒”原因OpenAIEmbeddings默认使用text-embedding-ada-002其训练数据以英文为主中文向量空间稀疏导致检索时中英文chunk距离计算失真LLM看到混杂的中英文上下文就“学舌”。解决替换embedding模型BgeEmbeddings需pip install langchain-community配置from langchain_community.embeddings import BgeEmbeddings embedding BgeEmbeddings( model_nameBAAI/bge-small-zh-v1.5, # 中文专用HuggingFace下载 encode_kwargs{normalize_embeddings: True} )或对中文文档预处理用jieba分词后拼接提升语义密度。3.5 现象RecursiveCharacterTextSplitter切分后同一段话被拆到两个chunk导致LLM无法理解指代关系如“该参数”找不到前文原因chunk_overlap设太小或separators未覆盖文档实际分隔符如某些技术文档用---分节。解决动态分析文档分隔特征import re with open(./docs/manual.md) as f: content f.read() # 统计高频分隔符 sep_stats { \n\n: len(re.findall(r\n\n, content)), ---: len(re.findall(r^---$, content, re.M)), ## : len(re.findall(r^## , content, re.M)) } print(sep_stats) # 根据输出调整separators顺序对关键指代词如“本节”“上述”“该”做后处理切分后扫描相邻chunk若前chunk末尾含指代词后chunk开头含被指代名词则合并。4. 参数调优实战用三组对照实验把RAG准确率从68%拉到91%参数不是靠猜而是靠测。我用同一份200页《某中间件运维手册》PDF在三个维度做了AB测试切分策略、检索数量、LLM温度。每组跑50个真实问题覆盖定义、步骤、参数、故障码四类人工标注答案是否正确。结果不是“哪个好”而是“什么场景用哪个”。4.1 文本切分chunk_size和chunk_overlap的黄金组合chunk_sizechunk_overlap准确率典型问题表现2563272%步骤类问题准确但定义类问题因上下文碎片化答不全5126491%平衡最佳92%的指代能跨chunk覆盖102412885%故障码类问题下降明显因chunk过大混入无关日志关键发现chunk_size512不是通用解。当文档含大量代码块时应降到384——因为代码行常超50字符512会强行截断函数名。代码包里data_loader.py第42行注释标明了此分支逻辑。4.2 检索数量search_kwargs{k: N}的边际效应k值平均响应时间准确率LLM幻觉率11.2s68%41%31.8s91%11%52.7s92%9%时间-效果曲线在k3处拐点最陡。k1时LLM常因上下文单薄而脑补k5虽准确率微升但LLM处理5段文本的token消耗剧增且第4、5个chunk常是噪声反拉低质量。生产环境一律锁死k3。4.3 LLM温度temperature0是底线但max_tokens要动态设temperature必须为0这点毫无商量余地。但max_tokens不能固定问“超时时间是多少” →max_tokens32足够问“列出所有配置项及默认值” →max_tokens512才不被截断。代码包中llm_chain.py的build_qa_chain函数已预留钩子def build_qa_chain(retriever, llm: OpenAI, max_tokens: int 256): # ... 其他代码 qa_chain RetrievalQA.from_chain_type( llmllm, chain_type_kwargs{ prompt: RAG_PROMPT, # 动态传入max_tokens llm_kwargs: {max_tokens: max_tokens} } ) return qa_chain调用时按问题类型分发# 问题分类规则简单正则可扩展为小模型 if re.search(r(多少|几|默认|范围|列表), question): max_toks 512 else: max_toks 128 qa_chain build_qa_chain(retriever, llm, max_toks)4.4 一个被低估的技巧用source_documents做答案置信度打分result[source_documents]不只是debug用还能量化答案可信度。我们给每个source_document打三档分A档强相关page_content含问题关键词 与问题语义相似度 0.85用sentence-transformers算B档弱相关含关键词但相似度 0.6~0.85C档噪声不含关键词或相似度 0.6。然后定义置信度def calc_confidence(source_docs: list) - float: scores [] for doc in source_docs: # 计算问题与doc.page_content的余弦相似度 sim cosine_similarity( embedder.encode([question]), embedder.encode([doc.page_content]) )[0][0] # 关键词命中加分 keyword_bonus 1.0 if any(kw in doc.page_content for kw in [超时, timeout]) else 0.0 scores.append(sim * 0.7 keyword_bonus * 0.3) return sum(scores) / len(scores) if scores else 0.0 confidence calc_confidence(result[source_documents]) if confidence 0.4: result[result] 答案置信度低建议核查原始文档上线后客服团队反馈“答案不可靠”投诉下降76%——因为系统主动说了“我不确定”而不是硬编一个错答案。5. 工程化落地从本地脚本到API服务的三步迁移与监控埋点把main.py跑通只是起点。真正交付给业务方得让它扛住并发、可监控、能回滚。我们用最轻量的方式完成迁移不引入K8s、不写Dockerfile就靠三个Python文件一行命令。5.1 第一步用FastAPI包装暴露/query接口但禁用Swagger UI# api_server.py from fastapi import FastAPI, HTTPException from pydantic import BaseModel import os from llm_chain import build_qa_chain from vector_store import Chroma, OpenAIEmbeddings from retriever import build_retriever from langchain.llms import OpenAI app FastAPI(docs_urlNone, redoc_urlNone) # 禁用文档界面防API key泄露 class QueryRequest(BaseModel): question: str top_k: int 3 # 允许客户端指定检索数量 # 全局单例启动时加载避免每次请求重建 vectorstore Chroma( persist_directory./chroma_db, embedding_functionOpenAIEmbeddings(openai_api_keyos.getenv(OPENAI_API_KEY)) ) llm OpenAI(temperature0, openai_api_keyos.getenv(OPENAI_API_KEY)) retriever build_retriever(vectorstore, llm) qa_chain build_qa_chain(retriever, llm) app.post(/query) async def handle_query(request: QueryRequest): try: # 关键把top_k透传给retriever实现动态控制 retriever.search_kwargs[k] request.top_k result qa_chain({query: request.question}) # 返回结构化JSON含答案、来源、置信度 return { answer: result[result], sources: [ { page: doc.metadata.get(page, N/A), content_preview: doc.page_content[:100] ... } for doc in result[source_documents] ], confidence: calc_confidence(result[source_documents]) # 复用上节函数 } except Exception as e: raise HTTPException(status_code500, detailfRAG执行失败: {str(e)})启动命令uvicorn api_server:app --host 0.0.0.0 --port 8000 --workers 2。--workers 2是关键——单worker在LLM调用时会阻塞2个worker可并行处理检索与生成。5.2 第二步加Prometheus监控盯住三个致命指标不用装Prometheus Server只用prometheus-client暴露指标端点# metrics.py from prometheus_client import Counter, Histogram, Gauge import time # 请求总量 QUERY_TOTAL Counter(rag_query_total, Total RAG queries) # 错误数 QUERY_ERROR Counter(rag_query_error_total, Total RAG query errors) # 响应时间单位秒 QUERY_DURATION Histogram(rag_query_duration_seconds, RAG query duration) # 当前活跃请求数 ACTIVE_REQUESTS Gauge(rag_active_requests, Active RAG requests) app.middleware(http) async def monitor_requests(request, call_next): QUERY_TOTAL.inc() ACTIVE_REQUESTS.inc() start_time time.time() try: response await call_next(request) return response except Exception as e: QUERY_ERROR.inc() raise e finally: duration time.time() - start_time QUERY_DURATION.observe(duration) ACTIVE_REQUESTS.dec()访问http://localhost:8000/metrics即可看到实时指标。我们设了告警规则rate(rag_query_error_total[5m]) 0.1→ 错误率超10%查LLM限流histogram_quantile(0.95, rate(rag_query_duration_seconds_bucket[5m])) 3→ 95分位响应超3秒查向量库性能。5.3 第三步日志结构化让每一次“翻车”都可追溯logging配置必须包含request_id否则并发时日志全乱# logging_config.py import logging import uuid from starlette.middleware.base import BaseHTTPMiddleware class RequestIdMiddleware(BaseHTTPMiddleware): async def dispatch(self, request, call_next): request_id str(uuid.uuid4()) # 注入到日志上下文 with logging.LoggerAdapter(logging.getLogger(), {request_id: request_id}): response await call_next(request) return response # 日志格式 LOGGING_CONFIG { version: 1, formatters: { default: { format: [%(asctime)s] %(levelname)s [%(name)s:%(lineno)s] [req_id:%(request_id)s] %(message)s, datefmt: %Y-%m-%d %H:%M:%S } }, # ... 其他配置 }当用户反馈“答案错了”运维只需查日志grep req_id:abc123 /var/log/rag/app.log # 输出含加载了哪些chunk、LLM输入prompt、最终输出、耗时从那以后我每次上线新版本RAG都强制走一遍这三步用curl -X POST http://localhost:8000/query测通基础接口用curl http://localhost:8000/metrics看指标是否上报用tail -f /var/log/rag/app.log发起一次请求确认request_id贯穿全程。少走一步线上就多一分不可知风险。希望帮到你。本文还有配套的精品资源点击获取