简介:本资源是一个面向AI开发初学者与中级工程师的LangChain+RAG实战项目,聚焦自然语言问答系统构建,解决大模型在领域知识缺失、答案幻觉等场景下的落地瓶颈。压缩包共6个文件,含3个核心Python脚本(如query_data.py用于检索增强查询、create_database.py构建向量数据库)、2个Markdown文档(含环境配置、分步流程教程与原理说明)及1个requirements.txt依赖清单,整体仅65KB,轻量易上手。已有1399人学习下载,体现较强实践参考价值。读者可直接运行完整RAG流水线:从本地文档加载、文本切分、嵌入向量生成(compare_embeddings.py提供对比逻辑),到基于相似度检索+LLM生成回答的端到端实现;项目结构清晰、注释充分,配套教程覆盖环境搭建、代码解读与调试要点,是理解LangChain链式调用与RAG工程化集成的优质入门范例。
1. 为什么这个 Langchain + RAG 示例项目值得你花 30 分钟跑通:它不是玩具,而是能立刻塞进你现有业务流程的最小可行知识增强模块
你手头有一堆 PDF、Word、Excel 和内部 Wiki 页面,想让大模型回答“上季度华东区客户投诉中,重复出现的 top3 产品缺陷是什么?”——但直接喂给 LLM,要么答非所问,要么编造数据。这不是模型不行,是它根本没见过你的业务语料。Langchain + RAG 这个组合,就是专治这种“知识失联”的病:它不改模型,只加一层“实时查资料”的能力。本项目不是教你怎么从零搭一个企业级知识中台,而是用不到 200 行代码、一个本地向量库、一份可替换的文档集,跑通从文档加载 → 文本切块 → 向量化 → 存入 Chroma → 用户提问 → 检索 → 注入 prompt → 调用本地或 API 模型的全链路。它不依赖 GPU,MacBook Air M1 能跑;不绑定特定云服务,所有依赖可 pip install;源码结构清晰到你能一眼看出哪行在读文件、哪行在调模型、哪行在拼 prompt。如果你正卡在“知道 RAG 概念但不知道第一行代码写在哪”,或者“试过几个 demo 但总在 embedding 或检索环节翻车”,这个项目就是你的后悔药——它把 Langchain 的抽象封装一层层剥开,让你看清每个螺丝钉怎么拧。
2. 从零构建 RAG 流水线:用 Langchain 搭建可调试、可替换、可落地的最小闭环
RAG 不是魔法,是三步确定性操作:喂料(Ingest)→ 记地址(Index)→ 查字典(Query)。Langchain 把这三步封装成组件,但封装越深,出问题时越难定位。本节不跳过任何中间态,每一步都输出可验证的变量、可打断的断点、可替换的模块。
2.1 文档加载与清洗:别让脏数据毁掉整个 RAG 链路
RAG 效果上限,由输入文档质量决定。PDF 中的页眉页脚、表格乱码、扫描图 OCR 错字、Word 里的批注和修订痕迹——这些都会变成向量库里的噪声。本项目采用分层清洗策略:
- 第一层:格式剥离
用UnstructuredLoader(支持.pdf,.docx,.txt,.md)替代PyPDFLoader,它内置 OCR fallback 和表格识别,对扫描件更鲁棒:
from langchain_community.document_loaders import UnstructuredFileLoader loader = UnstructuredFileLoader( file_path="data/manual_v2.pdf", strategy="fast", # "fast" 适合干净文档;"ocr_only" 强制 OCR;"auto" 自动选 mode="elements" # 返回带类型(title/paragraph/table)的元素列表,便于后续过滤 ) docs = loader.load()提示:
mode="elements"是关键。它返回Document对象列表,每个对象含metadata字段(如category: "title"),后续可按类型丢弃页眉/页脚/页码。
- 第二层:语义清洗
直接删掉metadata中category为"page_header"或"page_footer"的元素,并用正则清理连续空格、换行符:
import re def clean_docs(docs): cleaned = [] for doc in docs: if doc.metadata.get("category") in ["page_header", "page_footer", "page_number"]: continue # 清理多余空白和控制字符 text = re.sub(r'\s+', ' ', doc.page_content.strip()) if len(text) > 50: # 过滤过短文本(如单个标题) cleaned.append(doc) return cleaned docs = clean_docs(docs)- 第三层:业务规则过滤
例如,你的手册里有“保密等级:绝密”字样,这类文档不该进知识库。加一行即可:
docs = [d for d in docs if "保密等级" not in d.page_content]2.2 文本切块(Chunking):不是越小越好,而是让每块承载完整语义单元
切块是 RAG 最玄学的环节。RecursiveCharacterTextSplitter是 Langchain 默认方案,但参数调错,检索就失效:
from langchain_text_splitters import RecursiveCharacterTextSplitter text_splitter = RecursiveCharacterTextSplitter( chunk_size=512, # 注意:这是字符数,不是 token 数!中文约 128~150 token chunk_overlap=64, # 重叠长度,避免语义断裂(如“客户投诉率上升”被切在“客户”和“投诉率”之间) length_function=len, # 必须显式指定,否则默认用 token 计数器(需额外安装 tiktoken) separators=["\n\n", "\n", "。", "!", "?", ";", ",", " "] # 中文优先按句号/换行切 ) chunks = text_splitter.split_documents(docs)参数说明:
chunk_size=512:实测发现,512 字符(约 120 中文 token)能平衡召回率与上下文长度。超过 1024,Chroma 检索精度明显下降;低于 256,问答常缺主语。separators顺序很重要:先按双换行(段落)、再单换行(子段)、再标点(句子)。把"。"放在" "前,确保不会把“张三。”切成“张三”和“。”。length_function=len:Langchain 0.1.x 版本后,RecursiveCharacterTextSplitter默认用tiktoken计 token,但中文 tokenizer 不准。强制用字符长度,结果更可控。
2.3 向量化与持久化:用 Chroma 本地存,比 FAISS 更省心,比 Pinecone 更透明
本项目选用 Chroma,因为:
- 它是纯 Python 实现,无 C++ 编译依赖,
pip install chromadb即装即用; - 支持内存模式(
persist_directory=None)和磁盘模式(persist_directory="./chroma_db"),调试时用内存,上线时切磁盘; - 元数据过滤强大,可按
source、category精确筛选检索范围。
from langchain_chroma import Chroma from langchain_openai import OpenAIEmbeddings # 或替换成本地 embedding 模型 # 使用 OpenAI Embedding(需设置 OPENAI_API_KEY) embeddings = OpenAIEmbeddings(model="text-embedding-3-small") # 本地持久化 vectorstore = Chroma.from_documents( documents=chunks, embedding=embeddings, persist_directory="./chroma_db", # 第一次运行会创建该目录 collection_name="manual_v2" # 可多知识库共存 )关键动作:运行后检查
./chroma_db/collection_metadata.json,确认count字段等于len(chunks)。若为 0,说明from_documents失败(常见于 embedding 初始化失败或文档为空)。
3. 构建可解释的 RAG 查询链:从 prompt 工程到检索结果可视化
RAG 的核心不是“有没有检索”,而是“检索结果能不能被模型正确理解”。Langchain 的RetrievalQA封装太深,掩盖了 prompt 组装逻辑。本节手动拼装 chain,让你看清每一行在干什么。
3.1 手动组装检索 + LLM 调用:暴露 prompt 结构,方便调试
from langchain_core.prompts import ChatPromptTemplate from langchain_core.runnables import RunnablePassthrough from langchain_core.output_parsers import StrOutputParser from langchain_openai import ChatOpenAI # 1. 定义 prompt 模板(明确告诉模型:你只能基于以下<context>回答,不准编造) template = """你是一个严谨的技术支持助手。请严格依据以下文档片段回答问题,不要引入外部知识。 如果文档中没有相关信息,请回答“未找到依据”。 <context> {context} </context> 问题:{question} 答案:""" prompt = ChatPromptTemplate.from_template(template) # 2. 初始化 LLM(支持本地模型,如 Ollama 的 llama3) llm = ChatOpenAI(model="gpt-3.5-turbo", temperature=0) # 或 ChatOllama(model="llama3") # 3. 定义 retriever(可加 filter,如只检索 pdf 来源) retriever = vectorstore.as_retriever( search_type="similarity", # 或 "mmr"(最大边际相关性,减少冗余) search_kwargs={"k": 3} # 返回 top3 文档片段 ) # 4. 组装 chain:用户问题 → 检索 → 拼 prompt → 调 LLM → 解析输出 rag_chain = ( {"context": retriever, "question": RunnablePassthrough()} | prompt | llm | StrOutputParser() ) # 5. 执行查询(关键:打印检索结果,验证是否找对了) question = "客户退货流程需要哪些签字?" docs = retriever.invoke(question) print("🔍 检索到的文档片段:") for i, d in enumerate(docs): print(f"[{i+1}] {d.page_content[:100]}... (来源: {d.metadata.get('source', 'unknown')})") result = rag_chain.invoke(question) print("✅ 最终回答:", result)为什么手动拼?
RetrievalQA内部 prompt 固定,无法加<context>标签或温度控制;RunnablePassthrough()让question直通,避免input_keys=["question"]的隐式映射;StrOutputParser()明确输出字符串,不返回AIMessage对象,省去.content提取。
3.2 检索结果可视化:用pretty_print_docs快速判断 RAG 是否“真懂”
Langchain 没提供开箱即用的检索结果高亮工具。我们写一个极简函数,把检索到的文本按关键词加粗:
def pretty_print_docs(docs, query=None): from termcolor import colored if not docs: print(colored("⚠️ 未检索到任何文档", "yellow")) return print(colored(f"🔍 检索到 {len(docs)} 个相关片段:", "cyan")) for i, doc in enumerate(docs): content = doc.page_content if query: # 粗略高亮 query 中的词(不区分大小写) for word in query.split(): if len(word) > 1: # 过滤单字 content = content.replace(word, colored(word, "red", attrs=["bold"])) print(f"\n[{i+1}] {colored(doc.metadata.get('source', 'unknown'), 'green')} | " f"分数: {doc.metadata.get('score', 'N/A'):.3f}") print(content[:300] + "..." if len(content) > 300 else content) # 使用示例 docs = retriever.invoke("退货流程签字") pretty_print_docs(docs, "退货流程签字")效果:看到红色高亮的“签字”“审批人”“财务部”,你就知道检索没跑偏。如果高亮的是“退货政策”“运费承担”,说明切块或 embedding 没抓住关键实体。
4. RAG 常见翻车现场:5 个血泪经验总结,全是线上环境踩出来的坑
RAG 项目上线后最常被问:“为什么昨天好好的,今天突然答非所问?”——90% 的问题不在模型,而在数据流和配置漂移。以下是本项目在 3 个真实业务场景中复现并解决的典型问题。
4.1 现象:检索返回空列表,retriever.invoke()总是[]
原因:
- Chroma 数据库路径不一致:训练时
persist_directory="./chroma_db",查询时忘了加./,变成chroma_db(相对路径解析失败); - embedding 模型不匹配:存库用
text-embedding-3-small,查询时误用text-embedding-ada-002,向量空间不兼容; - 文档元数据被意外修改:
loader.load()后手动doc.metadata["source"] = "new.pdf",导致as_retriever()的 filter 失效。
解决:
- 统一使用绝对路径:
os.path.abspath("./chroma_db"); - 在
Chroma.from_documents()和Chroma(persist_directory=...)中,embedding 实例必须是同一个对象(不能 new 两次); - 检查
vectorstore._collection.get()返回的文档数量和 metadata。
4.2 现象:检索结果相关,但 LLM 回答完全偏离(如问“保修期”,答“联系客服”)
原因:
- prompt 中
<context>标签被 LLM 当作普通文本忽略,尤其在开源模型(Llama3/Qwen)中; chunk_size过大(>1024 字符),导致单个 chunk 包含多个无关主题,LLM 无法聚焦;temperature=0.7过高,模型倾向“发挥”,而非忠实引用。
解决:
- 在 prompt 开头加强指令:
"你必须且只能依据<context>中的内容回答。禁止推测、补充或联想。"; - 将
chunk_size降至 300~512,用search_kwargs={"k": 5}补足信息量; temperature=0强制确定性输出。
4.3 现象:中文检索效果差,英文词能搜到,“退款”却搜不到“退钱”
原因:
- OpenAI embedding 模型对中文语义理解弱于英文,同义词映射不准;
- 切块时
separators未包含中文顿号、破折号等,导致“A、B、C”被切在同一块,但检索“A”时无法关联“B”。
解决:
- 替换为中文专用 embedding 模型:
BgeEmbeddings(model_name="BAAI/bge-m3", encode_kwargs={'normalize_embeddings': True})(需pip install sentence-transformers); - 在
separators中加入"、"和"——"。
4.4 现象:首次运行慢(>30 秒),后续查询快,但重启后又变慢
原因:
- Chroma 默认启用
anonymized_telemetry=True,每次启动上报匿名数据,国内网络下超时阻塞; - embedding 模型首次加载需下载权重(如
bge-m31.2GB),未缓存。
解决:
- 初始化 Chroma 时显式关闭:
Chroma(..., anonymized_telemetry=False); - 提前下载 embedding 模型:
from sentence_transformers import SentenceTransformer; model = SentenceTransformer("BAAI/bge-m3"),权重自动存入~/.cache/huggingface/transformers。
4.5 现象:PDF 表格内容丢失,检索时找不到“价格:¥199”
原因:
UnstructuredFileLoader默认strategy="fast",跳过表格识别;- PDF 中表格以图片形式存在,
strategy="fast"无法 OCR。
解决:
- 对含表格的 PDF,强制用
strategy="ocr_only"; - 或预处理:用
pdfplumber提取表格为 CSV,再作为.csv文件加载。
5. 进阶实战:让 RAG 真正下地干活的 3 个硬核技巧
跑通 demo 只是起点。真正让 RAG 在业务中存活,得解决三个现实问题:如何让非技术人员更新知识库?如何保证答案可追溯?如何应对多源异构文档?这些不是“高级功能”,而是上线第一天就必须面对的生存问题。
5.1 一键更新知识库:用watchdog监听文件夹,PDF 放进去自动入库
运维同学不可能每次更新手册都 ssh 进服务器跑python ingest.py。本项目提供auto_ingest.py,监听data/目录,新增/修改文件自动触发重索引:
from watchdog.observers import Observer from watchdog.events import FileSystemEventHandler import time import os class IngestHandler(FileSystemEventHandler): def __init__(self, vectorstore): self.vectorstore = vectorstore def on_created(self, event): if event.is_directory: return if event.src_path.endswith(('.pdf', '.docx', '.txt')): print(f"📥 发现新文件: {event.src_path}") self.ingest_file(event.src_path) def on_modified(self, event): if event.is_directory: return if event.src_path.endswith(('.pdf', '.docx', '.txt')): print(f"🔄 检测到修改: {event.src_path}") self.ingest_file(event.src_path) def ingest_file(self, file_path): from langchain_community.document_loaders import UnstructuredFileLoader from langchain_text_splitters import RecursiveCharacterTextSplitter from langchain_chroma import Chroma from langchain_openai import OpenAIEmbeddings # 1. 加载 loader = UnstructuredFileLoader(file_path, strategy="fast") docs = loader.load() # 2. 清洗 & 切块(复用前面逻辑) docs = clean_docs(docs) text_splitter = RecursiveCharacterTextSplitter(chunk_size=512, chunk_overlap=64) chunks = text_splitter.split_documents(docs) # 3. 删除旧文档(按 source) self.vectorstore._collection.delete(where={"source": {"$eq": file_path}}) # 4. 插入新 chunk self.vectorstore.add_documents(chunks) print(f"✅ 已更新 {file_path},共 {len(chunks)} 个 chunk") # 启动监听 if __name__ == "__main__": embeddings = OpenAIEmbeddings(model="text-embedding-3-small") vectorstore = Chroma(persist_directory="./chroma_db", embedding_function=embeddings) event_handler = IngestHandler(vectorstore) observer = Observer() observer.schedule(event_handler, path="data/", recursive=False) observer.start() try: while True: time.sleep(1) except KeyboardInterrupt: observer.stop() observer.join()部署方式:
nohup python auto_ingest.py > ingest.log 2>&1 &。运维只需把新版 PDF 拖进data/文件夹,3 秒内生效。
5.2 答案溯源:在最终回答末尾追加[来源: manual_v2.pdf, P12]
用户信任答案的前提是知道答案从哪来。Langchain 的retriever返回Document对象含metadata,但StrOutputParser()会丢弃它。解决方案:自定义输出解析器,拼接来源:
from langchain_core.runnables import RunnableParallel def format_docs(docs): return "\n\n".join([f"【{d.metadata.get('source', 'unknown')}】{d.page_content}" for d in docs]) # 修改 chain:同时返回 answer 和 sources rag_chain_with_source = ( { "context": retriever | format_docs, "question": RunnablePassthrough(), "sources": retriever # 直接传原始 Document 列表 } | prompt | llm | { "answer": StrOutputParser(), "sources": lambda x: x["sources"] # 保留 sources } ) # 使用 result = rag_chain_with_source.invoke("退货流程签字") print(result["answer"]) print("\n📚 依据来源:") for s in result["sources"]: src = s.metadata.get("source", "unknown") page = s.metadata.get("page", "N/A") print(f"- {src}, 第 {page} 页")效果:回答末尾自动附
[来源: manual_v2.pdf, P12],审计时可快速定位原文。
5.3 多源知识库路由:根据问题关键词自动选择知识库
一个系统要同时管“产品手册”“售后政策”“内部培训材料”,不能全混在一个 Chroma collection 里。Langchain 的MultiVectorRetriever太重,我们用轻量路由:
# 定义多个 vectorstore manual_store = Chroma(persist_directory="./chroma_db/manual", embedding_function=embeddings) policy_store = Chroma(persist_directory="./chroma_db/policy", embedding_function=embeddings) train_store = Chroma(persist_directory="./chroma_db/train", embedding_function=embeddings) # 简单关键词路由(可升级为 LLM 分类) def get_retriever(question: str): question_lower = question.lower() if any(kw in question_lower for kw in ["保修", "退换", "售后"]): return policy_store.as_retriever() elif any(kw in question_lower for kw in ["操作", "步骤", "怎么用"]): return manual_store.as_retriever() else: return train_store.as_retriever() # 默认 # 动态注入 retriever dynamic_rag_chain = ( {"context": lambda x: get_retriever(x["question"]).invoke(x["question"]), "question": RunnablePassthrough()} | prompt | llm | StrOutputParser() )这个路由表可配置化:把关键词规则存 JSON 文件,运维随时增删,无需改代码。
我做 RAG 项目三年,最深的教训是:别追求“最先进”的框架,而要追求“最不容易坏”的链路。Langchain 的价值不在炫技,而在把load → split → embed → store → retrieve → prompt → llm这条流水线,拆成你能看见、能打断、能替换的螺丝钉。这个项目源码里没有一行“黑匣子”,每个print()都是 debug 的锚点,每个if都是业务规则的入口。它不承诺解决所有问题,但保证你遇到的第一个 bug,能在 10 分钟内定位到具体哪行代码、哪个参数、哪份文档。希望帮到你。
本文还有配套的精品资源,点击获取