沐沐 1 mês atrás
pai
commit
8b7a233354
44 arquivos alterados com 7100 adições e 0 exclusões
  1. 24 0
      .env.example
  2. 48 0
      .gitignore
  3. 100 0
      01_langchain_study/mysql_data.py
  4. BIN
      01_langchain_study/salary_chart.png
  5. 693 0
      02_RAG_study/3.RAG系统搭建实战.md
  6. 1692 0
      02_RAG_study/4.RAG全链路优化:多路召回、查询测优化、Reranker.md
  7. 105 0
      02_RAG_study/CONFIG.md
  8. 138 0
      02_RAG_study/HOW_TO_VIEW.md
  9. 212 0
      02_RAG_study/README_RAG.md
  10. BIN
      02_RAG_study/car_info.pdf
  11. BIN
      02_RAG_study/car_info_knowledge_db/chroma.sqlite3
  12. 37 0
      02_RAG_study/check_pdf_images.py
  13. BIN
      02_RAG_study/chroma_db1/7e155055-ebc9-482e-9303-28c2d2cacbf4/data_level0.bin
  14. BIN
      02_RAG_study/chroma_db1/7e155055-ebc9-482e-9303-28c2d2cacbf4/header.bin
  15. BIN
      02_RAG_study/chroma_db1/7e155055-ebc9-482e-9303-28c2d2cacbf4/length.bin
  16. 0 0
      02_RAG_study/chroma_db1/7e155055-ebc9-482e-9303-28c2d2cacbf4/link_lists.bin
  17. BIN
      02_RAG_study/chroma_db1/chroma.sqlite3
  18. BIN
      02_RAG_study/chroma_db1_db/chroma.sqlite3
  19. BIN
      02_RAG_study/investment_knowledge_db/54bfc2fc-ec6e-45fe-a99d-773918461412/data_level0.bin
  20. BIN
      02_RAG_study/investment_knowledge_db/54bfc2fc-ec6e-45fe-a99d-773918461412/header.bin
  21. BIN
      02_RAG_study/investment_knowledge_db/54bfc2fc-ec6e-45fe-a99d-773918461412/length.bin
  22. 0 0
      02_RAG_study/investment_knowledge_db/54bfc2fc-ec6e-45fe-a99d-773918461412/link_lists.bin
  23. BIN
      02_RAG_study/investment_knowledge_db/chroma.sqlite3
  24. BIN
      02_RAG_study/my_knowledge_db/bf091c50-d41b-46eb-ae65-0db9d6c8b768/data_level0.bin
  25. BIN
      02_RAG_study/my_knowledge_db/bf091c50-d41b-46eb-ae65-0db9d6c8b768/header.bin
  26. BIN
      02_RAG_study/my_knowledge_db/bf091c50-d41b-46eb-ae65-0db9d6c8b768/index_metadata.pickle
  27. BIN
      02_RAG_study/my_knowledge_db/bf091c50-d41b-46eb-ae65-0db9d6c8b768/length.bin
  28. BIN
      02_RAG_study/my_knowledge_db/bf091c50-d41b-46eb-ae65-0db9d6c8b768/link_lists.bin
  29. BIN
      02_RAG_study/my_knowledge_db/chroma.sqlite3
  30. 188 0
      02_RAG_study/quick_start.py
  31. 840 0
      02_RAG_study/rag_test.ipynb
  32. 25 0
      02_RAG_study/requirements.txt
  33. 111 0
      02_RAG_study/show_chunks.py
  34. 73 0
      02_RAG_study/simple_view.py
  35. 27 0
      02_RAG_study/test_pdf.py
  36. 251 0
      02_RAG_study/test_rag.py
  37. 240 0
      02_RAG_study/view_chunks.py
  38. 1396 0
      03_LangGraph_study/3.+LangGraph实战:Middleware.md
  39. 110 0
      03_LangGraph_study/LangGraph.py
  40. 476 0
      03_LangGraph_study/langGraph.ipynb
  41. 177 0
      README.md
  42. BIN
      car_info.pdf
  43. 65 0
      docker-compose.yml
  44. 72 0
      test.ipynb

+ 24 - 0
.env.example

@@ -0,0 +1,24 @@
+# 数据库配置
+# 请将以下配置复制到.env文件中,并根据实际情况修改
+DATABASE_URI=mysql+pymysql://用户名:密码@主机名:端口号/数据库名
+
+# 注意:密码中的特殊字符需要进行URL编码
+# 例如:@ 需要编码为 %40
+# 示例:password@123 -> password%40123
+
+# ============================================================
+# LLM API 配置(支持 OpenAI 和 DeepSeek)
+# ============================================================
+
+# 方式 1: 使用 OpenAI API
+# OPENAI_API_KEY=your_openai_api_key_here
+# MODEL_NAME=gpt-4o-mini
+
+# 方式 2: 使用 DeepSeek API(推荐用于国内环境)
+# OPENAI_API_KEY=your_deepseek_api_key_here
+# OPENAI_API_BASE=https://api.deepseek.com/v1
+# MODEL_NAME=deepseek-chat
+
+# DeepSeek 模型选项:
+# - deepseek-chat: 通用对话模型
+# - deepseek-coder: 代码专用模型(推荐用于数据分析场景)

+ 48 - 0
.gitignore

@@ -0,0 +1,48 @@
+# Python
+__pycache__/
+*.py[cod]
+*$py.class
+*.so
+.Python
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+wheels/
+*.egg-info/
+.installed.cfg
+*.egg
+MANIFEST
+
+# Virtual Environment
+venv/
+env/
+ENV/
+env.bak/
+venv.bak/
+
+# IDE
+.vscode/
+.idea/
+*.swp
+*.swo
+*~
+
+# Environment Variables
+.env
+.env.local
+.env.*.local
+
+# Logs
+*.log
+
+# OS
+.DS_Store
+Thumbs.db

+ 100 - 0
01_langchain_study/mysql_data.py

@@ -0,0 +1,100 @@
+import os
+import mysql.connector
+
+# ============================================================
+# 第一步:建立数据库连接
+# ============================================================
+# 使用环境变量管理敏感信息,这是生产级代码的基本规范
+conn = mysql.connector.connect(
+    host=os.getenv("DB_HOST", "127.0.0.1"),
+    port=int(os.getenv("DB_PORT", 3306)),
+    user=os.getenv("DB_USER", "root"),
+    password=os.getenv("DB_PASSWORD", "123456"),
+    database=os.getenv("DB_NAME", "agent_db"),
+)
+cursor = conn.cursor()
+
+# ============================================================
+# 第二步:创建表结构
+# ============================================================
+
+# 员工表:存储公司内部人员信息
+# 用于分析:部门人数分布、薪资结构、入职趋势等
+cursor.execute("""
+CREATE TABLE IF NOT EXISTS employees (
+    id          INT PRIMARY KEY AUTO_INCREMENT,  -- 自增主键
+    name        VARCHAR(50)  NOT NULL,           -- 姓名
+    department  VARCHAR(50)  NOT NULL,           -- 所属部门
+    salary      DECIMAL(10,2) NOT NULL,          -- 月薪(精确到分)
+    hire_date   DATE                             -- 入职日期
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+""")
+
+# 产品表:存储在售商品信息
+# 用于分析:品类销售、价格分布、库存预警等
+cursor.execute("""
+CREATE TABLE IF NOT EXISTS products (
+    id           INT PRIMARY KEY AUTO_INCREMENT,  -- 自增主键
+    product_name VARCHAR(100) NOT NULL,           -- 商品名称
+    category     VARCHAR(50)  NOT NULL,           -- 商品分类
+    price        DECIMAL(10,2) NOT NULL,          -- 单价
+    stock        INT DEFAULT 0                    -- 当前库存量
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+""")
+
+# 订单表:记录每笔交易
+# 用于分析:销售趋势、员工绩效、产品热度等
+cursor.execute("""
+CREATE TABLE IF NOT EXISTS orders (
+    id           INT PRIMARY KEY AUTO_INCREMENT,  -- 自增主键
+    employee_id  INT NOT NULL,                    -- 下单员工(外键)
+    product_id   INT NOT NULL,                    -- 购买商品(外键)
+    quantity     INT NOT NULL,                    -- 购买数量
+    order_date   DATE NOT NULL,                   -- 下单日期
+    FOREIGN KEY (employee_id) REFERENCES employees(id),   -- 关联员工表
+    FOREIGN KEY (product_id)  REFERENCES products(id)     -- 关联产品表
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+""")
+
+# ============================================================
+# 第三步:插入演示数据
+# ============================================================
+
+# 员工数据:5 个员工,分属 3 个部门
+employees_data = [
+    (1, "张三", "技术部",   20000.00, "2023-01-15"),
+    (2, "李四", "销售部",   11000.00, "2023-02-20"),
+    (3, "王五", "技术部",   16000.00, "2022-11-10"),
+    (4, "赵六", "人力资源", 5000.00, "2023-03-01"),
+    (5, "钱七", "销售部",   17000.00, "2022-12-05"),
+]
+
+# 产品数据:4 款商品,覆盖 2 个品类
+products_data = [
+    (1, "笔记本电脑", "电子产品", 6999.00, 500),
+    (2, "机械键盘",   "电子产品", 399.00,  1000),
+    (3, "办公椅",     "办公用品", 499.00,  300),
+    (4, "显示器",     "电子产品", 1200.00, 400),
+]
+
+# 订单数据:5 笔订单,模拟真实购买行为
+orders_data = [
+    (1, 1, 1, 2, "2024-01-15"),  # 张三买了 2 台笔记本
+    (2, 2, 2, 15, "2024-01-16"),  # 李四买了 15 个键盘
+    (3, 3, 1, 10, "2024-01-17"),  # 王五买了 10 台笔记本
+    (4, 5, 3, 6, "2024-01-18"),  # 钱七买了 6 把办公椅
+    (5, 2, 4, 5, "2024-01-19"),  # 李四买了 5 台显示器
+]
+
+# executemany 批量插入,比逐条 insert 高效得多
+cursor.executemany("INSERT IGNORE INTO employees VALUES (%s,%s,%s,%s,%s)", employees_data)
+cursor.executemany("INSERT IGNORE INTO products  VALUES (%s,%s,%s,%s,%s)", products_data)
+cursor.executemany("INSERT IGNORE INTO orders     VALUES (%s,%s,%s,%s,%s)", orders_data)
+
+conn.commit()
+conn.close()
+
+print("✅ 数据库初始化完成")
+print("   - employees 表:5 条员工记录")
+print("   - products  表:4 条产品记录")
+print("   - orders    表:5 条订单记录")

BIN
01_langchain_study/salary_chart.png


+ 693 - 0
02_RAG_study/3.RAG系统搭建实战.md

@@ -0,0 +1,693 @@
+# RAG 系统搭建实战
+> 从「让 AI 背书」到「让 AI 翻书」—— 给大模型接上私有知识库的完整方案
+>
+
+---
+
+## 一、RAG 整体架构
+RAG 分两个阶段:**索引阶段**(离线)和 **查询阶段**(在线)。
+
+<!-- 这是一张图片,ocr 内容为: -->
+![](https://cdn.nlark.com/yuque/0/2026/png/21571931/1782354823459-c9205203-c45a-40f6-92c6-d91882708de4.png)
+
++ **索引阶段**:跑一次就行,把文档处理好存进向量库
++ **查询阶段**:每次提问都跑,检索 → 拼 Prompt → 生成回答
+
+**关键认知**:检索质量的上限,取决于索引阶段的质量。切分策略、嵌入模型、向量库选型,每一个环节都影响最终效果。其中,**<font style="color:#DF2A3F;">分块策略决定了 RAG 质量的 70%</font>**<font style="color:#DF2A3F;">。</font>
+
+---
+
+## 二、文档加载:把知识「搬进来」
+### 2.1 文档加载器选型
+LangChain 提供了 100 多种文档加载器,基本覆盖了你能想到的所有格式。常见的:
+
+| 格式 | 推荐加载器 | 特点 |
+| :--- | :--- | :--- |
+| PDF | `PyMuPDFLoader` | 速度最快,支持元数据 |
+| Word | `Docx2txtLoader` | 轻量,无需额外依赖 |
+| Markdown | `UnstructuredMarkdownLoader` | 保留标题结构 |
+| 网页 | `WebBaseLoader` | 直接抓 URL |
+| CSV | `CSVLoader` | 表格数据友好 |
+
+
+### 2.2 实战:加载 PDF 文件
+```python
+# 安装依赖:uv pip install pymupdf -i https://pypi.tuna.tsinghua.edu.cn/simple
+from langchain_community.document_loaders import PyMuPDFLoader
+
+# 创建加载器实例,传入 PDF 文件路径
+pdf_loader = PyMuPDFLoader("./car_info.pdf")
+
+# 调用 load() 方法,返回一个 Document 列表(每页一个 Document)
+pdf_pages = pdf_loader.load()
+
+# 看看加载结果
+print(f"文档类型:{type(pdf_pages)}")
+print(f"PDF 共 {len(pdf_pages)} 页")
+```
+
+每个 `Document` 对象有两个核心属性:
+
++ `page_content`:该页的文本内容
++ `metadata`:描述性数据(页码、文件名等)
+
+```python
+# 查看第一页的内容和元数据
+first_page = pdf_pages[0]
+print(f"元数据:{first_page.metadata}")
+print(f"内容预览:{first_page.page_content[:200]}")
+```
+
+---
+
+## 三、数据清洗:给知识「去噪」
+原始数据往往很脏。PDF 解析出来的文本,经常有莫名其妙的换行符、特殊符号、多余空格。不清洗就直接用,检索效果会大打折扣。
+
+### 3.1 常见问题与清洗策略
+```python
+import re
+
+# ---- 问题1:PDF 解析产生的多余换行符 ----
+# 现象:一句话被拆成多行,中间插了 \n
+# 例如:"领克汽车\n车顶行李架\n最大载荷"
+# 解决:用正则匹配并删除非中文字符之间的换行符
+raw_text = "领克汽车\n车顶行李架\n最大载荷。"
+pattern = re.compile(r'[^一](\n)[^一]', re.DOTALL)
+clean_text = re.sub(pattern, lambda m: m.group(0).replace('\n', ''), raw_text)
+
+# ---- 问题2:特殊符号干扰 ----
+# 现象:PDF 中的项目符号 • 、多余空格等
+clean_text = clean_text.replace('•', '')
+clean_text = clean_text.replace('  ', ' ')  # 合并多余空格
+
+# ---- 问题3:页眉页脚噪声 ----
+# 现象:每页都有 "第X页"、"公司名称" 等重复内容
+# 解决:根据元数据中的页码信息,过滤掉固定位置的噪声文本
+```
+
+### 3.2 清洗函数封装
+把清洗逻辑封装成函数,方便复用:
+
+```python
+def clean_pdf_text(text: str) -> str:
+    """清洗 PDF 解析出的文本,去除常见噪声"""
+    import re
+    
+    # 删除非中文字符之间的换行符
+    text = re.sub(r'[^一](\n)[^一]', 
+                  lambda m: m.group(0).replace('\n', ''), text)
+    
+    # 删除项目符号和多余空格
+    text = text.replace('•', '').replace('  ', ' ')
+    
+    # 删除连续的换行符(保留一个)
+    text = re.sub(r'\n{2,}', '\n', text)
+    
+    return text.strip()
+```
+
+---
+
+## 四、文本分块:RAG 质量的命门
+**<font style="color:#DF2A3F;">分块做不好,后面的一切都是白搭。</font>**
+
+### 4.1 为什么要分块?
+单个文档的长度往往超过模型的上下文窗口。就算没超,把整本书塞进 Prompt 也不是个好主意 —— 模型会找不到关键信息。
+
+分块的逻辑:把长文档切成小段,每段作为一个检索单元。检索时返回 Top-K 个最相关的段落,拼进 Prompt 让模型回答。
+
+<!-- 这是一张图片,ocr 内容为: -->
+![](https://cdn.nlark.com/yuque/0/2026/png/21571931/1782354942369-5b650888-bb1c-420d-9f61-59e3a79e750d.png)
+
+### 4.2 两个核心参数
+不管用哪种分块器,都要理解这两个参数:
+
++ `chunk_size`:每个块包含的最大字符数(或 Token 数)
++ `chunk_overlap`:相邻块之间重叠的字符数,用于保持上下文连贯
+
+```plain
+chunk_overlap 的作用示意:
+
+块1: [AAAA BBBB CCCC DDDD]
+块2:         [CCCC DDDD EEEE FFFF]
+                 ↑↑↑↑↑↑↑↑
+              重叠区域,防止语义断裂
+```
+
+**调参经验**:
+
++ `chunk_size` 一般设 500-1000 字符(中文场景)
++ `chunk_overlap` 设为 `chunk_size` 的 10%-20%
++ `chunk_overlap=0` 是新手最常犯的错 —— 一个完整句子被切成两半,检索时两半都不完整
+
+### 4.3 策略一:固定字符分块(最简单)
+最直觉的方法:不管内容,按固定字符数硬切。
+
+<!-- 这是一张图片,ocr 内容为: -->
+![](https://cdn.nlark.com/yuque/0/2026/png/21571931/1782439888231-c79a2358-4773-4706-901f-63c928d8711c.png)
+
+```python
+#安装依赖:uv pip install langchain-text-splitters -i https://mirrors.aliyun.com/pypi/simple
+# 手动实现固定字符分块
+from langchain_text_splitters import CharacterTextSplitter
+
+# 1. 定义分块器
+text_splitter = CharacterTextSplitter(
+    separator="\n\n",      # 指定分隔符,默认为 "\n\n"
+    chunk_size=500,        # 每个块的最大字符数
+    chunk_overlap=100,      # 块与块之间的重叠字符数,建议设置以保持上下文连贯[citation:4]
+    length_function=len,   # 计算长度的方法,默认按字符数
+    is_separator_regex=False # 是否将分隔符视为正则表达式
+)
+
+# 2. 执行分块,返回字符串列表
+text = """
+LangChain 是一个让你的 LLM 变得更强大的开源框架。
+你想开发一个基于 LLM 的应用,需要什么组件它都有,直接使用就行。
+甚至针对常规的应用流程,它利用 Chain 这个概念已经内置标准化方案了。
+下面我们从新兴的大语言模型技术栈的角度来看看为何它的理念这么受欢迎。
+"""
+chunks = text_splitter.split_text(text)
+for i, chunk in enumerate(chunks):
+    print(f"--- 块{i+1} ---\n{chunk}\n")
+```
+
+**优点**:实现简单,速度最快  
+**缺点**:粗暴,容易把句子切断,语义碎片化严重  
+**适用场景**:快速原型验证、格式非常规整的文本
+
+### 4.4 策略二:递归字符分块(推荐首选)
+这是 LangChain 的「默认推荐」,也是大多数场景下的最佳起点。
+
+核心逻辑:**按优先级分隔符递归尝试,优先保持语义完整,实在不行再硬截断**。
+
+流程像「剥洋葱」一样层层递进:
+
+```plain
+第一步:按 \n\n(段落)拆分
+  ↓ 如果某段超长
+第二步:按 。(句号)拆分
+  ↓ 如果某句超长
+第三步:按 ,(逗号)拆分
+  ↓ 如果还是超长
+第四步:按空格拆分,实在不行就硬截断
+```
+
+<!-- 这是一张图片,ocr 内容为: -->
+![](https://cdn.nlark.com/yuque/0/2026/png/21571931/1782439934390-a44833ac-3965-43bc-a906-b8f4c96d4dc7.png)
+
+```python
+''' 
+* RecursiveCharacterTextSplitter 递归字符文本分割
+RecursiveCharacterTextSplitter 将按不同的字符递归地分割(按照这个优先级["\n\n", "\n", " ", ""]),
+    这样就能尽量把所有和语义相关的内容尽可能长时间地保留在同一位置
+RecursiveCharacterTextSplitter需要关注的是4个参数:
+
+* separators - 分隔符字符串数组
+* chunk_size - 每个文档的字符数量限制
+* chunk_overlap - 两份文档重叠区域的长度
+* length_function - 长度计算函数
+'''
+from langchain_text_splitters import RecursiveCharacterTextSplitter
+
+# 创建递归字符分割器
+text_splitter = RecursiveCharacterTextSplitter(
+    # 分隔符优先级:段落 → 换行 → 句号 → 空格 → 硬切
+    separators=["\n\n", "\n", "。", "!", "?", " ", ""],
+    
+    # 每个块最大 50 字符
+    chunk_size=50,
+    
+    # 相邻块重叠 10 字符(chunk_size 的 20%)
+    chunk_overlap=10,
+    
+    # 长度计算函数
+    length_function=len
+)
+
+# 对单段文本进行分割
+text = """
+LangChain 是一个让你的 LLM 变得更强大的开源框架。
+你想开发一个基于 LLM 的应用,需要什么组件它都有,直接使用就行。
+甚至针对常规的应用流程,它利用 Chain 这个概念已经内置标准化方案了。
+下面我们从新兴的大语言模型技术栈的角度来看看为何它的理念这么受欢迎。
+"""
+chunks = text_splitter.split_text(text)
+for i, chunk in enumerate(chunks):
+    print(f"--- 块{i+1} ---\n{chunk}\n")
+```
+
+```python
+#对整个文件进行切割
+split_docs = text_splitter.split_documents(pdf_pages)
+print(f"切分后的文件数量:{len(split_docs)}")
+print(f"切分后的字符数(可以用来大致评估 token 数):{sum([len(doc.page_content) for doc in split_docs])}")
+```
+
+**优点**:保持语义完整性好,适用范围广  
+**缺点**:需要调参 separators 和 chunk_size  
+**适用场景**:大多数场景的默认首选
+
+### 4.5 策略三:语义分块(效果最好)
+这种方法不按字符数硬切,而是用 Embedding 模型计算相邻句子的语义相似度,当相似度「断崖式下降」时,就在那里切一刀。
+
+```python
+from langchain_experimental.text_splitter import SemanticChunker
+from langchain_community.embeddings import DashScopeEmbeddings
+
+# 使用通义的 Embedding 模型做语义分块
+embedding_model = DashScopeEmbeddings(
+    model="text-embedding-v3",
+    dashscope_api_key="your-api-key"
+)
+
+# 创建语义分块器
+semantic_splitter = SemanticChunker(
+    embeddings=embedding_model,
+    breakpoint_threshold_type="percentile",  # 使用百分位数作为阈值
+    breakpoint_threshold_amount=85            # 相似度低于 85% 百分位时切分
+)
+
+# 分块
+docs = semantic_splitter.create_documents([raw_text])
+print(f"语义分块结果:{len(docs)} 个块")
+```
+
+**优点**:语义最完整,每个块都是一个「完整话题」  
+**缺点**:速度慢(每次都要调用 Embedding),适合离线批量处理  
+**适用场景**:技术文档、法律合同、学术论文等强结构文本
+
+### 4.6 策略四:文档结构分块
+利用文档本身的结构(标题、章节、段落)来定义块边界。
+
+```python
+from langchain_text_splitters import MarkdownHeaderTextSplitter
+
+# Markdown 按标题分块
+headers_to_split_on = [
+    ("#", "一级标题"),
+    ("##", "二级标题"),
+    ("###", "三级标题"),
+]
+
+markdown_splitter = MarkdownHeaderTextSplitter(
+    headers_to_split_on=headers_to_split_on
+)
+
+# 分块(会保留标题信息在 metadata 中)
+md_header_splits = markdown_splitter.split_text(markdown_content)
+```
+
+**优点**:保留文档原有逻辑结构,块的语义边界清晰  
+**缺点**:依赖文档格式质量,块大小不均匀  
+**适用场景**:Markdown 文档、有清晰标题结构的 PDF
+
+### 4.7 策略五:LLM 分块(最智能)
+直接让大模型来判断哪里该切、哪里该留。
+
+```python
+# 原理示意(实际实现需要调用 LLM API)
+# 
+# Prompt: "请将以下文本分成语义完整的段落,每段不超过 500 字:
+#          {text}"
+#
+# LLM 会返回切分好的段落列表
+```
+
+**优点**:语义理解最深,最智能  
+**缺点**:成本最高,速度最慢,受 LLM 上下文窗口限制  
+**适用场景**:高价值文档、小规模数据、对质量要求极高的场景
+
+### 4.8 分块策略选择指南
+```plain
+你的场景是什么?
+    │
+    ├─ 需要快速上手 → 固定字符分块
+    │
+    ├─ 通用场景 → 递归字符分块 ✓(推荐)
+    │
+    ├─ 追求最佳效果 → 语义分块
+    │
+    └─ 预算充足、数据量小 → LLM 分块
+```
+
+**新手建议**:从递归字符分块起步。效果不好,再试语义分块。不要一上来就搞最复杂的。
+
+### 4.9 分块后的过滤
+切分完记得过滤掉空块和无效块:
+
+```python
+# 过滤掉 page_content 为空或仅含空白的文档
+valid_docs = [
+    doc for doc in split_docs 
+    if doc.page_content and doc.page_content.strip()
+]
+
+print(f"有效块数量:{len(valid_docs)}")
+print(f"总字符数(可大致评估 Token 数):{sum(len(d.page_content) for d in valid_docs)}")
+```
+
+---
+
+## 五、向量嵌入:把文本变成「数字」
+### 5.1 Embedding 是什么?
+Embedding 是把文本映射成一串数字(向量)的过程。语义相近的文本,向量距离就近;语义无关的文本,向量距离就远。
+
+类比:把每段文字映射到一个高维空间里,「苹果手机」和「iPhone」在这个空间里距离很近,和「橙子」距离就很远。
+
+<!-- 这是一张图片,ocr 内容为: -->
+![](https://cdn.nlark.com/yuque/0/2026/png/21571931/1782378490327-b76a882b-5b73-4bd9-b7cf-58e8d8677c0e.png)
+
+### 5.2 国内 Embedding 模型选型
+| 模型 | 厂商 | 维度 | 特点 |
+| :--- | :--- | :--- | :--- |
+| `text-embedding-v3` | [阿里(通义)](https://help.aliyun.com/zh/model-studio/text-embedding-synchronous-api?spm=a2c4g.11186623.help-menu-2400256.d_2_7_0_0.2d9b785b0jVsx4) | 1024/768 | 性价比高,中文效果好 |
+| `embedding-3` | 百度(文心) | 1024/2048 | 中文语义理解强 |
+| `BAAI/bge-m3` | 智源 | 1024 | 开源,可本地部署 |
+| `M3E` | MokaAI | 768 | 开源,中文优化 |
+
+
+### 5.3 实战:使用通义 Embedding
+```python
+from langchain_community.embeddings import DashScopeEmbeddings
+
+# 初始化 Embedding 模型
+# 需要先在 https://dashscope.console.aliyun.com/ 获取 API Key
+embedding_model = DashScopeEmbeddings(
+    model="text-embedding-v3",        # 模型名称
+    dashscope_api_key="your-api-key"   # 替换为你的真实 Key
+)
+
+# 单条文本向量化
+text = "RAG系统搭建实战"
+embedding = embedding_model.embed_query(text)
+print(f"向量维度:{len(embedding)}")
+print(f"前5个值:{embedding[:5]}")
+```
+
+```python
+# 批量文本向量化
+texts = [
+    "hello world",
+    "什么是大语言模型",
+    "RAG的概念和原理",
+    "牛顿第一定律是什么",
+]
+
+embeddings = embedding_model.embed_documents(texts)
+print(f"生成了 {len(embeddings)} 个向量")
+print(f"每个向量维度:{len(embeddings[0])}")
+```
+
+---
+
+## 六、向量数据库:给向量安个「家」
+### 6.1 为什么需要向量数据库?
+传统数据库(MySQL、PostgreSQL)擅长精确查询,但不擅长「找相似的」。向量数据库专门为向量检索设计,能高效地做近似最近邻搜索(ANN--<font style="color:rgb(15, 17, 21);">用于在海量数据里面寻找“足够相似”的数据</font>)。
+
+<!-- 这是一张图片,ocr 内容为: -->
+![](https://cdn.nlark.com/yuque/0/2026/png/21571931/1782358322541-015eca7d-cbb7-4894-9342-bfc025a4f9bb.png)<!-- 这是一张图片,ocr 内容为: -->
+![](https://cdn.nlark.com/yuque/0/2026/png/21571931/1782358322687-cf959b5c-1ce0-4c00-a912-9805e4d0d6d5.png)
+
+### 6.2 常见向量数据库对比
+| 数据库 | 类型 | 特点 | 适用场景 |
+| :--- | :--- | :--- | :--- |
+| **ChromaDB** | 嵌入式 | 轻量、易用、Python 原生 | 快速原型、小规模 |
+| **FAISS** | 库 | Facebook 开源,速度快 | 大规模向量检索 |
+| **Milvus** | 分布式 | 云原生,支持海量数据 | 生产环境 |
+| **Weaviate** | 云服务 | 自带向量化,API 友好 | 全托管场景 |
+
+
+### 6.3 实战:ChromaDB 基础操作
+<!-- 这是一张图片,ocr 内容为: -->
+![](https://cdn.nlark.com/yuque/0/2026/png/21571931/1782358373027-6fcdf0f6-1746-4746-8ea5-c28ee5ddef16.png)<!-- 这是一张图片,ocr 内容为: -->
+![](https://cdn.nlark.com/yuque/0/2026/png/21571931/1782358373069-ee887c11-9e5d-4066-8b24-ea222eefee8c.png)
+
+```python
+#安装依赖:uv pip install chromadb -i https://pypi.tuna.tsinghua.edu.cn/simple
+from langchain_community.vectorstores import Chroma
+from langchain_community.embeddings import DashScopeEmbeddings
+
+# 初始化 Embedding 模型
+embedding_model = DashScopeEmbeddings(
+    model="text-embedding-v3",
+    dashscope_api_key="your-api-key"
+)
+
+# 准备一些测试数据
+datas = [
+    "小明特别喜欢吃脆甜多汁的苹果",
+    "小红对榴莲那独特的味道情有独钟",
+    "小明和小丽是一对甜蜜的情侣",
+    "王老师教学认真负责,是公认的好老师",
+    "小李每天都要吃一根香蕉",
+    "小王的男朋友长得阳光帅气,是大家公认的大帅哥"
+]
+
+# 创建向量数据库并持久化(会同时把文本和对应的向量存入数据库)
+db = Chroma.from_texts(
+    texts=datas,
+    embedding=embedding_model,
+    collection_metadata={"hnsw:space": "cosine"},  # 关键配置:指定距离算法为余弦相似度
+    persist_directory="./chroma_db1"  # 数据库存储路径
+)
+```
+
+```python
+# 相似度检索
+query = "谁是老师"
+results = db.similarity_search(query, k=1)  # 返回最相似的 1 条
+
+for doc in results:
+    print(f"匹配内容:{doc.page_content}")
+```
+
+```python
+# 带分数的相似度检索(分数越低越相似,0 表示完全匹配)
+query = "谁是老师"
+results = db.similarity_search_with_score(query, k=3)
+
+for doc, score in results:
+    print(f"内容:{doc.page_content} | 相似度分数:{score:.4f}")
+```
+
+
+
+<!-- 这是一张图片,ocr 内容为: -->
+![](https://cdn.nlark.com/yuque/0/2026/png/21571931/1782358211393-bf3cc0be-2a49-4019-8b3c-ccd8ca9911dc.png)<!-- 这是一张图片,ocr 内容为: -->
+![](https://cdn.nlark.com/yuque/0/2026/png/21571931/1782358211570-d664147a-ed51-4852-ab1d-d6fbfd2a943e.png)<!-- 这是一张图片,ocr 内容为: -->
+![](https://cdn.nlark.com/yuque/0/2026/png/21571931/1782358211693-c87aeb93-9361-4040-8d5e-2abb529c616b.png)<!-- 这是一张图片,ocr 内容为: -->
+![](https://cdn.nlark.com/yuque/0/2026/png/21571931/1782358211734-b8bfbea2-742c-4f2f-b076-05987063fac0.png)
+
+---
+
+## 七、检索器:连接「问题」和「答案」的桥梁
+### 7.1 检索器的工作流
+检索器(Retriever)是 LangChain 中的核心接口,负责根据用户问题从知识库中查询来获取相关文档。
+
+<!-- 这是一张图片,ocr 内容为: -->
+![](https://cdn.nlark.com/yuque/0/2026/png/21571931/1782358582915-8924a7af-036b-4184-a852-784247551a1b.png)
+
+核心数据流:
+
+```plain
+用户输入问题 
+    → 检索器将问题转为向量 
+    → 在向量库中查找语义最近的文档块 
+    → 返回 Top-K 相关文档
+```
+
+### 7.2 实战:创建检索器
+```python
+from langchain_community.vectorstores import Chroma
+from langchain_community.embeddings import DashScopeEmbeddings
+
+# 假设 split_docs 是前面分块后的文档列表
+embedding_model = DashScopeEmbeddings(
+    model="text-embedding-v3",
+    dashscope_api_key="your-api-key"
+)
+
+# 从文档创建向量库(写数据)
+vectorstore = Chroma.from_documents(
+    documents=valid_docs,
+    embedding=embedding_model,
+    collection_metadata={"hnsw:space": "cosine"},
+    persist_directory="./my_knowledge_db"
+)
+
+# 创建检索器,设置返回 Top-3 最相关文档
+retriever = vectorstore.as_retriever(search_kwargs={"k": 3})
+```
+
+```python
+# 测试检索
+query = "在多少速度范围内,HDC才会激活?"
+relevant_docs = retriever.invoke(query)
+
+for i, doc in enumerate(relevant_docs):
+    print(f"--- 结果{i+1} ---")
+    print(f"内容:{doc.page_content[:100]}...")
+    print(f"来源:{doc.metadata}")
+    print()
+```
+
+```python
+# 直接加载已持久化的数据库(无需再次添加文档或持久化)
+vectordb = Chroma(
+    persist_directory="./my_knowledge_db",
+    embedding_function=embedding_model
+)
+```
+
+---
+
+## 八、生成回答:最后一公里
+### 8.1 Prompt 工程
+检索到相关文档后,需要把它们拼进 Prompt,让大模型基于这些上下文生成回答。
+
+```python
+from langchain_core.prompts import ChatPromptTemplate
+
+# 构建 Prompt 模板
+prompt = ChatPromptTemplate.from_template("""
+你是一个专业的知识库助手。请根据以下检索到的上下文回答用户问题。
+
+**规则:**
+- 只基于提供的上下文回答,不要编造
+- 如果上下文中没有相关信息,直接说「根据现有资料,我找不到这个问题的答案」
+- 回答要简洁直接,引用原文时用引号
+
+**检索到的上下文:**
+{context}
+
+**用户问题:**
+{question}
+""")
+```
+
+### 8.2 调用大模型生成回答
+```python
+from langchain_community.chat_models import ChatTongyi
+from langchain_core.output_parsers import StrOutputParser
+
+# 初始化大模型(这里用通义千问,也可以换成 DeepSeek)
+llm = ChatTongyi(
+    model="qwen-plus",                 # 模型名称
+    dashscope_api_key="your-api-key"   # 替换为你的真实 Key
+)
+
+# 拼装上下文
+context_text = "\n\n---\n\n".join([doc.page_content for doc in relevant_docs])
+
+# 构建 Chain 并调用
+chain = prompt | llm | StrOutputParser()
+
+response = chain.invoke({
+    "context": context_text,
+    "question": query
+})
+
+print(response)
+```
+
+### 8.3 完整 RAG Chain 串联
+把前面所有步骤串起来,形成一个完整的 RAG 流程:
+
+```python
+from langchain_community.document_loaders import PyMuPDFLoader
+from langchain_text_splitters import RecursiveCharacterTextSplitter
+from langchain_community.embeddings import DashScopeEmbeddings
+from langchain_community.vectorstores import Chroma
+from langchain_core.prompts import ChatPromptTemplate
+from langchain_community.chat_models import ChatTongyi
+from langchain_core.output_parsers import StrOutputParser
+
+# ========== 第一步:加载文档 ==========
+loader = PyMuPDFLoader("./your_document.pdf")
+pages = loader.load()
+
+# ========== 第二步:清洗数据(可选,根据文档质量决定)==========
+# clean_pages = [clean_pdf_text(page) for page in pages]
+
+# ========== 第三步:分块 ==========
+splitter = RecursiveCharacterTextSplitter(
+    # 分隔符优先级:段落 → 换行 → 句号 → 空格 → 硬切
+    separators=["\n\n", "\n", "。", "!", "?", " ", ""],
+    # 每个块最大 50 字符
+    chunk_size=50,
+    # 相邻块重叠 10 字符(chunk_size 的 20%)
+    chunk_overlap=10,
+    # 长度计算函数
+    length_function=len
+)
+docs = splitter.split_documents(pages)
+
+# ========== 第四步:向量化 + 存入向量库 ==========
+embedding_model = DashScopeEmbeddings(
+    model="text-embedding-v3",
+    dashscope_api_key="your-api-key"
+)
+vectorstore = Chroma.from_documents(
+    documents=docs,
+    embedding=embedding_model,
+    persist_directory="./knowledge_db"
+)
+
+# ========== 第五步:创建检索器 ==========
+retriever = vectorstore.as_retriever(search_kwargs={"k": 3})
+
+# ========== 第六步:提问 ==========
+query = "你的问题"
+relevant_docs = retriever.invoke(query)
+
+# ========== 第七步:生成回答 ==========
+context = "\n\n---\n\n".join([d.page_content for d in relevant_docs])
+
+prompt = ChatPromptTemplate.from_template("""
+你是一个专业的知识库助手。请根据以下上下文回答问题。
+
+**规则:**
+- 只基于提供的上下文回答,不要编造
+- 如果上下文中没有相关信息,直接说「根据现有资料,我找不到这个问题的答案」
+- 回答要简洁直接,引用原文时用引号
+
+**上下文:**
+{context}
+
+**问题:**
+{question}
+""")
+
+llm = ChatTongyi(model="qwen-plus", dashscope_api_key="your-api-key")
+chain = prompt | llm | StrOutputParser()
+
+answer = chain.invoke({"context": context, "question": query})
+print(answer)
+```
+
+**这就是一次完整的 RAG 流程**:加载 → 清洗 → 分块 → 嵌入 → 存储 → 检索 → 生成。
+
+---
+
+## 九、实战建议与踩坑总结
+### 9.1 分块大小怎么定?
++ 中文文档:500-1000 字符
++ 英文文档:1000-2000 字符
++ 代码文档:按函数/类切分,不要按字符切
+
+### 9.2 Top-K 设多少?
++ K=3:适合简单问答
++ K=5:适合需要综合多个信息源的场景
++ K=10:适合需要全面覆盖的场景(但会增加 Token 消耗)
+
+### 9.3 常见踩坑
+1. **chunk_overlap=0**:最常见的错误。相邻块没有重叠,一个完整句子被切成两半,检索时两半都不完整。
+2. **不做数据清洗**:PDF 解析出来的噪声会严重干扰检索效果。
+3. **Embedding 模型和查询语言不匹配**:用英文 Embedding 模型处理中文,效果会很差。
+4. **Top-K 设太小**:只返回 1 条结果,很容易漏掉关键信息。
+
+---
+
+
+

+ 1692 - 0
02_RAG_study/4.RAG全链路优化:多路召回、查询测优化、Reranker.md

@@ -0,0 +1,1692 @@
+# RAG 全链路优化
+> 从"能用"到"好用"。
+>
+
+---
+
+## 第一章:RAG常见问题
+<!-- 这是一张图片,ocr 内容为: -->
+![](https://cdn.nlark.com/yuque/0/2026/png/21571931/1782606508574-bbc6bcd5-7eef-4ac4-8c88-98afbfc216a0.png)
+
+做过 RAG 的同学大概都踩过这三个坑:
+
+| 病灶 | 症状 | 根因 |
+| :--- | :--- | :--- |
+| **切片粗暴** | 检索回来的内容支离破碎,上下文断裂 | 按固定字符数硬切,把一句话切成两半 |
+| **检索不精准** | 召回的内容跟问题关系不大 | 只用向量相似度,忽略了关键词匹配 |
+| **没有大局观** | 找到了局部细节,但遗漏了全局信息 | 单次检索只能看到"一小块",缺乏多视角探索 |
+
+
+这些问题不是孤立的,它们会层层叠加:切片质量差 → 检索命中率低 → 大模型拿不到有效上下文 → 生成的答案牛头不对马嘴。
+
+---
+
+## 第二章:Milvus 向量数据库实战
+### 2.1 为什么选 Milvus
+Milvus 是目前国内生态最成熟的开源向量数据库之一,支持亿级向量检索,社区活跃,文档齐全。对比 FAISS(纯内存、无持久化)和 Chroma(轻量但功能有限),Milvus 在生产环境中更有优势。
+
+> 官方文档:[https://milvus.io/docs/zh](https://milvus.io/docs/zh)
+>
+
+### 2.2 安装与连接
+[docker-compose.yml](https://www.yuque.com/attachments/yuque/0/2026/yml/21571931/1782813228889-e4fb1613-1e0d-4798-a463-0bc67019df08.yml)
+
+```python
+使用docker安装
+docker compose up -d
+```
+
+```bash
+# 安装 Python SDK
+uv pip install pymilvus -i https://mirrors.aliyun.com/pypi/simple
+```
+
+```python
+from pymilvus import MilvusClient, DataType
+
+# 连接 Milvus 服务(默认端口 19530)
+client = MilvusClient(uri="http://localhost:19530")
+```
+
+### 2.3 创建集合(Collection)
+Milvus 的"集合"类似于关系型数据库的"表"。我们需要定义字段结构,然后创建集合和索引。
+
+```python
+dimension = 1024  # 向量维度,需要和 Embedding 模型的输出维度一致
+collection_name = "demo_collection"
+metric_type = "COSINE"  # 余弦相似度,适合文本语义匹配
+
+# ---- 第一步:定义 Schema ----
+# Schema 描述了集合中每条数据的结构
+schema = client.create_schema()
+# 主键字段,INT64 类型,自动递增
+schema.add_field(field_name="id", is_primary=True, auto_id=True, datatype=DataType.INT64)
+# 向量字段,存储文本的 Embedding 表示
+schema.add_field(field_name="vector", datatype=DataType.FLOAT_VECTOR, dim=dimension)
+# 文本字段,存储原始文本内容
+schema.add_field(field_name="text", datatype=DataType.VARCHAR, max_length=2000)
+
+# ---- 第二步:创建集合 ----
+# 如果已存在同名集合,先删除(开发阶段用,生产环境慎用)
+if client.has_collection(collection_name):
+    client.drop_collection(collection_name)
+
+client.create_collection(
+    collection_name=collection_name,
+    schema=schema,
+    metric_type=metric_type,
+)
+
+# ---- 第三步:创建索引 ----
+# 索引决定了向量检索的速度和精度平衡
+index_params = MilvusClient.prepare_index_params()
+index_params.add_index(
+    field_name="vector",
+    index_type="AUTOINDEX",  # 自动选择最优索引,适合大多数场景
+)
+client.create_index(
+    collection_name=collection_name,
+    index_params=index_params
+)
+```
+
+> **索引类型选择小贴士**:`AUTOINDEX` 是最省心的选择,Milvus 会根据数据量自动决策。如果你对性能有极致要求,可以研究 `IVF_FLAT`、`HNSW` 等具体索引类型。
+>
+
+### 2.4 插入数据
+插入数据的流程:文本 → Embedding 模型转成向量 → 存入 Milvus。
+
+```python
+from langchain_community.embeddings import DashScopeEmbeddings
+
+# 初始化 Embedding 模型(这里用阿里通义的 text-embedding-v3)
+embedding_model = DashScopeEmbeddings(
+    model="text-embedding-v3",
+    dashscope_api_key="your_api_key_here"  # 替换为你的真实 API Key
+)
+
+# 准备一些测试文本
+docs = [
+    "Milvus 就像是一个专门存放‘向量’的超级仓库,能在几毫秒内从千万级数据中找出最相似的片段。",
+    "RAG 技术的本质,就是给大模型配上一个能随时查阅的‘外部外挂大脑’(知识库)。",
+    "大模型之所以聪明,是因为它通过多层神经网络,学会了文字背后的隐藏含义和逻辑关系。",
+    "为了应对不同的数据量,向量数据库提供了多种‘索引’策略,选对了索引,检索速度能快十倍。",
+    "Embedding 就像是一座桥梁,把人类看得懂的文字,翻译成了计算机能算得清楚的空间坐标。"
+]
+
+# 将文本转为向量并组装成插入数据
+data = []
+for doc in docs:
+    vector = embedding_model.embed_query(doc)  # 文本 → 向量
+    data.append({"vector": vector, "text": doc})
+
+# 批量插入
+res = client.insert(
+    collection_name=collection_name,
+    data=data
+)
+print(f"成功插入 {len(data)} 条数据")
+```
+
+### 2.5 向量检索
+检索时,用户的问题同样需要先转成向量,然后在 Milvus 中做近似最近邻(ANN)搜索。
+
+```python
+# 加载集合到内存(检索前必须执行)
+if client.has_collection(collection_name):
+    client.load_collection(collection_name)
+
+    # 将查询文本转为向量
+    query_vector = embedding_model.embed_query("Milvus是什么?")
+
+    # 执行向量检索
+    res = client.search(
+        collection_name=collection_name,
+        data=[query_vector],       # 查询向量(支持批量)
+        anns_field="vector",       # 在哪个向量字段上搜索
+        limit=2,                   # 返回最相似的 Top-K 条
+        output_fields=["id", "text"]  # 需要返回哪些字段
+    )
+
+    # 打印结果
+    for hit in res[0]:
+        print(f"相似度: {hit['distance']:.4f}  内容: {hit['entity']['text']}")
+else:
+    print(f"集合 '{collection_name}' 不存在,请先创建")
+```
+
+### 2.6 与 LangChain 集成
+实际项目中,我们通常不会直接手写 Milvus 的增删改查,而是通过 LangChain 的封装来简化流程。
+
+```python
+# langchain_community要使用0.4.1版本
+from langchain_community.vectorstores import Milvus
+from langchain_community.document_loaders import PyMuPDFLoader
+from langchain_text_splitters import RecursiveCharacterTextSplitter
+from langchain_community.embeddings import DashScopeEmbeddings
+
+# 初始化 Embedding 模型
+embedding_model = DashScopeEmbeddings(
+    model="text-embedding-v3",
+    dashscope_api_key="your_api_key_here"
+)
+
+# 第一步:加载文档(以 PDF 为例)
+loader = PyMuPDFLoader("car_info.pdf")
+docs = loader.load()
+
+# 第二步:切片
+# chunk_size: 每个切片的最大字符数
+# chunk_overlap: 相邻切片的重叠字符数,防止上下文断裂
+text_splitter = RecursiveCharacterTextSplitter(
+    # 分隔符优先级:段落 → 换行 → 句号 → 空格 → 硬切
+    separators=["\n\n", "\n", "。", "!", "?", " ", ""],
+    
+    # 每个块最大 50 字符
+    chunk_size=50,
+    
+    # 相邻块重叠 10 字符(chunk_size 的 20%)
+    chunk_overlap=10,
+    
+    # 长度计算函数
+    length_function=len
+)
+split_docs = text_splitter.split_documents(docs)
+
+# 过滤空文档
+valid_docs = [doc for doc in split_docs if doc.page_content and doc.page_content.strip()]
+
+# 第三步:存入 Milvus(LangChain 会自动处理 Embedding 转换)
+vectorstore = Milvus.from_documents(
+    documents=valid_docs,
+    embedding=embedding_model,
+    connection_args={"uri": "http://localhost:19530"},
+    collection_name="car_info_collection"
+)
+print(f"已创建集合,包含 {len(valid_docs)} 个文档切片")
+```
+
+<font style="color:rgb(15, 17, 21);">在浏览器访问 </font><font style="color:#DF2A3F;background-color:rgb(235, 238, 242);">http://{你的Milvus IP}:9091/webui</font><font style="color:rgb(15, 17, 21);"> 打开Milvus 内置的图形化界面查看数据库和</font>**<font style="color:rgb(15, 17, 21);">集合 (Collection)</font>**<font style="color:rgb(15, 17, 21);"> 的列表及详细信息。</font>
+
+检索就更简单了:
+
+```python
+# 创建检索器,k=1 表示返回最相似的 1 条
+retriever = vectorstore.as_retriever(search_kwargs={"k": 1})
+
+# 执行检索
+query = "在多少速度范围内,HDC才会激活?"
+relevant_docs = retriever.invoke(query)
+
+for doc in relevant_docs:
+    print(f"内容: {doc.page_content}")
+    print(f"来源: {doc.metadata}")
+```
+
+
+
+### 2.7 检索工具与 Agent 集成
+#### 把检索器变成工具
+在 LangChain 的 Agent 架构中,检索器可以被封装成一个"工具",让 Agent 自主决定何时调用。
+
+```python
+from langchain_core.tools.retriever import create_retriever_tool
+from langchain_community.chat_models import ChatTongyi
+from langchain.agents import create_agent
+
+# 初始化大模型
+llm = ChatTongyi(model="qwen-plus", api_key="your_api_key_here")
+
+# 将向量存储转为检索器
+retriever = vectorstore.as_retriever(search_kwargs={"k": 2})
+
+# 把检索器封装为工具
+# name: 工具名称,Agent 用它来引用这个工具
+# description: 工具描述,Agent 根据描述决定何时使用——写得好不好直接影响 Agent 的决策质量
+retriever_tool = create_retriever_tool(
+    retriever,
+    name="product_info_retriever",
+    description="当用户询问产品相关信息时使用此工具,包括保修政策、技术参数、使用方法等。",
+)
+
+# 创建 Agent,传入工具列表
+tools = [retriever_tool]
+agent = create_agent(llm, tools)
+
+# Agent 会根据问题自动判断是否需要调用检索工具
+query = "在多少速度范围内,HDC才会激活?"
+res = agent.invoke({"messages": [("human", query)]})
+print(res['messages'][-1])
+```
+
+> **关键点**:`description` 是 Agent 决策的依据。写得太模糊(如"搜索信息"),Agent 可能在不该用的时候乱用;写得太具体(如"只搜索保修信息"),Agent 可能在该用的时候不用。建议写清楚工具的适用范围。
+>
+
+---
+
+## 第三章:Embedding 模型选型
+### 3.1 为什么 Embedding 选型很重要
+Embedding 模型是 RAG 系统的"眼睛"——它决定了系统如何"理解"文本。选错了模型,后续所有的切片、检索、精排优化都是事倍功半。
+
+Embedding 模型的核心任务:**把文本映射到高维向量空间,使得语义相近的文本在向量空间中距离也近**。
+
+```plain
+文本: "Milvus 是一个高性能向量数据库"
+       ↓ Embedding 模型
+向量: [0.23, -0.15, 0.87, 0.04, ..., -0.42]  (如 1024 维)
+```
+
+选择 Embedding 模型时,需要同时考虑**效果、速度、成本、部署方式**四个维度。
+
+### 3.2 主流 Embedding 模型对比
+#### MTEB 基准
+[MTEB](https://huggingface.co/spaces/mteb/leaderboard)(Massive Text Embedding Benchmark)是目前最权威的 Embedding 模型评测基准,涵盖分类、聚类、配对、重排、检索、语义相似度等 8 大类任务。
+
+> **注意**:MTEB 分数高不代表在你的数据上效果好。选型时一定要用自己的真实数据做二次评估。
+>
+
+#### 主流中文模型
+| 模型 | 维度 | 最大长度 | 特点 | 适用场景 |
+| :--- | :--- | :--- | :--- | :--- |
+| **BGE-M3** | 1024 | 8192 | 多语言、稀疏+稠密双表示、Matryoshka 自适应维度 | 通用中文 RAG、多语言场景 |
+| **BGE-Large-ZH-v1.5** | 1024 | 512 | 中文 SOTA、轻量 | 纯中文短文本 RAG |
+| **text2vec-large-chinese** | 1024 | 512 | 中文社区广泛使用 | 中文语义匹配 |
+| **stella-m3** | 1024 | 512 | 蒸馏模型、推理速度快 | 对延迟敏感的中文场景 |
+
+
+#### 主流多语言/API 模型
+| 模型 | 维度 | 最大长度 | 特点 | 适用场景 |
+| :--- | :--- | :--- | :--- | :--- |
+| **text-embedding-v3** | 1024 | 8192 | 阿里通义出品、中文优化、API 调用 | 中文场景快速接入 |
+| **text-embedding-3-large** | 256-3072 | 8191 | OpenAI 出品、Matryoshka 自适应维度 | 快速原型、多语言 |
+| **Cohere embed-v3** | 1024 | 512 | 英文效果好 | 英文场景 |
+| **GTE-Qwen2** | 维度可变 | 8192 | 阿里通义出品、长文本支持好 | 长文档场景 |
+| **E5-mistral-7b-instruct** | 4096 | 32768 | 基于 Mistral-7B、指令感知 | 需任务指令的复杂场景 |
+| **Jina-embeddings-v3** | 1024 | 8192 | 多语言、任务特定 LoRA | 多任务灵活切换 |
+
+
+### 3.3 关键选择维度
+#### 维度一:向量维度(Dimension)
+向量维度决定了存储成本和检索精度的平衡。
+
+| 维度 | 优势 | 劣势 | 推荐场景 |
+| :--- | :--- | :--- | :--- |
+| 384-512 | 存储小、检索快 | 语义表达力有限 | 简单 FAQ、小规模库 |
+| 768-1024 | 性价比最优 | — | **大多数 RAG 场景(推荐)** |
+| 1536-4096 | 语义表达力强 | 存储大、检索慢 | 高精度要求、大规模库 |
+
+
+> **经验值**:1024 维是当前工业界最主流的选择,效果和成本的甜点位置。
+>
+
+#### 维度二:最大文本长度
+Embedding 模型处理文本时有长度上限,超出部分会被截断。
+
+| 支持长度 | 适合的切片策略 | 代表模型 |
+| :--- | :--- | :--- |
+| 512 token | 小块切片(200-500 字) | BGE-ZH-v1.5、text2vec |
+| 8192 token | 大块切片 / Late Chunking | BGE-M3、text-embedding-v3、GTE-Qwen2 |
+
+
+选择策略:
+
++ 如果你的切片是 500 字符的小块 → 512 token 的模型足够
++ 如果你想做"大块检索"或"全文 Embedding" → 需要 8192 token 的模型
++ `BGE-M3` 同时支持长文本和 Matryoshka,是当前中文场景的"万能选手"
+
+#### 维度三:Matryoshka 自适应维度
+部分模型(`text-embedding-3`、`BGE-M3` 等)支持 Matryoshka 表示学习。核心思想:**高维向量天然包含低维信息**。
+
+```plain
+维度 1024 的向量
+  ↓ 取前 512 维(丢弃后 512 维)
+维度 512 的向量(精度损失 < 2%)
+  ↓ 取前 256 维
+维度 256 的向量(精度损失 < 5%)
+```
+
+这意味着:**一套 Embedding,多套索引**。
+
++ 粗排用 256 维,速度快
++ 精排用 1024 维,精度高
++ 存储 1024 维,检索时按需截断
+
+```python
+# BGE-M3 Matryoshka 用法示意
+from FlagEmbedding import BGEM3FlagModel
+
+model = BGEM3FlagModel('BAAI/bge-m3', use_fp16=True)
+
+sentences = ["什么是向量数据库?"]
+dense_vecs = model.encode(sentences, batch_size=1)['dense_vecs']
+
+# 需要 256 维?直接取前 256 列,无需重新 Embedding
+dense_256 = dense_vecs[:, :256]
+# 在 Milvus 中创建 256 维的 Collection 来存储,成本降低 75%
+```
+
+#### 维度四:API 服务 vs 本地部署
+| 方式 | 代表 | 优势 | 劣势 |
+| :--- | :--- | :--- | :--- |
+| **API 服务** | DashScope、OpenAI、Cohere | 免运维、弹性伸缩、接入快 | 网络依赖、长期成本较高、数据需出域 |
+| **本地部署** | BGE-M3、GTE、E5 | 延迟可控、数据不出域、长期成本低 | 需要 GPU、需要运维 |
+
+
+> **选型建议**:原型验证阶段用 API 服务(跑通链路),生产上线阶段优先考虑本地部署(成本与延迟可控)。混合方案也常见——Embedding 走 API,Reranker 部署在本地 GPU。
+>
+
+### 3.4 如何在自己的数据上评估
+MTEB 排名不能代替你的真实场景。一个轻量的评估流程:
+
+```python
+# 评估框架示意(简化版)
+import numpy as np
+
+def evaluate_hit_rate(embedding_model, test_pairs, vectorstore, k=5):
+    """
+    简单评估 Top-K 命中率
+    test_pairs: [(问题, 正确答案文本), ...]
+    """
+    hits = 0
+    total = len(test_pairs)
+
+    for question, ground_truth in test_pairs:
+        # 检索 Top-K
+        retrieved = vectorstore.similarity_search(question, k=k)
+        retrieved_texts = [doc.page_content for doc in retrieved]
+
+        # 检查正确答案是否在 Top-K 中
+        if any(ground_truth in text for text in retrieved_texts):
+            hits += 1
+
+    hit_rate = hits / total
+    print(f"Top-{k} 命中率: {hit_rate:.2%}")
+    return hit_rate
+
+
+# 使用示例
+test_pairs = [
+    ("产品保修期是多久?", "三年或十万公里"),
+    ("如何更换备胎?", "停车并拉手刹"),
+    ("发动机型号是什么?", "2.0T 涡轮增压"),
+    # ... 建议准备 50-100 对
+]
+
+# 分别用不同的 Embedding 模型测试
+models_to_test = {
+    "DashScope text-embedding-v3": embedding_model_dashscope,
+    "BGE-M3": embedding_model_bge,
+    "text2vec": embedding_model_text2vec,
+}
+
+for name, model in models_to_test.items():
+    print(f"\n--- {name} ---")
+    evaluate_hit_rate(model, test_pairs, vectorstore, k=5)
+```
+
+关键评估指标:
+
+| 指标 | 含义 | 计算方式 | 参考基准 |
+| :--- | :--- | :--- | :--- |
+| **Top-1 命中率** | 答案出现在第 1 位的概率 | 命中次数 / 总问题数 | > 60% 为可用 |
+| **Top-3 命中率** | 答案出现在前 3 位的概率 | 同上 | > 80% 为良好 |
+| **Top-5 命中率** | 答案出现在前 5 位的概率 | 同上 | > 90% 为优秀 |
+| **MRR** | 第一个正确结果排名的倒数的均值 | `mean(1 / rank_of_first_hit)` | 越接近 1 越好 |
+
+
+### 3.5 模型推荐速查
+| 你的场景 | 推荐模型 | 理由 |
+| :--- | :--- | :--- |
+| 中文、数据不出域 | `BGE-M3`(本地部署) | 多语言、长文本、Matryoshka、中文 SOTA 级效果 |
+| 中文、可以走 API | `DashScope text-embedding-v3` | 通义系列、中文优化、接入简单 |
+| 中英混合 | `BGE-M3` 或 `GTE-Qwen2` | 多语言效果好 |
+| 英文为主 | `Cohere embed-v3` | 英文 MTEB 排名靠前 |
+| 快速原型验证 | `text-embedding-3-small` | 便宜、快、够用 |
+| 短文本(< 512 token) | `BGE-Large-ZH-v1.5` | 轻量、效果好 |
+| 长文档(> 4096 token) | `GTE-Qwen2` 或 `BGE-M3` | 支持长上下文 Embedding |
+
+
+> **一句话总结**:中文场景默认选 `BGE-M3`(本地)或 `text-embedding-v3`(API),然后用自己的真实数据跑一轮命中率对比,再做最终决策。
+>
+
+---
+
+## 第四章:文档智能切片策略
+### 4.1 固定切片的局限
+最常用的 `RecursiveCharacterTextSplitter` 本质上是按字符数硬切——不管你句子说到哪,到了 500 字就一刀下去。这在大多数场景下够用,但遇到以下情况就会翻车:
+
++ 一段完整的技术说明被切成两半,语义残缺
++ 相邻的两个切片讲的是完全不相关的话题
++ 文档结构复杂(标题、段落、表格混排),硬切会破坏结构
+
+### 4.2 语义切块(SemanticChunker)
+`SemanticChunker` 的思路完全不同:**先按句子拆分,再按语义合并**。
+
+核心原理:
+
+1. 将文档拆成句子级别的片段
+2. 用 Embedding 模型计算每对相邻句子的语义相似度
+3. 在语义发生"突变"的地方切开——相似度低说明话题转了
+
+```python
+#安装依赖:uv pip install langchain_experimental==0.4.1 -i https://mirrors.aliyun.com/pypi/simple
+from langchain_experimental.text_splitter import SemanticChunker
+from langchain_community.document_loaders import PyMuPDFLoader
+from langchain_community.embeddings import DashScopeEmbeddings
+
+# 初始化 Embedding 模型
+embedding_model = DashScopeEmbeddings(
+    model="text-embedding-v3",
+    dashscope_api_key="your_api_key_here"
+)
+
+# 创建语义分块器
+# breakpoint_threshold_type="percentile" 表示用百分位数法确定切分阈值
+# breakpoint_threshold_amount=95 表示只有相似度排名后 5% 的位置才会被切开
+text_splitter = SemanticChunker(
+    embeddings=embedding_model,
+    breakpoint_threshold_type="percentile",
+    breakpoint_threshold_amount=95  # 值越大,切出来的块越少(越粗)
+)
+
+loader = PyMuPDFLoader("your_document.pdf")
+docs = text_splitter.split_documents(loader.load())
+
+print(f"共切分为 {len(docs)} 个语义块")
+print(f"第一个块: {docs[0].page_content[:100]}...")
+```
+
+### 4.3 百分位数法原理详解
+百分位数法是语义切块的核心算法,理解它对调参至关重要。
+
+#### 通俗解释:从"差生划线"说起
+假设全班 100 个学生考了一次试(成绩单上的 100 个分数,代表 100 对相邻句子的相似度):
+
+**固定阈值法(传统方式)**:设定一条固定分数线,比如 60 分,低于 60 的就是"差生"(分割点)。
+
++ 如果题目特别难,全班第一名才 55 分 → 按 60 分标准,全班都是"差生",全切开 → 碎片化
++ 如果题目特别简单,全班最低也有 80 分 → 按 60 分标准,一个"差生"都找不到 → 全不切
+
+固定阈值对"题目难度"(文档整体相似度分布)高度敏感,换个文档就失效了。
+
+**百分位数法**:不设固定分数线,而是按排名划——"找出排名倒数 5% 的学生"。
+
++ 题目难、大家分都低?没关系,仍是最差的那 5% 被划出来
++ 题目简单、大家分都高?同样是最差的那 5% 被划出来
+
+不管这次考试难度如何,始终把表现最差的 5% 的边界切出来。这样无论文档的整体相似度是高是低,都能动态、自适应地找到话题转变最剧烈的地方。
+
+#### 什么是百分位数
+**<font style="color:rgb(15, 17, 21);">在一组按从小到大排列的数据中,第p百分位数表示:有p%的数据小于或等于这个数值,同时有(100-p)%的数据大于或等于这个数值。</font>**百分位数描述的是"数据分布中的相对位置",用考试分数来举例最直观:
+
+```plain
+25 百分位数 = 70  →  25% 的学生分数 ≤ 70 分
+50 百分位数 = 80  →  50% 的学生分数 ≤ 80 分(中位数)
+75 百分位数 = 90  →  75% 的学生分数 ≤ 90 分
+90 百分位数 = 97  →  90% 的学生分数 ≤ 97 分
+```
+
+在语义切块中,`breakpoint_threshold_amount = 90` 的含义是:取第 90 百分位数的相似度值作为阈值。**只有相似度排在倒数 10% 的位置才会被切开**——这些位置是整篇文档中话题转变最剧烈的地方。
+
+#### 算法步骤
+假设一篇文档被拆成了 10 个句子:`[S1, S2, S3, ..., S10]`
+
+**Step 1:计算相邻句子的余弦相似度**
+
+```plain
+cos(S1, S2) = 0.95   ← 话题连贯
+cos(S2, S3) = 0.92   ← 话题连贯
+cos(S3, S4) = 0.30   ← 话题突变!
+cos(S4, S5) = 0.88
+cos(S5, S6) = 0.90
+cos(S6, S7) = 0.85
+cos(S7, S8) = 0.40   ← 话题突变!
+cos(S8, S9) = 0.91
+cos(S9, S10) = 0.89
+```
+
+得到相似度数组:`[0.95, 0.92, 0.30, 0.88, 0.90, 0.85, 0.40, 0.91, 0.89]`
+
+**Step 2:计算百分位数阈值**
+
+设置 `breakpoint_threshold_amount = 90`(第 90 百分位数)。
+
+将数组从小到大排序:`[0.30, 0.40, 0.85, 0.88, 0.89, 0.90, 0.91, 0.92, 0.95]`
+
+9 个元素,第 90 百分位数大约排在第 8 位(`9 × 0.9 = 8.1`),阈值约等于 0.91。
+
+**Step 3:基础方法——用阈值标记候选断点**
+
+用阈值 0.91 去比对原始的相邻相似度:
+
+```plain
+0.95 > 0.91 → 相似,不切
+0.92 > 0.91 → 相似,不切
+0.30 < 0.91 → ✂️ S3 和 S4 之间切开(话题确实变了)
+0.88 < 0.91 → ✂️ S4 和 S5 之间也会被切(问题:这里话题并没有明显转变!)
+0.90 < 0.91 → ✂️ S5 和 S6 之间也会被切
+0.85 < 0.91 → ✂️ ...
+0.40 < 0.91 → ✂️ S7 和 S8 之间切开
+0.91 = 0.91 → 恰好踩线,看实现,通常不切
+0.89 < 0.91 → ✂️ S9 和 S10 之间被切
+```
+
+> ⚠️ **重要问题**:如果只是简单地"一刀切",0.88、0.90、0.85、0.89 这些中等相似度的位置都会被切开,导致过度分割——在话题过渡区域产生大量碎片。这显然不是我们想要的。
+>
+
+#### LangChain 的优化:局部最小值策略
+为了避免上述过度分割问题,LangChain 的实际实现不只用单一阈值,而是分两步走:
+
+1. **粗筛**:用百分位数阈值找出所有"可疑"的候选断点(相似度低于阈值的位置)
+2. **精确定位**:在每个候选断点附近(前后若干句子),找到相似度的**局部最小值**(真正的"谷底")作为最终切分位置
+
+```plain
+相似度曲线示意:
+
+  1.0 │  ██
+      │  ██  ██     ██  ██         ██  ██  ██
+  0.5 │  ██  ██  ██  ██  ██  ██     ██  ██  ██
+      │  ██  ██  ██  ██  ██  ██  ██  ██  ██  ██
+  0.0 ├─────────────────────────────────────────
+      S1—S2 S3—S4 S5—S6 S7—S8 S9—S10
+                ▾           ▾
+            真正谷底      真正谷底
+```
+
++ 在 S3-S4 到 S6-S7 这一段中,0.30 就是局部最小值 → 只在这里切一刀
++ 在 S7-S8 到 S9-S10 这一段中,0.40 就是局部最小值 → 只在这里切一刀
++ 结果:9 个候选断点缩减为 2 个真正的切分点,避免了碎片化
+
+#### 参数调优
+`breakpoint_threshold_amount` 控制切分的"敏感度":
+
+| 值 | 含义 | 效果 | 适用场景 |
+| :--- | :--- | :--- | :--- |
+| **95** | 只有后 5% 的相似度被标记 | 切得少,每块大 | 长文档、连贯性强的技术文档 |
+| **90** | 后 10% 被标记 | 适中(推荐默认值) | 大多数场景 |
+| **80** | 后 20% 被标记 | 切得多,每块小 | 短文档、话题跳跃频繁的 QA 文档 |
+| **50** | 后 50% 被标记 | 切得非常碎 | 需要极高检索粒度的场景 |
+
+
+> **调参经验**:`breakpoint_threshold_amount` 值越大,阈值越高,切出来的块越少(越粗)。建议从 90 开始,观察检索效果——如果检索结果经常包含多个不相关话题,说明块太大,降低到 85–80;如果检索结果信息不全,说明切得太碎,提高到 92–95。
+>
+
+---
+
+## 第五章:多路召回与混合检索(重点)
+### 5.1 为什么需要多路召回
+单一检索方式各有盲区:
+
+| 检索方式 | 优势 | 劣势 |
+| :--- | :--- | :--- |
+| **向量检索** | 理解语义,"苹果公司"能匹配到"Apple Inc." | 对精确关键词不敏感,(例如:**<font style="color:rgb(15, 17, 21);">搜</font>**<font style="color:rgb(15, 17, 21);">:</font>`特斯拉 Model Y 2024款 后轮驱动版 报价`<br/>**<font style="color:rgb(15, 17, 21);">向量检索召回</font>**<font style="color:rgb(15, 17, 21);">:Model 3报价、Model Y 2023款、蔚来ES6报价</font>) |
+| **关键词检索(BM25)** | 精确匹配能力强,速度快 | 无法理解同义词和语义相似性(例如:"感冒"和"风寒") |
+
+
+多路召回的核心思想:**两条腿走路,取长补短**。
+
+### 5.2 BM25 原理:图书馆管理员的打分规则
+BM25 是经典的关键词检索算法,理解它有助于调优。用一个图书馆的例子来解释:
+
+你在图书馆找一本"怎么做蛋糕"的书,管理员(搜索引擎)用三条规则打分:
+
+**规则一:关键词出现越多,越可能是好书**
+
+书 A 提了 1 次"蛋糕",书 B 提了 10 次 → 书 B 更相关。
+
+但不是越多越好!如果一本书写了 100 次"蛋糕蛋糕蛋糕"全是废话,BM25 会"封顶"——到一定程度就不加分了。这叫**词频饱和**。
+
+**规则二:短书里提到一次,比长书里提到一次更有价值**
+
+书 C 只有 5 页,认真讲了"戚风蛋糕做法";书 D 有 500 页,在第 387 页顺带提了一句"蛋糕也是甜点"。BM25 会惩罚又长又水的书,给短小精悍的书更高分。这叫**文档长度归一化**。
+
+**规则三:越少见的词,越重要**
+
+查询"低糖无麸质蛋糕"——"蛋糕"很常见,很多书都有,重要性一般;"无麸质"很少见,只有几本书提到,那这些书很可能就是你要的。这叫**逆文档频率(IDF)**:越稀有,越珍贵。
+
+> **一句话总结**:BM25 喜欢内容相关、简洁不啰嗦、用词精准的文档,讨厌又长又水的"注水文"。
+>
+
+### 5.3 混合检索(EnsembleRetriever)
+<!-- 这是一张图片,ocr 内容为: -->
+![](https://cdn.nlark.com/yuque/0/2026/png/21571931/1782831019490-e7b6c97c-e76c-4661-90ae-3b1a2c0bfa42.png)
+
+`EnsembleRetriever` 是 LangChain 中混合检索的核心组件,它做的事情很简单:**并行调用多个检索器,然后加权融合结果**,解决单一检索器的局限性,提升检索的精准度、召回率和鲁棒性。
+
+```python
+#安装依赖:uv pip install rank_bm25 -i https://mirrors.aliyun.com/pypi/simple
+from langchain_community.retrievers import BM25Retriever
+from langchain_community.vectorstores import Milvus
+from langchain_community.embeddings import DashScopeEmbeddings
+from langchain_classic.retrievers import EnsembleRetriever
+
+# 初始化
+embedding_model = DashScopeEmbeddings(
+    model="text-embedding-v3",
+    dashscope_api_key="your_api_key_here"
+)
+
+# ---- 创建 BM25 检索器 ----
+# BM25 不需要向量,直接基于文本的关键词匹配
+bm25_retriever = BM25Retriever.from_documents(valid_docs)
+bm25_retriever.k = 10  # 返回 Top-10
+
+# ---- 创建向量检索器 ----
+vectorstore = Milvus(
+    embedding_function=embedding_model,
+    connection_args={"uri": "http://localhost:19530"},
+    collection_name="car_info_collection"
+)
+vector_retriever = vectorstore.as_retriever(search_kwargs={"k": 10})
+
+# ---- 创建混合检索器 ----
+# weights 控制各检索器的权重,总和建议为 1
+# 向量检索权重更高(0.6),因为语义理解通常更重要
+ensemble_retriever = EnsembleRetriever(
+    retrievers=[bm25_retriever, vector_retriever],
+    weights=[0.4, 0.6],
+    normalize_scores=True  # 将不同检索器的分数归一化到 [0,1],避免偏差
+)
+
+# 执行混合检索
+results = ensemble_retriever.invoke("在多少速度范围内,HDC才会激活?")
+print(f"混合检索返回 {len(results)} 条结果")
+print(results[0].page_content)
+```
+
+### 5.4 多查询检索(MultiQueryRetriever)
+向量检索有个常见问题:用户的提问方式和文档的表述方式存在"词汇鸿沟"。比如用户问"怎么修车",但文档里写的是"车辆故障排除指南"——语义一样,措辞不同,向量检索可能匹配不上。
+
+`MultiQueryRetriever` 的解决思路很巧妙:**让大模型把一个问题改写成多个不同角度的问题,分别检索,合并去重**。
+
+相当于从多个"视角"去探索知识库,大大增加命中率。
+
+<!-- 这是一张图片,ocr 内容为: -->
+![](https://cdn.nlark.com/yuque/0/2026/png/21571931/1782831166893-6731ec8b-161a-411a-85cf-0a97296adeaa.png)
+
+```python
+from langchain_core.prompts import PromptTemplate
+from langchain_community.chat_models import ChatTongyi
+from langchain_classic.retrievers.multi_query import MultiQueryRetriever
+
+# 初始化大模型
+llm = ChatTongyi(model="qwen-plus", api_key="your_api_key_here")
+
+# 自定义改写提示词(可选,不写则用默认的)
+CUSTOM_PROMPT = PromptTemplate(
+    input_variables=["question"],
+    template="""你是一个专业的问题改写助手。请为下面的问题生成 4 个不同的改写版本,
+每个版本应该:
+- 从不同角度表达相同的意图
+- 使用不同的关键词和表达方式
+- 保持问题的核心含义
+
+每个问题单独一行,不要编号。
+
+原始问题: {question}
+
+改写后的问题:"""
+)
+
+# 创建多查询检索器
+# 底层检索器用的是上面的混合检索器,这样每个改写问题都会走混合检索
+multi_query_retriever = MultiQueryRetriever.from_llm(
+    retriever=ensemble_retriever,
+    llm=llm,
+    prompt=CUSTOM_PROMPT
+)
+
+# 执行检索
+# 假设生成了 4 个改写问题,每个检索 10 条,去重后可能得到 20多 条结果
+results = multi_query_retriever.invoke("在多少速度范围内,HDC才会激活?")
+print(f"多查询检索返回 {len(results)} 条结果")
+print(results[0].page_content)
+```
+
+> **性能提醒**:多查询检索相当于一次请求做了 5 次检索(1 次原始 + 4 次改写),延迟和成本都会增加。适合对召回率要求高的场景,不适合实时性要求极高的在线服务。
+>
+
+### 5.5 完整的多路召回系统
+把上面的组件封装成一个可切换的检索系统:
+
+<!-- 这是一张图片,ocr 内容为: -->
+![](https://cdn.nlark.com/yuque/0/2026/png/21571931/1782831226063-ab6f791a-fd23-43c7-a955-93ad9e8681d6.png)
+
+```python
+class MultiRetrieverSystem:
+    """
+    多路召回检索系统
+    支持 BM25、向量检索、混合检索、多查询检索四种模式
+    """
+
+    def __init__(self, documents, embedding_model, llm):
+        self.documents = documents
+        self.embedding_model = embedding_model
+        self.llm = llm
+        self.setup_retrievers()
+
+    def setup_retrievers(self):
+        """初始化所有检索器"""
+        # BM25 检索器
+        self.bm25 = BM25Retriever.from_documents(self.documents)
+        self.bm25.k = 10
+
+        # 向量检索器
+        self.vectorstore = Milvus(
+            embedding_function=embedding_model,
+            connection_args={"uri": "http://localhost:19530"},
+            collection_name="car_info_collection"
+        )
+        self.vector = self.vectorstore.as_retriever(search_kwargs={"k": 10})
+
+        # 混合检索器
+        self.ensemble = EnsembleRetriever(
+            retrievers=[self.bm25, self.vector], # 待融合的基础检索器列表
+            weights=[0.4, 0.6], # BM25权重0.4,向量检索权重0.6 总和建议为1,向量检索权重更高
+            normalize_scores=True # 是否将不同检索器的分数归一化到[0,1](避免因分数范围差异导致融合偏差)
+        )
+
+        # 多查询检索器
+        self.multi_query = MultiQueryRetriever.from_llm(
+            retriever=self.ensemble,
+            llm=self.llm,
+            prompt=CUSTOM_PROMPT
+        )
+
+    def search(self, query, mode="ensemble"):
+        """
+        执行检索
+        mode: "bm25" | "vector" | "ensemble" | "multi_query"
+        """
+        retriever_map = {
+            "bm25": self.bm25,
+            "vector": self.vector,
+            "ensemble": self.ensemble,
+            "multi_query": self.multi_query,
+        }
+        if mode not in retriever_map:
+            raise ValueError(f"不支持的检索模式: {mode},可选: {list(retriever_map.keys())}")
+        return retriever_map[mode].invoke(query)
+```
+
+使用示例:
+
+```python
+# 初始化系统
+rag_system = MultiRetrieverSystem(valid_docs, embedding_model, llm)
+
+# 对比不同检索策略的效果
+query = "在多少速度范围内,HDC才会激活?"
+
+bm25_results = rag_system.search(query, "bm25")
+vector_results = rag_system.search(query, "vector")
+ensemble_results = rag_system.search(query, "ensemble")
+multi_results = rag_system.search(query, "multi_query")
+
+print(f"BM25: {len(bm25_results)} 条 | 向量: {len(vector_results)} 条")
+print(f"混合: {len(ensemble_results)} 条 | 多查询: {len(multi_results)} 条")
+```
+
+---
+
+## 第六章:查询侧优化——让"问题"本身变好(重点)
+前面几章我们一直在优化"检索"和"切片",但很多时候,**问题本身才是瓶颈**。用户的原始问题可能模糊、口语化、信息量不足,直接拿去检索效果自然不好。
+
+本章介绍四种查询侧优化技术:查询重写、查询分解、查询澄清、HyDE 查询扩展。它们的共同目标是——**在检索之前,先把问题"打磨"好**。
+
+### 6.1 查询重写(Query Rewriting)
+#### 问题场景
+用户的原始提问往往带有口语化、歧义性或信息不足的问题,直接用于检索效果不佳:
+
+| 原始问题 | 问题 | 优化后 |
+| :--- | :--- | :--- |
+| "这个东西怎么用" | 太模糊,"东西"指什么? | "该产品的安装使用方法" |
+| "保修坏了咋办" | 口语化 + 歧义 | "产品在保修期内出现故障的维修流程" |
+| "和 XX 比呢" | 缺乏上下文 | "本产品与 XX 产品在性能参数上的对比" |
+
+
+#### 原理
+查询重写的核心思想:**用 LLM 将用户的口语化问题,改写成更精确、更适合检索的形式**。
+
+与 `MultiQueryRetriever` 的区别:
+
++ **MultiQuery**:一个问题 → 多个改写版本,重点是**多角度覆盖**
++ **Query Rewriting**:一个问题 → 一个优化版本,重点是**精准提升**
+
+两者可以组合使用:先重写优化,再多角度扩展。
+
+#### 实现
+```python
+from langchain_core.prompts import PromptTemplate
+from langchain_core.output_parsers import StrOutputParser
+from langchain_community.chat_models import ChatTongyi
+
+llm = ChatTongyi(model="qwen-plus", api_key="your_api_key_here")
+
+# 查询重写 Prompt
+rewrite_prompt = PromptTemplate(
+    input_variables=["query"],
+    template="""你是一个查询优化助手。请将用户的口语化问题改写为更适合信息检索的精确查询。
+
+改写要求:
+1. 补充隐含的上下文信息
+2. 将口语化表达转为专业表述
+3. 消除歧义,明确查询意图
+4. 保持原意不变,不要添加原问题未提及的内容
+5. 直接输出改写后的查询,不要解释
+
+用户问题: {query}
+
+改写后的查询:"""
+)
+
+# 构建重写链
+rewrite_chain = rewrite_prompt | llm | StrOutputParser()
+
+# 测试
+original_query = "这个东西怎么用啊"
+rewritten = rewrite_chain.invoke({"query": original_query})
+print(f"原始问题: {original_query}")
+print(f"重写结果: {rewritten}")
+# 输出示例: "该产品的安装配置和使用操作方法"
+```
+
+#### 在 RAG 链路中集成
+将查询重写作为检索前的预处理步骤:
+
+```python
+class RAGWithQueryRewriting:
+    """集成查询重写的 RAG 系统"""
+
+    def __init__(self, retriever, llm, rewrite_chain):
+        self.retriever = retriever
+        self.llm = llm
+        self.rewrite_chain = rewrite_chain
+
+    def invoke(self, question):
+        # 第一步:重写查询
+        rewritten_query = self.rewrite_chain.invoke({"query": question})
+        print(f"原始问题: {question}")
+        print(f"重写后:   {rewritten_query}")
+
+        # 第二步:用重写后的查询进行检索
+        docs = self.retriever.invoke(rewritten_query)
+
+        # 第三步:用原始问题 + 检索结果生成答案
+        context = "\n\n".join([doc.page_content for doc in docs])
+        answer_prompt = f"""基于以下上下文回答用户问题。如果上下文中没有相关信息,请说明。
+
+上下文:{context}
+
+用户问题:{question}
+答案:"""
+        answer = self.llm.invoke(answer_prompt).content
+
+        return {
+            "original_query": question,
+            "rewritten_query": rewritten_query,
+            "retrieved_docs": docs,
+            "answer": answer
+        }
+
+# 使用示例
+rag = RAGWithQueryRewriting(
+    retriever=ensemble_retriever,
+    llm=llm,
+    rewrite_chain=rewrite_chain
+)
+result = rag.invoke("在多少速度范围内,HDC才会激活?")
+print(result["answer"])
+```
+
+### 6.2 查询分解(Query Decomposition)
+#### 问题场景
+有些问题包含多个子问题,或者需要多步推理才能回答:
+
+| 复杂问题 | 分解后 |
+| :--- | :--- |
+| "产品 A 和产品 B 的保修政策有什么区别?" | ① 产品 A 的保修政策是什么?② 产品 B 的保修政策是什么?③ 对比两者差异 |
+| "这个产品的安装流程和注意事项分别是什么?" | ① 安装流程是什么?② 有哪些注意事项? |
+| "为什么推荐用 X 型号,它比 Y 好在哪?" | ① X 型号有哪些优势?② Y 型号有哪些不足?③ 推荐 X 的理由是什么? |
+
+
+#### 原理
+查询分解的核心思想:**用 LLM 将复杂问题拆解为多个简单的子问题,分别检索后合并结果**。
+
+这比直接检索复杂问题效果更好,因为:
+
++ 子问题更聚焦,检索精度更高
++ 每个子问题可以从不同文档中获取信息
++ 最终合并的答案更全面
+
+#### 实现
+```python
+from langchain_core.prompts import PromptTemplate
+from langchain_core.output_parsers import StrOutputParser
+from langchain_community.chat_models import ChatTongyi
+import json
+
+llm = ChatTongyi(model="qwen-plus", api_key="your_api_key_here")
+
+# 查询分解 Prompt
+decompose_prompt = PromptTemplate(
+    input_variables=["question"],
+    template="""你是一个问题分解助手。请将用户的复杂问题分解为 2-4 个独立的子问题,
+每个子问题应该能独立检索和回答。
+
+要求:
+1. 子问题之间互不依赖,可以并行检索
+2. 子问题覆盖原始问题的所有方面
+3. 每个子问题简洁明确
+4. 以 JSON 数组格式输出
+
+用户问题: {question}
+
+输出格式示例: ["子问题1", "子问题2", "子问题3"]
+
+子问题列表:"""
+)
+
+decompose_chain = decompose_prompt | llm | StrOutputParser()
+
+
+def decompose_query(question: str) -> list[str]:
+    """将复杂问题分解为子问题"""
+    result = decompose_chain.invoke({"question": question})
+    # 解析 JSON 数组
+    try:
+        sub_queries = json.loads(result.strip())
+        return sub_queries
+    except json.JSONDecodeError:
+        # 兜底:按行分割
+        return [line.strip() for line in result.strip().split("\n") if line.strip()]
+
+
+# 测试
+question = "产品 A 和产品 B 的保修政策和价格有什么区别?"
+sub_queries = decompose_query(question)
+print(f"原始问题: {question}")
+print(f"分解结果:")
+for i, sq in enumerate(sub_queries, 1):
+    print(f"  {i}. {sq}")
+# 输出示例:
+# 1. 产品 A 的保修政策是什么?
+# 2. 产品 B 的保修政策是什么?
+# 3. 产品 A 和产品 B 的价格分别是多少?
+```
+
+#### 分解后并行检索与合并
+```python
+from concurrent.futures import ThreadPoolExecutor
+#使用 ThreadPoolExecutor 创建线程池,并行执行检索
+#每个子问题独立检索,互不干扰
+#按内容哈希值去重(避免重复文档)
+
+def parallel_retrieve_and_merge(sub_queries, retriever, k_per_query=5):
+    """
+    对每个子问题并行检索,然后合并去重
+    """
+    all_docs = []
+    seen_contents = set()
+
+    def retrieve_one(query):
+        return retriever.invoke(query)
+
+    # 创建线程池,线程数等于子问题数量
+    with ThreadPoolExecutor(max_workers=len(sub_queries)) as executor:
+        #提交所有检索任务
+        futures = [executor.submit(retrieve_one, q) for q in sub_queries]
+        ## 收集结果
+        for future in futures:
+            docs = future.result()
+            for doc in docs:
+                # 按内容去重
+                content_hash = hash(doc.page_content)
+                if content_hash not in seen_contents:
+                    seen_contents.add(content_hash)
+                    all_docs.append(doc)
+
+    return all_docs
+
+
+# 完整的分解-检索-生成流程
+class RAGWithDecomposition:
+    """集成查询分解的 RAG 系统"""
+
+    def __init__(self, retriever, llm):
+        self.retriever = retriever
+        self.llm = llm
+
+    def invoke(self, question):
+        # 第一步:分解问题
+        sub_queries = decompose_query(question)
+        print(f"分解为 {len(sub_queries)} 个子问题")
+
+        # 第二步:并行检索并合并
+        all_docs = parallel_retrieve_and_merge(sub_queries, self.retriever)
+        print(f"合并后共 {len(all_docs)} 个文档片段")
+
+        # 第三步:用所有上下文生成综合答案
+        context = "\n\n".join([doc.page_content for doc in all_docs])
+        answer_prompt = f"""基于以下上下文,全面回答用户的问题。
+请综合所有相关信息,给出完整、有条理的答案。
+
+上下文:{context}
+
+用户问题:{question}
+答案:"""
+        answer = self.llm.invoke(answer_prompt).content
+
+        return {
+            "question": question,
+            "sub_queries": sub_queries,
+            "doc_count": len(all_docs),
+            "answer": answer
+        }
+
+# 使用示例
+rag_decomp = RAGWithDecomposition(retriever=ensemble_retriever, llm=llm)
+result = rag_decomp.invoke("在多少速度范围内,HDC才会激活?")
+print(result["answer"])
+```
+
+> **查询分解 vs 多查询检索**:两者看起来相似,但目的不同。多查询检索是同一个问题的不同表述(平铺),查询分解是把一个问题拆成多个子问题(纵深)。在实际系统中可以组合使用。
+>
+
+### 6.3 查询澄清(Query Clarification)
+#### 问题场景
+有时用户的提问过于模糊或缺乏关键信息,**强行检索不如先问清楚**:
+
+| 模糊问题 | 问题所在 | 应该澄清 |
+| :--- | :--- | :--- |
+| "怎么退货?" | 哪个渠道买的?什么品类? | "请问您是在哪个渠道购买的?线上还是线下?" |
+| "这个多少钱?" | "这个"指什么? | "请问您想了解哪款产品的价格?" |
+| "出了问题怎么办?" | 什么问题?硬件还是软件? | "请问您遇到的是什么类型的问题?" |
+
+
+#### 原理
+查询澄清的核心思想:**用 LLM 判断问题是否信息充分,不充分则生成澄清反问**。
+
+这是一种"不急于回答,先搞清楚再行动"的策略,能显著提升用户体验——与其给一个答非所问的答案,不如先问清楚。
+
+#### 实现
+```python
+from langchain_core.prompts import PromptTemplate
+from langchain_core.output_parsers import StrOutputParser
+from langchain_community.chat_models import ChatTongyi
+import json
+
+llm = ChatTongyi(model="qwen-plus", api_key="your_api_key_here")
+
+# 查询澄清 Prompt
+clarify_prompt = PromptTemplate(
+    input_variables=["query"],
+    template="""你是一个智能客服助手。请判断用户的提问是否包含足够的信息来进行准确检索和回答。
+
+判断标准:
+1. 问题是否有明确的主语(指代的对象是否清晰)
+2. 问题是否包含必要的上下文(时间、地点、产品型号等)
+3. 问题是否存在歧义(可能有多种理解方式)
+
+请以 JSON 格式返回:
+- 如果问题清晰:{{"need_clarify": false, "reason": "问题清晰的原因"}}
+- 如果需要澄清:{{"need_clarify": true, "clarification": "向用户提出的澄清问题", "assumption": "如果必须回答时的合理假设"}}
+
+用户问题: {query}
+
+结果:"""
+)
+
+clarify_chain = clarify_prompt | llm | StrOutputParser()
+
+
+def check_and_clarify(query: str) -> dict:
+    """检查是否需要澄清,返回判断结果"""
+    result = clarify_chain.invoke({"query": query})
+    try:
+        return json.loads(result.strip())
+    except json.JSONDecodeError:
+        # 兜底:假设不需要澄清
+        return {"need_clarify": False, "reason": "解析失败,默认直接回答"}
+
+
+# 测试
+test_queries = [
+    "怎么退货?",
+    "iPhone 16 Pro 的保修期是多久?",
+    "这个多少钱?",
+]
+
+for q in test_queries:
+    result = check_and_clarify(q)
+    print(f"\n问题: {q}")
+    print(f"需要澄清: {result['need_clarify']}")
+    if result['need_clarify']:
+        print(f"澄清反问: {result['clarification']}")
+        print(f"默认假设: {result.get('assumption', '无')}")
+```
+
+#### 在对话流中集成
+```python
+class RAGWithClarification:
+    """集成查询澄清的 RAG 系统(支持多轮对话)"""
+
+    def __init__(self, retriever, llm):
+        self.retriever = retriever
+        self.llm = llm
+        self.clarify_chain = clarify_chain
+
+    def invoke(self, question, auto_clarify=False):
+        """
+        auto_clarify=True: 需要澄清时直接用假设继续(适合 API 调用)
+        auto_clarify=False: 需要澄清时返回澄清问题(适合交互式对话)
+        """
+        # 第一步:检查是否需要澄清
+        clarify_result = check_and_clarify(question)
+
+        if clarify_result["need_clarify"]:
+            if auto_clarify:
+                # 自动模式:用假设重新表述问题
+                question = clarify_result.get("assumption", question)
+                print(f"[自动澄清] 假设用户意图为: {question}")
+            else:
+                # 交互模式:返回澄清问题,等待用户补充
+                return {
+                    "status": "need_clarify",
+                    "clarification": clarify_result["clarification"],
+                    "original_question": question
+                }
+
+        # 第二步:正常检索 + 生成
+        docs = self.retriever.invoke(question)
+        context = "\n\n".join([doc.page_content for doc in docs])
+
+        answer_prompt = f"""基于以下上下文回答用户问题。
+
+上下文:{context}
+
+用户问题:{question}
+答案:"""
+        answer = self.llm.invoke(answer_prompt).content
+
+        return {
+            "status": "answered",
+            "question": question,
+            "answer": answer
+        }
+
+# 使用示例
+rag_clarify = RAGWithClarification(retriever=ensemble_retriever, llm=llm)
+
+# 交互模式
+result = rag_clarify.invoke("在多少速度范围内,HDC才会激活?", auto_clarify=False)
+if result["status"] == "need_clarify":
+    print(f"🤖: {result['clarification']}")  # "请问您是在哪个渠道购买的?"
+
+# 自动模式(API 场景)
+result = rag_clarify.invoke("在多少速度范围内,HDC才会激活?", auto_clarify=True)
+print(result["answer"]) 
+```
+
+### 6.4 HyDE 查询扩展(Hypothetical Document Embeddings)
+#### 问题场景
+用户的查询通常是**短句或关键词**,而知识库中的文档是**长段落或完整句子**。两者在向量空间中的分布存在天然差距——短查询的 Embedding 往往无法准确指向长文档。
+
+#### 原理
+HyDE(Hypothetical Document Embeddings)的思路非常巧妙:
+
+```plain
+传统流程:  用户问题 → Embedding → 向量检索
+HyDE 流程: 用户问题 → LLM 生成假设性回答 → Embedding → 向量检索
+```
+
+核心洞察:**一个"假设性回答"的 Embedding,比原始问题的 Embedding 更接近真实文档的向量**。
+
+为什么?因为假设性回答在形式上(长度、用词、句式)更接近知识库中的真实文档,向量空间中的距离自然更近。
+
+#### 实现
+```python
+from langchain_core.prompts import PromptTemplate
+from langchain_core.output_parsers import StrOutputParser
+from langchain_community.chat_models import ChatTongyi
+from langchain_community.embeddings import DashScopeEmbeddings
+
+llm = ChatTongyi(model="qwen-plus", api_key="your_api_key_here")
+embedding_model = DashScopeEmbeddings(
+    model="text-embedding-v3",
+    dashscope_api_key="your_api_key_here"
+)
+
+# HyDE Prompt:生成假设性文档
+hyde_prompt = PromptTemplate(
+    input_variables=["question"],
+    template="""请根据以下问题,写一段可能包含答案的文档片段。
+要求:
+1. 像真实文档一样专业、详细
+2. 包含具体的数据、步骤或事实
+3. 长度在 100-200 字之间
+4. 即使你不确定答案,也要根据问题合理推测,写出一段"看起来像真的"文档
+
+问题: {question}
+
+假设性文档:"""
+)
+
+hyde_chain = hyde_prompt | llm | StrOutputParser()
+
+
+def hyde_search(question: str, vectorstore, k=5):
+    """
+    HyDE 检索流程:
+    1. 生成假设性文档
+    2. 对假设性文档做 Embedding
+    3. 用假设性文档的向量去检索
+    """
+    # 第一步:生成假设性文档
+    hypothetical_doc = hyde_chain.invoke({"question": question})
+    print(f"假设性文档: {hypothetical_doc[:100]}...")
+
+    # 第二步 + 第三步:用假设性文档的向量检索
+    # LangChain 的 similarity_search 会自动对文本做 Embedding
+    results = vectorstore.similarity_search(
+        query=hypothetical_doc,  # 用假设性文档而非原始问题
+        k=k
+    )
+
+    return {
+        "original_question": question,
+        "hypothetical_doc": hypothetical_doc,
+        "retrieved_docs": results
+    }
+
+
+# 测试
+result = hyde_search(
+    question="产品保修期是多久?",
+    vectorstore=vectorstore,
+    k=3
+)
+
+print(f"\n原始问题: {result['original_question']}")
+print(f"\n检索到 {len(result['retrieved_docs'])} 个文档:")
+for i, doc in enumerate(result['retrieved_docs'], 1):
+    print(f"  {i}. {doc.page_content[:80]}...")
+```
+
+#### HyDE 的局限性与应对
+HyDE 并非万能,需要注意以下问题:
+
+| 局限 | 说明 | 应对方案 |
+| :--- | :--- | :--- |
+| **LLM 幻觉风险** | 假设性文档可能包含错误信息,导致检索方向偏离 | 结合原始问题和假设性文档的向量做加权融合 |
+| **额外延迟** | 多了一次 LLM 调用 | 用小模型生成假设性文档,大模型生成最终答案 |
+| **领域依赖** | 通用领域的假设性文档质量不如专业领域 | 在 Prompt 中加入领域上下文 |
+
+
+#### 进阶:HyDE + 原始查询融合
+同时用原始问题和假设性文档检索,融合结果:
+
+```python
+from langchain.retrievers import EnsembleRetriever
+
+def hybrid_hyde_search(question, vectorstore, bm25_retriever, k=10):
+    """HyDE 向量检索 + BM25 关键词检索的混合方案"""
+    # 生成假设性文档
+    hypothetical_doc = hyde_chain.invoke({"question": question})
+
+    # 创建两个向量检索器:一个用原始问题,一个用假设性文档
+    # 通过 EnsembleRetriever 加权融合
+    # 注意:这里需要在 Milvus 层面做两次搜索再合并
+
+    # 方案一:用假设性文档检索(偏语义)
+    hyde_docs = vectorstore.similarity_search(hypothetical_doc, k=k)
+
+    # 方案二:用原始问题检索(偏精确)
+    original_docs = vectorstore.similarity_search(question, k=k)
+
+    # 合并去重
+    seen = set()
+    merged_docs = []
+    for doc in hyde_docs + original_docs:
+        if hash(doc.page_content) not in seen:
+            seen.add(hash(doc.page_content))
+            merged_docs.append(doc)
+
+    return merged_docs[:k]
+```
+
+### 6.5 查询侧优化策略对比
+| 策略 | 核心思想 | 适用场景 | 额外开销 | 效果提升 |
+| :--- | :--- | :--- | :--- | :--- |
+| **查询重写** | 一个问题 → 一个更精准的问题 | 口语化、模糊查询 | 1 次 LLM 调用 | ⭐⭐⭐ |
+| **查询分解** | 一个问题 → 多个子问题 | 多方面、对比类复杂问题 | N 次 LLM 调用 + N 次检索 | ⭐⭐⭐⭐ |
+| **查询澄清** | 判断是否需要追问 | 信息不足的模糊问题 | 1 次 LLM 调用 | ⭐⭐⭐ |
+| **HyDE** | 生成假设性文档再检索 | 短查询 vs 长文档的鸿沟 | 1 次 LLM 调用 | ⭐⭐⭐⭐ |
+| **多查询检索** | 一个问题 → 多个改写版本 | 提升召回覆盖率 | N 次检索 | ⭐⭐⭐ |
+
+
+> **组合建议**:在实际生产中,这些策略不是互斥的。推荐组合:**查询澄清(入口过滤)→ 查询重写(标准化)→ HyDE 或多查询(扩展检索)→ 精排(兜底排序)**,形成完整的查询优化链路。
+>
+
+---
+
+## 第七章:Reranker 精排模型接入(重点)
+### 7.1 两阶段检索架构
+RAG 系统的检索通常分两步走:
+
+1. **粗排(召回阶段)**:用向量检索 / BM25 快速从海量文档中召回 Top-20 候选集。速度快,但精度有限。
+2. **精排(重排阶段)**:用 Reranker 模型对这 20 条候选逐一计算精细相关性分数,重新排序后选出 Top-3。
+
+粗排像是"海选",精排像是"面试"——海选看个大概,面试才看真本事。
+
+<!-- 这是一张图片,ocr 内容为: -->
+![](两阶段检索流程图.png)<!-- 这是一张图片,ocr 内容为: -->
+![Reranker是知识库的“智能质检员”,假设你在图书馆找书,先用关键词检索到100本书,但需要找出最相关的3本才行。图书管理员(Reranker)会综合评估书籍内容、出版时间、作者权威性等维度进行二次筛选。将检索到的候选文档(如100条)按照与问题的相关性重新排序,把最匹配的结果提升到Top位置。](https://cdn.nlark.com/yuque/0/2026/png/21571931/1782831339761-c2e08513-9da5-4caf-979d-b4c6f80c6fba.png)
+
+Reranker 和 Embedding 模型的本质区别:
+
++ **Embedding 模型**:把文本压缩成一个向量,用余弦相似度比较。信息有损,速度快。
++ **Reranker 模型**:直接把"问题 + 文档"一起输入模型,输出一个相关性分数。信息无损,速度慢,但精度高。
+
+### 7.2 排序的关键维度
+Reranker 不只是看语义相关性,还会综合考虑:
+
+| 维度 | 说明 | 示例 |
+| :--- | :--- | :--- |
+| **语义相关性** | 文档是否真的回答了问题 | 问"饮食禁忌"→ 回答"胰岛素注射"的文档排后面 |
+| **时效性** | 越新的文档通常越可靠 | 2024 年指南 > 2015 年资料 |
+| **多样性** | 避免返回内容高度重复的文档 | 3 篇都讲"糖分控制"不如 2 篇"糖分控制" + 1 篇"运动管理" |
+
+
+### 7.3 DashScope Rerank 实战
+阿里通义提供了开箱即用的 Rerank 服务,接入非常简单:
+
+```python
+import dashscope
+from http import HTTPStatus
+
+docs = list()
+for result in multi_results:
+    docs.append(result.page_content)
+
+
+resp = dashscope.TextReRank.call(
+    model="qwen3-rerank",# 通义的重排模型
+    api_key="your_api_key_here",
+    query="在多少速度范围内,HDC才会激活?",
+    documents=docs,
+    top_n=2, #精排后只保留 Top-2
+    return_documents=True,
+    )
+reRankdoc=list()
+if resp.status_code == HTTPStatus.OK and len(resp['output']['results'])>0:
+    for reRankoutput in resp['output']['results']:
+        reRankdoc.append(reRankoutput['document']['text'])
+        print(f"相关性分数: {reRankoutput['relevance_score']:.4f}")
+        print(f"内容: {reRankoutput['document']['text']}")
+        print("---")
+else:
+    print(resp)
+
+
+```
+
+### 7.4 精排后送入大模型生成答案
+精排完成后,把最相关的文档送入大模型生成最终答案:
+
+```python
+from langchain_core.prompts import ChatPromptTemplate
+
+# 构建 Prompt 模板
+prompt = ChatPromptTemplate.from_template("""
+你是一个专业的知识库助手。请根据以下检索到的上下文回答用户问题。
+
+**规则:**
+- 只基于提供的上下文回答,不要编造
+- 如果上下文中没有相关信息,直接说「根据现有资料,我找不到这个问题的答案」
+- 回答要简洁直接,引用原文时用引号
+
+**检索到的上下文:**
+{context}
+
+**用户问题:**
+{question}
+""")
+```
+
+```python
+from langchain_community.chat_models import ChatTongyi
+from langchain_core.output_parsers import StrOutputParser
+
+# 初始化大模型(这里用通义千问,也可以换成 DeepSeek)
+llm = ChatTongyi(
+    model="qwen-plus",                 # 模型名称
+    dashscope_api_key="your_api_key_here"   # 替换为你的真实 Key
+)
+
+# 拼装上下文
+context_text = "\n\n---\n\n".join([doc for doc in reRankdoc])
+
+# 构建 Chain 并调用
+chain = prompt | llm | StrOutputParser()
+
+response = chain.invoke({
+    "context": context_text,
+    "question": query
+})
+
+print(response)
+```
+
+## 第八章:RAG 效果评估体系
+### 10.1 为什么需要评估
+RAG 系统调优最大的痛点是:**你不知道改了某个参数后,效果到底变好了还是变差了**。没有量化评估,调优就是盲人摸象。
+
+<font style="color:#DF2A3F;">评估方法:</font>**<font style="color:#DF2A3F;">基于 LLM 的评估</font>**<font style="color:#DF2A3F;">(RAGAS):用大模型来判断检索和生成的质量</font>
+
+### 10.2 RAGAS 四大核心指标
+RAGAS 是目前最主流的 RAG 评估框架,提供四个关键指标:
+
+#### 指标一:Faithfulness(忠实度)⭐⭐⭐⭐⭐
+**作用**:防止模型幻觉,确保答案有据可依。
+
+**原理**:将生成的答案拆成多个独立陈述,检查每个陈述是否能从检索的上下文中找到支撑。
+
+```plain
+公式:Faithfulness = 被支撑的陈述数 / 总陈述数
+
+示例:
+  问题:"Python 的创始人是谁?"
+  上下文:"Python 由 Guido van Rossum 在 1991 年创建"
+  答案:"Python 的创始人是 Guido van Rossum,创建于 1991 年"
+  → 两个陈述都有支撑 → Faithfulness = 1.0 ✓
+```
+
+#### 指标二:Answer Relevancy(答案相关性)⭐⭐⭐⭐⭐
+**作用**:确保答案直接回答问题,不跑题。
+
+**原理**:反向操作——让 LLM 根据答案反推可能的问题,然后计算反推问题和原始问题的语义相似度。相似度越高,说明答案越切题。
+
+```plain
+示例:
+  原始问题:"如何安装 Python?"
+  生成答案:"访问 python.org 下载安装包..."
+  反推问题:"怎样安装 Python?" → 高相似度 → Answer Relevancy 高 ✓
+```
+
+#### 指标三:Context Precision(上下文精确度)⭐⭐⭐⭐
+**作用**:评估检索结果的排序质量——相关的文档是否排在前面。
+
+**原理**:类似信息检索中的 Precision@K,检查每个位置的上下文是否与问题相关。
+
+```plain
+示例:
+  检索到 5 个文档片段:
+  位置1: 相关 ✓
+  位置2: 相关 ✓
+  位置3: 不相关 ✗
+  位置4: 相关 ✓
+  位置5: 不相关 ✗
+  → 前面相关度高 → Context Precision 高
+```
+
+#### 指标四:Context Recall(上下文召回率)⭐⭐⭐⭐
+**作用**:评估检索系统是否找全了所需信息。
+
+**原理**:需要标准答案(ground truth),检查标准答案中的信息能否从检索的上下文中推导出来。
+
+```plain
+公式:Context Recall = 可归因的陈述数 / 标准答案总陈述数
+
+示例:
+  标准答案:"北京是中国首都,人口 2100 万,面积 16410 平方公里"
+  检索上下文包含:✓ 北京是首都  ✓ 人口 2100万  ✗ 面积信息缺失
+  → Context Recall = 2/3 ≈ 0.67
+```
+
+### 10.3 RAGAS 评估实战
+```python
+#安装依赖: pip install ragas -i https://pypi.tuna.tsinghua.edu.cn/simple
+from ragas import evaluate, RunConfig
+from ragas.metrics import (
+    faithfulness,
+    answer_relevancy,
+    context_precision,
+    context_recall
+)
+from datasets import Dataset
+from langchain_community.embeddings import DashScopeEmbeddings
+from langchain_community.chat_models import ChatTongyi
+
+# 初始化模型
+embedding_model = DashScopeEmbeddings(
+    model="text-embedding-v3",
+    dashscope_api_key="your_api_key_here"
+)
+llm = ChatTongyi(model="qwen-plus", api_key="your_api_key_here")
+
+# ---- 准备测试数据 ----
+test_questions = [
+    {
+        "question": "车顶行李架最大载荷是多少?",
+        "ground_truth": "车顶行李架最大载荷为70kg"
+    },
+    {
+        "question": "自适应巡航系统的工作速度范围是多少?",
+        "ground_truth": "自适应巡航系统(ACC)可以在0-150km/h范围内工作"
+    },
+    {
+        "question": "车辆推荐的轮胎胎压是多少(空载前轮)?",
+        "ground_truth": "空载时前轮推荐胎压为230 kPa"
+    },
+    {
+        "question": "保养周期是多少公里或多长时间?",
+        "ground_truth": "保养周期为15,000公里或12个月(以先到者为准)"
+    }
+]
+
+# ---- 构建评估数据集 ----
+evaluation_data = {
+    "question": [],
+    "answer": [],
+    "contexts": [],
+    "ground_truth": []
+}
+
+print("开始生成评估数据...")
+for i, item in enumerate(test_questions, 1):
+    question = item["question"]
+    ground_truth = item["ground_truth"]
+    
+    print(f"处理第 {i}/{len(test_questions)} 个问题: {question}")
+
+    # 1. 检索相关文档
+    retrieved_docs = rag_system.search(question, "multi_query")
+    contexts = [doc.page_content for doc in retrieved_docs]
+
+    # 2. 用大模型生成答案
+    context_text = "\n\n".join(contexts)
+    prompt = f"""基于以下上下文回答问题。如果没有相关信息,请说"无法从提供的信息中回答"。
+
+    上下文:{context_text}
+
+    问题:{question}
+    答案:"""
+    answer = llm.invoke(prompt).content
+
+    # 3. 保存评估数据
+    evaluation_data["question"].append(question)
+    evaluation_data["answer"].append(answer)
+    evaluation_data["contexts"].append(contexts)
+    evaluation_data["ground_truth"].append(ground_truth)
+
+# ---- 执行评估 ----
+dataset = Dataset.from_dict(evaluation_data)
+
+# 配置评估运行参数
+run_config = RunConfig(
+    timeout=600,       # 单次操作超时时间(秒)
+    max_workers=1,     # 并发数,本地模型建议设为1
+    max_retries=10,    # 最大重试次数
+    max_wait=120,      # 重试间隔上限(秒)
+)
+
+# 定义要使用的评估指标
+metrics = [
+    faithfulness,        # 忠实度:答案是否基于上下文
+    answer_relevancy,    # 答案相关性:答案是否与问题相关
+    context_precision,   # 上下文精确度:检索到的上下文是否相关
+    context_recall       # 上下文召回率:是否检索到所有必要信息
+]
+
+print("\n开始评估...")
+result = evaluate(
+    dataset,
+    metrics=metrics,
+    embeddings=embedding_model,
+    llm=llm,
+    run_config=run_config
+)
+
+# ---- 输出评估结果 ----
+print("评估结果:",result)
+```
+
+### 10.4 池化标注法:降低标注成本
+评估需要人工标注"哪些文档是相关的"——如果文档库有 1000 篇,逐篇标注成本太高。
+
+**池化标注法**的思路:先用多个检索器分别召回 Top-K,取并集形成"候选池",只标注候选池中的文档。
+
+```python
+# 各检索器的召回结果
+bm25_results = ["doc1", "doc3", "doc5", "doc7", "doc9"]
+vector_results = ["doc1", "doc2", "doc4", "doc6", "doc8"]
+hybrid_results = ["doc1", "doc2", "doc3", "doc10", "doc11"]
+
+# 创建候选池(并集)
+candidate_pool = set(bm25_results + vector_results + hybrid_results)
+print(f"候选池大小: {len(candidate_pool)}")  # 比如 11 个文档
+
+# 只需要标注这 11 个文档,而不是全部 1000 个
+# 标注完成后计算召回率
+def calculate_recall(retrieved, relevant):
+    """计算召回率"""
+    retrieved_set = set(retrieved)
+    relevant_set = set(relevant)
+    true_positives = len(retrieved_set & relevant_set)
+    return true_positives / len(relevant_set) if relevant_set else 0
+
+# 示例:假设标注后确认 doc1, doc2, doc4 是相关的
+relevant_docs = ["doc1", "doc2", "doc4"]
+retrieved = ["doc1", "doc3", "doc5"]  # 某个系统的检索结果
+
+recall = calculate_recall(retrieved, relevant_docs)
+print(f"Recall: {recall:.2f}")  # 1/3 ≈ 0.33
+```
+
+---
+
+## 附录:完整 RAG 链路总览
+<!-- 这是一张图片,ocr 内容为: -->
+![](https://cdn.nlark.com/yuque/0/2026/png/21571931/1782874754155-fbc8740f-83eb-4585-86d8-29a9a58112cb.png)
+

+ 105 - 0
02_RAG_study/CONFIG.md

@@ -0,0 +1,105 @@
+# RAG系统配置说明
+
+## 环境变量配置
+
+在项目根目录的 `.env` 文件中需要配置以下环境变量:
+
+### 必需配置
+
+```env
+# DeepSeek API 配置(用于大语言模型)
+OPENAI_API_KEY=your_deepseek_api_key_here
+OPENAI_API_BASE=https://api.deepseek.com/v1
+MODEL_NAME=deepseek-chat
+
+# DashScope API 配置(用于文本向量化)
+DASHSCOPE_API_KEY=your_dashscope_api_key_here
+EMBEDDING_MODEL=text-embedding-v3
+```
+
+### 配置项说明
+
+| 配置项 | 说明 | 默认值 | 获取地址 |
+|--------|------|--------|----------|
+| `OPENAI_API_KEY` | DeepSeek API密钥 | - | https://platform.deepseek.com/ |
+| `OPENAI_API_BASE` | DeepSeek API地址 | https://api.deepseek.com/v1 | - |
+| `MODEL_NAME` | 使用的模型名称 | deepseek-chat | 见下方模型列表 |
+| `DASHSCOPE_API_KEY` | 阿里云DashScope密钥 | - | https://dashscope.console.aliyun.com/ |
+| `EMBEDDING_MODEL` | Embedding模型名称 | text-embedding-v3 | 见下方模型列表 |
+
+### DeepSeek 可用模型
+
+- `deepseek-chat` - 通用对话模型(推荐)
+- `deepseek-coder` - 代码专用模型
+- `deepseek-v4-flash` - 快速响应模型
+
+### DashScope 可用Embedding模型
+
+- `text-embedding-v3` - 最新版本(推荐)
+- `text-embedding-v2` - 稳定版本
+
+## 快速开始
+
+### 1. 获取API密钥
+
+#### DeepSeek API密钥
+1. 访问 [DeepSeek官网](https://platform.deepseek.com/)
+2. 注册/登录账号
+3. 进入API Keys页面
+4. 创建新的API密钥
+
+#### DashScope API密钥
+1. 访问 [阿里云DashScope控制台](https://dashscope.console.aliyun.com/)
+2. 开通服务
+3. 创建API密钥
+
+### 2. 配置环境变量
+
+将获取的API密钥填入 `.env` 文件:
+
+```env
+OPENAI_API_KEY=sk-xxxxxxxxxxxxxxxx
+DASHSCOPE_API_KEY=sk-xxxxxxxxxxxxxxxx
+```
+
+### 3. 安装依赖
+
+```bash
+pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple
+```
+
+### 4. 运行测试
+
+```bash
+python test_rag.py
+```
+
+### 5. 启动系统
+
+```bash
+python 02_RAG_task.py
+```
+
+## 常见问题
+
+### Q: 提示"未配置 OPENAI_API_KEY"
+**A**: 检查 `.env` 文件是否在项目根目录,且配置正确。
+
+### Q: API调用失败
+**A**: 
+1. 检查API密钥是否正确
+2. 检查网络连接
+3. 确认API配额是否充足
+
+### Q: Embedding失败
+**A**: 
+1. 确认DashScope服务已开通
+2. 检查DASHSCOPE_API_KEY是否正确
+3. 确认账户余额充足
+
+## 注意事项
+
+1. **安全**: 不要将API密钥提交到代码仓库
+2. **成本**: API调用会产生费用,建议监控使用量
+3. **网络**: 确保能访问 DeepSeek 和 DashScope 的API服务
+4. **配置优先级**: 环境变量 > .env文件

+ 138 - 0
02_RAG_study/HOW_TO_VIEW.md

@@ -0,0 +1,138 @@
+# 如何查看分块结果和数据库内容
+
+## 方法1:使用查看工具(推荐)
+
+### 查看数据库内容
+
+```bash
+cd d:\agentlearning\lqq-agent-study\02_RAG_study
+python view_chunks.py
+```
+
+然后选择选项1查看数据库内容
+
+### 测试检索
+
+选择选项2或3进行检索测试
+
+## 方法2:直接在Python中查看
+
+创建一个Python脚本或使用Jupyter Notebook:
+
+```python
+import os
+from dotenv import load_dotenv
+from langchain_community.embeddings import DashScopeEmbeddings
+from langchain_community.vectorstores import Chroma
+
+# 加载环境变量
+load_dotenv()
+
+# 初始化Embedding模型
+embedding_model = DashScopeEmbeddings(
+    model="text-embedding-v3",
+    dashscope_api_key=os.getenv("DASHSCOPE_API_KEY", "")
+)
+
+# 加载向量数据库
+vectorstore = Chroma(
+    persist_directory="./car_info_knowledge_db",  # 你的数据库路径
+    embedding_function=embedding_model
+)
+
+# 获取所有文档
+result = vectorstore.get()
+documents = result.get('documents', [])
+metadatas = result.get('metadatas', [])
+
+# 打印统计信息
+print(f"总块数: {len(documents)}")
+print(f"总字符数: {sum(len(doc) for doc in documents):,}")
+
+# 查看前10个块
+for i in range(min(10, len(documents))):
+    print(f"\n块 #{i+1}")
+    print(f"页码: {metadatas[i].get('page', 'N/A')}")
+    print(f"大小: {len(documents[i])} 字符")
+    print(f"内容: {documents[i][:150]}...")
+```
+
+## 方法3:测试检索效果
+
+```python
+# 测试检索
+query = "你的问题"
+results = vectorstore.similarity_search_with_score(query, k=3)
+
+for i, (doc, score) in enumerate(results):
+    print(f"结果 #{i+1} (相似度: {score:.4f})")
+    print(f"页码: {doc.metadata.get('page', 'N/A')}")
+    print(f"内容: {doc.page_content[:200]}...")
+```
+
+## 方法4:使用Jupyter Notebook
+
+在 `rag_test.ipynb` 中添加以下代码:
+
+```python
+# 单元格1:加载库
+from langchain_community.embeddings import DashScopeEmbeddings
+from langchain_community.vectorstores import Chroma
+import os
+
+# 单元格2:加载数据库
+embedding = DashScopeEmbeddings(
+    model="text-embedding-v3",
+    dashscope_api_key=os.getenv("DASHSCOPE_API_KEY")
+)
+
+vectordb = Chroma(
+    persist_directory="./car_info_knowledge_db",
+    embedding_function=embedding
+)
+
+# 单元格3:查看统计
+result = vectordb.get()
+docs = result['documents']
+print(f"总块数: {len(docs)}")
+print(f"平均块大小: {sum(len(d) for d in docs) / len(docs):.1f} 字符")
+
+# 单元格4:查看具体块
+for i in range(min(5, len(docs))):
+    print(f"\n块 #{i+1} ({len(docs[i])}字符)")
+    print(docs[i][:200])
+    print("-" * 60)
+
+# 单元格5:测试检索
+query = "HDC系统"
+results = vectordb.similarity_search_with_score(query, k=3)
+for doc, score in results:
+    print(f"相似度: {score:.4f}")
+    print(doc.page_content[:150])
+    print("-" * 60)
+```
+
+## 常见问题
+
+### Q: 数据库在哪里?
+A: 默认存储在 `./car_info_knowledge_db` 或 `./investment_knowledge_db` 目录
+
+### Q: 能修改分块后重新查看吗?
+A: 可以。修改 `chunk_size` 和 `chunk_overlap` 后,重新构建数据库,然后用这些工具查看
+
+### Q: 相似度分数是什么意思?
+A: 分数越小越相似,0表示完全匹配。余弦距离。
+
+### Q: 如何导出数据?
+A: 使用 `vectorstore.get()` 获取所有数据后,可以保存为JSON或其他格式:
+
+```python
+import json
+
+result = vectorstore.get()
+with open('chunks_export.json', 'w', encoding='utf-8') as f:
+    json.dump({
+        'documents': result['documents'],
+        'metadatas': result['metadatas']
+    }, f, ensure_ascii=False, indent=2)
+```

+ 212 - 0
02_RAG_study/README_RAG.md

@@ -0,0 +1,212 @@
+# RAG系统实战 - 投资方法论知识库
+
+基于 LangChain 构建的检索增强生成(RAG)系统,用于对《疯狂的里海 · 投资方法论》进行智能问答。
+
+## 功能特性
+
+- **PDF文档加载**:使用 PyMuPDFLoader 快速加载PDF文档
+- **数据清洗**:自动清洗PDF解析产生的噪声(换行符、特殊符号、多余空格等)
+- **智能分块**:使用递归字符分割器,保持语义完整性
+- **向量化存储**:使用阿里通义 Embedding 模型进行向量化,存储到 ChromaDB
+- **智能检索**:基于余弦相似度检索最相关的文档片段
+- **答案生成**:使用通义千问大模型生成准确回答
+
+## 系统架构
+
+```
+用户问题
+    ↓
+检索器将问题转为向量
+    ↓
+在向量库中查找语义最近的文档块
+    ↓
+返回 Top-K 相关文档
+    ↓
+拼接上下文 + 问题 → 大模型生成答案
+```
+
+## 环境要求
+
+### 1. 安装依赖
+
+```bash
+pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple
+```
+
+### 2. 配置 API Key
+
+在项目根目录的 `.env` 文件中配置以下API Key:
+
+```env
+# DeepSeek API(用于大语言模型)
+OPENAI_API_KEY=your_deepseek_api_key
+OPENAI_API_BASE=https://api.deepseek.com/v1
+MODEL_NAME=deepseek-chat
+
+# DashScope API(用于文本向量化)
+DASHSCOPE_API_KEY=your_dashscope_api_key
+EMBEDDING_MODEL=text-embedding-v3
+```
+
+**获取 API Key**:
+1. **DeepSeek API Key**: 访问 [DeepSeek官网](https://platform.deepseek.com/) 注册并获取
+2. **DashScope API Key**: 访问 [阿里云DashScope控制台](https://dashscope.console.aliyun.com/) 获取
+
+### 3. 技术栈
+
+- **大语言模型**: DeepSeek(通过OpenAI兼容接口)
+- **Embedding模型**: 阿里通义 text-embedding-v3
+- **向量数据库**: ChromaDB
+- **框架**: LangChain
+
+## 使用方法
+
+### 方式1:运行完整系统
+
+```bash
+python 02_RAG_task.py
+```
+
+首次运行会自动构建知识库:
+1. 加载 PDF 文档
+2. 清洗数据
+3. 分块处理
+4. 向量化并存储
+
+后续运行会直接加载已有的向量数据库。
+
+### 方式2:在代码中调用
+
+```python
+from 02_RAG_task import RAGSystem
+
+# 创建 RAG 系统实例
+rag = RAGSystem(
+    pdf_path=r"D:\investment\疯狂的里海 · 投资方法论 — 基于 367 篇投资周记提炼.pdf",
+    persist_directory="./investment_knowledge_db"
+)
+
+# 构建知识库(首次运行)
+rag.build_knowledge_base()
+
+# 或者加载已有知识库
+# rag.load_vectorstore()
+# rag.create_retriever()
+
+# 提问
+answer = rag.query("什么是价值投资的核心原则?")
+print(answer)
+```
+
+### 方式3:分步骤调用
+
+```python
+# 步骤1:加载文档
+pages = rag.load_pdf()
+
+# 步骤2:清洗数据
+rag.clean_all_pages()
+
+# 步骤3:分块
+docs = rag.split_documents(chunk_size=800, chunk_overlap=160)
+
+# 步骤4:创建向量库
+vectorstore = rag.create_vectorstore()
+
+# 步骤5:创建检索器
+retriever = rag.create_retriever(k=3)
+
+# 步骤6:检索相关文档
+relevant_docs = rag.retrieve("投资策略有哪些?")
+
+# 步骤7:生成答案
+answer = rag.generate_answer("投资策略有哪些?", relevant_docs)
+```
+
+## 参数说明
+
+### 分块参数
+
+- `chunk_size`:每个块的最大字符数(推荐 500-1000)
+- `chunk_overlap`:相邻块之间的重叠字符数(推荐 chunk_size 的 10%-20%)
+
+```python
+# 调整分块大小
+rag.split_documents(chunk_size=1000, chunk_overlap=200)
+```
+
+### 检索参数
+
+- `k`:返回的最相关文档数量(推荐 3-5)
+
+```python
+# 返回更多相关文档
+rag.create_retriever(k=5)
+```
+
+## 目录结构
+
+```
+02_RAG_study/
+├── 02_RAG_task.py           # RAG系统主程序
+├── README_RAG.md             # 本说明文档
+├── investment_knowledge_db/  # 向量数据库存储目录(自动生成)
+│   ├── chroma.sqlite3
+│   └── ...
+├── rag_test.ipynb           # 测试笔记本
+└── 3.RAG系统搭建实战.md      # RAG系统搭建教程
+```
+
+## 常见问题
+
+### 1. 提示 "未设置 DASHSCOPE_API_KEY"
+
+**原因**:环境变量未配置  
+**解决**:在 `.env` 文件中添加 `DASHSCOPE_API_KEY=your-api-key`
+
+### 2. PDF 文件不存在
+
+**原因**:PDF 路径配置错误  
+**解决**:修改 `main()` 函数中的 `pdf_path` 变量,指向正确的 PDF 文件路径
+
+### 3. 向量数据库加载失败
+
+**原因**:向量数据库损坏或版本不兼容  
+**解决**:删除 `investment_knowledge_db` 目录,重新构建知识库
+
+### 4. 检索效果不佳
+
+**优化建议**:
+- 调整 `chunk_size` 和 `chunk_overlap` 参数
+- 增加 `k` 值以获取更多相关文档
+- 检查数据清洗是否充分
+
+## 技术栈
+
+- **LangChain**:LLM 应用开发框架
+- **PyMuPDF**:PDF 解析库
+- **ChromaDB**:向量数据库
+- **DashScope**:阿里云大模型服务
+  - text-embedding-v3:文本向量化模型
+  - qwen-plus:大语言模型
+
+## 注意事项
+
+1. **API 密钥安全**:不要将 API Key 提交到代码仓库
+2. **成本控制**:向量化过程会产生 API 调用费用,建议复用已有向量库
+3. **中文优化**:本系统针对中文文档优化,使用通义千问 Embedding 模型
+4. **分块质量**:分块策略对 RAG 效果影响很大,建议根据文档特点调整参数
+
+## 扩展功能
+
+可以基于此系统扩展:
+
+1. **多文档支持**:加载多个 PDF 文档
+2. **对话历史**:添加记忆功能,支持多轮对话
+3. **混合检索**:结合关键词检索和语义检索
+4. **流式输出**:实现打字机效果的答案输出
+5. **Web界面**:使用 Streamlit 或 Gradio 构建 Web 界面
+
+## 许可证
+
+MIT License

BIN
02_RAG_study/car_info.pdf


BIN
02_RAG_study/car_info_knowledge_db/chroma.sqlite3


+ 37 - 0
02_RAG_study/check_pdf_images.py

@@ -0,0 +1,37 @@
+"""检查PDF是否为图片型PDF"""
+import fitz  # PyMuPDF
+
+# 打开PDF文件
+pdf_path = r"D:\investment\疯狂的里海 · 投资方法论 — 基于 367 篇投资周记提炼.pdf"
+
+try:
+    doc = fitz.open(pdf_path)
+    
+    print(f"PDF文件: {pdf_path}")
+    print(f"总页数: {len(doc)}")
+    
+    # 检查前几页
+    for i in range(min(5, len(doc))):
+        page = doc[i]
+        
+        # 获取文本
+        text = page.get_text()
+        
+        # 获取图片
+        images = page.get_images()
+        
+        print(f"\n=== 第 {i+1} 页 ===")
+        print(f"文本长度: {len(text)}")
+        print(f"图片数量: {len(images)}")
+        
+        if len(text) == 0 and len(images) > 0:
+            print("  → 这页是图片型PDF,需要OCR处理")
+        elif len(text) > 0:
+            print(f"  → 文本内容: {text[:100]}...")
+            
+    doc.close()
+    
+except Exception as e:
+    print(f"错误: {e}")
+    import traceback
+    traceback.print_exc()

BIN
02_RAG_study/chroma_db1/7e155055-ebc9-482e-9303-28c2d2cacbf4/data_level0.bin


BIN
02_RAG_study/chroma_db1/7e155055-ebc9-482e-9303-28c2d2cacbf4/header.bin


BIN
02_RAG_study/chroma_db1/7e155055-ebc9-482e-9303-28c2d2cacbf4/length.bin


+ 0 - 0
02_RAG_study/chroma_db1/7e155055-ebc9-482e-9303-28c2d2cacbf4/link_lists.bin


BIN
02_RAG_study/chroma_db1/chroma.sqlite3


BIN
02_RAG_study/chroma_db1_db/chroma.sqlite3


BIN
02_RAG_study/investment_knowledge_db/54bfc2fc-ec6e-45fe-a99d-773918461412/data_level0.bin


BIN
02_RAG_study/investment_knowledge_db/54bfc2fc-ec6e-45fe-a99d-773918461412/header.bin


BIN
02_RAG_study/investment_knowledge_db/54bfc2fc-ec6e-45fe-a99d-773918461412/length.bin


+ 0 - 0
02_RAG_study/investment_knowledge_db/54bfc2fc-ec6e-45fe-a99d-773918461412/link_lists.bin


BIN
02_RAG_study/investment_knowledge_db/chroma.sqlite3


BIN
02_RAG_study/my_knowledge_db/bf091c50-d41b-46eb-ae65-0db9d6c8b768/data_level0.bin


BIN
02_RAG_study/my_knowledge_db/bf091c50-d41b-46eb-ae65-0db9d6c8b768/header.bin


BIN
02_RAG_study/my_knowledge_db/bf091c50-d41b-46eb-ae65-0db9d6c8b768/index_metadata.pickle


BIN
02_RAG_study/my_knowledge_db/bf091c50-d41b-46eb-ae65-0db9d6c8b768/length.bin


BIN
02_RAG_study/my_knowledge_db/bf091c50-d41b-46eb-ae65-0db9d6c8b768/link_lists.bin


BIN
02_RAG_study/my_knowledge_db/chroma.sqlite3


+ 188 - 0
02_RAG_study/quick_start.py

@@ -0,0 +1,188 @@
+"""
+快速开始示例 - RAG系统
+演示如何使用RAG系统进行知识库问答
+"""
+
+import os
+import sys
+from dotenv import load_dotenv
+
+# 添加当前目录到系统路径
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+
+# 导入RAG系统(使用importlib处理数字开头的文件名)
+import importlib.util
+spec = importlib.util.spec_from_file_location("rag_task", "./02_RAG_task.py")
+rag_module = importlib.util.module_from_spec(spec)
+spec.loader.exec_module(rag_module)
+RAGSystem = rag_module.RAGSystem
+
+# 加载环境变量
+load_dotenv()
+
+
+def example_1_build_and_query():
+    """示例1:构建知识库并查询"""
+    print("\n=== 示例1:构建知识库并查询 ===\n")
+    
+    # 创建RAG系统
+    rag = RAGSystem(
+        pdf_path=r"D:\investment\疯狂的里海 · 投资方法论 — 基于 367 篇投资周记提炼.pdf",
+        persist_directory="./investment_knowledge_db"
+    )
+    
+    # 构建知识库(首次运行需要,后续可跳过)
+    rag.build_knowledge_base()
+    
+    # 提问
+    questions = [
+        "什么是价值投资?",
+        "投资的核心原则是什么?",
+        "如何判断一个公司是否值得投资?"
+    ]
+    
+    for question in questions:
+        print(f"\n问题: {question}")
+        answer = rag.query(question)
+        print(f"回答: {answer}")
+        print("-" * 60)
+
+
+def example_2_load_and_query():
+    """示例2:加载已有知识库并查询"""
+    print("\n=== 示例2:加载已有知识库并查询 ===\n")
+    
+    # 创建RAG系统
+    rag = RAGSystem(
+        pdf_path=r"D:\investment\疯狂的里海 · 投资方法论 — 基于 367 篇投资周记提炼.pdf",
+        persist_directory="./investment_knowledge_db"
+    )
+    
+    # 加载已有向量数据库
+    rag.load_vectorstore()
+    rag.create_retriever()
+    
+    # 单次查询
+    question = "投资者应该如何控制风险?"
+    print(f"问题: {question}")
+    
+    answer = rag.query(question)
+    print(f"回答: {answer}")
+
+
+def example_3_custom_retrieval():
+    """示例3:自定义检索参数"""
+    print("\n=== 示例3:自定义检索参数 ===\n")
+    
+    # 创建RAG系统
+    rag = RAGSystem(
+        pdf_path=r"D:\investment\疯狂的里海 · 投资方法论 — 基于 367 篇投资周记提炼.pdf",
+        persist_directory="./investment_knowledge_db"
+    )
+    
+    # 加载知识库
+    rag.load_vectorstore()
+    
+    # 自定义检索器:返回更多相关文档
+    rag.create_retriever(k=5)
+    
+    # 检索相关文档
+    question = "投资周记中提到了哪些投资策略?"
+    relevant_docs = rag.retrieve(question, k=5)
+    
+    print(f"问题: {question}")
+    print(f"\n找到 {len(relevant_docs)} 个相关文档片段:\n")
+    
+    for i, doc in enumerate(relevant_docs, 1):
+        print(f"--- 文档片段 {i} ---")
+        print(f"内容: {doc.page_content[:200]}...")
+        print(f"来源: {doc.metadata}")
+        print()
+
+
+def example_4_step_by_step():
+    """示例4:分步骤执行RAG流程"""
+    print("\n=== 示例4:分步骤执行RAG流程 ===\n")
+    
+    # 创建RAG系统
+    rag = RAGSystem(
+        pdf_path=r"D:\investment\疯狂的里海 · 投资方法论 — 基于 367 篇投资周记提炼.pdf",
+        persist_directory="./custom_knowledge_db"
+    )
+    
+    # 步骤1:加载PDF
+    print("步骤1: 加载PDF文档...")
+    pages = rag.load_pdf()
+    print(f"  加载了 {len(pages)} 页\n")
+    
+    # 步骤2:清洗数据
+    print("步骤2: 清洗数据...")
+    rag.clean_all_pages()
+    print("  数据清洗完成\n")
+    
+    # 步骤3:分块(自定义参数)
+    print("步骤3: 文档分块...")
+    docs = rag.split_documents(chunk_size=1000, chunk_overlap=200)
+    print(f"  分成 {len(docs)} 个块\n")
+    
+    # 步骤4:创建向量库
+    print("步骤4: 创建向量数据库...")
+    vectorstore = rag.create_vectorstore()
+    print("  向量数据库创建完成\n")
+    
+    # 步骤5:创建检索器
+    print("步骤5: 创建检索器...")
+    retriever = rag.create_retriever(k=3)
+    print("  检索器创建完成\n")
+    
+    # 步骤6:检索
+    print("步骤6: 检索相关文档...")
+    question = "什么是安全边际?"
+    relevant_docs = rag.retrieve(question)
+    print(f"  找到 {len(relevant_docs)} 个相关文档\n")
+    
+    # 步骤7:生成答案
+    print("步骤7: 生成答案...")
+    answer = rag.generate_answer(question, relevant_docs)
+    print(f"问题: {question}")
+    print(f"回答: {answer}")
+
+
+def main():
+    """主函数"""
+    # 检查API Key
+    if not os.getenv("DASHSCOPE_API_KEY") or os.getenv("DASHSCOPE_API_KEY") == "your_dashscope_api_key_here":
+        print("错误: 请先在 .env 文件中配置 DASHSCOPE_API_KEY")
+        print("获取API Key: https://dashscope.console.aliyun.com/")
+        return
+    
+    print("=" * 60)
+    print("RAG系统快速开始示例")
+    print("=" * 60)
+    
+    # 选择要运行的示例
+    print("\n请选择示例:")
+    print("1. 构建知识库并查询(首次运行)")
+    print("2. 加载已有知识库并查询")
+    print("3. 自定义检索参数")
+    print("4. 分步骤执行RAG流程")
+    print("0. 退出")
+    
+    choice = input("\n请输入选项 (0-4): ").strip()
+    
+    if choice == "1":
+        example_1_build_and_query()
+    elif choice == "2":
+        example_2_load_and_query()
+    elif choice == "3":
+        example_3_custom_retrieval()
+    elif choice == "4":
+        example_4_step_by_step()
+    elif choice == "0":
+        print("退出程序")
+    else:
+        print("无效选项")
+
+
+if __name__ == "__main__":
+    main()

+ 840 - 0
02_RAG_study/rag_test.ipynb

@@ -0,0 +1,840 @@
+{
+ "cells": [
+  {
+   "cell_type": "code",
+   "execution_count": 2,
+   "id": "0b103582",
+   "metadata": {},
+   "outputs": [
+    {
+     "name": "stdout",
+     "output_type": "stream",
+     "text": [
+      "文档类型:<class 'list'>\n",
+      "PDF 共 354 页\n",
+      "元数据:{'producer': 'PDFlib+PDI 9.0.6 (C++/Win64)', 'creator': 'PTC Arbortext Publishing Engine', 'creationdate': '2023-06-16T15:35:59+08:00', 'source': './car_info.pdf', 'file_path': './car_info.pdf', 'total_pages': 354, 'format': 'PDF 1.7', 'title': '', 'author': '', 'subject': '', 'keywords': '', 'moddate': '2023-06-16T17:41:45+08:00', 'trapped': '', 'modDate': \"D:20230616174145+08'00'\", 'creationDate': \"D:20230616153559+08'00'\", 'page': 0}\n",
+      "内容预览:欢迎\n",
+      "感谢您选择了具有优良安全性、舒适性、动力性和经济性的Lynk & Co领克汽车。\n",
+      "首次使用前请仔细、完整地阅读本手册内容,将有助于您更好地了解和使用车辆。\n",
+      "本手册中的所有资料均为出版时的最新资料,但本公司将对产品进行不断的改进和优化,您所购的车辆可能与本手册中的描述有所不同,请以实际\n",
+      "接收的车辆为准。\n",
+      "如您有任何问题,或需要预约服务,请拨打电话4006-010101 联系我们。您也可以开车前\n"
+     ]
+    }
+   ],
+   "source": [
+    "from langchain_community.document_loaders import PyMuPDFLoader\n",
+    "# 安装依赖:uv pip install pymupdf -i https://pypi.tuna.tsinghua.edu.cn/simple\n",
+    "\n",
+    "# 创建加载器实例,传入 PDF 文件路径\n",
+    "pdf_loader = PyMuPDFLoader(\"./car_info.pdf\")\n",
+    "\n",
+    "# 调用 load() 方法,返回一个 Document 列表(每页一个 Document)\n",
+    "pdf_pages = pdf_loader.load()\n",
+    "\n",
+    "# 看看加载结果\n",
+    "print(f\"文档类型:{type(pdf_pages)}\")\n",
+    "print(f\"PDF 共 {len(pdf_pages)} 页\")\n",
+    "# 查看第一页的内容和元数据\n",
+    "first_page = pdf_pages[0]\n",
+    "print(f\"元数据:{first_page.metadata}\")\n",
+    "print(f\"内容预览:{first_page.page_content[:200]}\")"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 3,
+   "id": "f1ab96e1",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "import re\n",
+    "\n",
+    "# ---- 问题1:PDF 解析产生的多余换行符 ----\n",
+    "# 现象:一句话被拆成多行,中间插了 \\n\n",
+    "# 例如:\"领克汽车\\n车顶行李架\\n最大载荷\"\n",
+    "# 解决:用正则匹配并删除非中文字符之间的换行符\n",
+    "raw_text = \"领克汽车\\n车顶行李架\\n最大载荷。\"\n",
+    "pattern = re.compile(r'[^一](\\n)[^一]', re.DOTALL)\n",
+    "clean_text = re.sub(pattern, lambda m: m.group(0).replace('\\n', ''), raw_text)\n",
+    "\n",
+    "# ---- 问题2:特殊符号干扰 ----\n",
+    "# 现象:PDF 中的项目符号 • 、多余空格等\n",
+    "clean_text = clean_text.replace('•', '')\n",
+    "clean_text = clean_text.replace('  ', ' ')  # 合并多余空格\n",
+    "\n",
+    "# ---- 问题3:页眉页脚噪声 ----\n",
+    "# 现象:每页都有 \"第X页\"、\"公司名称\" 等重复内容\n",
+    "# 解决:根据元数据中的页码信息,过滤掉固定位置的噪声文本"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 9,
+   "id": "9defa53b",
+   "metadata": {},
+   "outputs": [
+    {
+     "name": "stdout",
+     "output_type": "stream",
+     "text": [
+      "原数据:领克汽车\n",
+      "车顶行李架\n",
+      "最大载荷。\n",
+      "领克汽车车顶行李架最大载荷。\n"
+     ]
+    }
+   ],
+   "source": [
+    "text = \"领克汽车\\n车顶行李架\\n最大载荷。\"\n",
+    "print(\"原数据:领克汽车\\n车顶行李架\\n最大载荷。\")\n",
+    "        \n",
+    "# 删除非中文字符之间的换行符\n",
+    "text = re.sub(r'[^一](\\n)[^一]', \n",
+    "                    lambda m: m.group(0).replace('\\n', ''), text)\n",
+    "        \n",
+    "# 删除项目符号和多余空格\n",
+    "text = text.replace('•', '').replace('  ', ' ')\n",
+    "        \n",
+    "# 删除连续的换行符(保留一个)\n",
+    "text = re.sub(r'\\n{2,}', '\\n', text)\n",
+    "print(text)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 21,
+   "id": "4c8a3996",
+   "metadata": {},
+   "outputs": [
+    {
+     "name": "stdout",
+     "output_type": "stream",
+     "text": [
+      "--- 块1 ---\n",
+      "LangChain 是一个让你的 LLM 变得更强大的开源框架。\n",
+      "你想开发一个基于 LLM 的应用,需要什么组件它都有,直接使用就行。\n",
+      "甚至针对常规的应用流程\n",
+      "\n",
+      "--- 块2 ---\n",
+      "需要什么组件它都有,直接使用就行。\n",
+      "甚至针对常规的应用流程,它利用 Chain 这个概念已经内置标准化方案了。\n",
+      "下面我们从新兴的大语言模型技术栈的角度来看看为何它的理念这么受欢迎。\n",
+      "\n"
+     ]
+    }
+   ],
+   "source": [
+    "#安装依赖:uv pip install langchain-text-splitters -i https://mirrors.aliyun.com/pypi/simple\n",
+    "# 手动实现固定字符分块\n",
+    "from langchain_text_splitters import CharacterTextSplitter\n",
+    "\n",
+    "# 1. 定义分块器\n",
+    "text_splitter = CharacterTextSplitter(\n",
+    "    separator=\",\",      # 指定分隔符,默认为 \"\\n\\n\"\n",
+    "    chunk_size=100,        # 每个块的最大字符数\n",
+    "    chunk_overlap=50,      # 块与块之间的重叠字符数,建议设置以保持上下文连贯[citation:4]\n",
+    "    length_function=len,   # 计算长度的方法,默认按字符数\n",
+    "    is_separator_regex=False # 是否将分隔符视为正则表达式\n",
+    ")\n",
+    "\n",
+    "# 2. 执行分块,返回字符串列表\n",
+    "text = \"\"\"\n",
+    "LangChain 是一个让你的 LLM 变得更强大的开源框架。\n",
+    "你想开发一个基于 LLM 的应用,需要什么组件它都有,直接使用就行。\n",
+    "甚至针对常规的应用流程,它利用 Chain 这个概念已经内置标准化方案了。\n",
+    "下面我们从新兴的大语言模型技术栈的角度来看看为何它的理念这么受欢迎。\n",
+    "\"\"\"\n",
+    "chunks = text_splitter.split_text(text)\n",
+    "for i, chunk in enumerate(chunks):\n",
+    "    print(f\"--- 块{i+1} ---\\n{chunk}\\n\")"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 22,
+   "id": "192375ec",
+   "metadata": {},
+   "outputs": [
+    {
+     "name": "stdout",
+     "output_type": "stream",
+     "text": [
+      "--- 块1 ---\n",
+      "LangChain 是一个让你的 LLM 变得更强大的开源框架。\n",
+      "\n",
+      "--- 块2 ---\n",
+      "你想开发一个基于 LLM 的应用,需要什么组件它都有,直接使用就行。\n",
+      "\n",
+      "--- 块3 ---\n",
+      "甚至针对常规的应用流程,它利用 Chain 这个概念已经内置标准化方案了。\n",
+      "\n",
+      "--- 块4 ---\n",
+      "下面我们从新兴的大语言模型技术栈的角度来看看为何它的理念这么受欢迎。\n",
+      "\n"
+     ]
+    }
+   ],
+   "source": [
+    "''' \n",
+    "* RecursiveCharacterTextSplitter 递归字符文本分割\n",
+    "RecursiveCharacterTextSplitter 将按不同的字符递归地分割(按照这个优先级[\"\\n\\n\", \"\\n\", \" \", \"\"]),\n",
+    "    这样就能尽量把所有和语义相关的内容尽可能长时间地保留在同一位置\n",
+    "RecursiveCharacterTextSplitter需要关注的是4个参数:\n",
+    "\n",
+    "* separators - 分隔符字符串数组\n",
+    "* chunk_size - 每个文档的字符数量限制\n",
+    "* chunk_overlap - 两份文档重叠区域的长度\n",
+    "* length_function - 长度计算函数\n",
+    "'''\n",
+    "from langchain_text_splitters import RecursiveCharacterTextSplitter\n",
+    "\n",
+    "# 创建递归字符分割器\n",
+    "text_splitter = RecursiveCharacterTextSplitter(\n",
+    "    # 分隔符优先级:段落 → 换行 → 句号 → 空格 → 硬切\n",
+    "    separators=[\"\\n\\n\", \"\\n\", \"。\", \"!\", \"?\", \" \", \"\"],\n",
+    "    \n",
+    "    # 每个块最大 50 字符\n",
+    "    chunk_size=50,\n",
+    "    \n",
+    "    # 相邻块重叠 10 字符(chunk_size 的 20%)\n",
+    "    chunk_overlap=10,\n",
+    "    \n",
+    "    # 长度计算函数\n",
+    "    length_function=len\n",
+    ")\n",
+    "\n",
+    "# 对单段文本进行分割\n",
+    "text = \"\"\"\n",
+    "LangChain 是一个让你的 LLM 变得更强大的开源框架。\n",
+    "你想开发一个基于 LLM 的应用,需要什么组件它都有,直接使用就行。\n",
+    "甚至针对常规的应用流程,它利用 Chain 这个概念已经内置标准化方案了。\n",
+    "下面我们从新兴的大语言模型技术栈的角度来看看为何它的理念这么受欢迎。\n",
+    "\"\"\"\n",
+    "chunks = text_splitter.split_text(text)\n",
+    "for i, chunk in enumerate(chunks):\n",
+    "    print(f\"--- 块{i+1} ---\\n{chunk}\\n\")"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 23,
+   "id": "45738e55",
+   "metadata": {},
+   "outputs": [
+    {
+     "name": "stdout",
+     "output_type": "stream",
+     "text": [
+      "切分后的文件数量:4243\n",
+      "切分后的字符数(可以用来大致评估 token 数):148617\n"
+     ]
+    }
+   ],
+   "source": [
+    "split_docs = text_splitter.split_documents(pdf_pages)\n",
+    "print(f\"切分后的文件数量:{len(split_docs)}\")\n",
+    "print(f\"切分后的字符数(可以用来大致评估 token 数):{sum([len(doc.page_content) for doc in split_docs])}\")"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 25,
+   "id": "b6061663",
+   "metadata": {},
+   "outputs": [
+    {
+     "name": "stdout",
+     "output_type": "stream",
+     "text": [
+      "有效块数量:4243\n",
+      "总字符数(可大致评估 Token 数):148617\n"
+     ]
+    }
+   ],
+   "source": [
+    "# 过滤掉 page_content 为空或仅含空白的文档\n",
+    "valid_docs = [\n",
+    "    doc for doc in split_docs \n",
+    "    if doc.page_content and doc.page_content.strip()\n",
+    "]\n",
+    "\n",
+    "print(f\"有效块数量:{len(valid_docs)}\")\n",
+    "print(f\"总字符数(可大致评估 Token 数):{sum(len(d.page_content) for d in valid_docs)}\")"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 44,
+   "id": "a2f58237",
+   "metadata": {},
+   "outputs": [
+    {
+     "name": "stdout",
+     "output_type": "stream",
+     "text": [
+      "向量维度:1024\n",
+      "前5个值:[-0.013686168007552624, 0.015642698854207993, -0.052721332758665085, 0.033709585666656494, -0.08253694325685501]\n"
+     ]
+    }
+   ],
+   "source": [
+    "from langchain_community.embeddings import DashScopeEmbeddings\n",
+    "\n",
+    "# 初始化 Embedding 模型\n",
+    "# 需要先在 https://dashscope.console.aliyun.com/ 获取 API Key\n",
+    "embedding_model = DashScopeEmbeddings(\n",
+    "    model=\"text-embedding-v3\",        # 模型名称\n",
+    "    dashscope_api_key=\"sk-ws-H.EMYERML.28mU.MEQCIBRtVOGRuQoEtyXeguqxh68NBcmxhdFiY2aj5VOUpwLGAiApVSH0WKpaWounDN24jiTo-kNa2uSZzcgvNBMl5QwVMw\"   # 替换为你的真实 Key\n",
+    ")\n",
+    "\n",
+    "# 单条文本向量化\n",
+    "text = \"RAG系统搭建实战\"\n",
+    "embedding = embedding_model.embed_query(text)\n",
+    "print(f\"向量维度:{len(embedding)}\")\n",
+    "print(f\"前5个值:{embedding[:5]}\")"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 54,
+   "id": "dd6b3aff",
+   "metadata": {},
+   "outputs": [
+    {
+     "name": "stdout",
+     "output_type": "stream",
+     "text": [
+      "总块数: 48\n",
+      "平均块大小: 15.3 字符\n",
+      "\n",
+      "块 #1 (14字符)\n",
+      "小明特别喜欢吃脆甜多汁的苹果\n",
+      "\n",
+      "块 #2 (15字符)\n",
+      "小红对榴莲那独特的味道情有独钟\n",
+      "\n",
+      "块 #3 (13字符)\n",
+      "小明和小丽是一对甜蜜的情侣\n",
+      "\n",
+      "块 #4 (17字符)\n",
+      "王老师教学认真负责,是公认的好老师\n",
+      "\n",
+      "块 #5 (11字符)\n",
+      "小李每天都要吃一根香蕉\n"
+     ]
+    }
+   ],
+   "source": [
+    "from langchain_community.embeddings import DashScopeEmbeddings\n",
+    "from langchain_community.vectorstores import Chroma\n",
+    "import os\n",
+    "from dotenv import load_dotenv\n",
+    "\n",
+    "# 加载环境变量\n",
+    "load_dotenv()\n",
+    "# 加载数据库\n",
+    "embedding = DashScopeEmbeddings(\n",
+    "    model=\"text-embedding-v3\",\n",
+    "    dashscope_api_key=os.getenv(\"DASHSCOPE_API_KEY\")\n",
+    ")\n",
+    "\n",
+    "vectordb = Chroma(\n",
+    "    persist_directory=\"./chroma_db1\",\n",
+    "    embedding_function=embedding\n",
+    ")\n",
+    "\n",
+    "# 查看所有块\n",
+    "result = vectordb.get()\n",
+    "docs = result['documents']\n",
+    "\n",
+    "print(f\"总块数: {len(docs)}\")\n",
+    "print(f\"平均块大小: {sum(len(d) for d in docs) / len(docs):.1f} 字符\")\n",
+    "\n",
+    "# 查看前5个块\n",
+    "for i in range(5):\n",
+    "    print(f\"\\n块 #{i+1} ({len(docs[i])}字符)\")\n",
+    "    print(docs[i][:200])\n"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 32,
+   "id": "c4ebd145",
+   "metadata": {},
+   "outputs": [
+    {
+     "name": "stdout",
+     "output_type": "stream",
+     "text": [
+      "生成了 4 个向量\n",
+      "每个向量维度:1024\n"
+     ]
+    }
+   ],
+   "source": [
+    "# 批量文本向量化\n",
+    "texts = [\n",
+    "    \"hello world\",\n",
+    "    \"什么是大语言模型\",\n",
+    "    \"RAG的概念和原理\",\n",
+    "    \"牛顿第一定律是什么\",\n",
+    "]\n",
+    "\n",
+    "embeddings = embedding_model.embed_documents(texts)\n",
+    "print(f\"生成了 {len(embeddings)} 个向量\")\n",
+    "print(f\"每个向量维度:{len(embeddings[0])}\")"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "f0d7504a",
+   "metadata": {},
+   "outputs": [
+    {
+     "name": "stdout",
+     "output_type": "stream",
+     "text": [
+      "匹配内容:小明特别喜欢吃脆甜多汁的苹果\n",
+      "内容:小明特别喜欢吃脆甜多汁的苹果 | 相似度分数:0.4177\n",
+      "内容:小明特别喜欢吃脆甜多汁的苹果 | 相似度分数:0.4177\n",
+      "内容:小明特别喜欢吃脆甜多汁的苹果 | 相似度分数:0.4177\n",
+      "内容:小明特别喜欢吃脆甜多汁的苹果 | 相似度分数:0.4177\n",
+      "内容:小明特别喜欢吃脆甜多汁的苹果 | 相似度分数:0.4177\n"
+     ]
+    }
+   ],
+   "source": [
+    "#安装依赖:uv pip install chromadb -i https://pypi.tuna.tsinghua.edu.cn/simple\n",
+    "from langchain_community.vectorstores import Chroma\n",
+    "from langchain_community.embeddings import DashScopeEmbeddings\n",
+    "\n",
+    "# 准备一些测试数据\n",
+    "datas = [\n",
+    "    \"小明特别喜欢吃脆甜多汁的苹果\",\n",
+    "    \"小红对榴莲那独特的味道情有独钟\",\n",
+    "    \"小明和小丽是一对甜蜜的情侣\",\n",
+    "    \"王老师教学认真负责,是公认的好老师\",\n",
+    "    \"小李每天都要吃一根香蕉\",\n",
+    "    \"小王的男朋友长得阳光帅气,是大家公认的大帅哥\"\n",
+    "]\n",
+    "\n",
+    "# 创建向量数据库并持久化(会同时把文本和对应的向量存入数据库)\n",
+    "db = Chroma.from_texts(\n",
+    "    texts=datas,\n",
+    "    embedding=embedding_model,\n",
+    "    collection_metadata={\"hnsw:space\": \"cosine\"},  # 关键配置:指定距离算法为余弦相似度\n",
+    "    persist_directory=\"./chroma_db1\"  # 数据库存储路径\n",
+    ")\n",
+    "# 相似度检索\n",
+    "query = \"夏天适合吃什么水果\"\n",
+    "results = db.similarity_search(query, k=1)  # 返回最相似的 1 条\n",
+    "\n",
+    "for doc in results:\n",
+    "    print(f\"匹配内容:{doc.page_content}\")\n",
+    "# 带分数的相似度检索(分数越低越相似,0 表示完全匹配)\n",
+    "query = \"夏天适合吃什么水果\"\n",
+    "results = db.similarity_search_with_score(query, k=3)\n",
+    "\n",
+    "for doc, score in results:\n",
+    "    print(f\"内容:{doc.page_content} | 相似度分数:{score:.4f}\")"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 45,
+   "id": "51a34231",
+   "metadata": {},
+   "outputs": [
+    {
+     "name": "stdout",
+     "output_type": "stream",
+     "text": [
+      "--- 结果1 ---\n",
+      "内容:说明!\n",
+      "□当车速在0 – 40km/h的范围内,且车辆在陡坡上低速下坡行驶\n",
+      "时,HDC才会激活。...\n",
+      "来源:{'modDate': \"D:20230616174145+08'00'\", 'producer': 'PDFlib+PDI 9.0.6 (C++/Win64)', 'author': '', 'total_pages': 354, 'creator': 'PTC Arbortext Publishing Engine', 'creationDate': \"D:20230616153559+08'00'\", 'title': '', 'format': 'PDF 1.7', 'creationdate': '2023-06-16T15:35:59+08:00', 'file_path': './car_info.pdf', 'subject': '', 'keywords': '', 'moddate': '2023-06-16T17:41:45+08:00', 'page': 160, 'trapped': '', 'source': './car_info.pdf'}\n",
+      "\n",
+      "--- 结果2 ---\n",
+      "内容:□当车速在40-60 km/h范围内无法激活HDC功能,车速超过60 km/...\n",
+      "来源:{'author': '', 'producer': 'PDFlib+PDI 9.0.6 (C++/Win64)', 'modDate': \"D:20230616174145+08'00'\", 'file_path': './car_info.pdf', 'moddate': '2023-06-16T17:41:45+08:00', 'total_pages': 354, 'creationDate': \"D:20230616153559+08'00'\", 'source': './car_info.pdf', 'format': 'PDF 1.7', 'keywords': '', 'trapped': '', 'title': '', 'creationdate': '2023-06-16T15:35:59+08:00', 'creator': 'PTC Arbortext Publishing Engine', 'page': 160, 'subject': ''}\n",
+      "\n",
+      "--- 结果3 ---\n",
+      "内容:h时,HDC自动退出。\n",
+      "□激活HDC后,您可以通过踩下制动踏板或油门踏板调整下坡车\n",
+      "速。...\n",
+      "来源:{'creator': 'PTC Arbortext Publishing Engine', 'source': './car_info.pdf', 'page': 160, 'producer': 'PDFlib+PDI 9.0.6 (C++/Win64)', 'creationdate': '2023-06-16T15:35:59+08:00', 'subject': '', 'author': '', 'keywords': '', 'moddate': '2023-06-16T17:41:45+08:00', 'title': '', 'creationDate': \"D:20230616153559+08'00'\", 'format': 'PDF 1.7', 'trapped': '', 'modDate': \"D:20230616174145+08'00'\", 'file_path': './car_info.pdf', 'total_pages': 354}\n",
+      "\n"
+     ]
+    },
+    {
+     "name": "stderr",
+     "output_type": "stream",
+     "text": [
+      "C:\\Users\\Sundear\\AppData\\Local\\Temp\\ipykernel_7968\\2664928941.py:26: LangChainDeprecationWarning: The class `Chroma` was deprecated in LangChain 0.2.9 and will be removed in 1.0. An updated version of the class exists in the `langchain-chroma package and should be used instead. To use it run `pip install -U `langchain-chroma` and import as `from `langchain_chroma import Chroma``.\n",
+      "  vectordb = Chroma(\n"
+     ]
+    }
+   ],
+   "source": [
+    "from langchain_community.vectorstores import Chroma\n",
+    "from langchain_community.embeddings import DashScopeEmbeddings\n",
+    "\n",
+    "# 假设 split_docs 是前面分块后的文档列表\n",
+    "\n",
+    "# 从文档创建向量库(写数据)\n",
+    "vectorstore = Chroma.from_documents(\n",
+    "    documents=valid_docs,\n",
+    "    embedding=embedding_model,\n",
+    "    collection_metadata={\"hnsw:space\": \"cosine\"},\n",
+    "    persist_directory=\"./my_knowledge_db\"\n",
+    ")\n",
+    "\n",
+    "# 创建检索器,设置返回 Top-3 最相关文档\n",
+    "retriever = vectorstore.as_retriever(search_kwargs={\"k\": 3})\n",
+    "# 测试检索\n",
+    "query = \"在多少速度范围内,HDC才会激活?\"\n",
+    "relevant_docs = retriever.invoke(query)\n",
+    "\n",
+    "for i, doc in enumerate(relevant_docs):\n",
+    "    print(f\"--- 结果{i+1} ---\")\n",
+    "    print(f\"内容:{doc.page_content[:100]}...\")\n",
+    "    print(f\"来源:{doc.metadata}\")\n",
+    "    print()\n",
+    "# 直接加载已持久化的数据库(无需再次添加文档或持久化)\n",
+    "vectordb = Chroma(\n",
+    "    persist_directory=\"./my_knowledge_db\",\n",
+    "    embedding_function=embedding_model\n",
+    ")"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 46,
+   "id": "7a744b66",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "from langchain_core.prompts import ChatPromptTemplate\n",
+    "\n",
+    "# 构建 Prompt 模板\n",
+    "prompt = ChatPromptTemplate.from_template(\"\"\"\n",
+    "你是一个专业的知识库助手。请根据以下检索到的上下文回答用户问题。\n",
+    "\n",
+    "**规则:**\n",
+    "- 只基于提供的上下文回答,不要编造\n",
+    "- 如果上下文中没有相关信息,直接说「根据现有资料,我找不到这个问题的答案」\n",
+    "- 回答要简洁直接,引用原文时用引号\n",
+    "\n",
+    "**检索到的上下文:**\n",
+    "{context}\n",
+    "\n",
+    "**用户问题:**\n",
+    "{question}\n",
+    "\"\"\")"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 51,
+   "id": "0dff7c44",
+   "metadata": {},
+   "outputs": [
+    {
+     "name": "stdout",
+     "output_type": "stream",
+     "text": [
+      "根据上下文,HDC激活的速度范围是“0 – 40km/h”。\n"
+     ]
+    }
+   ],
+   "source": [
+    "from langchain_community.chat_models import ChatTongyi\n",
+    "from langchain_core.output_parsers import StrOutputParser\n",
+    "from langchain_openai import ChatOpenAI\n",
+    "\n",
+    "# 初始化大模型(这里用通义千问,也可以换成 DeepSeek)\n",
+    "llm = ChatOpenAI(\n",
+    "    model_name=\"deepseek-v4-flash\",\n",
+    "    api_key='sk-80a123483afb480285c6452985eea18e',\n",
+    "    base_url=\"https://api.deepseek.com/v1\"\n",
+    ")\n",
+    "\n",
+    "# 拼装上下文\n",
+    "context_text = \"\\n\\n---\\n\\n\".join([doc.page_content for doc in relevant_docs])\n",
+    "\n",
+    "# 构建 Chain 并调用\n",
+    "chain = prompt | llm | StrOutputParser()\n",
+    "\n",
+    "response = chain.invoke({\n",
+    "    \"context\": context_text,\n",
+    "    \"question\": query\n",
+    "})\n",
+    "\n",
+    "print(response)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "24f7f23b",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "from langchain_community.document_loaders import PyMuPDFLoader\n",
+    "from langchain_text_splitters import RecursiveCharacterTextSplitter\n",
+    "from langchain_community.embeddings import DashScopeEmbeddings\n",
+    "from langchain_community.vectorstores import Chroma\n",
+    "from langchain_core.prompts import ChatPromptTemplate\n",
+    "from langchain_community.chat_models import ChatTongyi\n",
+    "from langchain_core.output_parsers import StrOutputParser\n",
+    "\n",
+    "# ========== 第一步:加载文档 ==========\n",
+    "loader = PyMuPDFLoader(\"./your_document.pdf\")\n",
+    "pages = loader.load()\n",
+    "\n",
+    "# ========== 第二步:清洗数据(可选,根据文档质量决定)==========\n",
+    "# clean_pages = [clean_pdf_text(page) for page in pages]\n",
+    "\n",
+    "# ========== 第三步:分块 ==========\n",
+    "splitter = RecursiveCharacterTextSplitter(\n",
+    "    # 分隔符优先级:段落 → 换行 → 句号 → 空格 → 硬切\n",
+    "    separators=[\"\\n\\n\", \"\\n\", \"。\", \"!\", \"?\", \" \", \"\"],\n",
+    "    # 每个块最大 50 字符\n",
+    "    chunk_size=50,\n",
+    "    # 相邻块重叠 10 字符(chunk_size 的 20%)\n",
+    "    chunk_overlap=10,\n",
+    "    # 长度计算函数\n",
+    "    length_function=len\n",
+    ")\n",
+    "docs = splitter.split_documents(pages)\n",
+    "\n",
+    "# ========== 第四步:向量化 + 存入向量库 ==========\n",
+    "embedding_model = DashScopeEmbeddings(\n",
+    "    model=\"text-embedding-v3\",\n",
+    "    dashscope_api_key=\"your-api-key\"\n",
+    ")\n",
+    "vectorstore = Chroma.from_documents(\n",
+    "    documents=docs,\n",
+    "    embedding=embedding_model,\n",
+    "    persist_directory=\"./knowledge_db\"\n",
+    ")\n",
+    "\n",
+    "# ========== 第五步:创建检索器 ==========\n",
+    "retriever = vectorstore.as_retriever(search_kwargs={\"k\": 3})\n",
+    "\n",
+    "# ========== 第六步:提问 ==========\n",
+    "query = \"你的问题\"\n",
+    "relevant_docs = retriever.invoke(query)\n",
+    "\n",
+    "# ========== 第七步:生成回答 ==========\n",
+    "context = \"\\n\\n---\\n\\n\".join([d.page_content for d in relevant_docs])\n",
+    "\n",
+    "prompt = ChatPromptTemplate.from_template(\"\"\"\n",
+    "你是一个专业的知识库助手。请根据以下上下文回答问题。\n",
+    "\n",
+    "**规则:**\n",
+    "- 只基于提供的上下文回答,不要编造\n",
+    "- 如果上下文中没有相关信息,直接说「根据现有资料,我找不到这个问题的答案」\n",
+    "- 回答要简洁直接,引用原文时用引号\n",
+    "\n",
+    "**上下文:**\n",
+    "{context}\n",
+    "\n",
+    "**问题:**\n",
+    "{question}\n",
+    "\"\"\")\n",
+    "\n",
+    "llm = ChatTongyi(model=\"qwen-plus\", dashscope_api_key=\"your-api-key\")\n",
+    "chain = prompt | llm | StrOutputParser()\n",
+    "\n",
+    "answer = chain.invoke({\"context\": context, \"question\": query})\n",
+    "print(answer)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 59,
+   "id": "5f02c3cf",
+   "metadata": {},
+   "outputs": [
+    {
+     "name": "stdout",
+     "output_type": "stream",
+     "text": [
+      "总块数: 94\n",
+      "平均块大小: 114.9 字符\n",
+      "\n",
+      "块 #1 (674字符)\n",
+      "疯狂的里海投资体系框架\n",
+      "数据来源:《疯狂的里海》2019-2025年度文集合集(含投资周记、周直播、公众号文章)\n",
+      "整理日期:2026年5月26日\n",
+      "一、总体框架:\"2444\"体系\n",
+      "疯狂的里海将自己的投资体系浓缩为 \"2-4-4-4\" 结构:\n",
+      "层次\n",
+      "内容\n",
+      "说明\n",
+      "\"2\"\n",
+      "认知 + 修行\n",
+      "投资分为两大部分:认知是知识的集合,修行是心性的磨练\n",
+      "第一个\"4\"\n",
+      "常识、概率、赔率、频率\n",
+      "底层思维框架\n",
+      "第二个\"4\"\n",
+      "\n",
+      "块 #2 (508字符)\n",
+      "四、选股模型:隐形冠军模型\n",
+      "核心公式:\n",
+      "隐形冠军 + 资产结构简单 + 低估值 + 有变化 = 买入信号\n",
+      "四大要素详解:\n",
+      "1. 简单(最重要的前提)\n",
+      "业务结构简单:主营业务清晰,产品线不复杂,散户看得懂\n",
+      "资产负债结构简单:没有大量有息负债,没有复杂的表外风险,资产质量\"实\"\n",
+      "典型特征:货币资金充裕、经营性负债为主、无暴雷风险\n",
+      "2. 底部(安全边际)\n",
+      "股价在历史低位区间,跌无可跌\n",
+      "估值指标:PB 1\n",
+      "\n",
+      "块 #3 (591字符)\n",
+      "维度\n",
+      "战略股\n",
+      "战术股\n",
+      "持有时间\n",
+      "1-3年甚至更长\n",
+      "数周到数月\n",
+      "预期收益\n",
+      "翻倍以上(3-10倍)\n",
+      "30-80%\n",
+      "仓位\n",
+      "重仓(40-60%)\n",
+      "轻仓/中仓(10-30%)\n",
+      "核心逻辑\n",
+      "业绩从低点释放到高点的全过程\n",
+      "赔率保护下的价值发现波段\n",
+      "典型案例\n",
+      "涪陵榨菜、太极集团\n",
+      "秦安股份、阳谷华泰、花园生物\n",
+      "操作要点\n",
+      "低位重仓、不惧波动、拿住\n",
+      "到预期目标果断走,切换到下一个\n",
+      "关键认知:\n",
+      "\"战略股都是战术股走出来的\"\n",
+      "\n",
+      "块 #4 (555字符)\n",
+      "在市场不关注(成交低迷)时买入\n",
+      "利用市场定价错误获取安全边际\n",
+      "强者突破(能力圈内的深度跟踪)\n",
+      "光选股不够,必须跟踪变化\n",
+      "在能力圈内做\"强者\":深入产业链、实地调研、跟踪数据\n",
+      "本地公司优势:随时上门、通过合作机构了解、朋友在公司内\n",
+      "\"散户做强者突破,只能是一点:能力圈内选股\"\n",
+      "八、操作节奏与卖出原则\n",
+      "买入时机\n",
+      "股价在底部区间(PB低、PE低、成交萎缩)\n",
+      "基本面出现向上的\"变化\"信号\n",
+      "大势不需要太好,\n",
+      "\n",
+      "块 #5 (569字符)\n",
+      "震荡市/结构性行情\n",
+      "\"聚焦微观,做好当下,死磕投资\"\n",
+      "放低预期,追求跑赢指数\n",
+      "找结构性机会:局部需求增长的行业/产业链\n",
+      "十、投资进阶路径\n",
+      "里海总结的散户进阶之路:\n",
+      "第一阶段:中巴式(买好公司长期持有)\n",
+      " ↓\n",
+      "第二阶段:成长股(关注业绩增长)\n",
+      " ↓\n",
+      "第三阶段:基本面 + 市场情绪(戴维斯双击)\n",
+      " ↓\n",
+      "第四阶段:基本面 + 技术/交易(择时能力)\n",
+      " ↓\n",
+      "第五阶段:赚时代的钱(前瞻性认知)\n",
+      "里海自身定位\n"
+     ]
+    }
+   ],
+   "source": [
+    "from langchain_community.embeddings import DashScopeEmbeddings\n",
+    "from langchain_community.vectorstores import Chroma\n",
+    "import os\n",
+    "from dotenv import load_dotenv\n",
+    "from langchain_community.embeddings import DashScopeEmbeddings\n",
+    "\n",
+    "# 加载环境变量\n",
+    "load_dotenv()\n",
+    "\n",
+    "# 加载数据库\n",
+    "embedding = DashScopeEmbeddings(\n",
+    "    model=\"text-embedding-v3\",\n",
+    "    dashscope_api_key=os.getenv(\"DASHSCOPE_API_KEY\")\n",
+    ")\n",
+    "\n",
+    "vectordb = Chroma(\n",
+    "    persist_directory=\"./investment_knowledge_db\",\n",
+    "    embedding_function=embedding\n",
+    ")\n",
+    "\n",
+    "# 查看所有块\n",
+    "result = vectordb.get()\n",
+    "docs = result['documents']\n",
+    "\n",
+    "print(f\"总块数: {len(docs)}\")\n",
+    "print(f\"平均块大小: {sum(len(d) for d in docs) / len(docs):.1f} 字符\")\n",
+    "\n",
+    "# 查看前5个块\n",
+    "for i in range(5):\n",
+    "    print(f\"\\n块 #{i+1} ({len(docs[i])}字符)\")\n",
+    "    print(docs[i][:200])\n"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 60,
+   "id": "1fd285f3",
+   "metadata": {},
+   "outputs": [
+    {
+     "ename": "ModuleNotFoundError",
+     "evalue": "No module named 'pymilvus'",
+     "output_type": "error",
+     "traceback": [
+      "\u001b[1;31m---------------------------------------------------------------------------\u001b[0m",
+      "\u001b[1;31mModuleNotFoundError\u001b[0m                       Traceback (most recent call last)",
+      "Cell \u001b[1;32mIn[60], line 1\u001b[0m\n\u001b[1;32m----> 1\u001b[0m \u001b[38;5;28;01mfrom\u001b[39;00m\u001b[38;5;250m \u001b[39m\u001b[38;5;21;01mpymilvus\u001b[39;00m\u001b[38;5;250m \u001b[39m\u001b[38;5;28;01mimport\u001b[39;00m MilvusClient, DataType\n\u001b[0;32m      3\u001b[0m \u001b[38;5;66;03m# 连接 Milvus 服务(默认端口 19530)\u001b[39;00m\n\u001b[0;32m      4\u001b[0m client \u001b[38;5;241m=\u001b[39m MilvusClient(uri\u001b[38;5;241m=\u001b[39m\u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mhttp://localhost:19530\u001b[39m\u001b[38;5;124m\"\u001b[39m)\n",
+      "\u001b[1;31mModuleNotFoundError\u001b[0m: No module named 'pymilvus'"
+     ]
+    }
+   ],
+   "source": [
+    "from pymilvus import MilvusClient, DataType\n",
+    "\n",
+    "# 连接 Milvus 服务(默认端口 19530)\n",
+    "client = MilvusClient(uri=\"http://localhost:19530\")"
+   ]
+  }
+ ],
+ "metadata": {
+  "kernelspec": {
+   "display_name": ".venv",
+   "language": "python",
+   "name": "python3"
+  },
+  "language_info": {
+   "codemirror_mode": {
+    "name": "ipython",
+    "version": 3
+   },
+   "file_extension": ".py",
+   "mimetype": "text/x-python",
+   "name": "python",
+   "nbconvert_exporter": "python",
+   "pygments_lexer": "ipython3",
+   "version": "3.10.20"
+  }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}

+ 25 - 0
02_RAG_study/requirements.txt

@@ -0,0 +1,25 @@
+# RAG系统依赖包
+# 使用清华源加速安装: pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple
+
+# LangChain 核心组件
+langchain>=0.1.0
+langchain-community>=0.0.20
+langchain-core>=0.1.20
+langchain-text-splitters>=0.0.1
+langchain-openai>=0.1.0
+
+# PDF 处理
+pymupdf>=1.23.0
+
+# 向量数据库
+chromadb>=0.4.0
+
+# 阿里云 DashScope (通义千问 Embedding)
+dashscope>=1.14.0
+
+# 环境变量管理
+python-dotenv>=1.0.0
+
+# 可选依赖(如果需要使用其他功能)
+# pandas>=2.0.0
+# numpy>=1.24.0

+ 111 - 0
02_RAG_study/show_chunks.py

@@ -0,0 +1,111 @@
+"""
+直接显示向量数据库内容(非交互式)
+"""
+
+import os
+from dotenv import load_dotenv
+from langchain_community.embeddings import DashScopeEmbeddings
+from langchain_community.vectorstores import Chroma
+
+# 加载环境变量
+load_dotenv()
+
+
+def show_database(persist_directory: str):
+    """显示数据库内容"""
+    print("=" * 80)
+    print(f"向量数据库: {persist_directory}")
+    print("=" * 80)
+    
+    if not os.path.exists(persist_directory):
+        print(f"❌ 数据库不存在: {persist_directory}")
+        return
+    
+    # 初始化Embedding模型
+    embedding_model = DashScopeEmbeddings(
+        model=os.getenv("EMBEDDING_MODEL", "text-embedding-v3"),
+        dashscope_api_key=os.getenv("DASHSCOPE_API_KEY", "")
+    )
+    
+    # 加载向量数据库
+    vectorstore = Chroma(
+        persist_directory=persist_directory,
+        embedding_function=embedding_model
+    )
+    
+    # 获取所有文档
+    result = vectorstore.get()
+    documents = result.get('documents', [])
+    metadatas = result.get('metadatas', [])
+    ids = result.get('ids', [])
+    
+    print(f"\n📊 数据库统计:")
+    print(f"   总块数: {len(documents)}")
+    
+    if len(documents) == 0:
+        print("\n❌ 数据库为空")
+        return
+    
+    # 统计信息
+    total_chars = sum(len(doc) for doc in documents)
+    avg_chars = total_chars / len(documents)
+    max_chars = max(len(doc) for doc in documents)
+    min_chars = min(len(doc) for doc in documents)
+    
+    print(f"   总字符数: {total_chars:,}")
+    print(f"   平均块大小: {avg_chars:.1f} 字符")
+    print(f"   最大块: {max_chars} 字符")
+    print(f"   最小块: {min_chars} 字符")
+    
+    # 显示前20个块
+    print(f"\n📄 前20个块预览:")
+    print("=" * 80)
+    
+    for i in range(min(20, len(documents))):
+        doc = documents[i]
+        metadata = metadatas[i] if i < len(metadatas) else {}
+        
+        print(f"\n【块 #{i+1}】")
+        print(f"ID: {ids[i] if i < len(ids) else 'N/A'}")
+        print(f"页码: {metadata.get('page', 'N/A')}")
+        print(f"大小: {len(doc)} 字符")
+        print(f"内容: {doc[:150].replace(chr(10), ' ')}...")
+        print("-" * 80)
+
+
+if __name__ == "__main__":
+    # 检查API Key
+    if not os.getenv("DASHSCOPE_API_KEY"):
+        print("❌ 请先配置 DASHSCOPE_API_KEY")
+        exit(1)
+    
+    # 显示数据库内容
+    show_database("./car_info_knowledge_db")
+    
+    # 测试检索
+    print("\n\n" + "=" * 80)
+    print("测试检索功能")
+    print("=" * 80)
+    
+    embedding_model = DashScopeEmbeddings(
+        model=os.getenv("EMBEDDING_MODEL", "text-embedding-v3"),
+        dashscope_api_key=os.getenv("DASHSCOPE_API_KEY", "")
+    )
+    
+    vectorstore = Chroma(
+        persist_directory="./car_info_knowledge_db",
+        embedding_function=embedding_model
+    )
+    
+    query = "HDC系统在什么速度下会激活?"
+    print(f"\n查询: {query}")
+    print("-" * 80)
+    
+    results = vectorstore.similarity_search_with_score(query, k=3)
+    
+    for i, (doc, score) in enumerate(results):
+        print(f"\n结果 #{i+1}")
+        print(f"相似度: {score:.4f}")
+        print(f"页码: {doc.metadata.get('page', 'N/A')}")
+        print(f"内容: {doc.page_content[:200]}...")
+        print("-" * 80)

+ 73 - 0
02_RAG_study/simple_view.py

@@ -0,0 +1,73 @@
+"""简单查看数据库内容"""
+import os
+import sys
+from dotenv import load_dotenv
+
+# 加载环境变量
+load_dotenv()
+
+# 切换到正确的工作目录
+os.chdir(r"d:\agentlearning\lqq-agent-study\02_RAG_study")
+
+from langchain_community.embeddings import DashScopeEmbeddings
+from langchain_community.vectorstores import Chroma
+
+# 初始化
+embedding_model = DashScopeEmbeddings(
+    model="text-embedding-v3",
+    dashscope_api_key=os.getenv("DASHSCOPE_API_KEY", "")
+)
+
+# 加载数据库
+db_path = "./car_info_knowledge_db"
+print(f"加载数据库: {db_path}")
+
+if not os.path.exists(db_path):
+    print(f"数据库不存在!")
+    sys.exit(1)
+
+vectorstore = Chroma(
+    persist_directory=db_path,
+    embedding_function=embedding_model
+)
+
+# 获取所有数据
+result = vectorstore.get()
+documents = result.get('documents', [])
+metadatas = result.get('metadatas', [])
+
+print(f"\n总块数: {len(documents)}")
+
+if documents:
+    # 统计
+    sizes = [len(doc) for doc in documents]
+    print(f"总字符数: {sum(sizes):,}")
+    print(f"平均块大小: {sum(sizes)/len(sizes):.1f}")
+    print(f"最大块: {max(sizes)}")
+    print(f"最小块: {min(sizes)}")
+    
+    # 显示前10个块
+    print("\n" + "=" * 80)
+    print("前10个块的内容:")
+    print("=" * 80)
+    
+    for i in range(min(10, len(documents))):
+        doc = documents[i]
+        metadata = metadatas[i] if i < len(metadatas) else {}
+        
+        print(f"\n【块 #{i+1}】")
+        print(f"页码: {metadata.get('page', 'N/A')}")
+        print(f"大小: {len(doc)} 字符")
+        print(f"内容: {doc[:150].replace(chr(10), ' ')}...")
+        print("-" * 80)
+    
+    # 测试检索
+    print("\n\n测试检索:")
+    query = "HDC系统"
+    print(f"查询: {query}")
+    
+    results = vectorstore.similarity_search_with_score(query, k=3)
+    
+    for i, (doc, score) in enumerate(results):
+        print(f"\n结果 #{i+1} (相似度: {score:.4f})")
+        print(f"内容: {doc.page_content[:150]}...")

+ 27 - 0
02_RAG_study/test_pdf.py

@@ -0,0 +1,27 @@
+"""测试PDF文件是否能正常加载"""
+import fitz  # PyMuPDF
+
+# 打开PDF文件
+pdf_path = r"D:\investment\疯狂的里海 · 投资方法论 — 基于 367 篇投资周记提炼.pdf"
+
+try:
+    doc = fitz.open(pdf_path)
+    
+    print(f"PDF文件: {pdf_path}")
+    print(f"总页数: {len(doc)}")
+    print(f"元数据: {doc.metadata}")
+    
+    # 查看前几页的内容
+    for i in range(min(3, len(doc))):
+        page = doc[i]
+        text = page.get_text()
+        print(f"\n=== 第 {i+1} 页 ===")
+        print(f"文本长度: {len(text)}")
+        print(f"内容预览:\n{text[:500]}...")
+        
+    doc.close()
+    
+except Exception as e:
+    print(f"错误: {e}")
+    import traceback
+    traceback.print_exc()

+ 251 - 0
02_RAG_study/test_rag.py

@@ -0,0 +1,251 @@
+"""
+RAG系统测试脚本
+用于验证系统各个组件是否正常工作
+"""
+
+import os
+import sys
+from dotenv import load_dotenv
+
+# 加载环境变量
+load_dotenv()
+
+
+def test_api_key():
+    """测试API Key配置"""
+    print("\n=== 测试1: API Key配置 ===")
+    
+    # 检查DeepSeek API Key
+    openai_api_key = os.getenv("OPENAI_API_KEY")
+    dashscope_api_key = os.getenv("DASHSCOPE_API_KEY")
+    
+    openai_ok = True
+    dashscope_ok = True
+    
+    if not openai_api_key:
+        print("❌ 未配置 OPENAI_API_KEY (DeepSeek)")
+        print("   请在 .env 文件中添加: OPENAI_API_KEY=your-deepseek-api-key")
+        openai_ok = False
+    else:
+        print(f"✅ OPENAI_API_KEY 已配置 (长度: {len(openai_api_key)})")
+    
+    if not dashscope_api_key:
+        print("❌ 未配置 DASHSCOPE_API_KEY (用于Embedding)")
+        print("   请在 .env 文件中添加: DASHSCOPE_API_KEY=your-dashscope-api-key")
+        dashscope_ok = False
+    else:
+        print(f"✅ DASHSCOPE_API_KEY 已配置 (长度: {len(dashscope_api_key)})")
+    
+    # 打印模型配置
+    model_name = os.getenv("MODEL_NAME", "deepseek-chat")
+    api_base = os.getenv("OPENAI_API_BASE", "https://api.deepseek.com/v1")
+    embedding_model = os.getenv("EMBEDDING_MODEL", "text-embedding-v3")
+    
+    print(f"\n模型配置:")
+    print(f"   LLM模型: {model_name}")
+    print(f"   API地址: {api_base}")
+    print(f"   Embedding模型: {embedding_model}")
+    
+    return openai_ok and dashscope_ok
+
+
+def test_pdf_file():
+    """测试PDF文件是否存在"""
+    print("\n=== 测试2: PDF文件检查 ===")
+    
+    pdf_path = r"D:\investment\疯狂的里海 · 投资方法论 — 基于 367 篇投资周记提炼.pdf"
+    
+    if os.path.exists(pdf_path):
+        file_size = os.path.getsize(pdf_path) / (1024 * 1024)  # MB
+        print(f"✅ PDF文件存在")
+        print(f"   路径: {pdf_path}")
+        print(f"   大小: {file_size:.2f} MB")
+        return True
+    else:
+        print(f"❌ PDF文件不存在")
+        print(f"   路径: {pdf_path}")
+        print("   请检查文件路径是否正确")
+        return False
+
+
+def test_dependencies():
+    """测试依赖包是否安装"""
+    print("\n=== 测试3: 依赖包检查 ===")
+    
+    required_packages = {
+        'langchain': 'langchain',
+        'langchain_community': 'langchain-community',
+        'langchain_core': 'langchain-core',
+        'langchain_text_splitters': 'langchain-text-splitters',
+        'fitz': 'pymupdf',  # PyMuPDF
+        'chromadb': 'chromadb',
+        'dashscope': 'dashscope',
+    }
+    
+    all_installed = True
+    
+    for module_name, package_name in required_packages.items():
+        try:
+            __import__(module_name)
+            print(f"✅ {package_name} 已安装")
+        except ImportError:
+            print(f"❌ {package_name} 未安装")
+            print(f"   安装命令: pip install {package_name}")
+            all_installed = False
+    
+    return all_installed
+
+
+def test_embedding_model():
+    """测试Embedding模型"""
+    print("\n=== 测试4: Embedding模型 ===")
+    
+    try:
+        from langchain_community.embeddings import DashScopeEmbeddings
+        
+        api_key = os.getenv("DASHSCOPE_API_KEY")
+        if not api_key:
+            print("⚠️ 跳过测试(API Key未配置)")
+            return None
+        
+        # 创建Embedding模型
+        embedding_model = DashScopeEmbeddings(
+            model=os.getenv("EMBEDDING_MODEL", "text-embedding-v3"),
+            dashscope_api_key=api_key
+        )
+        
+        # 测试向量化
+        test_text = "这是一个测试文本"
+        embedding = embedding_model.embed_query(test_text)
+        
+        print(f"✅ Embedding模型正常工作")
+        print(f"   使用模型: {os.getenv('EMBEDDING_MODEL', 'text-embedding-v3')}")
+        print(f"   向量维度: {len(embedding)}")
+        print(f"   前5个值: {embedding[:5]}")
+        return True
+        
+    except Exception as e:
+        print(f"❌ Embedding模型测试失败")
+        print(f"   错误: {str(e)}")
+        return False
+
+
+def test_llm_model():
+    """测试大语言模型"""
+    print("\n=== 测试5: 大语言模型 ===")
+    
+    try:
+        from langchain_openai import ChatOpenAI
+        
+        api_key = os.getenv("OPENAI_API_KEY")
+        api_base = os.getenv("OPENAI_API_BASE", "https://api.deepseek.com/v1")
+        model_name = os.getenv("MODEL_NAME", "deepseek-chat")
+        
+        if not api_key:
+            print("⚠️ 跳过测试(API Key未配置)")
+            return None
+        
+        # 创建LLM(使用DeepSeek)
+        llm = ChatOpenAI(
+            model=model_name,
+            openai_api_key=api_key,
+            openai_api_base=api_base,
+            temperature=0.7
+        )
+        
+        # 测试调用
+        test_message = "你好,请回复'测试成功'"
+        response = llm.invoke(test_message)
+        
+        print(f"✅ 大语言模型正常工作")
+        print(f"   使用模型: {model_name}")
+        print(f"   API地址: {api_base}")
+        print(f"   测试问题: {test_message}")
+        print(f"   模型回复: {response.content}")
+        return True
+        
+    except Exception as e:
+        print(f"❌ 大语言模型测试失败")
+        print(f"   错误: {str(e)}")
+        return False
+
+
+def test_rag_system():
+    """测试完整的RAG系统"""
+    print("\n=== 测试6: RAG系统完整性 ===")
+    
+    try:
+        # 添加路径
+        sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+        
+        # 导入RAG系统
+        import importlib.util
+        spec = importlib.util.spec_from_file_location("rag_task", "./02_RAG_task.py")
+        rag_module = importlib.util.module_from_spec(spec)
+        spec.loader.exec_module(rag_module)
+        RAGSystem = rag_module.RAGSystem
+        
+        print("✅ RAG系统模块加载成功")
+        return True
+        
+    except Exception as e:
+        print(f"❌ RAG系统测试失败")
+        print(f"   错误: {str(e)}")
+        return False
+
+
+def main():
+    """运行所有测试"""
+    print("=" * 60)
+    print("RAG系统诊断测试")
+    print("=" * 60)
+    
+    # 运行测试
+    results = {
+        "API Key配置": test_api_key(),
+        "PDF文件检查": test_pdf_file(),
+        "依赖包检查": test_dependencies(),
+    }
+    
+    # 如果API Key配置正确,运行额外测试
+    if results["API Key配置"]:
+        results["Embedding模型"] = test_embedding_model()
+        results["大语言模型"] = test_llm_model()
+    else:
+        results["Embedding模型"] = None
+        results["大语言模型"] = None
+    
+    results["RAG系统"] = test_rag_system()
+    
+    # 总结
+    print("\n" + "=" * 60)
+    print("测试总结")
+    print("=" * 60)
+    
+    passed = sum(1 for v in results.values() if v is True)
+    failed = sum(1 for v in results.values() if v is False)
+    skipped = sum(1 for v in results.values() if v is None)
+    
+    for test_name, result in results.items():
+        if result is True:
+            status = "✅ 通过"
+        elif result is False:
+            status = "❌ 失败"
+        else:
+            status = "⚠️ 跳过"
+        print(f"{test_name}: {status}")
+    
+    print(f"\n总计: {passed} 通过, {failed} 失败, {skipped} 跳过")
+    
+    if failed == 0 and passed > 0:
+        print("\n🎉 所有测试通过!系统准备就绪。")
+        print("\n运行以下命令启动系统:")
+        print("  python 02_RAG_task.py")
+    elif failed > 0:
+        print("\n⚠️ 部分测试失败,请检查上述错误信息。")
+    else:
+        print("\n⚠️ 请先配置 API Key 和安装依赖包。")
+
+
+if __name__ == "__main__":
+    main()

+ 240 - 0
02_RAG_study/view_chunks.py

@@ -0,0 +1,240 @@
+"""
+查看RAG知识库内容工具
+功能:查看分块结果、向量数据库内容、检索测试
+"""
+
+import os
+from dotenv import load_dotenv
+from langchain_community.embeddings import DashScopeEmbeddings
+from langchain_community.vectorstores import Chroma
+
+# 加载环境变量
+load_dotenv()
+
+
+def view_vectorstore(persist_directory: str = "./car_info_knowledge_db"):
+    """
+    查看向量数据库内容
+    
+    Args:
+        persist_directory: 向量数据库路径
+    """
+    print("=" * 80)
+    print(f"查看向量数据库: {persist_directory}")
+    print("=" * 80)
+    
+    if not os.path.exists(persist_directory):
+        print(f"❌ 数据库不存在: {persist_directory}")
+        return
+    
+    # 初始化Embedding模型
+    embedding_model = DashScopeEmbeddings(
+        model=os.getenv("EMBEDDING_MODEL", "text-embedding-v3"),
+        dashscope_api_key=os.getenv("DASHSCOPE_API_KEY", "")
+    )
+    
+    # 加载向量数据库
+    vectorstore = Chroma(
+        persist_directory=persist_directory,
+        embedding_function=embedding_model
+    )
+    
+    # 获取所有文档
+    try:
+        # 使用get方法获取所有数据
+        result = vectorstore.get()
+        
+        documents = result.get('documents', [])
+        metadatas = result.get('metadatas', [])
+        ids = result.get('ids', [])
+        
+        print(f"\n📊 数据库统计信息:")
+        print(f"   总文档数: {len(documents)}")
+        
+        if len(documents) == 0:
+            print("\n❌ 数据库为空")
+            return
+        
+        # 统计信息
+        total_chars = sum(len(doc) for doc in documents)
+        avg_chars = total_chars / len(documents) if len(documents) > 0 else 0
+        
+        print(f"   总字符数: {total_chars}")
+        print(f"   平均每块字符数: {avg_chars:.1f}")
+        print(f"   最大块: {max(len(doc) for doc in documents)} 字符")
+        print(f"   最小块: {min(len(doc) for doc in documents)} 字符")
+        
+        # 显示前N个块
+        print(f"\n📄 前10个块的内容预览:")
+        print("-" * 80)
+        
+        for i in range(min(10, len(documents))):
+            doc = documents[i]
+            metadata = metadatas[i] if i < len(metadatas) else {}
+            
+            print(f"\n块 #{i+1} (ID: {ids[i] if i < len(ids) else 'N/A'})")
+            print(f"页码: {metadata.get('page', 'N/A')}")
+            print(f"字符数: {len(doc)}")
+            print(f"内容预览:")
+            print(f"  {doc[:200]}...")
+            print("-" * 80)
+        
+    except Exception as e:
+        print(f"❌ 读取数据库出错: {e}")
+        import traceback
+        traceback.print_exc()
+
+
+def test_search(persist_directory: str = "./car_info_knowledge_db", query: str = None):
+    """
+    测试检索功能
+    
+    Args:
+        persist_directory: 向量数据库路径
+        query: 查询文本
+    """
+    print("\n" + "=" * 80)
+    print("测试检索功能")
+    print("=" * 80)
+    
+    if not os.path.exists(persist_directory):
+        print(f"❌ 数据库不存在: {persist_directory}")
+        return
+    
+    # 初始化Embedding模型
+    embedding_model = DashScopeEmbeddings(
+        model=os.getenv("EMBEDDING_MODEL", "text-embedding-v3"),
+        dashscope_api_key=os.getenv("DASHSCOPE_API_KEY", "")
+    )
+    
+    # 加载向量数据库
+    vectorstore = Chroma(
+        persist_directory=persist_directory,
+        embedding_function=embedding_model
+    )
+    
+    # 默认查询
+    if not query:
+        query = "HDC系统在什么速度下会激活?"
+    
+    print(f"\n🔍 查询: {query}")
+    print("-" * 80)
+    
+    try:
+        # 执行相似度搜索
+        results = vectorstore.similarity_search_with_score(query, k=3)
+        
+        print(f"\n找到 {len(results)} 个相关结果:\n")
+        
+        for i, (doc, score) in enumerate(results):
+            print(f"结果 #{i+1}")
+            print(f"相似度分数: {score:.4f} (越小越相似)")
+            print(f"页码: {doc.metadata.get('page', 'N/A')}")
+            print(f"字符数: {len(doc.page_content)}")
+            print(f"内容:")
+            print(f"  {doc.page_content[:300]}...")
+            print("-" * 80)
+            
+    except Exception as e:
+        print(f"❌ 检索出错: {e}")
+        import traceback
+        traceback.print_exc()
+
+
+def interactive_query(persist_directory: str = "./car_info_knowledge_db"):
+    """
+    交互式查询模式
+    """
+    print("\n" + "=" * 80)
+    print("交互式查询模式")
+    print("=" * 80)
+    print("输入问题进行检索,输入 'quit' 或 'exit' 退出\n")
+    
+    if not os.path.exists(persist_directory):
+        print(f"❌ 数据库不存在: {persist_directory}")
+        return
+    
+    # 初始化Embedding模型
+    embedding_model = DashScopeEmbeddings(
+        model=os.getenv("EMBEDDING_MODEL", "text-embedding-v3"),
+        dashscope_api_key=os.getenv("DASHSCOPE_API_KEY", "")
+    )
+    
+    # 加载向量数据库
+    vectorstore = Chroma(
+        persist_directory=persist_directory,
+        embedding_function=embedding_model
+    )
+    
+    while True:
+        try:
+            query = input("你的问题: ").strip()
+            
+            if query.lower() in ['quit', 'exit', 'q']:
+                print("\n再见!")
+                break
+            
+            if not query:
+                continue
+            
+            # 执行检索
+            results = vectorstore.similarity_search_with_score(query, k=3)
+            
+            print(f"\n找到 {len(results)} 个相关结果:\n")
+            
+            for i, (doc, score) in enumerate(results):
+                print(f"结果 #{i+1} (相似度: {score:.4f})")
+                print(f"内容: {doc.page_content[:200]}...")
+                print("-" * 60)
+            
+        except KeyboardInterrupt:
+            print("\n\n再见!")
+            break
+        except Exception as e:
+            print(f"错误: {e}")
+
+
+def main():
+    """主函数"""
+    print("\n" + "=" * 80)
+    print("RAG知识库内容查看工具")
+    print("=" * 80)
+    
+    # 检查API Key
+    if not os.getenv("DASHSCOPE_API_KEY"):
+        print("❌ 请先配置 DASHSCOPE_API_KEY")
+        return
+    
+    # 查看数据库
+    db_path = "./car_info_knowledge_db"
+    
+    while True:
+        print("\n请选择功能:")
+        print("1. 查看向量数据库内容")
+        print("2. 测试检索功能")
+        print("3. 交互式查询")
+        print("4. 更换数据库路径")
+        print("0. 退出")
+        
+        choice = input("\n请输入选项 (0-4): ").strip()
+        
+        if choice == "1":
+            view_vectorstore(db_path)
+        elif choice == "2":
+            query = input("输入查询内容 (直接回车使用默认查询): ").strip()
+            test_search(db_path, query if query else None)
+        elif choice == "3":
+            interactive_query(db_path)
+        elif choice == "4":
+            new_path = input(f"输入数据库路径 (当前: {db_path}): ").strip()
+            if new_path:
+                db_path = new_path
+        elif choice == "0":
+            print("\n再见!")
+            break
+        else:
+            print("无效选项")
+
+
+if __name__ == "__main__":
+    main()

+ 1396 - 0
03_LangGraph_study/3.+LangGraph实战:Middleware.md

@@ -0,0 +1,1396 @@
+## 前言
+如果你正在用 LangGraph 搭建 Agent,大概率遇到过这些场景:
+
++ Agent 陷入循环,疯狂调用工具把 Token 烧光
++ 模型 API 偶尔抽风,整个链路直接崩掉
++ 用户把身份证号、手机号一股脑塞进对话框
++ 某个"删除生产数据"的操作,Agent 二话不说就执行了
+
+这些问题,靠多写几行 `if-else` 或者往 System Prompt 里塞规则是解决不了的。你需要的是一个能**横切 Agent 执行流程**的治理层——这就是 Middleware。
+
+本文把 LangGraph Agent Middleware 的完整知识体系梳理成一篇文章,从架构认知到底层原理,从内置中间件实战到自定义进阶。文中的所有示例围绕一个统一场景——**智能报表导出平台**——展开,模型选用国内开发者更熟悉的 DeepSeek 系列。
+
+读完这篇,你应该能回答下面几个问题:
+
++ Middleware 在 Agent 执行循环的哪些位置介入?
++ 怎么给 Agent 装"刹车"(次数限制、人工确认、脱敏)?
++ 怎么让 Agent 长期稳定运行(重试降级、摘要压缩、上下文清理)?
++ 什么时候用内置 Middleware,什么时候自己写?
++ `request` / `handler` / `state` / `runtime` 这几个参数到底怎么用?
+
+---
+
+## 一、架构认知:Middleware 是什么,怎么运转
+### 1.1 为什么 Agent 需要被"管"
+先看一个典型的 Agent 执行流程:
+
+<!-- 这是一个文本绘图,源码为:flowchart LR
+    A[👤 用户输入] --> B[🤖 调用大模型]
+    B --> C{模型决定}
+    C -->|调工具| D[⚙️ 执行工具]
+    D --> E[📋 观察结果]
+    E --> B
+    C -->|直接回复| F[✅ 返回最终答案] -->
+![](https://cdn.nlark.com/yuque/__mermaid_v3/e9ca0629bff21f3991eedad528df50ae.svg)
+
+这套循环本身没什么问题,但放到真实业务里,麻烦就来了。假设你在做一个智能报表导出 Agent,它有两个工具:
+
++ `check_export_permission`:查用户有没有导出权限
++ `create_export_task`:创建导出任务,可能一次导出几十万行数据
+
+Agent 能自己决策、自己调工具。但如果它决定反复查权限(浪费 Token)、在用户没说清楚场景时直接建导出任务(产生脏数据),或者用户消息里混着别人的手机号就送进了模型——这些都不是 Prompt 里多写两句话能拦住的事。
+
+**Middleware 的定位就是:不改变 Agent 做业务的方式,但在它做业务的时候盯着、拦着、兜着底。**
+
+<!-- 这是一个文本绘图,源码为:flowchart TB
+    subgraph Agent执行循环
+        direction LR
+        U[用户输入] --> M1[before_agent]
+        M1 --> M2[before_model]
+        M2 --> M3[wrap_model_call]
+        M3 --> LLM[大模型调用]
+        LLM --> M4[after_model]
+        M4 --> M5[wrap_tool_call]
+        M5 --> Tool[工具执行]
+        Tool --> M6[after_agent]
+        M6 --> U
+    end
+    M1 -.->|"启动前检查"| M1
+    M2 -.->|"调用前日志"| M2
+    M3 -.->|"拦截/重试"| M3
+    M4 -.->|"响应后处理"| M4
+    M5 -.->|"工具管控"| M5
+    M6 -.->|"收尾清理"| M6 -->
+![](https://cdn.nlark.com/yuque/__mermaid_v3/953fa28923796d3c0ba1023a4d126fb4.svg)
+
+LangGraph 提供了**六大钩子点**,分别对应 Agent 执行流程的不同阶段:
+
+| 钩子 | 触发时机 | 典型用途 |
+| --- | --- | --- |
+| `before_agent` | Agent 启动前 | 初始化状态、检查前置条件 |
+| `before_model` | 每次调模型之前 | 审计日志、脱敏、状态检查 |
+| `wrap_model_call` | 包裹模型调用过程 | 重试、降级、模型切换 |
+| `after_model` | 模型返回之后 | 记录响应、检测 tool_calls、更新计数 |
+| `wrap_tool_call` | 包裹工具调用过程 | 权限校验、参数拦截、耗时统计 |
+| `after_agent` | Agent 执行结束后 | 清理状态、发送通知 |
+
+
+一句话总结:**业务逻辑写在工具里,治理逻辑写在 Middleware 里。**职责分离是 Middleware 最核心的设计哲学。
+
+### 1.2 第一段 Middleware:从零到能观察到 Agent 在做什么
+先搭一个最基础的 Agent——没有任何 Middleware,只有两个工具。场景是报表导出平台的权限校验:
+
+```python
+from langchain.chat_models import init_chat_model
+from langchain.tools import tool
+
+
+# 使用 DeepSeek 模型,通过阿里云百炼平台接入
+# init_chat_model 会自动适配 OpenAI 兼容接口
+model = init_chat_model(
+    model="deepseek-v4-flash",
+    model_provider="openai",
+    base_url="https://api.deepseek.com",
+    api_key="your_api_key",
+)
+
+# ---- 模拟数据 ----
+# 实际项目中,这些数据来自数据库或 API
+EXPORT_RIGHTS = {
+    "zhangsan": {"role": "finance", "region": "all"},
+    "lisi": {"role": "operation", "region": "east"},
+}
+
+
+@tool
+def check_export_permission(username: str) -> dict:
+    """查询用户是否有报表导出权限。
+    参数 username 为员工账号(英文名)。
+    """
+    user_info = EXPORT_RIGHTS.get(username)
+    if not user_info:
+        return {"username": username, "can_export": False, "reason": "用户不存在"}
+    return {
+        "username": username,
+        "role": user_info["role"],
+        "region": user_info["region"],
+        "can_export": user_info["role"] in ("finance", "ops_manager"),
+    }
+
+
+@tool
+def create_export_task(
+    report_name: str, file_format: str, estimated_rows: int, reason: str
+) -> dict:
+    """创建报表导出任务。
+    report_name: 报表名称
+    file_format: 导出格式 (xlsx / csv)
+    estimated_rows: 预计导出行数
+    reason: 导出原因
+    """
+    return {
+        "task_id": "EXPORT-20260706-001",
+        "report_name": report_name,
+        "file_format": file_format,
+        "estimated_rows": estimated_rows,
+        "reason": reason,
+        "status": "queued",
+    }
+
+
+tools = [check_export_permission, create_export_task]
+```
+
+这个 Agent 已经能干活了,但我们对它的内部行为一无所知。加上两个最轻量的 Middleware 来观察:
+
+```python
+from langchain.agents import create_agent
+from langchain.agents.middleware import before_model, wrap_tool_call, AgentState
+
+
+@before_model
+def log_before_model(state: AgentState, runtime):
+    """模型调用前执行:看一眼当前上下文里有多少条消息。"""
+    # state["messages"] 是 Agent 当前累积的全部对话历史
+    # runtime 携带运行时环境信息(后面会详细讲)
+    print(f"[审计] 准备调用模型,当前上下文消息数:{len(state['messages'])}")
+    return None  # 返回 None 表示不做任何修改
+
+
+@wrap_tool_call
+def log_tool_call(request, handler):
+    """工具调用前后各打一条日志,并记录耗时。"""
+    tool_name = request.tool_call["name"]
+    print(f"[审计] 开始执行工具:{tool_name}")
+
+    # handler(request) 是"真正执行工具"的入口
+    # 不调用它,工具就不会执行
+    result = handler(request)
+
+    print(f"[审计] 工具执行完毕:{tool_name}")
+    return result
+
+
+# 组装 Agent,把 Middleware 列表传进去
+agent = create_agent(
+    model=model,
+    tools=tools,
+    middleware=[
+        log_before_model,   # 排在前面:先记录状态
+        log_tool_call,      # 排在后面:包裹工具调用
+    ],
+    system_prompt=(
+        "你是报表导出平台的智能助手。"
+        "用户询问导出相关问题前,先调用 check_export_permission 确认权限。"
+        "只有在用户明确提出导出需求时,才调用 create_export_task 创建任务。"
+    ),
+)
+
+# 跑一次看看
+response = agent.invoke({
+    "messages": [
+        {
+            "role": "user",
+            "content": "我是 zhangsan,需要导出本月 east 区域的订单报表,大约 5000 行,xlsx 格式。",
+        }
+    ]
+})
+
+print(response["messages"][-1].content)
+```
+
+运行时你会看到类似这样的输出:
+
+```plain
+[审计] 准备调用模型,当前上下文消息数:1
+[审计] 开始执行工具:check_export_permission
+[审计] 工具执行完毕:check_export_permission
+[审计] 准备调用模型,当前上下文消息数:3
+[审计] 开始执行工具:create_export_task
+[审计] 工具执行完毕:create_export_task
+[审计] 准备调用模型,当前上下文消息数:5
+```
+
+Agent 的业务能力没变,但现在你能清楚地看到:模型被调了几次、每次调了哪些工具、消息在什么时候膨胀。这就是 Middleware 的第一个价值——**可观测性**,不侵入业务代码。
+
+### 1.3 深入 `request` 与 `handler`
+写 `@wrap_tool_call` 或 `@wrap_model_call` 时,你一定会遇到这两个参数。它们的直觉含义是"拦住一次调用,看看参数,再决定要不要继续"。但很多人卡在对这两个对象的具体结构不熟悉上。
+
+#### 1.3.1 工具级:`ToolCallRequest`
+当你在 `@wrap_tool_call` 里拿到 `request` 时,它是一个 `ToolCallRequest` 对象,包含以下关键字段:
+
+| 字段 | 含义 | 用法举例 |
+| --- | --- | --- |
+| `request.tool_call["name"]` | 模型决定调哪个工具 | `if tool_name == "create_export_task"` |
+| `request.tool_call["args"]` | 模型生成的调用参数 | 校验 `estimated_rows` 是否超标 |
+| `request.tool_call["id"]` | 本次调用的唯一 ID | 日志追踪 |
+| `request.tool` | 工具对象本身 | 读取工具的 docstring |
+| `request.state` | Agent 当前累积状态 | 查看已有消息数 |
+| `request.runtime` | 运行时上下文 | 读取从业务层传入的 `context` |
+
+
+`handler`** 是一个可调用对象**——`handler(request)` 执行之后,真正的工具才会运行。这意味着你可以在调用 `handler` 之前做校验、修改参数,甚至直接跳过它。
+
+下面是一个实用的例子:在工具执行前拦截超标请求,不调用真正的导出工具,而是返回伪造的 `ToolMessage` 让 Agent 知道被拦了:
+
+```python
+from langgraph_core.messages import ToolMessage
+
+
+@wrap_tool_call
+def guard_export_scale(request, handler):
+    """超过 10 万行的导出请求直接拦截,不进入执行队列。"""
+    tool_name = request.tool_call["name"]
+    args = request.tool_call.get("args", {})
+
+    # 只拦截 create_export_task
+    if tool_name == "create_export_task" and args.get("estimated_rows", 0) > 100_000:
+        # 不调 handler,直接返回 ToolMessage 给模型
+        # 模型收到这条消息后,会告知用户"太大了,换个小范围"
+        return ToolMessage(
+            content=f"导出被拦截:预计行数 {args.get('estimated_rows')} 超过上限 100000,请缩小筛选范围后重试。",
+            tool_call_id=request.tool_call["id"],
+        )
+
+    # 正常放行
+    return handler(request)
+```
+
+四种 `handler` 用法模式:
+
+<!-- 这是一个文本绘图,源码为:flowchart TD
+    R[收到 request] --> Q{怎么处理 handler?}
+    Q -->|"模式1: 透明放行"| A["return handler(request)"]
+    Q -->|"模式2: 拦截替换"| B["return ToolMessage(...)"]
+    Q -->|"模式3: 重试覆盖"| C["handler(request.override(model=backup))"]
+    Q -->|"模式4: 链式修改"| D["修改 request 后调 handler"] -->
+![](https://cdn.nlark.com/yuque/__mermaid_v3/62f94cf516ce1e70acfd1c64366f453a.svg)
+
+> ⚠️ **重要**:不调 `handler` = 原始操作从未发生;调两次 `handler` = 操作执行两次——对于发邮件、扣款这类操作,这可能造成严重后果。
+>
+
+#### 1.3.2 模型级:`ModelRequest`
+`@wrap_model_call` 里的 `request` 是 `ModelRequest`,结构更"厚":
+
+| 字段 | 含义 |
+| --- | --- |
+| `request.messages` | 即将发送给模型的消息列表——就是上文说的"上下文" |
+| `request.model` | 当前使用的模型对象 |
+| `request.tools` | 模型可用的工具列表 |
+| `request.model_settings` | 模型参数(temperature 等) |
+| `request.state` / `request.runtime` | 同工具级 |
+
+
+这里有一个很关键的用法——`request.override(model=...)`,可以在不重建 Agent 的情况下临时切换模型:
+
+```python
+@wrap_model_call
+def local_first_then_cloud(request, handler):
+    """先用本地模型,失败 3 次后切云端模型。"""
+    # 先用本地 Ollama 模型(省成本)
+    try:
+        return handler(request)  # 使用 Agent 默认模型
+    except Exception:
+        # 本地挂了,切到 DeepSeek 云端
+        print("[降级] 本地模型不可用,切换到云端 DeepSeek")
+        return handler(request.override(model=cloud_model))
+```
+
+```python
+# 一个典型的 Agent 运行流程:
+用户输入 → 
+  [wrap_model_call 拦截] → LLM 思考 → 决定调用工具 get_weather →
+  [wrap_tool_call 拦截] → 执行 get_weather 工具 → 
+  [wrap_model_call 拦截] → LLM 再次思考 → 生成最终回复 → 返回给用户
+```
+
++ `**<font style="color:rgb(15, 17, 21);background-color:rgb(235, 238, 242);">wrap_model_call</font>**`<font style="color:rgb(15, 17, 21);"> </font><font style="color:rgb(15, 17, 21);">在模型每次被调用时触发,包括首次决策和后续的反思轮次。</font>
++ `**<font style="color:rgb(15, 17, 21);background-color:rgb(235, 238, 242);">wrap_tool_call</font>**`<font style="color:rgb(15, 17, 21);"> 只在模型明确要求调用工具时触发。</font>
+
+### 1.4 深入 `state` 与 `runtime`
+如果说 `request` / `handler` 关注的是"**这一次调用**",那 `state` / `runtime` 关注的就是"**Agent 当前整体处于什么状态**"。
+
+<!-- 这是一个文本绘图,源码为:flowchart LR
+    subgraph 单次调用视角
+        R[request] --> H[handler]
+    end
+    subgraph 全局运行视角
+        S[state: 累积消息/计数] --- RT[runtime: 外部上下文]
+    end -->
+![](https://cdn.nlark.com/yuque/__mermaid_v3/13c6d865e0270f532a4306f1f95c86e9.svg)
+
+#### 1.4.1 `state`:Agent 的内部状态
+`state`(类型 `AgentState`)是 Agent 在运行过程中不断累积的数据。最核心的字段是 `state["messages"]`——所有对话历史和工具结果都在里面。
+
+你可以扩展 `AgentState`,添加自定义字段来追踪业务指标:
+
+```python
+from typing import TypedDict
+from typing_extensions import NotRequired
+from langchain.agents.middleware import AgentState, before_model, after_model
+
+
+class ExportAgentState(AgentState):
+    """扩展默认状态,增加模型调用计数和大任务标记。"""
+    # AgentState 已经自带 messages 字段,这里只加新字段
+    # NotRequired 表示可以不传,Middleware 内部自己维护
+    model_call_count: NotRequired[int]
+    blocked_requests: NotRequired[int]  # 被拦截的请求次数
+
+
+@before_model(state_schema=ExportAgentState)
+def audit_before_model(state: ExportAgentState, runtime):
+    """每次调模型前,看一眼当前统计。"""
+    count = state.get("model_call_count", 0)
+    blocked = state.get("blocked_requests", 0)
+    print(f"[统计] 第 {count + 1} 次调模型 | 已拦截 {blocked} 次")
+    return None
+
+
+@after_model(state_schema=ExportAgentState)
+def update_stats(state: ExportAgentState, runtime):
+    """模型返回后,把计数器 +1。"""
+    # after_model 返回 dict 可以直接更新 AgentState
+    return {"model_call_count": state.get("model_call_count", 0) + 1}
+```
+
+关键点:`@before_model` / `@after_model` 需要指定 `state_schema`,告诉 LangGraph 你的自定义状态有哪些字段。`@after_model` 返回的 `dict` 会被浅合并进 `state`。
+
+#### 1.4.2 `runtime`:外部注入的上下文
+`runtime.context` 存放的是**不属于对话本身、但从外部传入的运行时信息**——比如请求 ID、用户角色、业务标识。
+
+```python
+from langgraph.runtime import Runtime
+
+
+class RunContext(TypedDict):
+    """定义 runtime.context 的结构。"""
+    request_id: str     # 用于日志追踪
+    user_role: str      # 当前用户的角色
+    tenant_id: str      # 租户标识(多租户场景)
+
+
+@before_model(state_schema=ExportAgentState)
+def inject_context(state: ExportAgentState, runtime: Runtime[RunContext]):
+    """从 runtime.context 读取业务信息,用于日志关联。"""
+    ctx = runtime.context or {}
+    print(
+        f"[上下文] request_id={ctx.get('request_id')} "
+        f"user_role={ctx.get('user_role')} "
+        f"tenant={ctx.get('tenant_id')}"
+    )
+    return None
+
+
+# 创建 Agent 时声明 context 结构
+agent = create_agent(
+    model=model,
+    tools=tools,
+    middleware=[audit_before_model, inject_context, update_stats],
+    state_schema=ExportAgentState,  # 声明自定义状态
+    context_schema=RunContext,      # 声明上下文结构
+    system_prompt="你是报表导出平台的智能助手。",
+)
+
+# 调用时传入 context
+result = agent.invoke(
+    {"messages": [{"role": "user", "content": "导出本月的订单报表"}]},
+    context={
+        "request_id": "req-20260706-001",
+        "user_role": "finance",
+        "tenant_id": "t-1234",
+    },
+)
+```
+
+> **重要**:`runtime.context` **不会自动让模型看到**。如果你想让它影响模型行为,需要在 Middleware 里显式地把信息写入 state 或拼入 prompt——这正是 `@dynamic_prompt` 的用武之地(见第四章)。
+>
+
+---
+
+## 二、安全风控:给 Agent 装上刹车和护栏
+线上 Agent 有三类典型风险:**调用失控**(无限循环烧 Token)、**操作越权**(关键动作未经确认)、**隐私泄露**(PII 直接喂给模型)。这一章用三个内置 Middleware 分别解决。
+
+### 2.1 调用次数限制:防住"无穷循环"
+假设 Agent 要查一个异步导出任务的进度。任务可能跑好几分钟,Agent 会不停地轮询。但如果没有限制,它能在 30 秒内调用 200 次模型——Token 账单直接爆炸。
+
+`ModelCallLimitMiddleware` 和 `ToolCallLimitMiddleware` 就是专门干这个的。
+
+#### ModelCallLimitMiddleware:限制模型调用次数
+```python
+from langchain.agents.middleware import ModelCallLimitMiddleware
+
+
+# 模拟一个"前 4 次返回处理中,第 5 次返回完成"的进度查询工具
+export_progress = {"task_001": 0}
+
+
+@tool
+def check_export_progress(task_id: str) -> str:
+    """查询导出任务的进度。"""
+    export_progress[task_id] = export_progress.get(task_id, 0) + 1
+    attempt = export_progress[task_id]
+    if attempt < 5:
+        return f"第 {attempt} 次查询:任务仍在处理中,请稍候。"
+    return f"第 {attempt} 次查询:导出完成,文件已生成。"
+
+
+# 不加限制:Agent 会一直查到第 5 次
+# 加上 ModelCallLimitMiddleware(run_limit=3):最多调 3 次模型就强制结束
+agent = create_agent(
+    model=model,
+    tools=[check_export_progress],
+    middleware=[
+        ModelCallLimitMiddleware(
+            run_limit=3,          # 单次 invoke 最多调 3 次模型
+            exit_behavior="end",  # 到达上限后尝试优雅结束(生成总结)
+        ),
+    ],
+    system_prompt=(
+        "你是导出任务进度查询助手。"
+        "当用户查询任务进度时,调用 check_export_progress。"
+        "如果任务还在处理中,继续查询直到完成。"
+    ),
+)
+
+result = agent.invoke({
+    "messages": [{"role": "user", "content": "帮我查 task_001 的导出进度,持续查到完成为止。"}]
+})
+# 输出类似:"Model call limits exceeded: run limit (3/3)"
+# Agent 被强制刹车,不会无限循环
+print(result)
+```
+
+#### ToolCallLimitMiddleware:限制特定工具的调用次数
+某些场景下你不想限制整个 Agent 的模型调用次数,只想限制**某一个工具**——比如进度查询工具被频繁轮询。`ToolCallLimitMiddleware` 只针对指定工具生效:
+
+```python
+from langchain.agents.middleware import ToolCallLimitMiddleware
+
+agent = create_agent(
+    model=model,
+    tools=[check_export_progress],
+    middleware=[
+        ToolCallLimitMiddleware(
+            tool_name="check_export_progress",  # 只限制这个工具
+            run_limit=2,                         # 最多调 2 次
+            exit_behavior="continue",            # 达到上限后通知模型继续(不抛异常)
+        ),
+    ],
+    system_prompt=(
+        "你是导出任务进度查询助手。"
+        "当 check_export_progress 返回限制信息时,告知用户当前进度并建议稍后再查。"
+    ),
+)
+# exit_behavior="continue" 的效果:
+# 工具达到上限后,Middleware 返回一条 ToolMessage 告知模型"已达调用上限",
+# 模型可以据此生成友好的回复,而不是直接报错
+```
+
+> **两个 Middleware 可以混用**:同时配置 `ModelCallLimitMiddleware`(全局上限)和 `ToolCallLimitMiddleware`(单工具上限),Agent 会在任一限制触发时终止或被拦截。
+>
+
+#### run_limit vs thread_limit
+| 参数 | 作用域 | 适用场景 |
+| --- | --- | --- |
+| `run_limit` | 单次 `invoke()` | 防止一次请求中过度调用 |
+| `thread_limit` | 整个对话线程(需 checkpointer) | 跨多轮对话的累计限制 |
+
+
+`thread_limit` 需要配合 `checkpointer` 使用,因为跨轮追踪需要持久化状态:
+
+```python
+from langgraph.checkpoint.memory import InMemorySaver
+
+agent = create_agent(
+    model=model,
+    tools=[check_export_progress],
+    middleware=[
+        ModelCallLimitMiddleware(
+            run_limit=10,
+            thread_limit=50,  # 整个对话生命周期最多 50 次模型调用
+            exit_behavior="end",
+        ),
+    ],
+    checkpointer=InMemorySaver(),  # 必需:跨轮追踪需要存储
+    system_prompt="你是导出任务进度查询助手。",
+)
+```
+
+#### `exit_behavior` 的三种模式
+| 值 | 行为 | 适用场景 |
+| --- | --- | --- |
+| `"end"` | 优雅结束,生成兜底回复 | 大多数场景 |
+| `"error"` | 抛出异常,上层捕获处理 | 需要明确感知限流事件 |
+| `"continue"` | (仅工具限制)返回限制信息给模型,让模型决定怎么说 | 模型可以继续回复用户的场景 |
+
+
+### 2.2 关键操作人工确认:Human-in-the-Loop
+有些操作 Agent 不应该自己决定——比如创建一个可能消耗大量资源的导出任务。`HumanInTheLoopMiddleware` 让 Agent 在执行特定工具前**暂停并等待人类审批**。
+
+<!-- 这是一个文本绘图,源码为:sequenceDiagram
+    participant U as 👤 用户
+    participant A as 🤖 Agent
+    participant M as 🛑 HITL Middleware
+    participant T as ⚙️ 工具
+
+    U->>A: 帮我导出一份报表
+    A->>M: 准备调用 create_export_task
+    M-->>A: ⏸️ 暂停!需要人工审批
+    M->>U: 请确认:创建导出任务?
+    U->>M: ✅ approve(或 ✏️ edit / ❌ reject)
+    M->>T: 放行(或修改参数后放行)
+    T->>A: 返回执行结果
+    A->>U: 任务已创建 -->
+![](https://cdn.nlark.com/yuque/__mermaid_v3/f8c955f1417539fabab1b1cb0d73d5c3.svg)
+
+#### 实战:为危险操作加上确认环节
+```python
+from typing import Literal
+from pydantic import BaseModel, Field
+from langchain.agents.middleware import HumanInTheLoopMiddleware
+from langgraph.checkpoint.memory import InMemorySaver
+
+
+# 第一步:用 Pydantic 定义工具的输入结构
+# 结构化的参数让模型生成更准确,也方便人工审批时查看
+class ExportTaskInput(BaseModel):
+    """导出任务的参数结构。模型会按照这个 schema 生成参数。"""
+    report_name: str = Field(description="报表名称,如 'east_region_orders_202607'")
+    file_format: Literal["xlsx", "csv"] = Field(description="导出格式")
+    estimated_rows: int = Field(description="预计导出行数")
+    reason: str = Field(description="导出原因,用于审计")
+    priority: Literal["low", "normal", "high"] = Field(
+        default="normal", description="优先级"
+    )
+
+
+# 第二步:安全工具不加 args_schema(自动执行)
+@tool
+def check_export_permission(username: str) -> dict:
+    """查询用户导出权限。安全操作,无需审批。"""
+    return {"username": username, "can_export": True, "region": "all"}
+
+
+# 第三步:危险工具加上 args_schema,配合 HITL 拦截
+@tool(args_schema=ExportTaskInput)
+def create_export_task(
+    report_name: str,
+    file_format: str,
+    estimated_rows: int,
+    reason: str,
+    priority: str = "normal",
+) -> dict:
+    """创建报表导出任务。这是一个高风险操作,需要人工审批。"""
+    print(
+        f"[导出] 创建任务:{report_name} | {file_format} | "
+        f"{estimated_rows} 行 | 优先级 {priority}"
+    )
+    return {
+        "task_id": "EXPORT-20260706-002",
+        "report_name": report_name,
+        "file_format": file_format,
+        "estimated_rows": estimated_rows,
+        "status": "queued",
+    }
+
+
+# 第四步:创建带 HITL 的 Agent
+checkpointer = InMemorySaver()  # HITL 必需:暂停后需要从这里恢复状态
+
+agent = create_agent(
+    model=model,
+    tools=[check_export_permission, create_export_task],
+    middleware=[
+        HumanInTheLoopMiddleware(
+            interrupt_on={
+                # 安全工具:不中断,自动执行
+                "check_export_permission": False,
+                # 危险工具:中断,提供三种审批选项
+                "create_export_task": {
+                    "allowed_decisions": ["approve", "edit", "reject"],
+                },
+            },
+        ),
+    ],
+    checkpointer=checkpointer,
+    system_prompt=(
+        "你是报表导出平台的智能助手。"
+        "用户查询权限时,直接调用 check_export_permission。"
+        "只在用户明确要求创建导出任务时调用 create_export_task。"
+    ),
+)
+
+# 第五步:第一次 invoke——会被 HITL 拦截
+config = {"configurable": {"thread_id": "export-001"}}
+result = agent.invoke(
+    {
+        "messages": [
+            {
+                "role": "user",
+                "content": "我是 zhangsan,导出 east 区本月订单报表,xlsx,约 5000 行,月度对账用。",
+            }
+        ]
+    },
+    config=config,
+)
+
+# result 里会包含中断信息,UI 层可以据此展示审批界面
+print("Agent 已暂停,等待审批...")
+```
+
+#### 三种审批操作
+```python
+# 审批通过:工具以原始参数执行
+agent.invoke(
+    Command(resume={"decisions": [{"type": "approve"}]}),
+    config=config,
+)
+
+# 编辑后通过:修改参数再执行(比如把 estimated_rows 从 50000 改成 5000)
+agent.invoke(
+    Command(resume={
+        "decisions": [
+            {
+                "type": "edit",
+                "edited_action": {
+                    "name": "create_export_task",
+                    "args": {
+                        "report_name": "east_region_orders_202607",
+                        "file_format": "xlsx",
+                        "estimated_rows": 5000,  # 人工修正
+                        "reason": "月度对账",
+                        "priority": "high",
+                    },
+                },
+            }
+        ]
+    }),
+    config=config,
+)
+
+# 拒绝:工具不执行,拒绝原因会反馈给模型
+agent.invoke(
+    Command(resume={
+        "decisions": [
+            {
+                "type": "reject",
+                "message": "审批被驳回:本月对账已由系统自动完成,无需手动导出。",
+            }
+        ]
+    }),
+    config=config,
+)
+```
+
+> ⚠️ **HITL 的三个要点**:
+>
+> 1. **必须有 checkpointer**——Agent 暂停后需要持久化状态,`InMemorySaver` 适合开发调试,生产环境建议用 `SqliteSaver` 或 `PostgresSaver`
+> 2. `thread_id`** 是恢复的钥匙**——创建任务和审批操作必须用同一个 `thread_id`
+> 3. **不是所有工具都要审批**——只对真正高危的操作加 HITL,给每个操作都加确认会让用户体验很差
+>
+
+### 2.3 敏感信息自动脱敏
+用户经常不经意地在消息里放敏感信息:"帮我查一下导出记录,我的手机号 13800138000,身份证 110101199003070013"。
+
+这些信息不应该原样送进模型。`PIIMiddleware` 在消息进入模型**之前**做检测和替换:
+
+<!-- 这是一个文本绘图,源码为:flowchart LR
+    U[👤 用户消息] --> P[🔍 PIIMiddleware]
+    P -->|检测通过| M[🤖 模型]
+    P -->|检测到 PII| R{策略?}
+    R -->|redact| R1["替换为 [REDACTED_PHONE]"]
+    R -->|hash| R2["替换为一致的 hash 值"]
+    R -->|mask| R3["部分遮盖如 138****8000"]
+    R -->|block| R4["直接拒绝,抛出异常"]
+    R1 --> M
+    R2 --> M
+    R3 --> M -->
+![](https://cdn.nlark.com/yuque/__mermaid_v3/ca4a93d5a95984fb5cc6f78dac7e5f68.svg)
+
+#### 内置检测类型 + 自定义正则
+LangGraph 内置支持 email、信用卡号、IP 地址、MAC 地址、URL 等。中文场景常用的手机号、身份证号需要用自定义正则:
+
+```python
+from langchain.agents.middleware import PIIMiddleware
+
+# 中国大陆手机号:1 开头,第二位 3-9,共 11 位
+PHONE_PATTERN = r"(?<!\d)1[3-9]\d{9}(?!\d)"
+
+# 身份证号:15 位或 18 位(末尾可能是 X)
+ID_CARD_PATTERN = r"(?<!\d)(?:\d{17}[\dXx]|\d{15})(?!\d)"
+
+# 银行卡号:16-19 位数字
+BANK_CARD_PATTERN = r"(?<!\d)\d{16,19}(?!\d)"
+
+
+agent = create_agent(
+    model=model,
+    tools=[],
+    middleware=[
+        # 内置检测:邮箱
+        PIIMiddleware("email", strategy="redact", apply_to_input=True),
+        # 自定义检测:手机号
+        PIIMiddleware(
+            "phone_number",
+            detector=PHONE_PATTERN,
+            strategy="redact",
+            apply_to_input=True,
+        ),
+        # 自定义检测:身份证
+        PIIMiddleware(
+            "id_card",
+            detector=ID_CARD_PATTERN,
+            strategy="redact",
+            apply_to_input=True,
+        ),
+    ],
+    system_prompt="你是报表导出平台的客服助手。回复要简洁,不要提及用户的个人隐私信息。",
+)
+
+result = agent.invoke({
+    "messages": [
+        {
+            "role": "user",
+            "content": (
+                "我的手机号 13800138000,邮箱 zhangsan@example.com,"
+                "身份证 110101199003070013。帮我查一下上个月的导出记录。"
+            ),
+        }
+    ]
+})
+# 模型实际收到的消息:
+# "我的手机号 [REDACTED_PHONE_NUMBER],邮箱 [REDACTED_EMAIL],
+#  身份证 [REDACTED_ID_CARD]。帮我查一下上个月的导出记录。"
+print(result)
+```
+
+#### 四种脱敏策略对比
+| 策略 | 效果示例 | 适用场景 |
+| --- | --- | --- |
+| `redact` | `13800138000` → `[REDACTED_PHONE_NUMBER]` | 大多数场景,完全隐藏 |
+| `hash` | `13800138000` → `a1b2c3d4...`(同一输入永远同一 hash) | 需要去重但不需要原值 |
+| `mask` | `13800138000` → `138****8000` | 需要保留部分信息(如尾号) |
+| `block` | 直接抛出 `PIIDetectionError` | 严格禁止任何 PII 进入模型 |
+
+
+#### block 策略实战:直接拒绝含 PII 的请求
+`block` 策略和其他三种不同——它不会替换敏感信息,而是直接抛出 `PIIDetectionError`,阻止模型调用。适合对合规要求极高的场景(如金融、医疗):
+
+```python
+from langchain.agents.middleware import PIIDetectionError
+
+try:
+    result = agent.invoke({
+        "messages": [
+            {
+                "role": "user",
+                "content": "我的身份证号是 110101199003070013,帮我查导出记录。",
+            }
+        ]
+    })
+    print(result)
+except PIIDetectionError as e:
+    # block 策略触发后不会调用模型,直接在这里拦截
+    print(f"[合规拦截] 检测到敏感信息:{e.matched_entities}")
+    # 可以在这里返回通用回复或转人工处理
+```
+
+> `block` 抛出异常后会中断整个 Agent 执行流。如果你的应用需要优雅降级(比如告诉用户"请勿发送敏感信息"),需要在 `agent.invoke()` 外层 `try/except PIIDetectionError`。
+>
+
+#### 不止输入:工具返回和输出也要查
+```python
+agent = create_agent(
+    model=model,
+    tools=tools,
+    middleware=[
+        PIIMiddleware(
+            "email", strategy="redact",
+            apply_to_input=True,           # 用户输入先过滤
+            apply_to_tool_results=True,    # 工具返回值也过滤
+            apply_to_output=True,          # 模型最终输出再检查一遍
+        ),
+    ],
+    system_prompt="你是报表导出客服助手。",
+)
+```
+
+> ⚠️ **注意**:`PIIMiddleware` 只保护 **Agent 消息管道**。你自己工具里打的日志、调用的外部 API,不在它的保护范围内——那些需要另外做脱敏。
+>
+
+---
+
+## 三、稳定性与上下文治理:让 Agent 长期可靠运行
+Agent 在生产环境跑久了,会遇到两类问题:**外部依赖偶尔失败**(模型超时、接口抖动)和**内部状态持续膨胀**(对话越来越长、旧工具结果堆积)。这一章用三组 Middleware 解决。
+
+### 3.1 失败重试与模型降级
+网络抖动、API 限流、模型服务临时不可用——这些在生产环境是常态。`ToolRetryMiddleware` 和 `ModelRetryMiddleware` 处理"重试"问题,`ModelFallbackMiddleware` 处理"降级"问题。
+
+<!-- 这是一个文本绘图,源码为:flowchart TD
+    A[调用开始] --> B{成功?}
+    B -->|是| C[继续执行]
+    B -->|否| D{还有重试次数?}
+    D -->|是| E[等待延迟后重试]
+    E --> B
+    D -->|否| F{有备用模型?}
+    F -->|是| G[切换备用模型]
+    F -->|否| H[按 on_failure 策略处理] -->
+![](https://cdn.nlark.com/yuque/__mermaid_v3/0b6975d8057a98e1a6503223a113a080.svg)
+
+#### 工具重试:处理间歇性超时
+```python
+from langgraph.agents.middleware import ToolRetryMiddleware
+
+
+# 模拟一个前两次超时、第三次成功的导出接口
+QUERY_ATTEMPTS = {}
+
+
+@tool
+def fetch_export_history(user_id: str) -> dict:
+    """查询用户的历史导出记录。"""
+    QUERY_ATTEMPTS[user_id] = QUERY_ATTEMPTS.get(user_id, 0) + 1
+    attempt = QUERY_ATTEMPTS[user_id]
+
+    if attempt < 3:
+        # 前两次模拟网络超时
+        raise TimeoutError(f"查询接口超时(第 {attempt} 次)")
+    # 第三次成功
+    return {
+        "user_id": user_id,
+        "records": [
+            {"task_id": "EXP-001", "report": "月度订单", "time": "2026-07-01"},
+            {"task_id": "EXP-002", "report": "客户分析", "time": "2026-07-05"},
+        ],
+    }
+
+
+agent = create_agent(
+    model=model,
+    tools=[fetch_export_history],
+    middleware=[
+        ToolRetryMiddleware(
+            tools=["fetch_export_history"],  # 只对这个工具启用重试
+            max_retries=2,                    # 额外重试 2 次(共 3 次机会)
+            retry_on=(TimeoutError,),         # 只在超时时重试,权限错误不重试
+            initial_delay=0.2,               # 第一次重试前等 0.2 秒
+            max_delay=1.0,                    # 重试间隔上限 1 秒(指数退避)
+            on_failure="continue",            # 重试耗尽后让模型继续(不抛异常)
+        ),
+    ],
+    system_prompt="你是报表导出平台的查询助手。根据查询结果回答用户。",
+)
+```
+
+关键设计理念:**重试逻辑不写进工具函数,由 Middleware 统一处理。** 你的工具只管"查数据",超时了怎么办、重试多少次、间隔多久——这些是 Middleware 该操心的事。
+
+#### 模型重试与降级:本地模型挂了切云端
+```python
+from langchain.agents.middleware import ModelRetryMiddleware, ModelFallbackMiddleware
+
+# 本地模型:Ollama + Qwen3,成本低但在高负载下可能不可用
+local_model = init_chat_model("ollama:qwen3:14b")
+
+# 云端模型:DeepSeek,稳定但要花钱
+cloud_model = init_chat_model(
+    model="deepseek-v4-flash",
+    model_provider="openai",
+    base_url="https://api.deepseek.com",
+    api_key="your_api_key_here",
+)
+
+agent = create_agent(
+    model=local_model,  # 默认用本地模型
+    tools=tools,
+    middleware=[
+        # 第一层:同一个模型失败后重试
+        ModelRetryMiddleware(
+            max_retries=3,                  # 最多重试 3 次
+            retry_on=(Exception,),          # 任何异常都重试
+            on_failure="continue",
+        ),
+        # 第二层:重试也失败了,切备用模型
+        ModelFallbackMiddleware(
+            cloud_model,                    # 备用的云端模型
+        ),
+    ],
+    system_prompt="你是报表导出平台的智能助手。",
+)
+```
+
+> **选型建议**:重试适合处理临时性抖动(网络波动、短暂限流);降级适合处理持续性不可用(服务宕机、额度耗尽)。不要把验证错误(如参数校验失败)放进重试范围——重试一万次也不会通过。
+>
+
+### 3.2 长对话自动摘要:别让上下文吃掉你的 Token
+多轮对话中,用户可能前前后后提了十几次需求。所有历史消息都塞进上下文,模型窗口再大也扛不住——而且大部分旧消息对当前回答帮助不大。
+
+`SummarizationMiddleware` 的思路很朴素:**超过阈值后,把旧消息压缩成一段摘要,只保留最近几条原文。**
+
+<!-- 这是一个文本绘图,源码为:flowchart TD
+    A[消息累积中] --> B{达到触发阈值?}
+    B -->|否| A
+    B -->|是| C[取旧消息 → 调摘要模型压缩]
+    C --> D[摘要 + 最近 N 条原文 → 新上下文] -->
+![](https://cdn.nlark.com/yuque/__mermaid_v3/b9c4231915c73c0d75d22bda433b977f.svg)
+
+```python
+from langchain.agents.middleware import SummarizationMiddleware
+
+
+agent = create_agent(
+    model=cloud_model,
+    tools=[],
+    middleware=[
+        SummarizationMiddleware(
+            model=model,            # 
+            trigger=("messages", 8),        # 消息数达到 8 条时触发
+            keep=("messages", 4),           # 保留最近 4 条原文
+            # 自定义摘要提示词:强调保留"不做的事情"
+            summary_prompt=(
+                "请将以下对话历史压缩为一份结构化摘要。必须包含:\n"
+                "1. 用户的最终目标\n"
+                "2. 已确认的需求要点\n"
+                "3. 用户明确表示不需要的功能(否定约束)\n"
+                "4. 当前待解决的问题\n"
+                "摘要不超过 300 字。"
+            ),
+        ),
+    ],
+    system_prompt="你是报表导出平台的需求分析助手。",
+)
+```
+
+**关键参数说明**:
+
+| 参数 | 可选值 | 说明 |
+| --- | --- | --- |
+| `trigger` | `("messages", N)` / `("tokens", N)` / `("fraction", 0.8)` | 触发摘要的条件 |
+| `keep` | `("messages", N)` / `("tokens", N)` / `("fraction", 0.25)` | 保留多少最近内容不压缩 |
+| `model` | 任何 ChatModel | 做摘要的模型,可以和主模型不同 |
+| `summary_prompt` | 自定义 prompt 字符串 | 默认摘要有时会漏掉"不做的事" |
+
+
+> **常见坑**:默认摘要往往会**忽略否定信息**。比如用户说过"不支持 PDF 导出"、"移动端不做",摘要里很可能就丢了。自定义 `summary_prompt` 时,显式要求保留这类约束。
+>
+
+### 3.3 清理旧工具结果:别让过期的数据干扰模型
+`SummarizationMiddleware` 压缩的是**聊天历史**,但还有一种膨胀——**工具返回值**。
+
+Agent 一个任务调 4-5 次工具很正常。每次工具返回几百上千字,几轮下来上下文里塞满了旧结果。但模型做下一轮决策时,通常只需要最近一次工具的结果。`ContextEditingMiddleware` + `ClearToolUsesEdit` 解决的就是这个问题。
+
+```python
+from langchain.agents.middleware import ContextEditingMiddleware, ClearToolUsesEdit
+
+agent = create_agent(
+    model=model,
+    tools=tools,
+    middleware=[
+        ContextEditingMiddleware(
+            edits=[
+                ClearToolUsesEdit(
+                    trigger=120,                      # token 数超过 120 时触发
+                    keep=1,                           # 保留最近 1 次工具结果
+                    placeholder="[旧工具结果已被清理,节省上下文空间]",
+                    exclude_tools=("critical_audit_log",),  # 审计日志不清理
+                    clear_at_least=50,                # 每次至少清理 50 token
+                    clear_tool_inputs=True,           # 同时截断 AIMessage 里的 tool_call args
+                ),
+            ],
+        ),
+    ],
+    system_prompt="你是报表导出平台的智能助手。",
+)
+```
+
+**关键补充参数**:
+
+| 参数 | 含义 | 说明 |
+| --- | --- | --- |
+| `clear_tool_inputs` | 是否同时清理 AIMessage 中的工具调用参数 | 设 `True` 后,旧 AIMessage 里的 `tool_calls[].args` 也会被截断,进一步节省空间 |
+| `token_count_method` | Token 计数策略 | `"approximate"`(默认):用字符数估算,零开销;`"model"`:调模型精确计数,更准确但有额外成本 |
+| `clear_at_least` | 触发后最少清理多少 token | 防止触发清理但实际回收空间太小(比如刚好跨过阈值 1 token) |
+
+
+#### 摘要 vs 清理:什么时候用哪个
+| 维度 | SummarizationMiddleware | ContextEditingMiddleware |
+| --- | --- | --- |
+| 处理对象 | 对话消息(Human + AI) | 工具调用结果(ToolMessage) |
+| 处理方式 | 压缩成摘要文本 | 替换为占位符 |
+| 适合场景 | 历史对话信息密度低 | 工具返回数据量大且时效性强 |
+| 组合使用 | 可以,先摘要再清理,各自负责不同维度 |  |
+
+
+---
+
+## 四、自定义进阶:写出自己的 Middleware
+内置 Middleware 覆盖了大部分通用场景,但每个项目的业务规则千差万别。比如:"导出任务超过 10 万行时自动降级为异步"、"模型返回后检查是否包含敏感字段名"——这些就需要自己动手了。
+
+LangGraph 提供了**两条路径**:装饰器模式和类模式。
+
+### 4.1 装饰器模式:轻量级,适合单个 Hook
+四个装饰器分别对应四个钩子点:
+
+| 装饰器 | 适用场景 |
+| --- | --- |
+| `@before_model` | 调用前作日志、状态检查、上下文注入 |
+| `@after_model` | 调用后记录结果、更新计数、触发后续动作 |
+| `@wrap_model_call` | 包裹模型调用,实现重试、降级、超时 |
+| `@wrap_tool_call` | 包裹工具调用,实现参数校验、权限拦截、耗时统计 |
+
+
+下面是一个完整的自定义 Middleware 组,覆盖报表导出平台的业务规则:
+
+```python
+import time
+from langchain.agents.middleware import (
+    before_model, after_model, wrap_model_call, wrap_tool_call,
+    AgentState,
+)
+from langchain_core.messages import ToolMessage
+
+
+# ---- Middleware 1:模型调用前记录审计信息 ----
+@before_model
+def audit_before_model(state: AgentState, runtime):
+    """在模型调用前,记录当前上下文规模和来源用户。"""
+    ctx = runtime.context or {}
+    print(
+        f"[审计] 模型调用 #{state.get('call_count', 0) + 1} | "
+        f"消息数 {len(state['messages'])} | "
+        f"用户 {ctx.get('username', 'unknown')}"
+    )
+    return None
+
+
+# ---- Middleware 2:模型返回后更新调用计数 ----
+@after_model
+def update_call_counter(state: AgentState, runtime):
+    """模型返回后,调用计数 +1。"""
+    return {"call_count": state.get("call_count", 0) + 1}
+
+
+# ---- Middleware 3:包裹模型调用,实现本地重试 + 云端降级 ----
+local_failures = 0
+MAX_LOCAL_FAILURES = 3
+
+
+@wrap_model_call
+def retry_local_then_cloud(request, handler):
+    """先用本地 Ollama 模型,失败 N 次后切到云端 DeepSeek。"""
+    global local_failures
+
+    try:
+        result = handler(request)  # 尝试用本地模型
+        local_failures = 0         # 成功后重置计数器
+        return result
+    except Exception as e:
+        local_failures += 1
+        if local_failures < MAX_LOCAL_FAILURES:
+            print(f"[降级] 本地模型失败({local_failures}/{MAX_LOCAL_FAILURES}),重试中...")
+            raise  # 重新抛出,让 ModelRetryMiddleware 处理
+        print(f"[降级] 本地模型不可用,切换到云端 DeepSeek")
+        # request.override 临时换模型,不影响 Agent 默认配置
+        return handler(request.override(model=cloud_model))
+
+
+# ---- Middleware 4:包裹工具调用,实现业务规则拦截 ----
+@wrap_tool_call
+def guard_export_tool(request, handler):
+    """在 create_export_task 执行前,做业务规则校验。"""
+    tool_name = request.tool_call["name"]
+
+    if tool_name == "create_export_task":
+        args = request.tool_call.get("args", {})
+        estimated_rows = args.get("estimated_rows", 0)
+
+        # 规则 1:超过 100 万行直接拒绝
+        if estimated_rows > 1_000_000:
+            return ToolMessage(
+                content=(
+                    f"导出被拦截:预计行数 {estimated_rows} 超过系统上限 1000000。"
+                    "建议:缩小时间范围或分批次导出。"
+                ),
+                tool_call_id=request.tool_call["id"],
+            )
+
+        # 规则 2:超过 10 万行发出警告但放行(打印日志供运维关注)
+        if estimated_rows > 100_000:
+            print(
+                f"[警告] 大导出任务:{args.get('report_name')} | "
+                f"{estimated_rows} 行 | 用户可能需要等待较长时间"
+            )
+
+        # 规则 3:记录工具调用耗时
+        start = time.time()
+        result = handler(request)
+        elapsed = time.time() - start
+        print(f"[性能] {tool_name} 执行耗时 {elapsed:.2f}s")
+
+        return result
+
+    # 非目标工具直接放行
+    return handler(request)
+```
+
+### 4.2 类模式:可配置、可复用的 Middleware
+装饰器适合快速写一个 hook,但如果你的 Middleware 需要配置参数(比如"不同角色的用户有不同的导出上限"),类模式更合适——继承 `AgentMiddleware`。
+
+```python
+from langchain.agents.middleware import AgentMiddleware
+
+
+class ExportGovernanceMiddleware(AgentMiddleware):
+    """报表导出治理中间件:按角色限制导出规模。
+
+    用法:
+        ExportGovernanceMiddleware(
+            role_limits={"finance": 500_000, "operation": 100_000, "default": 50_000}
+        )
+    """
+
+    def __init__(self, role_limits: dict = None):
+        super().__init__()
+        self.role_limits = role_limits or {"default": 100_000}
+
+    def before_model(self, state, runtime):
+        """每次模型调用前,注入当前用户的权限信息到日志。"""
+        ctx = runtime.context or {}
+        role = ctx.get("user_role", "default")
+        limit = self.role_limits.get(role, self.role_limits["default"])
+        print(f"[治理] 用户角色 {role},导出上限 {limit} 行")
+        return None
+
+    def wrap_tool_call(self, request, handler):
+        """在创建导出任务前,检查是否超过该角色的导出上限。"""
+        if request.tool_call["name"] != "create_export_task":
+            return handler(request)
+
+        ctx = request.runtime.context or {}
+        role = ctx.get("user_role", "default")
+        limit = self.role_limits.get(role, self.role_limits["default"])
+        args = request.tool_call.get("args", {})
+        estimated_rows = args.get("estimated_rows", 0)
+
+        if estimated_rows > limit:
+            return ToolMessage(
+                content=(
+                    f"导出被拦截:预计 {estimated_rows} 行,"
+                    f"超过 {role} 角色的上限 {limit} 行。"
+                    "请联系上级审批或缩小导出范围。"
+                ),
+                tool_call_id=request.tool_call["id"],
+            )
+
+        return handler(request)
+
+
+# 使用
+agent = create_agent(
+    model=model,
+    tools=tools,
+    middleware=[
+        ExportGovernanceMiddleware(
+            role_limits={
+                "finance": 500_000,
+                "operation": 100_000,
+                "default": 50_000,
+            }
+        ),
+    ],
+    context_schema=RunContext,
+    system_prompt="你是报表导出平台的智能助手。",
+)
+```
+
+> **装饰器 vs 类模式**:装饰器适合一次性逻辑(写一次、一个场景用);类模式适合需要参数化、跨 Agent 复用的治理规则。
+>
+
+### 4.3 动态提示词:让同一个 Agent 适配不同场景
+有些 Agent 需要同时处理多种风格的任务——比如同一个报表助手,既要写周报(专业干练),又要写事故复盘(严谨客观),还要回答日常使用问题(简洁友好)。
+
+硬写一个又大又全的 System Prompt 效果很差。`@dynamic_prompt` 根据每次请求的**实际内容动态生成 System Prompt**:
+
+```python
+from langchain.agents.middleware import ModelRequest, dynamic_prompt
+
+
+def latest_user_text(request: ModelRequest) -> str:
+    """从消息列表中提取最近一条用户消息的文本。"""
+    # 倒序遍历,找到最新的 human 类型消息
+    for message in reversed(request.messages):
+        if message.type == "human":
+            return str(message.content)
+    return ""
+
+
+@dynamic_prompt
+def adaptive_prompt(request: ModelRequest) -> str:
+    """根据用户请求的内容,动态选择 System Prompt 风格。"""
+    user_text = latest_user_text(request)
+
+    # 从 runtime.context 读取受众信息(由外部系统传入)
+    ctx = request.runtime.context or {}
+    audience = ctx.get("audience", "内部团队")
+
+    # 分支 1:事故复盘 → 严谨结构化
+    if "事故" in user_text or "复盘" in user_text:
+        return (
+            "你是一名资深 SRE 工程师,正在撰写线上事故复盘报告。\n"
+            f"目标受众:{audience}。\n"
+            "请按以下结构组织内容:\n"
+            "1. 事故概述(时间、影响范围、持续时间)\n"
+            "2. 根因分析(直接原因、间接原因)\n"
+            "3. 处理过程(时间线)\n"
+            "4. 改进措施(短期 + 长期)\n"
+            "语气:客观、严谨;不甩锅,不带情绪。"
+        )
+
+    # 分支 2:周报 → 简洁干练
+    if "周报" in user_text:
+        return (
+            "你是一名技术团队的 PM,正在编写项目周报。\n"
+            f"目标受众:{audience}。\n"
+            "请按以下结构组织内容:\n"
+            "1. 本周完成事项(按优先级排列)\n"
+            "2. 关键进展(数据支撑)\n"
+            "3. 遇到的问题(附解决思路)\n"
+            "4. 下周计划\n"
+            "要求:简洁、有数据、不写流水账。"
+        )
+
+    # 分支 3:默认日常回复
+    return (
+        "你是报表导出平台的智能助手。\n"
+        f"目标受众:{audience}。\n"
+        "请用简洁清晰的语言回答用户问题,必要时给出操作指引。"
+    )
+
+
+agent = create_agent(
+    model=model,
+    middleware=[adaptive_prompt],
+    context_schema=RunContext,
+)
+
+# 两个不同场景的请求,会得到完全不同的 System Prompt
+questions = [
+    "帮我写一份本周的报表系统开发周报,涵盖导出接口优化和权限模块改造",
+    "帮我对上周六报表导出服务宕机的事故做一份复盘报告",
+]
+
+for question in questions:
+    result = agent.invoke(
+        {"messages": [{"role": "user", "content": question}]},
+        context={"audience": "研发管理层"},
+    )
+    print(result["messages"][-1].content)
+    print("---")
+```
+
+`@dynamic_prompt`** 适用场景**:
+
++ 同一个 Agent 处理多种不同类型的任务
++ 不同用户角色需要不同的回复风格
++ Prompt 需要根据上下文实时调整
+
+**不适用场景**:
+
++ 审计日志 → 用 `@before_model`
++ 模型重试 → 用 `@wrap_model_call`
++ 工具拦截 → 用 `@wrap_tool_call`
+
+---
+
+## 附录:Middleware 选型速查表
+### 按关注维度分类
+| 维度 | Middleware | 一句话说明 | 关键参数 |
+| --- | --- | --- | --- |
+| 🔢 次数限制 | `ModelCallLimitMiddleware` | 限制模型调用次数 | `run_limit`, `thread_limit`, `exit_behavior` |
+| 🔢 次数限制 | `ToolCallLimitMiddleware` | 限制特定工具调用次数 | `tool_name`, `run_limit`, `exit_behavior` |
+| 🛡️ 安全审批 | `HumanInTheLoopMiddleware` | 关键操作暂停等人类确认 | `interrupt_on`, `allowed_decisions` |
+| 🔒 隐私脱敏 | `PIIMiddleware` | 敏感信息进入模型前脱敏 | `detector`, `strategy`, `apply_to_*` |
+| 🔄 故障重试 | `ToolRetryMiddleware` | 工具失败后自动重试 | `max_retries`, `retry_on`, `on_failure` |
+| 🔄 故障重试 | `ModelRetryMiddleware` | 模型调用失败后重试 | `max_retries`, `retry_on` |
+| ⬇️ 模型降级 | `ModelFallbackMiddleware` | 主模型不可用时切换备用 | `fallback_model` |
+| 📝 上下文压缩 | `SummarizationMiddleware` | 长对话自动摘要压缩 | `trigger`, `keep`, `model`, `summary_prompt` |
+| 🧹 上下文清理 | `ContextEditingMiddleware` | 清理旧工具结果 | `edits=[ClearToolUsesEdit(...)]` |
+| 🎯 动态提示 | `@dynamic_prompt` | 每次模型调用前动态生成 System Prompt | 函数返回字符串 |
+
+
+### 按执行阶段分类
+<!-- 这是一个文本绘图,源码为:flowchart TB
+    subgraph 启动阶段
+        before_agent
+    end
+    subgraph 模型调用阶段
+        direction LR
+        before_model --> wrap_model_call --> LLM[大模型] --> after_model
+    end
+    subgraph 工具调用阶段
+        wrap_tool_call --> Tool[工具执行]
+    end
+    subgraph 结束阶段
+        after_agent
+    end -->
+![](https://cdn.nlark.com/yuque/__mermaid_v3/c39852109b8074334ebca56007a74298.svg)
+
+| 阶段 | 可用的 Middleware 类型 |
+| --- | --- |
+| Agent 启动前 | 自定义 `before_agent` |
+| 模型调用前 | `@before_model`、`PIIMiddleware`(`apply_to_input`) |
+| 模型调用中 | `@wrap_model_call`、`ModelRetryMiddleware`、`ModelFallbackMiddleware` |
+| 模型调用后 | `@after_model`、`ModelCallLimitMiddleware`(计数判定) |
+| 工具调用中 | `@wrap_tool_call`、`ToolRetryMiddleware`、`ToolCallLimitMiddleware`、`HumanInTheLoopMiddleware` |
+| Agent 结束后 | 自定义 `after_agent` |
+| 上下文治理 | `SummarizationMiddleware`、`ContextEditingMiddleware` |
+| 提示词 | `@dynamic_prompt` |
+
+
+### 组合建议:一份"生产级" Middleware 栈
+把前面各章的内容组合起来,一份典型的**生产环境 Agent 配置**大概长这样:
+
+```python
+agent = create_agent(
+    model=local_model,
+    tools=tools,
+    middleware=[
+        # —— 安全层(最先执行) ——
+        PIIMiddleware("email", strategy="redact", apply_to_input=True),
+        HumanInTheLoopMiddleware(
+            interrupt_on={"create_export_task": {"allowed_decisions": ["approve", "reject"]}}
+        ),
+
+        # —— 可靠性层 ——
+        ModelRetryMiddleware(max_retries=3, retry_on=(Exception,)),
+        ModelFallbackMiddleware(cloud_model),
+        ToolRetryMiddleware(
+            tools=["fetch_export_history"], max_retries=2, retry_on=(TimeoutError,)
+        ),
+
+        # —— 限流层 ——
+        ModelCallLimitMiddleware(run_limit=20, exit_behavior="end"),
+        ToolCallLimitMiddleware(tool_name="check_export_progress", run_limit=10),
+
+        # —— 上下文治理层 ——
+        SummarizationMiddleware(model=summary_model, trigger=("messages", 12), keep=("messages", 4)),
+        ContextEditingMiddleware(edits=[ClearToolUsesEdit(trigger=3000, keep=2)]),
+
+        # —— 自定义业务规则 ——
+        ExportGovernanceMiddleware(role_limits={...}),
+
+        # —— 动态提示词(最后,覆盖 System Prompt) ——
+        adaptive_prompt,
+    ],
+    checkpointer=SqliteSaver.from_conn_string("checkpoints.db"),
+    state_schema=ExportAgentState,
+    context_schema=RunContext,
+    system_prompt="你是报表导出平台的智能助手。",  # 兜底 prompt,会被 @dynamic_prompt 覆盖
+)
+```
+
+> **Middleware 顺序很重要**。上面的栈里,脱敏在最前面(确保脏数据不进入后续环节),动态提示词在最后面(覆盖最终的 System Prompt)。调换顺序可能导致行为不一致,上线前务必在测试环境验证整条 Middleware 链的执行顺序。
+>
+
+---
+

+ 110 - 0
03_LangGraph_study/LangGraph.py

@@ -0,0 +1,110 @@
+from langchain.chat_models import init_chat_model
+from langchain.tools import tool
+from langchain.agents import create_agent
+from langchain.agents.middleware import before_model, wrap_tool_call, AgentState
+
+# 使用 DeepSeek 模型,通过阿里云百炼平台接入
+# init_chat_model 会自动适配 OpenAI 兼容接口
+model = init_chat_model(
+    model="deepseek-v4-flash",
+    model_provider="openai",
+    base_url="https://api.deepseek.com",
+    api_key="sk-80a123483afb480285c6452985eea18e",
+)
+# ---- 模拟数据 ----
+# 实际项目中,这些数据来自数据库或 API
+EXPORT_RIGHTS = {
+    "zhangsan": {"role": "finance", "region": "all"},
+    "lisi": {"role": "operation", "region": "east"},
+}
+
+
+@tool
+def check_export_permission(username: str) -> dict:
+    """查询用户是否有报表导出权限。
+    参数 username 为员工账号(英文名)。
+    """
+    user_info = EXPORT_RIGHTS.get(username)
+    if not user_info:
+        return {"username": username, "can_export": False, "reason": "用户不存在"}
+    return {
+        "username": username,
+        "role": user_info["role"],
+        "region": user_info["region"],
+        "can_export": user_info["role"] in ("finance", "ops_manager"),
+    }
+
+
+@tool
+def create_export_task(
+    report_name: str, file_format: str, estimated_rows: int, reason: str
+) -> dict:
+    """创建报表导出任务。
+    report_name: 报表名称
+    file_format: 导出格式 (xlsx / csv)
+    estimated_rows: 预计导出行数
+    reason: 导出原因
+    """
+    return {
+        "task_id": "EXPORT-20260706-001",
+        "report_name": report_name,
+        "file_format": file_format,
+        "estimated_rows": estimated_rows,
+        "reason": reason,
+        "status": "queued",
+    }
+
+
+tools = [check_export_permission, create_export_task]
+
+
+
+@before_model
+def log_before_model(state: AgentState, runtime):
+    """模型调用前执行:看一眼当前上下文里有多少条消息。"""
+    # state["messages"] 是 Agent 当前累积的全部对话历史
+    # runtime 携带运行时环境信息(后面会详细讲)
+    print(f"[审计] 准备调用模型,当前上下文消息数:{len(state['messages'])}")
+    return None  # 返回 None 表示不做任何修改
+
+
+@wrap_tool_call
+def log_tool_call(request, handler):
+    """工具调用前后各打一条日志,并记录耗时。"""
+    tool_name = request.tool_call["name"]
+    print(f"[审计] 开始执行工具:{tool_name}")
+
+    # handler(request) 是"真正执行工具"的入口
+    # 不调用它,工具就不会执行
+    result = handler(request)
+
+    print(f"[审计] 工具执行完毕:{tool_name}")
+    return result
+
+
+# 组装 Agent,把 Middleware 列表传进去
+agent = create_agent(
+    model=model,
+    tools=tools,
+    middleware=[
+        log_before_model,   # 排在前面:先记录状态
+        log_tool_call,      # 排在后面:包裹工具调用
+    ],
+    system_prompt=(
+        "你是报表导出平台的智能助手。"
+        "用户询问导出相关问题前,先调用 check_export_permission 确认权限。"
+        "只有在用户明确提出导出需求时,才调用 create_export_task 创建任务。"
+    ),
+)
+
+# 跑一次看看
+response = agent.invoke({
+    "messages": [
+        {
+            "role": "user",
+            "content": "我是 zhangsan,需要导出本月 east 区域的订单报表,大约 5000 行,xlsx 格式。",
+        }
+    ]
+})
+
+print(response["messages"][-1].content)

+ 476 - 0
03_LangGraph_study/langGraph.ipynb

@@ -0,0 +1,476 @@
+{
+ "cells": [
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "b7fa019b",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "from langchain.chat_models import init_chat_model\n",
+    "from langchain.tools import tool\n",
+    "\n",
+    "\n",
+    "# 使用 DeepSeek 模型,通过阿里云百炼平台接入\n",
+    "# init_chat_model 会自动适配 OpenAI 兼容接口\n",
+    "model = init_chat_model(\n",
+    "    model=\"deepseek-v4-flash\",\n",
+    "    model_provider=\"openai\",\n",
+    "    base_url=\"https://api.deepseek.com\",\n",
+    "    api_key=\"sk-80a123483afb480285c6452985eea18e\",\n",
+    "    timeout=30,  # 30秒超时\n",
+    ")\n",
+    "# ---- 模拟数据 ----\n",
+    "# 实际项目中,这些数据来自数据库或 API\n",
+    "EXPORT_RIGHTS = {\n",
+    "    \"zhangsan\": {\"role\": \"finance\", \"region\": \"all\"},\n",
+    "    \"lisi\": {\"role\": \"operation\", \"region\": \"east\"},\n",
+    "}\n",
+    "\n",
+    "\n",
+    "@tool\n",
+    "def check_export_permission(username: str) -> dict:\n",
+    "    \"\"\"查询用户是否有报表导出权限。\n",
+    "    参数 username 为员工账号(英文名)。\n",
+    "    \"\"\"\n",
+    "    user_info = EXPORT_RIGHTS.get(username)\n",
+    "    if not user_info:\n",
+    "        return {\"username\": username, \"can_export\": False, \"reason\": \"用户不存在\"}\n",
+    "    return {\n",
+    "        \"username\": username,\n",
+    "        \"role\": user_info[\"role\"],\n",
+    "        \"region\": user_info[\"region\"],\n",
+    "        \"can_export\": user_info[\"role\"] in (\"finance\", \"ops_manager\"),\n",
+    "    }\n",
+    "\n",
+    "\n",
+    "@tool\n",
+    "def create_export_task(\n",
+    "    report_name: str, file_format: str, estimated_rows: int, reason: str\n",
+    ") -> dict:\n",
+    "    \"\"\"创建报表导出任务。\n",
+    "    report_name: 报表名称\n",
+    "    file_format: 导出格式 (xlsx / csv)\n",
+    "    estimated_rows: 预计导出行数\n",
+    "    reason: 导出原因\n",
+    "    \"\"\"\n",
+    "    return {\n",
+    "        \"task_id\": \"EXPORT-20260706-001\",\n",
+    "        \"report_name\": report_name,\n",
+    "        \"file_format\": file_format,\n",
+    "        \"estimated_rows\": estimated_rows,\n",
+    "        \"reason\": reason,\n",
+    "        \"status\": \"queued\",\n",
+    "    }\n",
+    "\n",
+    "\n",
+    "tools = [check_export_permission, create_export_task]"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "f716b5f2",
+   "metadata": {},
+   "outputs": [
+    {
+     "name": "stdout",
+     "output_type": "stream",
+     "text": [
+      "[审计] 准备调用模型,当前上下文消息数:1\n",
+      "[审计] 开始执行工具:check_export_permission\n",
+      "[审计] 工具执行完毕:check_export_permission\n",
+      "[审计] 准备调用模型,当前上下文消息数:3\n",
+      "✅ 权限验证通过!zhangsan 您好,您有导出权限,可以创建导出任务。\n",
+      "\n",
+      "在创建任务之前,请问您导出这份报表的**原因**是什么呢?比如用于数据分析、汇报、存档等?这样我可以帮您提交完整的导出申请。\n"
+     ]
+    }
+   ],
+   "source": [
+    "from langchain.agents import create_agent\n",
+    "from langchain.agents.middleware import before_model, wrap_tool_call, AgentState\n",
+    "\n",
+    "\n",
+    "@before_model\n",
+    "def log_before_model(state: AgentState, runtime):\n",
+    "    \"\"\"模型调用前执行:看一眼当前上下文里有多少条消息。\"\"\"\n",
+    "    # state[\"messages\"] 是 Agent 当前累积的全部对话历史\n",
+    "    # runtime 携带运行时环境信息(后面会详细讲)\n",
+    "    print(f\"[审计] 准备调用模型,当前上下文消息数:{len(state['messages'])}\")\n",
+    "    return None  # 返回 None 表示不做任何修改\n",
+    "\n",
+    "\n",
+    "@wrap_tool_call\n",
+    "def log_tool_call(request, handler):\n",
+    "    \"\"\"工具调用前后各打一条日志,并记录耗时。\"\"\"\n",
+    "    tool_name = request.tool_call[\"name\"]\n",
+    "    print(f\"[审计] 开始执行工具:{tool_name}\")\n",
+    "\n",
+    "    # handler(request) 是\"真正执行工具\"的入口\n",
+    "    # 不调用它,工具就不会执行\n",
+    "    result = handler(request)\n",
+    "\n",
+    "    print(f\"[审计] 工具执行完毕:{tool_name}\")\n",
+    "    return result\n",
+    "\n",
+    "\n",
+    "# 组装 Agent,把 Middleware 列表传进去\n",
+    "agent = create_agent(\n",
+    "    model=model,\n",
+    "    tools=tools,\n",
+    "    middleware=[\n",
+    "        log_before_model,   # 排在前面:先记录状态\n",
+    "        log_tool_call,      # 排在后面:包裹工具调用\n",
+    "    ],\n",
+    "    system_prompt=(\n",
+    "        \"你是报表导出平台的智能助手。\"\n",
+    "        \"用户询问导出相关问题前,先调用 check_export_permission 确认权限。\"\n",
+    "        \"只有在用户明确提出导出需求时,才调用 create_export_task 创建任务。\"\n",
+    "    ),\n",
+    ")\n",
+    "\n",
+    "# 跑一次看看\n",
+    "response = agent.invoke({\n",
+    "    \"messages\": [\n",
+    "        {\n",
+    "            \"role\": \"user\",\n",
+    "            \"content\": \"我是 zhangsan,需要导出本月 east 区域的订单报表,大约 5000 行,xlsx 格式。\",\n",
+    "        }\n",
+    "    ]\n",
+    "})\n",
+    "\n",
+    "print(response[\"messages\"][-1].content)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "7e3cc5e1",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "from langchain_core.messages import ToolMessage\n",
+    "\n",
+    "\n",
+    "\n",
+    "@wrap_tool_call\n",
+    "def guard_export_scale(request, handler):\n",
+    "    \"\"\"超过 10 万行的导出请求直接拦截,不进入执行队列。\"\"\"\n",
+    "    tool_name = request.tool_call[\"name\"]\n",
+    "    args = request.tool_call.get(\"args\", {})\n",
+    "\n",
+    "    # 只拦截 create_export_task\n",
+    "    if tool_name == \"create_export_task\" and args.get(\"estimated_rows\", 0) > 100_000:\n",
+    "        # 不调 handler,直接返回 ToolMessage 给模型\n",
+    "        # 模型收到这条消息后,会告知用户\"太大了,换个小范围\"\n",
+    "        return ToolMessage(\n",
+    "            content=f\"导出被拦截:预计行数 {args.get('estimated_rows')} 超过上限 100000,请缩小筛选范围后重试。\",\n",
+    "            tool_call_id=request.tool_call[\"id\"],\n",
+    "        )\n",
+    "\n",
+    "    # 正常放行\n",
+    "    return handler(request)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "74326430",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "from langchain.agents.middleware import wrap_model_call\n",
+    "\n",
+    "@wrap_model_call\n",
+    "def local_first_then_cloud(request, handler):\n",
+    "    \"\"\"先用本地模型,失败 3 次后切云端模型。\"\"\"\n",
+    "    # 先用本地 Ollama 模型(省成本)\n",
+    "    try:\n",
+    "        return handler(request)  # 使用 Agent 默认模型\n",
+    "    except Exception:\n",
+    "        # 本地挂了,切到 DeepSeek 云端\n",
+    "        print(\"[降级] 本地模型不可用,切换到云端 DeepSeek\")\n",
+    "        return handler(request.override(model=cloud_model))"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "29548a30",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "from typing import TypedDict\n",
+    "from typing_extensions import NotRequired\n",
+    "from langchain.agents.middleware import AgentState, before_model, after_model\n",
+    "\n",
+    "\n",
+    "class ExportAgentState(AgentState):\n",
+    "    \"\"\"扩展默认状态,增加模型调用计数和大任务标记。\"\"\"\n",
+    "    # AgentState 已经自带 messages 字段,这里只加新字段\n",
+    "    # NotRequired 表示可以不传,Middleware 内部自己维护\n",
+    "    model_call_count: NotRequired[int]\n",
+    "    blocked_requests: NotRequired[int]  # 被拦截的请求次数\n",
+    "\n",
+    "\n",
+    "@before_model(state_schema=ExportAgentState)\n",
+    "def audit_before_model(state: ExportAgentState, runtime):\n",
+    "    \"\"\"每次调模型前,看一眼当前统计。\"\"\"\n",
+    "    count = state.get(\"model_call_count\", 0)\n",
+    "    blocked = state.get(\"blocked_requests\", 0)\n",
+    "    print(f\"[统计] 第 {count + 1} 次调模型 | 已拦截 {blocked} 次\")\n",
+    "    return None\n",
+    "\n",
+    "\n",
+    "@after_model(state_schema=ExportAgentState)\n",
+    "def update_stats(state: ExportAgentState, runtime):\n",
+    "    \"\"\"模型返回后,把计数器 +1。\"\"\"\n",
+    "    # after_model 返回 dict 可以直接更新 AgentState\n",
+    "    return {\"model_call_count\": state.get(\"model_call_count\", 0) + 1}"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "aeb64f3e",
+   "metadata": {},
+   "outputs": [
+    {
+     "name": "stdout",
+     "output_type": "stream",
+     "text": [
+      "[统计] 第 1 次调模型 | 已拦截 0 次\n",
+      "[上下文] request_id=req-20260706-001 user_role=finance tenant=t-1234\n"
+     ]
+    }
+   ],
+   "source": [
+    "from langgraph.runtime import Runtime\n",
+    "\n",
+    "\n",
+    "class RunContext(TypedDict):\n",
+    "    \"\"\"定义 runtime.context 的结构。\"\"\"\n",
+    "    request_id: str     # 用于日志追踪\n",
+    "    user_role: str      # 当前用户的角色\n",
+    "    tenant_id: str      # 租户标识(多租户场景)\n",
+    "\n",
+    "\n",
+    "@before_model(state_schema=ExportAgentState)\n",
+    "def inject_context(state: ExportAgentState, runtime: Runtime[RunContext]):\n",
+    "    \"\"\"从 runtime.context 读取业务信息,用于日志关联。\"\"\"\n",
+    "    ctx = runtime.context or {}\n",
+    "    print(\n",
+    "        f\"[上下文] request_id={ctx.get('request_id')} \"\n",
+    "        f\"user_role={ctx.get('user_role')} \"\n",
+    "        f\"tenant={ctx.get('tenant_id')}\"\n",
+    "    )\n",
+    "    return None\n",
+    "\n",
+    "\n",
+    "# 创建 Agent 时声明 context 结构\n",
+    "agent = create_agent(\n",
+    "    model=model,\n",
+    "    tools=tools,\n",
+    "    middleware=[audit_before_model, inject_context, update_stats],\n",
+    "    state_schema=ExportAgentState,  # 声明自定义状态\n",
+    "    context_schema=RunContext,      # 声明上下文结构\n",
+    "    system_prompt=\"你是报表导出平台的智能助手。\",\n",
+    ")\n",
+    "\n",
+    "# 调用时传入 context\n",
+    "result = agent.invoke(\n",
+    "    {\"messages\": [{\"role\": \"user\", \"content\": \"导出本月的订单报表\"}]},\n",
+    "    context={\n",
+    "        \"request_id\": \"req-20260706-001\",\n",
+    "        \"user_role\": \"finance\",\n",
+    "        \"tenant_id\": \"t-1234\",\n",
+    "    },\n",
+    ")"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "7907d3f4",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "from langchain.agents.middleware import ModelCallLimitMiddleware\n",
+    "\n",
+    "\n",
+    "# 模拟一个\"前 4 次返回处理中,第 5 次返回完成\"的进度查询工具\n",
+    "export_progress = {\"task_001\": 0}\n",
+    "\n",
+    "\n",
+    "@tool\n",
+    "def check_export_progress(task_id: str) -> str:\n",
+    "    \"\"\"查询导出任务的进度。\"\"\"\n",
+    "    export_progress[task_id] = export_progress.get(task_id, 0) + 1\n",
+    "    attempt = export_progress[task_id]\n",
+    "    if attempt < 5:\n",
+    "        return f\"第 {attempt} 次查询:任务仍在处理中,请稍候。\"\n",
+    "    return f\"第 {attempt} 次查询:导出完成,文件已生成。\"\n",
+    "\n",
+    "\n",
+    "# 不加限制:Agent 会一直查到第 5 次\n",
+    "# 加上 ModelCallLimitMiddleware(run_limit=3):最多调 3 次模型就强制结束\n",
+    "agent = create_agent(\n",
+    "    model=model,\n",
+    "    tools=[check_export_progress],\n",
+    "    middleware=[\n",
+    "        ModelCallLimitMiddleware(\n",
+    "            run_limit=3,          # 单次 invoke 最多调 3 次模型\n",
+    "            exit_behavior=\"end\",  # 到达上限后尝试优雅结束(生成总结)\n",
+    "        ),\n",
+    "    ],\n",
+    "    system_prompt=(\n",
+    "        \"你是导出任务进度查询助手。\"\n",
+    "        \"当用户查询任务进度时,调用 check_export_progress。\"\n",
+    "        \"如果任务还在处理中,继续查询直到完成。\"\n",
+    "    ),\n",
+    ")\n",
+    "\n",
+    "result = agent.invoke({\n",
+    "    \"messages\": [{\"role\": \"user\", \"content\": \"帮我查 task_001 的导出进度,持续查到完成为止。\"}]\n",
+    "})\n",
+    "# 输出类似:\"Model call limits exceeded: run limit (3/3)\"\n",
+    "# Agent 被强制刹车,不会无限循环\n",
+    "print(result)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 1,
+   "id": "763486a0",
+   "metadata": {},
+   "outputs": [
+    {
+     "ename": "NameError",
+     "evalue": "name 'tool' is not defined",
+     "output_type": "error",
+     "traceback": [
+      "\u001b[31m---------------------------------------------------------------------------\u001b[39m",
+      "\u001b[31mNameError\u001b[39m                                 Traceback (most recent call last)",
+      "\u001b[36mCell\u001b[39m\u001b[36m \u001b[39m\u001b[32mIn[1]\u001b[39m\u001b[32m, line 21\u001b[39m\n\u001b[32m     17\u001b[39m     )\n\u001b[32m     18\u001b[39m \n\u001b[32m     19\u001b[39m \n\u001b[32m     20\u001b[39m \u001b[38;5;66;03m# 第二步:安全工具不加 args_schema(自动执行)\u001b[39;00m\n\u001b[32m---> \u001b[39m\u001b[32m21\u001b[39m @tool\n\u001b[32m     22\u001b[39m \u001b[38;5;28;01mdef\u001b[39;00m check_export_permission(username: str) -> dict:\n\u001b[32m     23\u001b[39m     \u001b[33m\"\"\"查询用户导出权限。安全操作,无需审批。\"\"\"\u001b[39m\n\u001b[32m     24\u001b[39m     \u001b[38;5;28;01mreturn\u001b[39;00m {\u001b[33m\"username\"\u001b[39m: username, \u001b[33m\"can_export\"\u001b[39m: \u001b[38;5;28;01mTrue\u001b[39;00m, \u001b[33m\"region\"\u001b[39m: \u001b[33m\"all\"\u001b[39m}\n",
+      "\u001b[31mNameError\u001b[39m: name 'tool' is not defined"
+     ]
+    }
+   ],
+   "source": [
+    "from typing import Literal\n",
+    "from pydantic import BaseModel, Field\n",
+    "from langchain.agents.middleware import HumanInTheLoopMiddleware\n",
+    "from langgraph.checkpoint.memory import InMemorySaver\n",
+    "\n",
+    "\n",
+    "# 第一步:用 Pydantic 定义工具的输入结构\n",
+    "# 结构化的参数让模型生成更准确,也方便人工审批时查看\n",
+    "class ExportTaskInput(BaseModel):\n",
+    "    \"\"\"导出任务的参数结构。模型会按照这个 schema 生成参数。\"\"\"\n",
+    "    report_name: str = Field(description=\"报表名称,如 'east_region_orders_202607'\")\n",
+    "    file_format: Literal[\"xlsx\", \"csv\"] = Field(description=\"导出格式\")\n",
+    "    estimated_rows: int = Field(description=\"预计导出行数\")\n",
+    "    reason: str = Field(description=\"导出原因,用于审计\")\n",
+    "    priority: Literal[\"low\", \"normal\", \"high\"] = Field(\n",
+    "        default=\"normal\", description=\"优先级\"\n",
+    "    )\n",
+    "\n",
+    "\n",
+    "# 第二步:安全工具不加 args_schema(自动执行)\n",
+    "@tool\n",
+    "def check_export_permission(username: str) -> dict:\n",
+    "    \"\"\"查询用户导出权限。安全操作,无需审批。\"\"\"\n",
+    "    return {\"username\": username, \"can_export\": True, \"region\": \"all\"}\n",
+    "\n",
+    "\n",
+    "# 第三步:危险工具加上 args_schema,配合 HITL 拦截\n",
+    "@tool(args_schema=ExportTaskInput)\n",
+    "def create_export_task(\n",
+    "    report_name: str,\n",
+    "    file_format: str,\n",
+    "    estimated_rows: int,\n",
+    "    reason: str,\n",
+    "    priority: str = \"normal\",\n",
+    ") -> dict:\n",
+    "    \"\"\"创建报表导出任务。这是一个高风险操作,需要人工审批。\"\"\"\n",
+    "    print(\n",
+    "        f\"[导出] 创建任务:{report_name} | {file_format} | \"\n",
+    "        f\"{estimated_rows} 行 | 优先级 {priority}\"\n",
+    "    )\n",
+    "    return {\n",
+    "        \"task_id\": \"EXPORT-20260706-002\",\n",
+    "        \"report_name\": report_name,\n",
+    "        \"file_format\": file_format,\n",
+    "        \"estimated_rows\": estimated_rows,\n",
+    "        \"status\": \"queued\",\n",
+    "    }\n",
+    "\n",
+    "\n",
+    "# 第四步:创建带 HITL 的 Agent\n",
+    "checkpointer = InMemorySaver()  # HITL 必需:暂停后需要从这里恢复状态\n",
+    "\n",
+    "agent = create_agent(\n",
+    "    model=model,\n",
+    "    tools=[check_export_permission, create_export_task],\n",
+    "    middleware=[\n",
+    "        HumanInTheLoopMiddleware(\n",
+    "            interrupt_on={\n",
+    "                # 安全工具:不中断,自动执行\n",
+    "                \"check_export_permission\": False,\n",
+    "                # 危险工具:中断,提供三种审批选项\n",
+    "                \"create_export_task\": {\n",
+    "                    \"allowed_decisions\": [\"approve\", \"edit\", \"reject\"],\n",
+    "                },\n",
+    "            },\n",
+    "        ),\n",
+    "    ],\n",
+    "    checkpointer=checkpointer,\n",
+    "    system_prompt=(\n",
+    "        \"你是报表导出平台的智能助手。\"\n",
+    "        \"用户查询权限时,直接调用 check_export_permission。\"\n",
+    "        \"只在用户明确要求创建导出任务时调用 create_export_task。\"\n",
+    "    ),\n",
+    ")\n",
+    "\n",
+    "# 第五步:第一次 invoke——会被 HITL 拦截\n",
+    "config = {\"configurable\": {\"thread_id\": \"export-001\"}}\n",
+    "result = agent.invoke(\n",
+    "    {\n",
+    "        \"messages\": [\n",
+    "            {\n",
+    "                \"role\": \"user\",\n",
+    "                \"content\": \"我是 zhangsan,导出 east 区本月订单报表,xlsx,约 5000 行,月度对账用。\",\n",
+    "            }\n",
+    "        ]\n",
+    "    },\n",
+    "    config=config,\n",
+    ")\n",
+    "\n",
+    "# result 里会包含中断信息,UI 层可以据此展示审批界面\n",
+    "print(\"Agent 已暂停,等待审批...\")"
+   ]
+  }
+ ],
+ "metadata": {
+  "kernelspec": {
+   "display_name": "Python 3",
+   "language": "python",
+   "name": "python3"
+  },
+  "language_info": {
+   "codemirror_mode": {
+    "name": "ipython",
+    "version": 3
+   },
+   "file_extension": ".py",
+   "mimetype": "text/x-python",
+   "name": "python",
+   "nbconvert_exporter": "python",
+   "pygments_lexer": "ipython3",
+   "version": "3.14.4"
+  }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}

+ 177 - 0
README.md

@@ -0,0 +1,177 @@
+# 数据分析 Agent
+
+基于 LangChain 框架构建的数据分析 Agent,集成 SQL 查询和 Python 代码执行能力,让 LLM 自动决定调用哪些工具。
+
+## 功能特性
+
+- **SQL 查询工具**: 安全执行只读 SQL 查询,自动防护危险操作
+- **Python 代码执行工具**: 支持 pandas、numpy、matplotlib 等数据分析库
+- **智能路由**: LLM 自动判断何时使用 SQL,何时使用 Python 进行分析
+- **多模型支持**: 支持 OpenAI 和 DeepSeek 等兼容 OpenAI API 的模型
+
+## 环境配置
+
+### 1. 安装依赖
+
+```bash
+pip install langchain langchain-openai langchain-community pymysql python-dotenv pandas numpy matplotlib
+```
+
+### 2. 配置环境变量
+
+复制 `.env.example` 为 `.env` 并填写配置:
+
+```bash
+cp .env.example .env
+```
+
+#### 配置项说明:
+
+**数据库配置**
+```env
+DATABASE_URI=mysql+pymysql://用户名:密码@主机名:端口号/数据库名
+```
+
+**LLM API 配置**
+
+方式 1: 使用 OpenAI API
+```env
+OPENAI_API_KEY=your_openai_api_key
+MODEL_NAME=gpt-4o-mini
+```
+
+方式 2: 使用 DeepSeek API(推荐国内环境)
+```env
+OPENAI_API_KEY=your_deepseek_api_key
+OPENAI_API_BASE=https://api.deepseek.com/v1
+MODEL_NAME=deepseek-chat
+```
+
+### 3. 初始化数据库
+
+```bash
+python mysql_data.py
+```
+
+## 使用方法
+
+### 方式 1: 命令行运行
+
+```bash
+python 01_langchain_task.py
+```
+
+### 方式 2: 在代码中调用
+
+```python
+from 01_langchain_task import run_data_analysis
+
+# 基础查询
+result = run_data_analysis("查询每个部门的员工人数")
+print(result)
+
+# 复杂分析
+result = run_data_analysis("统计各部门的平均薪资、最高薪资和最低薪资")
+print(result)
+
+# 多表关联
+result = run_data_analysis("查询销量最好的产品及其总销售额")
+print(result)
+
+# 使用 DeepSeek 模型
+result = run_data_analysis(
+    "分析订单趋势",
+    model_name="deepseek-chat"
+)
+print(result)
+```
+
+## 工具说明
+
+### run_sql_query
+
+执行只读 SQL 查询,用于从数据库查询数据。
+
+**安全限制**:
+- 仅允许 SELECT 语句
+- 禁止 INSERT、UPDATE、DELETE、DROP 等修改操作
+
+**适用场景**:
+- 查询员工信息、薪资数据
+- 查询产品信息、库存数据
+- 查询订单数据、销售统计
+- 多表关联查询分析
+
+### execute_python_code
+
+执行 Python 代码进行数据分析、可视化或复杂计算。
+
+**预导入库**:
+- `pandas as pd`
+- `numpy as np`
+- `matplotlib.pyplot as plt`
+
+**安全限制**:
+- 禁止导入 os、subprocess、sys、shutil、socket 等危险模块
+
+**适用场景**:
+- 数据分析
+- 数据可视化
+- 统计计算、数据聚合
+- 复杂数学运算
+
+## 数据库结构
+
+### employees (员工表)
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| id | INT | 员工ID (主键) |
+| name | VARCHAR(50) | 姓名 |
+| department | VARCHAR(50) | 所属部门 |
+| salary | DECIMAL(10,2) | 月薪 |
+| hire_date | DATE | 入职日期 |
+
+### products (产品表)
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| id | INT | 产品ID (主键) |
+| product_name | VARCHAR(100) | 商品名称 |
+| category | VARCHAR(50) | 商品分类 |
+| price | DECIMAL(10,2) | 单价 |
+| stock | INT | 当前库存量 |
+
+### orders (订单表)
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| id | INT | 订单ID (主键) |
+| employee_id | INT | 下单员工ID (外键) |
+| product_id | INT | 购买商品ID (外键) |
+| quantity | INT | 购买数量 |
+| order_date | DATE | 下单日期 |
+
+## 示例查询
+
+```python
+# 1. 部门人数统计
+"查询每个部门的员工人数"
+
+# 2. 薪资分析
+"统计各部门的平均薪资、最高薪资和最低薪资"
+
+# 3. 产品销量
+"查询销量最好的产品及其总销售额"
+
+# 4. 复杂分析(需要 Python)
+"分析各部门的薪资分布并绘制柱状图"
+```
+
+## 注意事项
+
+1. **安全**: SQL 工具仅支持 SELECT 查询,Python 执行环境已限制危险操作
+2. **API 密钥**: 请妥善保管 API 密钥,不要提交到代码仓库
+3. **模型选择**: DeepSeek 模型对国内用户更友好,响应速度快且成本低
+4. **日志**: 所有操作都有详细日志记录,便于问题排查
+
+## 许可证
+
+MIT License

BIN
car_info.pdf


+ 65 - 0
docker-compose.yml

@@ -0,0 +1,65 @@
+version: '3.5'
+
+services:
+  etcd:
+    container_name: milvus-etcd
+    image: quay.io/coreos/etcd:v3.5.18
+    environment:
+      - ETCD_AUTO_COMPACTION_MODE=revision
+      - ETCD_AUTO_COMPACTION_RETENTION=1000
+      - ETCD_QUOTA_BACKEND_BYTES=4294967296
+      - ETCD_SNAPSHOT_COUNT=50000
+    volumes:
+      - ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/etcd:/etcd
+    command: etcd -advertise-client-urls=http://127.0.0.1:2379 -listen-client-urls http://0.0.0.0:2379 --data-dir /etcd
+    healthcheck:
+      test: ["CMD", "etcdctl", "endpoint", "health"]
+      interval: 30s
+      timeout: 20s
+      retries: 3
+
+  minio:
+    container_name: milvus-minio
+    image: minio/minio:RELEASE.2023-03-20T20-16-18Z
+    environment:
+      MINIO_ACCESS_KEY: minioadmin
+      MINIO_SECRET_KEY: minioadmin
+    ports:
+      - "9001:9001"
+      - "9000:9000"
+    volumes:
+      - ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/minio:/minio_data
+    command: minio server /minio_data --console-address ":9001"
+    healthcheck:
+      test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
+      interval: 30s
+      timeout: 20s
+      retries: 3
+
+  standalone:
+    container_name: milvus-standalone
+    image: milvusdb/milvus:v2.5.5
+    command: ["milvus", "run", "standalone"]
+    security_opt:
+    - seccomp:unconfined
+    environment:
+      ETCD_ENDPOINTS: etcd:2379
+      MINIO_ADDRESS: minio:9000
+    volumes:
+      - ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/milvus:/var/lib/milvus
+    healthcheck:
+      test: ["CMD", "curl", "-f", "http://localhost:9091/healthz"]
+      interval: 30s
+      start_period: 90s
+      timeout: 20s
+      retries: 3
+    ports:
+      - "19530:19530"
+      - "9091:9091"
+    depends_on:
+      - "etcd"
+      - "minio"
+
+networks:
+  default:
+    name: milvus

Diferenças do arquivo suprimidas por serem muito extensas
+ 72 - 0
test.ipynb


Alguns arquivos não foram mostrados porque muitos arquivos mudaram nesse diff