Teeeeen/legal_rag --- 先把它的 RAG 管线读懂
面向中国法律领域 的本地 RAG 系统,基于 LangChain + Qwen3(llama本地) + BGE-M3(嵌入模型) + ChromaDB(向量数据库)。
为什么最适合:
- embedding 就是 BGE-M3,和你教程里用的是同一个,直接复用你已有的理解;
- 完整覆盖你学的那条链路,但向量库从 Milvus 换成了 ChromaDB(帮你对比两种向量库的差异);
- 最有价值的是它加了 6 项法律领域 RAG 优化 (结构化分块、上下文标头注入、HyDE、重排序等)------它教你怎么用 rerank / 混合检索修好召回;
- 可插拔管线 + FastAPI + React 前后端,还能看到"从脚本到工程"的落地。
后端项目基础知识:
一、为什么这条命令能启动后端
uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
拆开来看每个部分:
| 片段 | 含义 |
|---|---|
uvicorn |
一个 ASGI 服务器(Python 异步 Web 服务器),负责监听端口、收发 HTTP 请求、把请求交给 FastAPI 应用处理 |
app.main:app |
导入路径 + 应用对象名 。app.main 是"在 app/main.py 这个模块里",冒号后面的 app 是app/main.py:8 里创建的 app = FastAPI(...) 实例。所以本质上就是"加载 app/main.py,找到名为 app 的 FastAPI 应用" |
--host 0.0.0.0 |
绑定到所有网卡接口,局域网内其他机器也能访问(如果只在本机访问,用 127.0.0.1 即可) |
--port 8000 |
监听 8000 端口 |
--reload |
开发模式:代码一改动,uvicorn 自动重启服务(由 watchfiles 监听文件变化),不用手动重启 |
所以这条命令 = 用 uvicorn 把 FastAPI 应用跑起来。而 FastAPI 只是"框架"(定义路由、处理请求),真正干活(监听网络、并发)的是 uvicorn。这是 FastAPI 项目的标准启动方式。
二、后端框架逻辑
先看整体目录结构:
backend/
├── requirements.txt # 依赖:fastapi, uvicorn, langchain, chromadb, ollama...
├── scripts/ # 离线脚本:数据导入、抓取法律、集成测试
├── tests/ # 测试
├── data/ # 法律条文、案例、知识图谱数据
└── app/
├── main.py # ★ 应用入口:创建 FastAPI、挂中间件、注册路由
├── config.py # 全局配置(pydantic-settings + .env)
├── api/ # 路由层:chat / knowledge / performance
├── services/ # 业务层:RAG 管线、KB 管理、质量评估等
├── core/ # 基础设施层:llm / embeddings / vectorstore / retriever
├── models/schemas.py # Pydantic 数据模型(请求/响应)
└── utils/ # 工具:中文文档分块、元数据格式化
这是一个经典的分层架构 :api(路由)→ services(业务)→ core(基础设施),各层只依赖下一层,职责清晰。
1. 入口层 ------ main.py
main.py 做的事:
- 创建 FastAPI 实例 (第 8 行),并配置文档地址
/docs(Swagger UI,浏览器打开就能调试接口) - 挂 CORS 中间件(第 17 行):允许前端(Vue 的 5173 端口)跨域调用
- 注册三个路由模块 (第 26-28 行),统一加
/api前缀:
| 路由 | 功能 | 文件 |
|---|---|---|
/api/chat |
问答接口(核心)、保存/下载问答记录 | app/api/chat.py |
/api/knowledge |
知识库:上传文档、删除、重建索引、统计 | app/api/knowledge.py |
/api/performance |
性能测试、基准测试、生成报告 | app/api/performance.py |
- startup 预热钩子(第 31-50 行):启动时主动把 Ollama 的 LLM 和 Embedding 模型加载进显存。因为本地模型冷启动要 10-30 秒,预热后用户首次提问就不必等。
/和/health:健康检查接口。
2. 配置层 ------ config.py
用 pydantic-settings 管理配置。值得注意两点:
- 模型栈配置 :LLM 用
qwen3:8b、Embedding 用bge-m3,都跑在本地 Ollama(http://localhost:11434),说明这是个完全本地化、不依赖云端 API 的部署。 - NO_PROXY 处理 (第 14-19 行):文件顶部有个很关键的 hack ------ Windows 系统代理(Clash 等)会拦截对 localhost 的请求返回 502,而 Python 只认环境变量。所以先手动把
localhost/127.0.0.1加进NO_PROXY,否则后端调用 Ollama 会失败。这就是为什么 Ollama 要在本地先启动。
3. 基础设施层 ------ core/
四个模块,都做了单例缓存(避免重复创建连接,单 GPU 场景很重要):
- llm.py:
get_llm()→ 返回ChatOllama(qwen3:8b)实例(按 model+temperature 缓存) - embeddings.py:
get_embeddings()→OllamaEmbeddings(bge-m3) - vectorstore.py:
get_vectorstore(collection)→ ChromaDB,两个集合:laws(法条)、cases(案例) - retriever.py:混合检索器 ------ BM25 关键词检索(jieba 中文分词)+ 向量稠密检索,再用 RRF(Reciprocal Rank Fusion) 融合两个排名,兼顾"关键词精确命中"和"语义相似"
4. 业务层 ------ services/pipeline.py(核心)
这是整个系统的灵魂:一个可插拔的 RAG 管线 。一次问答请求会依次走这几道工序(见 pipeline.py:88-146):
用户提问
│
├─ Stage 1 查询变换(可选)
│ 多查询重写 / HyDE 假设文档 / 子问题分解 / 组合模式
│ ── 提升召回率:一个问题变多个角度去检索
│
├─ Stage 2 混合检索(BM25 + 向量 + RRF 融合,去重)
│
├─ Stage 2.5 知识图谱查找(可选,use_kg=True)
│ 刑事法律场景:抽取罪名实体 → 查"犯罪知识图谱"补充结构化知识
│
├─ Stage 3 重排序(simple 词面相似 / llm 大模型打分)
│ ── 检索出的 top-N 再精排,只留最相关的
│
└─ Stage 4 生成
标准 prompt / 思维链 / 自我反思纠错 / 结构化法律回答
── 把拼好的上下文喂给 qwen3:8b,产出答案
每道工序都通过策略枚举 (QueryTransformStrategy、RerankStrategy、GenerationStrategy)自由组合,所以前端能任意开关"多查询""HyDE""知识图谱""LLM 重排"这些高级功能,而不改后端代码。
管线还全程记录每阶段耗时和 LLM 调用次数 (StageMetrics),方便做性能基准测试和成本统计。
5. 数据层 ------ models/schemas.py
用 Pydantic 定义所有请求/响应结构:
ChatRequest:问答参数(问题、是否重排、top_k、选哪个策略组合...,都带校验)ChatResponse:答案 + 引用来源 + 分阶段耗时指标 + 质量评估APIResponse:统一响应包装{code, data, message}
一次请求的完整流转(以问答为例)
浏览器/前端 POST /api/chat
→ FastAPI 校验请求体(schemas.ChatRequest)
→ chat.py 调用 rag_service.rag_query() ← 业务层
→ rag_service 组装 PipelineConfig,交给 RAGPipeline.execute()
→ pipeline 走完 查询变换→检索→KG→重排→生成 ← 核心逻辑
→ core/retriever 调 ChromaDB + Ollama embedding ← 基础设施
→ core/llm 调 Ollama qwen3:8b 生成回答
→ 结果层层返回,包装成 ChatResponse → JSON 响应
总结
- 启动方式 :FastAPI(框架)+ uvicorn(服务器),
app.main:app定位应用对象,命令各参数见上表。 - 架构特点:分层清晰(路由/业务/基础设施),RAG 管线可插拔,完全本地化部署(Ollama + Qwen3:8B + BGE-M3 + ChromaDB),带模型预热、性能监控、质量评估(ROUGE、忠实度)等进阶能力。
- 前置条件 :启动前需要本地先跑起 Ollama (拉好
qwen3:8b和bge-m3两个模型),并导入过法律/案例数据(scripts/下的脚本),否则/health会提示模型预热失败。
几个值得记住的"坑"与决策
- 全本地 Ollama 而非 API → 法律数据"不出域"(隐私)
- ChromaDB 而非 Milvus/FAISS → 嵌入式零配置
- 内存字典 而非 Neo4j → 需求只是键值查询,O(1) 够用
config.py启动时把 localhost 写进NO_PROXY→ Windows 系统代理(Clash/Verge)会拦截本地 Ollama 请求返回 502,这是实测踩过的坑main.py启动预热模型 → Ollama 首次加载到 GPU 需 10-30s,避免用户首查冷启动
想要真正搞懂该项目:
**步骤1、**让Ai帮你构建一下这个项目的整个架构的系统设计,让你明白这个项目到底在干啥?具体是怎么构建的?
**步骤2、**让Ai以一个Agent应用开发工程师的视角,告诉你如何从0到1再到100来构建当前这个项目,帮你构建一个roadmap,你需要跟着这个roadmap一步步去学习了解,做到知其然也知其所以然。
一、分块 + 向量化 + 写入 ChromaDB(两个 collection 分开处理)
整个数据导入流程的链路(数据读取 -> 分块 -> Embedding向量化 -> 持久化存储)
主逻辑:
1. 初始化阶段 (main)
- 逻辑 :修正
sys.path导入项目依赖 → 清空缓存reset_store_cache()→ 分别实例化laws和cases两个向量数据库集合。
2. 获取/创建 laws 集合
3. 法律条文导入 (import_laws)
- 逻辑 :递归扫描
.txt/.md→ 尝试多编码读取 → 调用split_legal_document切片 → 批量_batch_add_documents写入向量库。
4. 获取/创建 cases 集合
5. 案例数据导入 (import_cases_jsonl)
- 逻辑 :逐行读取 JSONL → 解析 JSON 对象 → 提取文本与元数据 → 长度校验 (≥50字) → 切片 → 维护累加池
batch_chunks,每满 200 块写入一次向量库 → 达到上限max_cases(5000条)即终止。
核心语法:
1. 动态修正模块搜索路径 (sys.path)
python
# 将 backend 目录加入 Python path,保证 `python -m scripts.import_data` 能导入 app 包
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
-
__file__:Python 内置变量,代表当前执行的 Python 脚本文件的相对或绝对路径(取决于运行时如何调用)。 -
os.path.abspath(__file__):将__file__转换为标准且规范的绝对路径 (例如/Users/username/project/src/main.py),消除相对路径带来的干扰。 -
内层
os.path.dirname(...):获取上述绝对路径的父级目录 (即当前文件所在的文件夹路径,例如/Users/username/project/src)。 -
外层
os.path.dirname(...):再次嵌套一层dirname,获取父级目录的父级目录 (即当前文件所在文件夹的上一级目录,如/Users/username/project)。 -
sys.path.insert(0, ...)-
sys.path:一个包含所有模块搜索路径的字符串列表。Python 在执行import时会按这个列表中的索引顺序依次查找模块。 -
insert(0, path):将计算出来的"上上级目录"插入到sys.path的首位(索引 0)。
-
这行代码是 Python 开发中极其常见的环境路径导入技巧 ,用于将当前文件的上上级目录 添加到 Python 模块搜索路径(sys.path)的最前面,从而确保代码能顺利导入其他文件夹中的模块。
2. 编码回退与异常流控制 (Fallback Encoding Strategy)
python
for enc in ["utf-8", "gbk", "gb2312"]:
try:
with open(filepath, "r", encoding=enc) as f:
return f.read()
except (UnicodeDecodeError, UnicodeError):
continue
-
语法拆解 :利用
for...else或try...except进行多方案依次尝试。 -
教学点:在处理国内爬取或历史遗留的中文文本时,编码极度不统一。这种"尝试-捕获-继续(Fallback)"模式是处理未知文本编码的最佳实践。
3. 生成器式目录递归 (os.walk) 与 解包下划线
for root, _dirs, files in os.walk(dir_path):
...
-
语法拆解:
-
os.walk(path)返回一个三元组(当前目录路径, 子目录列表, 文件列表)。 -
_dirs前加下划线_是 Python 的命名惯例,表示"我知道这里有一个返回值,但我不需要用到它"。
-
-
教学点 :配合
os.path.splitext(fname)[1].lower()可以非常高效、安全的筛选出任意深度的特定后缀文件。
4. 切片步长批量处理(Batching via Slicing)
def _batch_add_documents(store, chunks, batch_size: int = 5000):
for i in range(0, len(chunks), batch_size):
store.add_documents(chunks[i:i + batch_size])
-
语法拆解 :
range(start, stop, step)函数的第 3 个参数为步长。-
假设
len(chunks) = 12000,range(0, 12000, 5000)会生成0, 5000, 10000。 -
chunks[0:5000]、chunks[5000:10000]、chunks[1000:12000]。Python 列表切片超出索引不会报错,会自动截断到末尾。
-
-
教学点:大批量向数据库写入数据时,必须做分批(Batching),这是防止内存溢出(OOM)和数据库 API 请求超限的标准写法。
5. 字典安全取值与多条件字段提取
# 通用格式:依次尝试常见文本字段名
for key in ("text", "content", "body"):
if key in obj and obj[key]:
return obj[key], extra_meta
-
语法拆解:
-
obj.get("meta", {}):安全取值,若meta键不存在,则返回空字典{},避免触发KeyError。 -
";".join(...):利用字符串的.join()方法将列表快速拼接为长字符串。
-
-
教学点:兼容多数据源时(如不同标注团队提供的 JSON),通过遍历候选 Key + 异常保护,能够极大地提升 ETL 脚本的鲁棒性(Robustness)。
get_vectorstore()函数:获取/创建 指定Chroma 向量库
这部分代码的核心作用是 单例/缓存模式管理。在 Debug 或实际运行场景中,有以下几点需要特别注意:
1. 缓存机制与内存复用
-
逻辑 :通过全局字典
_store_cache存储已经初始化的Chroma客户端实例。当传入相同collection_name(如"laws"或"cases")时,直接返回已有实例。 -
Debug 意图:避免在一次脚本运行中多次连接、初始化底层的向量数据库驱动,从而节省开销、提升速度。
2. reset_store_cache() 的妙用
-
场景 :在导入脚本
import_data.py开头,程序明确调用了reset_store_cache()。 -
Debug 隐患 :如果不清空缓存,假设之前的业务逻辑修改了磁盘上的持久化文件,已有内存中的
Chroma实例可能会继续持有旧的数据索引 (Stale Data),导致写入冲突或查询不一致。通过清空字典,强制下一次get_vectorstore()从磁盘重新读入最新的数据状态。
核心语法解析
这几行代码虽然精简,但涵盖了现代 Python 规范和设计模式中的几个核心要素:
1. 类型注解与泛型字典(Type Hints & Generics)
python
_store_cache: dict[str, Chroma] = {}
-
语法拆解:
-
dict[str, Chroma]是 Python 3.9+ 引入的原生类型注解(Type Hinting)写法。 -
str表示字典的 键(Key) 必须是字符串(如"laws")。 -
Chroma表示字典的 值(Value) 必须是Chroma对象。
-
-
教学点 :虽然 Python 是动态类型语言,但在大型项目或使用 VS Code / PyCharm 等 IDE 时,加上类型注解能提供极强的代码自动补全 和静态语法检查,大幅减少因拼写错误引发的 Bug。
2. 模块级单例缓存模式(Module-Level Singleton Pattern)
python
if collection_name in _store_cache:
return _store_cache[collection_name]
# ... 实例化 ...
_store_cache[collection_name] = store
-
语法拆解:
- 单下划线前缀
_store_cache:这是 Python 的命名惯例,表明该变量是模块私有(Module-Private)的,提示外部不要直接修改它,而应该通过暴露的函数(如reset_store_cache())来操作。
- 单下划线前缀
-
教学点:利用模块作用域下的全局变量做字典缓存(也叫惰性加载 / Lazy Loading),是 Python 中实现单例模式(Singleton)最简洁优雅的方式,无需编写复杂的类(Class)结构。
3. 工厂函数模式(Factory Pattern)
python
store = Chroma(
collection_name=collection_name,
embedding_function=get_embeddings(),
persist_directory=settings.CHROMA_PERSIST_DIR,
)
-
语法拆解:
get_vectorstore()本质上是一个工厂函数(Factory Function) ,它隐藏了Chroma实例繁琐的构建细节(如 Embedding 模型的加载、配置文件路径的读取)。
-
教学点 :调用方(如
import_data.py)只需要关心"给我一个名字叫laws的向量库",而不需要关心 Embedding 怎么传、路径配置在哪。这样解耦之后,后续如果要更换向量数据库(例如从 Chroma 换成 Milvus),只需修改get_vectorstore()函数内部实现即可,外部导入脚本一行都不需要改。
模块级单例缓存模式与工厂函数模式组合如何理解?请见附录3
**import_laws():**往向量数据库导入法律条文文档
是法律文本导入链路的核心函数,负责将原始法条文件(.txt/.md)转化为数据库可索引的向量块。
把代码拆解为标准的 ETL 三步法(提取-转换-写入):
[收集文件] ──> [读取内容] ──> [文本切片] ──> [分批入库] ──> [统计返回]
1. 边界条件防御 (Guarding Edge Cases)
python
if not files:
print(" 目录为空或不存在")
return 0
-
检查逻辑 :通过
if not files:处理路径不存在或空目录的情况。 -
Debug 场景 :若路径拼写错误或文件夹内没有任何符合条件的文件,函数会优雅打印提示并直接返回
0,避免引发后续迭代空对象的异常或报错。
2. 文件隔离与故障阻断 (Fault Isolation)
-
检查逻辑 :
try...except Exception as e:放在for循环内部。 -
Debug 场景 :假设目录中有 100 个法律文本,其中第 5 个文件因损坏或特殊字符导致
read_file报错:-
良好设计(当前代码) :捕获第 5 个文件的异常并打印
✗ 坏文件名: 错误信息,接着继续处理第 6 到第 100 个文件,保障整体导入任务不中断。 -
反面示例(若把 try 放在 loop 外):第 5 个文件报错直接导致程序崩溃退出,前 4 个成功处理,后 95 个全部丢失。
-
核心语法解析
1. 集合字面量与快速成员检测 (Set Literal for Lookups)
files = collect_files(dir_path, {".txt", ".md"})
-
语法拆解 :
{".txt", ".md"}使用大括号构建了一个 Python 集合(Set)。 -
教学点 :虽然这里元素较少,但在 Python 中,将查找目标传入集合(Set)而非列表(List)是极佳的习惯。集合底层是哈希表(Hash Table) ,成员检测
ext in extensions的时间复杂度为 O(1),而列表检测为 O(n)。
collect_files()函数:递归收集目录下指定后缀的文件
collect_files() 是整个数据导入管道中的 "数据源提取器(Extractor)",负责从文件系统中安全、稳定地筛选出符合条件的文本文件。
一、 Debug 视角:文件收集流与边界防御
这个函数虽然只有十多行,但考虑到了很多实际文件系统处理时的 "坑点":
[传入根目录 dir_path] ──> [os.walk 深度递归] ──> [文件名称排序]
│
[返回完整路径列表] <── [拼接路径 os.path.join] <── [扩展名比对 & 过滤隐藏文件]
1. 确定性保证(Deterministic Execution)
-
逻辑 :使用了
sorted(files)对当前目录下的文件名进行排序。 -
Debug 意图 :不同操作系统(如 macOS, Linux, Windows)或者不同文件系统(NTFS, ext4)下,
os.walk返回的文件顺序可能是随机的。如果导入向量库的顺序不固定,重新构建索引时向量 ID 或数据批次就会发生偏移,导致测试结果不可复现 。使用sorted()保证了无论在什么平台上运行,处理文件的顺序完全一致。
2. 系统杂质过滤(System Noise Filtering)
-
逻辑 :通过
fname.startswith(".")忽略隐藏文件。 -
Debug 意图 :在 macOS 或 Linux 系统中,操作系统或编辑器常常会生成
.DS_Store、._filename.txt等隐藏文件。如果不做处理,直接读取可能会导致编码解析异常或提取出乱码数据。
二、 核心语法与最佳实践解析
1. 目录树生成器 os.walk
for root, _dirs, files in os.walk(dir_path):
-
语法拆解:
-
os.walk(dir_path)会自顶向下递归遍历指定目录。 -
每遍历一个目录,它都会返回一个三元组:
(root, dirs, files)。-
root:当前正在遍历的目录路径(字符串)。 -
_dirs:当前目录下的子目录名称列表(此处用_dirs表示后续不会用到该变量)。 -
files:当前目录下的非目录文件名称列表。
-
-
-
教学点 :相比于
os.listdir()只能列出当前一层目录,os.walk()是处理多层嵌套目录(如data/laws/民法/分则/)的标准利器。
2. 路径切分与大小写归一化(Path Splitting & Normalization)
ext = os.path.splitext(fname)[1].lower()
-
语法拆解:
-
os.path.splitext("民法典.TXT")会把文件名拆分为元组('民法典', '.TXT')。 -
[1]取出第二个元素,即扩展名'.TXT'。 -
.lower()将扩展名统一转为小写'.txt'。
-
-
教学点 :Windows 环境下很多文件的后缀名可能是大写(如
.TXT、.JSON),如果不做.lower()归一化处理,直接用{".txt", ".md"}去匹配就会遗漏这些大写后缀的文件。
3. 跨平台路径拼接 os.path.join
results.append(os.path.join(root, fname))
-
语法拆解 :将目录路径
root和文件名fname拼接成完整的绝对/相对路径。 -
教学点 :绝对不要用字符串相加(如
root + "/" + fname)来拼接路径! 因为 Linux/macOS 使用正斜杠/,而 Windows 使用反斜杠\。os.path.join()会根据当前运行的操作系统自动选择正确的路径分隔符,保障代码的跨平台兼容性。
read_file():尝试多种编码读取文件
read_file() 是整个导入流程中的 "字符解码器",专门解决中文文本处理中最令人头疼的编码不一致/乱码(UnicodeDecodeError)问题。
核心语法解析
-
多异常元组捕获 (Tuple of Exceptions)
except (UnicodeDecodeError, UnicodeError):
continue
-
语法拆解:
-
except后跟用圆括号()包裹的元组,可以同时捕获多种指定的异常类型。 -
UnicodeDecodeError是UnicodeError的子类,这里同时显式捕获,确保只要是解码问题就会被拦截,而不会误拦截其他的异常(例如文件权限不足PermissionError)。
-
-
教学点 :捕获异常时应尽可能精细(Specific),切忌写成盲目的
except Exception:。如果因"文件找不到"或"磁盘损坏"报错,应该让程序报错抛出,而不是误以为是"编码错误"而继续continue。
2. 显式抛出业务异常 (raise)
raise ValueError(f"无法解码: {filepath}")
-
语法拆解:
-
raise关键字用于主动抛出一个内置或自定义的异常对象。 -
ValueError表示传入的参数/数据不符合预期(即给定的路径对应的文件内容无法用常见中文编码解析)。
-
-
教学点 :当函数无法完成其承诺的功能(读取文本)时,显式抛出带有明确上下文(如文件路径
filepath)的异常,比静默返回None或空字符串要安全得多。这能让调用方(如import_laws中的try...except)第一时间获取到报错详情并打印日志。
split_legal_document():对每个文档分块
是整个法律 RAG 系统中 文本预处理(Chunking)的核心调度入口。在 RAG(检索增强生成)系统中,"切块质量直接决定检索精度"。
分块处理流程
该函数采用了 策略模式(Strategy Pattern) 与 管道模式(Pipeline Pattern) 的结合,将原始文本加工成高质量的向量库 Document 对象:
[原始文本 text]
│
├── 1. 策略选择 ──> 法律條文: LegalArticleSplitter (512字) + extract_law_metadata
│ 司法案例: LegalCaseSplitter (1024字) + extract_case_metadata
│
├── 2. 结构化切分 ──> splitter.create_documents([text])
│
├── 3. 元数据增强 ──> enrich_chunk_metadata (注入章节、条号、块序号 i)
│
└── 4. 上下文标头注入 (按需开关) ──> add_contextual_header (将法律/案例名称拼入正文头部)
Debug & 架构排错要点:
-
差异化 Chunk Size 的合理性:
-
法律(Law) :条文通常精炼(如"第一百条 ..."),采用标准的
512字符块大小,能精准定位到具体法条,避免检索噪声。 -
案例(Case) :判决书/案情描述往往篇幅冗长,需要更大的上下文(
chunk_size * 2 = 1024)来容纳完整的因果逻辑和案情事实。
-
-
上下文标头注入(Contextual Chunking):
-
在纯文本检索中,单个 Chunk 可能会因为切分丢失全局背景(例如分块后只剩下"处三年以下有期徒刑",丢失了前面"犯盗窃罪的"这一前提)。
-
add_contextual_header会把parent_metadata(如法律名称《刑法》)动态拼接到 Chunk 的开头,显著提升 Vector Embedding 在向量空间中的语义表达能力。
-
核心语法与设计模式解析
1. 带默认值的可选参数 (Default Arguments)
python
def split_legal_document(
text: str,
doc_type: str = "law",
filename: str = "",
chunk_size: int = 512,
chunk_overlap: int = 64,
) -> list[Document]:
-
语法拆解:
-
在函数定义时为参数指定
= value,使其变为可选参数。 -
doc_type: str = "law":如果不传该参数,默认当作法律条文处理。
-
-
教学点 :默认参数必须放在无默认值参数(如 text)的后面,否则 Python 会报 SyntaxError: non-default argument follows default argument。
2. 工厂与策略模式 (Factory & Strategy Pattern)
python
if doc_type == "case":
splitter = LegalCaseSplitter(...)
parent_metadata = extract_case_metadata(...)
else:
splitter = LegalArticleSplitter(...)
parent_metadata = extract_law_metadata(...)
-
语法拆解 :根据传入的
doc_type动态决定实例化哪一个切分器(Splitter)和元数据提取器。 -
教学点 :这种方式实现了逻辑解耦。上层调用者(如
import_laws)只需告诉split_legal_document"我要切分法律"还是"我要切分案例",而不需要关心底层具体的切分算法和正则提取规则。
3. 动态/延迟导入 (Lazy Import)
python
# 可选:注入上下文标头(由配置 CONTEXTUAL_CHUNKING 开关控制)
from app.config import settings
if settings.CONTEXTUAL_CHUNKING:
...
-
语法拆解 :在函数体内部(非文件顶部)执行
from app.config import settings。 -
教学点 :这被称为 延迟导入(Lazy Import)。优点有两个:
-
避免循环导入(Circular Import) :如果
app.config在初始化时也间接依赖了legal_chunker,在顶部导入会导致 Python 报错。 -
性能优化:只有当代码真正运行到这个分支时才会去加载配置模块,减少不必要的初始化开销。
-
4. 带索引的列表迭代 (enumerate)
for i, chunk in enumerate(chunks):
enriched = enrich_chunk_metadata(chunk, parent_metadata, i)
-
语法拆解:
enumerate(chunks)会在迭代列表元素的同时,生成一个从0开始的递增索引i。
-
教学点 :在对切片数据进行元数据增强时,记录
i(块序号chunk_id)非常关键。后续如果在向量库中查到了第 3 块,系统可以通过索引便捷地拉取第 2 块和第 4 块,恢复原文档的上下的连续上下文。
LegalArticleSplitter():法律条文分块器
python
class LegalArticleSplitter(RecursiveCharacterTextSplitter):
"""法律条文分块器 --- 以"条"为基本单位"""
def __init__(self, chunk_size: int = 512, chunk_overlap: int = 64, **kwargs):
# 法律条文层级分隔符(优先级从高到低)
separators = [
r"\n第[一二三四五六七八九十百千]+编", # 编
r"\n第[一二三四五六七八九十百千]+章", # 章
r"\n第[一二三四五六七八九十百千]+节", # 节
r"\n第[一二三四五六七八九十\d]+条", # 条(核心切分点)
r"\n[一二三四五六七八九十]+、", # 款
r"\n([一二三四五六七八九十]+)", # 项
r"\n\(\d+\)", # 数字项
r"\n\d+\.", # 数字编号
r"\n", # 换行
]
super().__init__(
separators=separators,
chunk_size=chunk_size,
chunk_overlap=chunk_overlap,
is_separator_regex=True,
keep_separator=True,
**kwargs,
)
LegalArticleSplitter 是专门针对中国法律文本结构设计的 定制化递归文本切分器 。普通文本切分器(如按固定字符数切分)很容易把一条完整的法条切割成两半,破坏法律语义;而该切分器通过继承 LangChain 的 RecursiveCharacterTextSplitter,并注入法律特有的层级正则表达式,实现了"优先保持完整法条"的精准切分。
RecursiveCharacterTextSplitter 的工作机制是:按 separators 列表中的分隔符优先级,从高到低依次尝试切分,直到切出的文本块大小不超过 chunk_size。
1. 分隔符优先级与法律结构的精准匹配
[编] (第X编) ──优先级1──> 若整编超长,降级按 [章] 切分
└── [章] (第X章) ──优先级2──> 若整章超长,降级按 [节] 切分
└── [节] (第X节) ──优先级3──> 若整节超长,降级按 [条] 切分
└── [条] (第X条) ──核心目标──> 尽可能让每一个 Chunk 包含完整的"一条法律"
└── [款/项] (一、/(一)) ──兜底逻辑──> 若单条法条极长,才被迫拆分为款/项
2. 关键参数配置分析
-
is_separator_regex=True:告诉切分器separators列表里的每一个字符串都是正则表达式,而不是普通文本。 -
keep_separator=True:非常关键! 默认情况下,切分器会将匹配到的分隔符(如
\n第一百条)直接吃掉/丢弃;设置keep_separator=True后,切分器会将"第一百条"保留并附在切出的新文本块头部,保证切片数据仍然保有"第XX条"的完整结构。
核心语法与正则表达式解析
1. 类继承与父类构造函数调用 (super().__init__)
class LegalArticleSplitter(RecursiveCharacterTextSplitter):
def __init__(self, chunk_size: int = 512, chunk_overlap: int = 64, **kwargs):
...
super().__init__(
separators=separators,
chunk_size=chunk_size,
chunk_overlap=chunk_overlap,
is_separator_regex=True,
keep_separator=True,
**kwargs,
)
-
语法拆解:
-
class SubClass(ParentClass)::子类LegalArticleSplitter继承父类RecursiveCharacterTextSplitter。 -
super().__init__(...):显式调用父类的初始化构造方法,将自定义好的separators参数以及外部传入的配置传给父类,使得子类完美拥有父类的全部切分能力。
-
-
教学点 :通过继承父类并仅覆写
__init__初始化逻辑,用不到 20 行代码就构建了一个高度定制化的法律切分器,这是 Python 面向对象编程(OOP)中"重用与扩展"的标准范例。
面向对象编程(OOP)中"重用与扩展"详细介绍见附录6
2. 可变关键字参数 (**kwargs)
def __init__(self, ..., **kwargs):
super().__init__(..., **kwargs)
-
语法拆解:
-
**kwargs(Keyword Arguments)用于接收所有未显式定义的关键字参数,并将其打包为一个字典。 -
在调用
super().__init__(..., **kwargs)时,前面的**会将字典打散解包为关键字参数传递给父类。
-
-
教学点 :使用
**kwargs赋予了类极强的扩展性。如果 LangChain 未来给RecursiveCharacterTextSplitter增加了新的参数(例如strip_whitespace),调用方在实例化LegalArticleSplitter时可以直接传入,而无需修改LegalArticleSplitter的源代码。
3. 正则表达式中文数字字符集与量词
r"\n第[一二三四五六七八九十百千]+条"
-
语法拆解:
-
r"...":Raw String(原始字符串) 。在普通的 Python 字符串中,\n表示换行符;但在正则表达式中,前缀r能防止转义字符被 Python 提前解析,确保正则表达式引擎能原汁原味接收到\n。 -
[一二三四五六七八九十百千]:字符集(Character Class) ,匹配中括号内出现的任意单个中文数字。 -
+:量词(Quantifier),匹配前面的字符集中 1 个或多个字符(例如匹配"十"、"一百二十三")。 -
\d+(出现在r"\n第[一二三四五六七八九十\d]+条"中):\d匹配阿拉伯数字0-9,用以兼容"第10条"这种采用数字编号的现代法规/补充案文件。
-
extract_law_metadata():从法律条文文本中提取元数据
返回的metadata如下:
python
{
'doc_type': 'law',
'source_file': '中华人民共和国城市维护建设税法.txt',
'law_name': '中华人民共和国城市维护建设税法',
'effective_date': '2021年9月1日'
}
extract_law_metadata() 是 RAG 数据处理中的 元数据提取器(Metadata Extractor) 。它的核心作用是从法律全文中抽取诸如法律名称、生效日期等全局结构化信息,以便后续注入到每个 Chunk 的元数据字典中,从而在检索时支持按法律名称筛选(Filtering)或提供精确的上下文标头。
一、 提取逻辑与兜底策略
该函数采用了 "模式优先级匹配 + 范围限定 + 降级兜底" 的防御性设计:
[初始化元数据字典]
│
├── 1. 提取法律名称 (前500字内) ──> 依次匹配:全称模式 -> 书名号模式 -> 行首标题模式
│ │
│ └── 兜底方案:未匹配到则截取去掉后缀的文件名
│
└── 2. 提取生效日期 (前2000字内) ──> 依次匹配:"X年X月X日施行" -> "自...起施行" -> "YYYY-MM-DD"
-
限定检索窗口(
text[:500]/text[:2000]):-
性能控制:法律文件(如《民法典》)可能长达几十万字。如果在全文本上执行复杂的正则表达式搜索,耗时会随文本长度呈非线性增长。
-
精确度提升:法律名称通常出现在文首标题,生效日期通常出现在前言、文首文号或颁布说明中。限制在文首窗口搜索,既大幅提升了正则匹配效率,又避免了误匹配正文中引用的其他法律名称或历史日期。
-
-
多重正则优先级(Pattern Waterfall):
- 优先提取含"中华人民共和国"的最严谨全称;若无,再匹配带书名号《》的名称;最后尝试匹配文首行标题。这种从严格到宽泛的匹配瀑布,能有效应对不同格式的法条文件。
二、 核心语法与正则表达式解析
1. 正则非贪婪匹配与非捕获分组 (\S+? / (?:...))
r"(中华人民共和国\S+?(?:法典|法|条例|规定|办法|决定|解释))"
-
语法拆解:
-
\S+?:\S匹配任意非空白字符,+?是非贪婪(惰性)量词。它会尽可能少地匹配字符,只要遇到后面的后缀名就立即停止,防止过长匹配。 -
(?:法典|法|条例|...):(?:...)是非捕获分组(Non-capturing Group) 。它仅用于分组逻辑(匹配多个可能的法律后缀之一),不会 将其单独作为一个group()结果捕获。 -
match.group(1):因此外部的外层圆括号(...)仍是唯一被捕获的Group 1,直接提取出完整的法律名称。
-
2. 字符串两端裁剪 (strip)
metadata["law_name"] = match.group(1).strip("《》")
-
语法拆解:
strip("《》")会移除字符串头部和尾部 包含的所有《或》字符。
-
教学点 :通过
strip归一化法律名称(例如将《中华人民共和国刑法》统一清理为中华人民共和国刑法),可保证后续在向量库进行元数据过滤(Metadata Filter)时,键值完全一致。
3. 字符串正则替换 (re.sub)
metadata["law_name"] = re.sub(r"\.\w+$", "", filename)
-
语法拆解:
-
re.sub(pattern, replacement, string):将匹配到的文本替换为指定内容。 -
\.\w+$:\.匹配点号,\w+匹配 1 个以上的字母/数字/下划线,$匹配字符串末尾(即匹配末尾的文件扩展名如.txt、.md)。 -
将后缀替换为空字符串
"",从而实现剔除扩展名、获取纯文件名作为兜底法律名称的目的。
-
4. 正则数字重复范围与可选捕获 (\d{1,2} / (?:起)?)
r"(\d{4}年\d{1,2}月\d{1,2}日)(?:起)?施行"
-
语法拆解:
-
\d{4}:精确匹配 4 位数字(年份)。 -
\d{1,2}:匹配 1 到 2 位数字(兼顾1月与01月)。 -
(?:起)?:量词?表示前面的非捕获分组(?:起)出现 0 次或 1 次,从而同时兼容"2021年1月1日施行"和"2021年1月1日起施行"两种常见的法律公文表述。
-
enrich_chunk_metadata():为分块添加详细的元数据
enrich_chunk_metadata() 是 RAG 系统预处理流水线中的 "元数据标注员" 。它在文档切片完成后,对每一个独立的 Document 块进行二次分析,提取出结构化的微观特征(如章节名、条号、案情段落类型),并将其注入到块的 metadata 属性中。
一、 实现逻辑
在 RAG 系统中,给 Chunk 打上精准的元数据有两个巨大的检索与重排优势:
-
精确过滤(Metadata Filtering) :用户搜索"盗窃罪量刑标准"时,数据库可以先筛选出
article_number = "264"或section_type = "裁判结果"的数据块,直接排除无关的"诉讼程序"或"被告人身份"文本。 -
重构上下文 :利用
chunk_index可以轻松拼接相邻的上下文 Chunk(如加载前后的第 i-1 和 i+1 块)。
业务处理逻辑流:
[传入单个 Chunk & 父级元数据]
│
├── 1. 字典解包合并 parent_metadata,注入 chunk_index
│
├── 2. 正则抽取章节信息 ──> 匹配 "第X章 标题"
│
├── 3. 正则抽取条号数字 ──> 匹配 "第X条" 中的 X 写入 article_number
│
└── 4. 案情段落归一化 ──> 前 50 字检索关键词,映射为标准板块(裁判要旨/基本案情/裁判结果/裁判理由)
-
关键词范围限制 (
text[:50]):- 设计意图 :判决书中常出现"本院在基本案情 审查中认为..."这样的描述。如果全局搜索关键词,非段落头部的正文讨论会造成误判。限制在 前 50 个字符 内匹配,确保只有当该块是该段落的起始开头 时,才打上
section_type标签。
- 设计意图 :判决书中常出现"本院在基本案情 审查中认为..."这样的描述。如果全局搜索关键词,非段落头部的正文讨论会造成误判。限制在 前 50 个字符 内匹配,确保只有当该块是该段落的起始开头 时,才打上
-
映射归一化(Normalisation via Dictionary):
- 不同的法院判决书用词不一(有的叫"裁判要旨",有的叫"裁判要点";有的写"裁判理由",有的写"法院认为")。通过
section_types字典,将多种别名统一归一化为标准的 4 类,极大地降低了后续检索过滤的复杂度。
- 不同的法院判决书用词不一(有的叫"裁判要旨",有的叫"裁判要点";有的写"裁判理由",有的写"法院认为")。通过
二、 核心语法解析
-
字典解包合并 (Dictionary Unpacking)
enriched = {**parent_metadata}
-
语法拆解:
**是 Python 的字典解包运算符。它把parent_metadata字典中的所有键值对"展开"并浅拷贝到一个全新的字典enriched中。
-
教学点 :这种写法比
enriched = parent_metadata.copy()更方便,且在需要合并多个字典时非常优雅(例如{**dict1, **dict2, "key": "value"})。同时,它生成了一个新字典 ,避免了直接修改原始parent_metadata对象而引发的全局副作用(Side Effects)。
2. 正则选择性匹配与分组处理 ((?:\n|$) / group(0) vs group(1))
python
# 提取章节(匹配整行标题)
chapter_match = re.search(r"第[一二三四五六七八九十百千]+章\s*(.+?)(?:\n|$)", text)
if chapter_match:
enriched["chapter"] = chapter_match.group(0).strip()
# 提取条号(仅提取编号本身)
article_match = re.search(r"第([一二三四五六七八九十百千\d]+)条", text)
if article_match:
enriched["article_number"] = article_match.group(1)
-
语法拆解:
-
(?:\n|$):非捕获分组,匹配"换行符"或"文本结尾$",确保把整行章标题截取完整。 -
group(0):返回正则表达式整体匹配到的全部字符串 (例如匹配到"第一章 总则")。 -
group(1):只返回正则表达式中第一个圆括号(...)捕获的内容 。在提取条号时,第([一二三...] \d+)条中的圆括号只括住了数字部分,所以article_match.group(1)提取出的直接就是"264"或"二百六十四",方便后续进行数字对比和过滤。
-
3. 字典迭代与多对一映射 (Dictionary Key-Value Mapping)
section_types = {
"裁判要旨": "裁判要旨",
"裁判要点": "裁判要旨",
"法院认为": "裁判理由",
}
for keyword, section_type in section_types.items():
if keyword in text[:50]:
enriched["section_type"] = section_type
break
-
语法拆解:
-
section_types.items():返回由字典的(键, 值)组成的元组可迭代对象,通过for keyword, section_type in ...进行解包赋值。 -
break:一旦匹配到第一个关键词就停止后续循环,避免不必要的比较。
-
-
教学点:将"别名 \\rightarrow 标准名"建立为映射字典,是数据清洗和文本标准化中最常用的设计模式之一。
add_contextual_header():为分块内容添加结构化上下文标头,提升 Embedding 质量
add_contextual_header() 是整个 RAG 预处理链路中的 "上下文前置增强器"(Contextual Header Injection) 。它直接解决了向量检索(Vector Search)中的核心痛点 ------ "切片孤岛现象"。
当文本被拆分成独立 Chunk 后,很多句子会失去前因后果(例如仅包含"处三年以下有期徒刑")。该函数通过把元数据"文本化"并拼接在 Chunk 头部,使每一个切片都带有全局标识,从而大幅提升向量 Embedding 在语义空间中的表达精度。
一、上下文拼接逻辑
该函数采用 "动态字段收集 → 结构化文本化 → 正文头部注入" 的流水线:
[传入 Chunk & 元数据]
│
├── 1. 读取法律元数据 ──> 提取 law_name ──> 添加 "法律名称: X"
│
├── 2. 读取块元数据 ──> 提取 chapter / article_number ──> 添加 "章节: Y" / "条号: 第Z条"
│
├── 3. 读取案例元数据 ──> 提取 guiding_number / section_type ──> 添加 "案例: N" / "段落: S"
│
└── 4. 条件组装与注入 ──> 拼成 "[法律名称: X | 条号: 第Z条]\n" 拼接至 page_content 头部
-
解决 Embedding 的孤岛问题:
-
未增强前 :Chunk 正文为
"犯前款罪的,处三年以下有期徒刑..."(Embedding 模型不知道这是哪部法律、哪一条,相似度计算效果差)。 -
增强后 :Chunk 变成了
[法律名称: 中华人民共和国刑法 | 条号: 第264条]\n犯前款罪的,处三年以下有期徒刑...(Embedding 模型能完美捕获《刑法》和第 264 条的向量特征)。
-
-
安全防护(无脏数据前缀):
- 通过
if parts:机制,只有当成功提取到至少一个有效元数据字段时才会生成header。如果所有字段皆为空,不会插入类似[]的无效空白标头,保障了文本的整洁。
- 通过
二、 核心语法解析
1. 字典安全取值 (dict.get)
law_name = parent_metadata.get("law_name", "")
-
语法拆解:
parent_metadata.get("key", default_value):从字典中获取"key"的值。如果"key"不存在,不会抛出KeyError异常,而是安全返回默认值""(空字符串)。
-
教学点 :处理外部数据或非强制字段时,永远优先使用
.get()而不是方括号dict["key"]。这能极大地提升代码在面对缺失数据时的健壮性。
2. 条件化列表构建 (Conditional List Building)
parts = []
if law_name:
parts.append(f"法律名称: {law_name}")
if chapter:
parts.append(f"章节: {chapter}")
# ...
-
语法拆解 :利用 Python 隐式布尔值转换(Implicit Booleans),空字符串
""会被评估为False,只有非空字符串才会触发parts.append(...)。 -
教学点:这种"先定义列表,再根据条件 append"的模式,是构建动态拼接文本的标准写法。
3. 高效字符串连接 (str.join)
header = "[" + " | ".join(parts) + "]\n"
-
语法拆解:
-
" | ".join(parts):使用指定的分隔符" | ",将列表parts中的所有字符串元素连接成一个长字符串。 -
例如,当
parts = ["法律名称: 刑法", "条号: 第264条"]时,.join()结果为"法律名称: 刑法 | 条号: 第264条"。
-
-
教学点 :千万不要在循环里用
+频繁拼接字符串! 在 Python 中,+拼接字符串会频繁创建新的内存对象(时间复杂度 O(n\^2)),而str.join()是由 C 语言底层优化的,一次性分配内存(时间复杂度 O(n)),性能显著更高。
4. 对象属性原地修改 (In-Place Property Mutation)
chunk.page_content = header + chunk.page_content
-
语法拆解 :直接重新给 LangChain
Document对象的page_content属性赋值,实现对象的"原地更新(In-Place Modification)"。 -
教学点 :由于 Python 中对象传递的是引用(Reference) ,函数内部修改了
chunk.page_content后,外层的enriched_chunks列表里的对象内容也会同步更新,无需重新创建新的Document对象。
_batch_add_documents():分批写入向量库
这段函数构成了 RAG 系统的 "文档批处理与向量库写入引擎" 。其中 import_laws() 负责文件读取、切片与错误隔离,_batch_add_documents() 负责解决向量数据库(如 ChromaDB)的批处理限制与内存溢出问题。
一、 批量导入与分批写入逻辑
整个模块的核心设计哲学是 "容错隔离" 与 "批次控制(Batching Control)":
[传入文件目录 dir_path]
│
├── 1. 扫描匹配文件 (.txt / .md)
│
└── 2. 遍历处理单个文件
│
├── Read & Split (读取与切片)
│
├── 容错隔离 (try...except) ──> 单个文件损坏/报错仅打印 ✗,不中断整体循环
│
└── 3. 进入 _batch_add_documents 分批写入
│
└── range(0, len, 5000) 步长切片 ──> 按批次写入 store 向量库
-
向量数据库批次上限(Batch Size Overhead):
-
为什么需要
_batch_add_documents? 许多向量数据库(如 ChromaDB、Milvus 等)底层有 API 单次写入记录数的限制(例如 ChromaDB 单次请求最多包含约 5461 个向量,否则会直接抛出BatchSizeError)。 -
通过设置
batch_size=5000并使用列表切片分批写入,避免了内存溢出(OOM)和数据库请求超限。
-
-
错误隔离(Fault Isolation):
- 在
import_laws()的循环内部使用了try...except。如果某个法律文件存在编码错误或格式损坏,程序会捕获异常并打印✗ filename: error,继续处理下一个文件,保证了大批量文件导入时的系统稳定性。
- 在
二、 核心语法解析
1. 带步长的 range() 列表切片分批 (Batching with Step Range)
for i in range(0, len(chunks), batch_size):
store.add_documents(chunks[i:i + batch_size])
-
语法拆解:
-
range(start, stop, step):生成一个按step(步长)递增的整数序列。 -
假设
len(chunks) = 12000,batch_size = 5000,range(0, 12000, 5000)会产生三个i值:0、5000、10000。 -
chunks[i:i + batch_size]:利用列表切片提取当前批次的元素。-
第 1 次:
chunks[0:5000](前 5000 个) -
第 2 次:
chunks[5000:10000](中间 5000 个) -
第 3 次:
chunks[10000:15000](自动截断到末尾,取剩余 2000 个)
-
-
-
教学点 :利用
range(0, N, batch_size)+slice[i:i+batch_size]是 Python 中对数据进行分批处理(Batch Generation)最标准、最高效的写法。
import_cases_jsonl():往向量数据库种导入JSONL案例文件
import_cases_jsonl() 是法律 RAG 系统中用于大规模数据清洗和并发加载的 "流式 JSONL 案例解析与批处理写库引擎" 。与单纯读取 .txt 文件不同,真实场景中的司法数据集(如 CAIL 司法考试/预测数据集)通常以 JSON Lines (JSONL) 格式存储,包含了成千上万条结构化判决书。
一、 业务逻辑与流式控制
该函数的架构核心在于 "流式读取(Line-by-Line Streaming) + 脏数据三级过滤 + 双重缓存刷盘(Double-Buffer Flush)":
[扫描 JSON/JSONL 文件列表]
│
└── 遍历文件 ──> 双层嵌套 try...except 隔离错误
│
├── 1. 逐行流式读取 (Line-by-Line Read)
│ ├── 空行校验 (if not line)
│ ├── JSON 解析校验 (try json.loads) ──> 剔除损坏 JSON 行
│ └── 文本质量校验 (len(text) < 50) ──> 剔除无意义或噪音样本
│
├── 2. 文本切片与元数据追加
│ ├── split_legal_document() 生成分块
│ └── chunk.metadata.update(extra_meta) 批量注入罪名/刑期等特征
│
└── 3. 内存批次控制 (batch_chunks >= 200)
├── 满 200 块 -> 刷入向量库 -> 清空缓存池 -> 打印日志
└── 循环结束 -> 清算尾部剩余 chunk (收尾刷盘)
-
流式处理(Streaming)避免内存暴涨(OOM):
-
JSONL 文件往往高达数 GB,如果一次性
f.readlines()或json.load(f)加载,内存会瞬间爆满。 -
设计亮点 :代码采用
for line_no, line in enumerate(f, 1)遍历文件句柄,内存中同一时刻只保留当前行的字符串,对海量数据极其友好。
-
-
尾部清算(Tail Flush)机制:
- 在文件内循环结束后,必须包含
if batch_chunks:判断。如果最后一个批次只有 45 个 Chunk(未达到 200 个的写入阈值),如果不做尾部清算,这 45 个 Chunk 就会在内存中被丢弃,造成数据丢失。
- 在文件内循环结束后,必须包含
-
全局上限截断(Multi-file Global Limit):
case_count在最外层循环(文件间)累加,并在行级 和文件级 做了双重if case_count >= max_cases: break。确保达到阈值时不仅能立刻打断单文件的读取,还能跳过后续所有未处理的文件。
二、 核心语法解析
1. 带起始序号的枚举迭代 (enumerate(f, 1))
for line_no, line in enumerate(f, 1):
-
语法拆解:
-
enumerate(iterable, start=0):同时获取当前元素的索引和值。 -
f作为文件句柄本身就是可迭代对象(每次迭代返回一行)。 -
将
start设置为1,使得line_no直接对应文本文件中真实的行号(Line Number),方便在捕获解析错误时精准定位到具体第几行。
-
2. JSON 安全解析与专用异常捕获 (json.JSONDecodeError)
try:
obj = json.loads(line)
except json.JSONDecodeError:
continue
-
语法拆解:
-
json.loads(s):将 JSON 格式的字符串反序列化为 Python 字典/列表。 -
except json.JSONDecodeError:精准捕获 JSON 格式错误 (如截断的 JSON、引号未闭合等),而不是粗暴地使用 genericexcept Exception。这能确保语法错误的行被安全跳过,同时不会意外掩盖系统级中断信号(如KeyboardInterrupt)。
-
3. 字典的原地批量更新 (dict.update())
for chunk in chunks:
chunk.metadata.update(extra_meta)
-
语法拆解:
dict.update(other_dict):将other_dict中的所有键值对合并注入到当前字典中。如果键已存在则覆盖,不存在则新建。
-
教学点 :在对 LangChain
Document附加提取到的额外特征(如罪名penalty、相关法条articles等)时,使用update()可以一行代码完成多元数据的批量合并,极大地简化了代码逻辑。
4. 列表扩展与内存清空 (list.extend() & 重置)
batch_chunks.extend(chunks)
...
batch_chunks = []
-
语法拆解:
-
batch_chunks.extend(chunks):将chunks列表中的所有元素追加 到batch_chunks末尾(类似于解包拼接,注意区别于append(),append会将整个列表当成一个子元素插入)。 -
batch_chunks = []:将变量重新指向一个新的空列表,旧列表在写入数据库后会被 Python 的垃圾回收机制(GC)自动回收,释放内存空间。
-
_extract_case_text():从 JSON 对象中提取案例文本和额外元数据
_extract_case_text() 是数据清洗阶段的 "多源异构 JSON 数据适配器(Data Adapter)"。
在实际的司法 AI 项目中,训练集与测试集(例如中国法研杯 CAIL2018、CAIL2019-SCM 以及各种自定义格式)的数据结构往往各不相同。该函数通过策略适配模式,自动兼容多种异构 Schema,提取出统一的案情正文与结构化元数据(罪名、涉案法条、被告人、刑期等)。
一、 多格式匹配逻辑
该函数采用了 "降级匹配链(Fallback Chain)" 的架构设计:
[传入单个 JSON 对象 obj]
│
┌──────────────────────────┼──────────────────────────┐
▼ ▼ ▼
【策略 1:CAIL2018 格式】 【策略 2:CAIL2019 格式】 【策略 3:通用 JSON 格式】
判断依据: "fact" in obj 判断依据: "A" in obj 判断依据: ("text", "content", "body")
│ │ │
├─ 提取正文 obj["fact"] └─ 提取正文 obj["A"] └─ 循环按优先顺序匹配字段
└─ 提取/清洗 meta 子字典 │
(罪名/法条/被告/刑期) └─ 找到非空文本即返回
│ │ │
└──────────────────────────┼──────────────────────────┘
▼
[返回 (text, extra_meta)]
-
元数据扁平化归一化(Flattening & Normalization):
-
在向量数据库中(如 ChromaDB),
metadata字典通常不支持存入嵌套列表 (例如['盗窃罪', '抢劫罪'])。直接存入会导致数据库报错。 -
设计亮点 :代码将列表统一通过
";".join(...)转为以中文分号间隔的单层字符串,确保兼容性。
-
-
多源兼容与兜底(Schema Robustness):
- 某些预处理管道会提前将
meta中的accusation提至顶层(扁平格式)。代码中if "accusation" in obj and not extra_meta.get("accusation")巧妙解决了"嵌套结构 vs 顶层结构"的数据异构冲突。
- 某些预处理管道会提前将
-
安全数据元组返回 (
tuple[str, dict]):- 函数统一保证返回类型为
(正文字符串, 元数据字典)二元组。若所有字段均不匹配,返回("", {}),方便上层调用方直接使用text, meta = _extract_case_text(obj)进行解包,不会发生类型崩溃。
- 函数统一保证返回类型为
二、 核心语法解析
1. 列表推导式与生成器表达式 (Generator Expression)
extra_meta["relevant_articles"] = ";".join(
str(a) for a in meta["relevant_articles"]
)
-
语法拆解:
-
str(a) for a in meta["relevant_articles"]是一个生成器表达式(Generator Expression)。 -
meta["relevant_articles"]可能是数字列表(如[264, 267]),而";".join()强制要求传入字符串序列。 -
生成器表达式无需在内存中一次性创建新的列表(比列表推导式
[str(a) for ...]更节省内存),直接边遍历转为字符串,边交由.join()进行拼接。
-
2. 多条件元组遍历查找 (Tuple Iteration for Fallback Keys)
for key in ("text", "content", "body"):
if key in obj and obj[key]:
return obj[key], extra_meta
-
语法拆解:
-
使用元组
("text", "content", "body")维护一个优先级有序列表。 -
if key in obj and obj[key]包含两重安全校验:-
key in obj:确保字典中存在该键(避免KeyError)。 -
obj[key]:利用 Python 的短路求值(Short-circuiting),确保该键对应的值非空 (非""或None)。
-
-
3. 链式安全取值与默认值机制 (dict.get())
meta = obj.get("meta", {})
if meta.get("accusation"):
# ...
-
语法拆解:
-
obj.get("meta", {}):若obj中不存在"meta"字段,安全返回空字典{},防止触发异常。 -
紧接着使用
meta.get("accusation"):即使meta是临时生成的{},也能安全返回None并让 if 判断为假,提升了代码容错率。
-
split_legal_document(): 对每一个文档进行分块(同上)
步骤1、LegalCaseSplitter():案例文档分块器(同上)
python
class LegalCaseSplitter(RecursiveCharacterTextSplitter):
"""指导案例分块器 --- 按案例结构切分"""
def __init__(self, chunk_size: int = 1024, chunk_overlap: int = 128, **kwargs):
# 指导案例的结构分隔符
separators = [
... ...
]
super().__init__(
separators=separators,
chunk_size=chunk_size,
chunk_overlap=chunk_overlap,
is_separator_regex=True,
keep_separator=True,
**kwargs,
)
步骤2、extract_case_metadata():从案例文本中提取元数据(同上)
获取到metadata字典如下:
python
{
'doc_type': 'law',
'source_file': '中华人民共和国城市维护建设税法.txt',
'law_name': '中华人民共和国城市维护建设税法',
'effective_date': '2021年9月1日'
}
步骤3、chunks = splitter.create_documents(text) # 分块
例如:该文本被(分块器)分成3块,分段后的文本放在page_content字符串属性中,此时的metadata字典属性为空。

步骤4、enrich_chunk_metadata():为每个分块添加元数据(同上)
元数据增强:为每个块 补充章节/条号/段落类型等信息,增强完后chunk.metadata字典如下:
python
{
'doc_type': 'law',
'source_file': '中华人民共和国城市维护建设税法.txt',
'law_name': '中华人民共和国城市维护建设税法',
'effective_date': '2021年9月1日',
'chunk_index': 0,
'article_number': '一'
... ...
}
步骤5、add_contextual_header():为每个分块添加上下文标头(同上)
为每个块的内容添加结构化上下文标头,提升 Embedding 质量。
在 chunk 内容前插入 法律名称: X \| 章节: Y \| 条号: 第Z条 形式的标头。添加完标头后,chunk.page_content字符串如下:
python
'[法律名称: 中华人民共和国城市维护建设税法 | 条号: 第十一条]\n《中华人民共和国城市维护建设税法》第十一条规定,本法自2021年9月1日起施行。1985年2月8日国务院发布的《中华人民共和国城市维护建设税暂行条例》同时废止。'
步骤6、_batch_add_documents():分批写入向量库
python
def _batch_add_documents(store, chunks, batch_size: int = 5000):
"""分批添加文档,避免超出 ChromaDB 单批限制"""
# 按固定步长切片分批写入,防止一次写入过多导致内存/批次超限
for i in range(0, len(chunks), batch_size):
store.add_documents(chunks[i:i + batch_size])
二、直连 pipeline,不经过 HTTP(学习管线更快)
为了调试pipeline整个流程的逻辑实现,每次起服务+发请求太重。我写好了 scripts/debug_one_query.py,它直接构造 PipelineConfig 调 RAGPipeline.execute(),和 run_integration_test.py 同款跑法,但没有服务进程。
在launch.json中配置好launch_debugpy启动信息,打断点进行调试如下:
debug_one_query.py单条管线调试脚本
语法分析:
-
if __name__ == "__main__"::-
Python 内置变量
__name__:当直接运行该文件时,__name__的值为"__main__";当该文件被其他文件import时,__name__为文件名。 -
作用:保证该调试逻辑只有在"直接运行当前脚本"时才触发。
-
-
asyncio.run(main()):- 启动异步事件循环(Event Loop) 。Python 无法直接运行
async def定义的顶层函数,必须借助asyncio.run()建立异步环境并运行main()协程。
- 启动异步事件循环(Event Loop) 。Python 无法直接运行
异步事件循环的基础知识点复习请见附录2
get_llm():单例/缓存模式来管理 Ollama 的 LLM 实例
get_llm() 函数是一个非常典型的基于内存字典的轻量级享元/单例模式(Flyweight / Cache Pattern)实现。它的核心目的在于:按需创建并复用大模型(LLM)客户端实例,避免在高频调用或并发请求中重复建立 HTTP 连接与对象初始化开销。
一、 代码核心逻辑梳理
调用 get_llm(model, temperature)
│
┌───────────────┴───────────────┐
▼ ▼
计算模型名称 _model 计算温度参数 _temp
(优先用入参,无则用默认) (精准判断 None,保留 0.0)
└───────────────┬───────────────┘
│
▼
生成元组 cache_key = (_model, _temp)
│
cache_key 是否在 _llm_cache 中?
/ \
[否] [是]
│ │
▼ │
实例化 ChatOllama 对象 │
写入 _llm_cache[cache_key] │
│ │
└─────────┬─────────┘
│
▼
返回缓存中的 ChatOllama 实例
-
参数解析与默认值兜底 :首先对输入的
model和temperature进行校验。如果调用方没有显式传参,则自动读取全局配置settings中的默认参数。 -
组合唯一缓存键 :把确定好的
_model和_temp组合成一个不可变元组cache_key,以此代表一种特定的 LLM 配置。 -
缓存命中判断与延迟加载(Lazy Initialization):
-
未命中 :如果
_llm_cache字典里还没有对应的cache_key,才真正实例化ChatOllama,并填入 Ollama 服务地址(base_url)和上下文长度(num_ctx)等固定配置,存入字典。 -
命中:直接跳过创建步骤。
-
-
返回复用实例:直接返回字典中已有的对象。
二、 核心语法详解
1. 类型注解与联合类型语法 (str | None)
-
语法点 :在 Python 3.10+ 中,可以使用
|运算符简化联合类型声明。 -
解析 :
model: str | None = None表示model参数可以接收str类型或None类型,且默认值为None。这在旧版本 Python 中通常写为Optional[str]或Union[str, None]。 -
返回值注解 :
-> ChatOllama明确告知调用方和 IDE 该函数最终返回一个ChatOllama类的对象,便于代码自动补全与静态检查。
2. 参数兜底机制的区别(逻辑运算符 or vs 显式判断 is not None)
这是该函数中最体现 Python 经验细节的地方:
-
模型名称兜底 :
_model = model or settings.LLM_MODEL- 利用了 Python 的短路求值(Short-circuiting) 。若
model为None或空字符串""(假值/Falsy),表达式会直接取右侧的settings.LLM_MODEL。
- 利用了 Python 的短路求值(Short-circuiting) 。若
-
温度参数兜底 :
_temp = temperature if temperature is not None else settings.LLM_TEMPERATURE-
为什么不能写成
temperature or settings...? -
因为在 Python 中,数值
0.0会被判定为False。在法律或严谨问答场景下,经常需要把temperature显式设为0.0(以获取最高确定性的回答)。如果写成or,传入0.0时会被误判为假值,从而强制退回到默认温度(例如0.7)。使用is not None可以精准识别0.0。
-
3. 不可变元组作为字典键 (cache_key = (_model, _temp))
-
语法点 :Python 字典的键(Key)必须是可哈希的(Hashable) ,这意味着它必须是不可变对象(如
int,str,tuple等),而不能是列表(list)或字典(dict)。 -
应用 :将字符串
_model和浮点数_temp封包为二元组(_model, _temp),既保持了不可变性,又能完美作为组合主键,区分不同配置下的模型实例。
4. 字典查找语法 (cache_key not in _llm_cache)
-
语法点 :成员运算符
in/not in在 Python 字典上的时间复杂度为 O(1)(基于哈希表查找)。 -
效果 :每次调用
get_llm()时,判断缓存是否存在的速度极快,几乎不产生性能损耗。
get_embeddings():单例/缓存模式来管理Ollama的 Embedding实例
get_embeddings() 函数的设计与前面的 get_llm() 一脉相承,也是一个基于字典缓存的轻量级单例/享元模式(Flyweight Pattern)实现。
它的主要任务是:按模型名称管理和复用 OllamaEmbeddings 文本向量化实例,避免重复创建 HTTP 客户端连接以及加载模型元数据的开销。
一、 代码逻辑梳理
调用 get_embeddings(model)
│
▼
确定向量模型名称 _model
(优先使用入参,为 None 时回退至全局配置)
│
▼
检查 _model 是否在 _embed_cache 中
/ \
[否] [是]
│ │
▼ │
实例化 OllamaEmbeddings │
存入 _embed_cache[_model] │
│ │
└────────────┬──────────────┘
│
▼
返回缓存中的 Embeddings 实例
-
缺省模型获取 :检查入参
model。若调用时没有传参,则自动读取settings.EMBEDDING_MODEL(如默认的bge-m3)。 -
字典缓存检索 :以字符串
_model为键,在全局字典_embed_cache中查找对应的向量模型对象。 -
延迟初始化(Lazy Initialization):
-
未命中 :实例化
OllamaEmbeddings,配置模型名称与Ollama服务基准地址(base_url),并写入_embed_cache。 -
命中:跳过创建流程。
-
-
返回对象 :直接返回可进行文本向量化操作(如
.embed_query())的实例。
二、 重点语法详解
1. 带泛型的类型注解:dict[str, OllamaEmbeddings](PEP 585)
-
语法点 :Python 3.9+ 支持直接使用内置类型(如
dict,list,tuple)加上方括号进行泛型标注,不再需要引入typing.Dict。 -
解析 :
_embed_cache: dict[str, OllamaEmbeddings]表示这个字典的 键(Key)必须是str,值(Value)必须是OllamaEmbeddings实例。 -
作用:提供极其清晰的代码自文档化,并在 IDE(如 VSCode)中实现精准的类型补全与静态错误提醒。
2. 短路逻辑求值:_model = model or settings.EMBEDDING_MODEL
-
原理 :
or运算符从左到右计算。如果左侧的model是"真值"(非空字符串),直接返回model;如果是"假值"(None或""),则计算并返回右侧的默认配置。 -
为什么这里可以用
or而不用is not None?- 与
temperature不同,模型名称(model)在实际业务中必须是非空字符串,不存在像数值0.0这种既是"假值"又是"合法有效输入"的边界情况,因此直接使用or既简洁又安全。
- 与
3. 简化版缓存主键(单参数 vs 组合元组)
-
与
get_llm的对比:-
get_llm需要同时区分模型名称和温度配置,所以使用了元组(_model, _temp)做主键。 -
get_embeddings只取决于模型名称_model本身(Embedding 操作通常没有随机温度调节),因此直接用纯字符串_model作为字典的 Key 即可。
-
创建RAGPipeline实例对象(RAGPipeline.__init__方法)
python
class RAGPipeline:
"""可插拔 RAG 管线,按配置调度各阶段策略"""
def __init__(self, config: PipelineConfig):
self.config = config
self.metrics: dict = {
"query_rewrite_ms": None,
"retrieval_ms": 0,
"rerank_ms": None,
"generation_ms": 0,
"total_ms": 0,
"kg_lookup_ms": None,
"self_reflect_ms": None,
"was_corrected": False,
"llm_calls": 0,
"llm_calls_saved": 0,
}
pipe.execute():整个RAG管道执行流程
这段代码是 RAG(检索增强生成)系统的核心调度引擎 RAGPipeline.execute()。它将复杂的 RAG 管线标准化为了 查询变换 → 检索与知识图谱融合 → 重排序 → 策略化生成 四大主阶段,同时内置了精准的性能监控与 LLM 调用次数埋点。
一、 代码逻辑梳理
整个 execute() 函数采用典型的 管线模式(Pipeline Pattern) 串联执行:
用户输入 question
│
┌─────────────────────────────────┴─────────────────────────────────┐
▼ Stage 1: 查询变换 (_query_transform) │
├─ NONE: 保持原样 │
├─ MULTI_QUERY: 多路重写 │
├─ HYDE: 生成假设性法律文档 │
├─ DECOMPOSE: 复杂问题拆解 │
└─ MULTI_QUERY_HYDE: 并行(asyncio.gather)同时运行重写与HyDE │
│ │
▼ │
┌─────────────────────────────────┴─────────────────────────────────┐
▼ Stage 2 & 2.5: 混合检索与图谱融合 (_retrieve & _kg_lookup) │
├─ 检索向量数据库与 BM25 (拆分查询/常规多查询) │
├─ (可选) 抽取罪名实体并检索 KG (知识图谱) │
└─ 内存哈希去重:以 content[:200] 作为唯一 Key 防重 │
│ │
▼ │
┌─────────────────────────────────┴─────────────────────────────────┐
▼ Stage 3: 文档重排序 (_rerank) │
├─ SIMPLE: 轻量级评分/规则精排 │
├─ LLM: 大模型语义重排序 │
└─ 截取 Top-K 核心上下文文档 │
│ │
▼ │
┌─────────────────────────────────┴─────────────────────────────────┐
▼ Stage 4: 策略化生成 (_generate) │
├─ 拼装 Context 并匹配 Prompt (标准 / CoT链式思考 / 结构化) │
├─ 组装 LCEL 链: prompt | llm 异步生成 │
└─ (可选) SELF_REFLECT: 触发"自我反思与修正"机制 │
│ │
▼ │
封装 ChatResponse 返回
二、 核心 Python 语法与 LangChain 高级特性
1. 高级异步并发:asyncio.create_task 与 asyncio.gather
在 MULTI_QUERY_HYDE 策略中,为了避免串行等待耗时,采用了 Python 标准库的高性能并发写法:
python
mq_task = asyncio.create_task(multi_query_rewrite(question))
hyde_task = asyncio.create_task(hyde_transform(question))
mq, (_, hypo) = await asyncio.gather(mq_task, hyde_task)
-
create_task:将协程包装为 Task 并直接投递给事件循环(Event Loop)开始后台调度。 -
asyncio.gather:并行等待多个异步任务完成。这里使用了 解构赋值(Unpacking) :hyde_transform返回一个元组(original, hypo),用(_, hypo)只提取第二个返回值(假设文档),丢弃第一个。
高级异步并发详细讲解请见附录7
2. LangChain 表达式语言 (LCEL) 管道运算符 |
在 _generate 函数中:
chain = prompt | llm
response = await chain.ainvoke({"context": context, "question": question})
-
|运算符 :LangChain 重载了 Python 的按位或运算符|,构建了一个 RunnableChain 。前者的输出(格式化后的 Prompt)会自动作为后者的输入传给llm。 -
ainvoke:异步调用链,避免阻塞主线程。
3. 基于哈希切片的快速去重策略
key = hash(doc.page_content[:200])
if key not in seen_contents:
seen_contents.add(key)
all_docs.append(doc)
-
原理 :
Document对象是不可变或复杂的类实例,直接比较效率低。截取前 200 个字符进行hash()运算,能以接近 O(1) 的时间复杂度快速判断文档内容是否重复。 -
细节 :在 Stage 2.5 中,命中 KG 时使用
all_docs.insert(0, kd),确保知识图谱导出的精确图谱事实排在最前面,作为最高优先级上下文提供给 LLM。
哈希指纹 + set 查重请见附录8
4. 延迟局部导入(Lazy Import)
代码中多次在函数内部使用 from ... import ...:
# 例如在 _kg_lookup 中
from app.services.kg_service import extract_crime_entities, kg_lookup
-
作用:
-
加快启动速度:避免在模块初始化时就加载耗时较长的依赖库(如重型图算法、NLP 模型)。
-
避免循环依赖(Circular Import) :当多个服务之间存在交叉引用时,将
import延迟到函数执行时触发可彻底避免此问题。
-
5. 解包运算符与字典动态构建:StageMetrics(**self.metrics)
在生成返回值时:
python
metrics=StageMetrics(**self.metrics)
- 语法点 :
**为字典解包运算符。它会将self.metrics字典中的键值对拉平,并以关键字参数的形式传给StageMetricsPydantic 数据模型进行类型校验与实例化。
字典解包运算符详解请见附录9
Stage 1: _query_transform():查询变换(多种变换策略可选)
_query_transform() 函数是 RAG 管线第一阶段的策略路由中心,主要负责将用户原始问题改写或扩展为更利于检索的形式,同时记录该阶段的耗时指标。
一、 函数代码逻辑梳理
python
输入 question & 读取配置 strategy
│
strategy == QueryTransformStrategy.NONE ?
├── [是] ──► 快速返回 ([question], None, None)
└── [否] ──► 开启计时器 t0 = time.time()
│
▼
匹配具体的查询变换策略分支(4选1)
┌────────────────────┼────────────────────┬────────────────────┐
▼ ▼ ▼ ▼
MULTI_QUERY HYDE DECOMPOSE MULTI_QUERY_HYDE
调用重写函数 生成假设性文档 拆解复杂子问题 创建 asyncio.Task
扩展多条相似查询 用于语义向量匹配 用于分步检索 并发(gather)同时运行
│ │ │ │
└────────────────────┼────────────────────┴────────────────────┘
│
▼
计算耗时并记录: self.metrics["query_rewrite_ms"]
│
▼
返回元组 (search_queries, hyde_doc, rewritten_queries)
-
零开销快速退出(Short-circuiting) :优先检查策略是否为
NONE。如果是,直接返回原问题,跳过后续所有初始化和耗时统计。 -
策略分发(Strategy Pattern):
-
MULTI_QUERY:调用大模型将单句提问泛化改写为多角度同义问题,提升检索召回率。 -
HYDE(假设性文档嵌入):让大模型先盲写一份"假想答案",后续用这份假想答案去向量库查真实文献。 -
DECOMPOSE:把多条件或复杂的逻辑问题拆解为多个独立子问题。 -
MULTI_QUERY_HYDE:融合策略,利用异步并发同时触发MULTI_QUERY与HYDE。
-
-
指标埋点与归一化输出 :计算该阶段总毫秒数写入
metrics,统一返回标准的 3 元组数据。
二、 重点语法与设计细节详解
1. 复杂元组类型注解 tuple[list[str], str | None, list[str] | None]
-
语法点 :Python 3.9+ 可以在
tuple[...]中直接指定元组内每一个位置的具体类型。 -
解析:
-
第 1 项
list[str]:实际用于检索的查询列表(必有值,至少包含原问题)。 -
第 2 项
str | None:HyDE 生成的假设文档(可能为空)。 -
第 3 项
list[str] | None:被重写或拆解出的子查询列表,用于日志打印或前端展示(可能为空)。
-
2. asyncio.create_task + asyncio.gather 高效并发
在 MULTI_QUERY_HYDE 分支中,同时需要调两次大模型(一次重写,一次生成假设文档)。如果使用 await 串行执行,耗时会叠加;采用 asyncio 并发可以将耗时缩短为两者的最大值:
# 1. 立即将协程包装为 Task,交由事件循环并发调度
mq_task = asyncio.create_task(multi_query_rewrite(question))
hyde_task = asyncio.create_task(hyde_transform(question))
# 2. 挂起等待两个任务全部完成,并解包结果
mq, (_, hypo) = await asyncio.gather(mq_task, hyde_task)
3. 模式匹配与变量解构(Unpacking)
在接收 gather 返回值时,使用了深层解构语法:
mq, (_, hypo) = await asyncio.gather(mq_task, hyde_task)
-
原理 :
hyde_transform的返回值是一个二元组(original_query, hypothetical_doc)。 -
解构 :
(_, hypo)中,使用下划线_显式占位并丢弃不需要的original_query,直接提取目标字符串hypo给hyde_doc。
4. 函数级延迟导入(Lazy Import)
在 HYDE 和 DECOMPOSE 分支内部可以看到 from app.services... import ... 的局部导入写法。
- 作用:只有在配置确实开启了对应策略时才去加载相关模块,有效降低了启动内存开销,避免因未开启某些依赖服务而导致整体报错。
hyde_transform():生成假设性法律文档
其实就是把原问题直接问LLM,返回AIMessage回答。
hyde_transform() 是 HyDE(Hypothetical Document Embeddings,假设性文档嵌入)策略的执行函数。它的核心逻辑是:通过 LLM 先生成一份"假想的完美法律回答/文档",后续利用这份假想文档去向量库检索真实的法律条文与案例。
同时,该函数内置了兜底防错与质量过滤机制,即使大模型生成失败或生成内容过短,也不会导致整条 RAG 管线崩溃。
一、 函数代码逻辑梳理
输入 question (原始问题)
│
▼
配置并获取 LLM (使用 HYDE_TEMPERATURE)
│
▼
构造 LCEL 链:HYDE_PROMPT | llm
│
▼
异步调用 chain.ainvoke({"question": question})
│
是否有 Exception 抛出?
/ \
[是] [否]
│ │
▼ ▼
捕获异常,回退 去除首尾空白: hypothetical_doc
返回 (question, None) │
▼
hypothetical_doc 长度 < 10?
/ \
[是] [否]
│ │
▼ ▼
过滤无效生成, 返回有效假设文档:
返回 (question, None) (question, hypothetical_doc)
-
特定温度的 LLM 示例获取 :调用
get_llm(temperature=settings.HYDE_TEMPERATURE)。HyDE 通常需要模型具有一定的创造力来生成假设文档,因此其温度(Temperature)设置一般略高于严格问答时的温度。 -
异步链式调用 :利用 LCEL 表达式
HYDE_PROMPT | llm将 Prompt 模板与大模型绑定,并使用await chain.ainvoke(...)异步生成假设文档。 -
输出清洗与质量校验:
-
.strip()去除生成文本前后的空格和换行符。 -
校验长度
len(hypothetical_doc) < 10:若生成内容太短(如模型只回复了"不知道"或输出异常空文本),判定为无效生成,回退为None。
-
-
防御性编程(异常兜底) :整个流程包裹在
try...except块中。如果网络超时、Ollama 崩溃或 API 报错,直接捕获异常并返回(question, None),保证主 RAG 流程能回退到常规检索,不影响用户正常使用。
二、 重点语法与细节详解
1. 简写类型注解与元组返回 tuple[str, str | None]
- 语法点 :定义了函数返回一个二元组。第一个元素永远是原始问题
str,第二个元素可能是生成的假设文档字符串str,也可能是失败/过滤时的None。
2. try...except 容错控制与 Graceful Degradation(优雅降级)
try:
...
return question, hypothetical_doc
except Exception:
return question, None
- 设计模式 :这是生产环境 RAG 系统中极重要的降级设计 。RAG 管线中的某些高级策略(如 HyDE、Query Rewrite)属于"锦上添花"组件,不能因为这些额外 LLM 调用的失败而阻塞核心问答流程。捕获所有异常并返回
None,下游的_retrieve函数就会自动降级为传统 BM25/向量检索。
3. 边界值过滤(len(hypothetical_doc) < 10)
- 原理 :防止"垃圾数据污染向量检索"。如果 LLM 生成了极短的无意义文本(如
"OK"、"好的"),将这种文本转为 Embedding 去向量库检索会严重破坏检索精度。设置阈值丢弃这些低质量输出,是保证向量检索质量的小技巧。
4. 显式温度控制(temperature=settings.HYDE_TEMPERATURE)
-
与之前
get_llm的联动 :还记得之前分析的get_llm()函数吗?cache_key = (_model, _temp)在这里,当传入
HYDE_TEMPERATURE(例如0.7)时,get_llm会自动生成(model, 0.7)的缓存 Key,并复用或创建专门对应该温度的ChatOllama实例,不会干扰默认生成策略使用的低温度(如0.0或0.1)模型实例。
其余4种策略逐个拆解
_query_transform是 RAG 管线的 Stage 1(检索前处理) :在真正去向量库检索之前,先把用户的原始问题加工成更好检索的形式------可以是多个查询、一个"假设文档"、或拆成的子问题。
它回答了上一轮的问题里隐含的一个矛盾:用户的原始问题往往不适合直接拿去检索。
第一步:为什么要"先做查询变换"?
RAG 的黄金法则是 "垃圾进,垃圾出"(GIGO):检索到的文档质量决定答案质量。拿用户原话直接检索,有三个典型痛点:
痛点 1:口语化 → 检索词不匹配 用户问"偷东西判几年",但库里条文写的是"盗窃罪"。BM25 按字面匹配,"偷东西"和"盗窃"一个字都对不上;向量检索也未必把这两个词拉近。
痛点 2:一个复杂问题拆开才搜得全 "打人抢东西还欠钱不还,怎么判?"------这其实涉及故意伤害、抢劫、民间借贷三个领域。一次检索只能命中一部分,别的部分就漏了。
痛点 3:短问题对向量检索不友好 向量检索用 embedding 算相似度。用户问题往往只有一句话(几十个字),而库里的文档是几百上千字的正式文本。"问句"和"文档"在向量空间里离得远------你拿一个短问句去匹配长文档,语义距离天然偏大。
所以查询变换的目的就一个:把用户的问题,翻译成"检索系统更擅长匹配"的形式,提高召回率(recall)。
第二步:函数的骨架------三个返回值
看签名:
async def _query_transform(self, question: str) -> tuple[list[str], str | None, list[str] | None]:
"""返回 (search_queries, hyde_doc_or_none, rewritten_queries_or_none)"""
不管选哪种策略,最后都返回一个三元组,三个东西各有分工:
| 返回值 | 类型 | 干什么用 |
|---|---|---|
search_queries |
list[str] |
真正拿去检索的查询列表(核心产出) |
hyde_doc |
`str | None` |
rewritten_queries |
`list[str] | None` |
而策略来自配置 self.config.query_transform,枚举定义在 pipeline.py:25-31,共 5 种。下面逐个拆。
第三步:5 种策略逐个拆解
策略 0:NONE(不做变换)
if strategy == QueryTransformStrategy.NONE:
return [question], None, None # 无变换:仅原问题
- 什么都不做,原问题直接拿去检索。
- 零 LLM 调用、零延迟 。这是系统的"默认基线",也是拿来和花里胡哨策略做效果对比的对照组------看看变换到底值不值那点延迟和 token 钱。
策略 1:MULTI_QUERY(多查询重写)⭐ 最常用
elif strategy == QueryTransformStrategy.MULTI_QUERY:
search_queries = await multi_query_rewrite(question)
rewritten_queries = search_queries
思想 :一次检索只从一个角度找。让 LLM 把一个问题改写成多个不同角度的查询,每个角度检索一遍,把结果合并,覆盖更全。
具体执行 (query_rewriter.py:63-79):拿下面的 prompt 让 LLM 生成 3 个查询:
① 第一个查询侧重法律条文检索,使用标准法律术语 ② 第二个查询侧重司法解释或指导案例 ③ 第三个查询使用不同的术语或角度表述
然后代码里还有几步"兜底处理":
- 过滤空行、过短的行(
len(q) > 4) - 始终把原问题插到最前面 (
queries.insert(0, question))------因为 LLM 可能改得不准,原问题最可靠 - 最多取 4 个(
queries[:4])
效果示意:用户问"偷东西判几年?" →
[ "偷东西判几年", ← 原问题
"盗窃罪的法律规定及量刑标准", ← 标准术语条文角度
"最高人民法院关于盗窃罪的司法解释", ← 司法解释角度
"盗窃罪的构成要件与从重处罚情形" ] ← 不同术语角度
每条查询拿去 retriever.invoke(q) 分别检索,再按内容哈希去重合并(pipeline.py:231-238)。
💡 细心的你可能会问:
query_rewriter.py里还有个LEGAL_TERM_MAP(query_rewriter.py:23-45)是干嘛的?那是字典式 的术语规范化("偷东西"→"盗窃"),零 LLM 开销。但multi_query_rewrite里没有调用它 ------这是个设计上可以优化的点(字典匹配快又准,完全可以先用字典规范化,再让 LLM 多角度扩展)。这里 LLM 走的是另一个_REWRITE_PROMPT(query_rewriter.py:9-18)。
策略 2:HYDE(假设文档)------ 解决痛点 3
elif strategy == QueryTransformStrategy.HYDE:
original, hypo = await hyde_transform(question)
search_queries = [original] # 原始查询 → BM25 检索
hyde_doc = hypo # 假设文档 → 向量检索
HyDE = Hypothetical Document Embeddings(假设性文档嵌入),这是 2022 年的一篇经典论文提出的技巧。
核心思想 :既然"问句和文档在向量空间离得远",那就让 LLM 先凭空写一段"假设的答案文档",然后用这段假设文档(而不是问题)去做向量检索。
为什么这招有效?因为:
向量相似度 = f(问题的embedding, 文档的embedding) ← 问句 vs 长文档,天然远
向量相似度 = f(假设文档的embedding, 真实文档的embedding) ← 文档 vs 文档,体裁一致,近
假设文档虽然内容可能不准,但它的**"形状"(体裁、句式、用词风格)**和真实文档一致,在 embedding 空间里更容易贴到相关内容。
具体执行 (hyde.py:11-27 + prompts.py:76-84):让 LLM 扮演"法律文献数据库",针对问题生成一段可能包含答案 的条文/案例摘要,要求含法条编号、200 字以内、"不需要完全准确"------因为它的定位是检索工具,不是最终答案。
关键:这里做了"分离式检索" 。看注释和 pipeline.py:219-224:
docs = retriever.search_with_split_queries(
bm25_query=search_queries[0], # BM25 用原始问题(字面精确匹配)
vector_query=hyde_doc, # 向量 用假设文档(语义匹配)
)
两条通道各用各的查询:
- BM25 用原始问题------假设文档里有编造的法条编号,拿去 BM25 字面匹配反而会误导(库里的真编号和编的不一样)
- 向量用假设文档------正好发挥"文档对文档"的语义优势
这就是 HyDE 的精髓:假设文档不是用来给用户的,是用来骗向量检索的。
策略 3:DECOMPOSE(查询分解)------ 解决痛点 2
elif strategy == QueryTransformStrategy.DECOMPOSE:
sub_qs = await decompose_query(question)
search_queries = sub_qs
rewritten_queries = sub_qs
思想 :一个复杂问题一次检索找不全,那就拆成几个子问题分别检索,最后合并。
具体执行 (query_rewriter.py:82-98 + prompts.py:123-132):prompt 要求拆成 2-4 个子问题,每个子问题关注不同方面------适用法律、构成要件、法律后果、术语规范化。
效果示意:"借了高利贷还不上,被暴力催收怎么办?" →
[ 原问题,
"网络借贷与高利贷的利率上限规定是什么", ← 适用法律
"暴力催收行为构成什么罪名", ← 构成要件
"欠款人如何主张权益和赔偿" ] ← 法律后果
和 MULTI_QUERY 的区别:MULTI_QUERY 是"同一个问题换角度问",DECOMPOSE 是"把一个问题拆成几个不同的问题"。前者召回面广,后者专门针对复合型问题。
策略 4:MULTI_QUERY_HYDE(组合策略)------ 全都要
elif strategy == QueryTransformStrategy.MULTI_QUERY_HYDE:
import asyncio
# 并行执行多查询重写和 HyDE 生成,降低整体延迟
mq_task = asyncio.create_task(multi_query_rewrite(question))
hyde_task = asyncio.create_task(hyde_transform(question))
mq, (_, hypo) = await asyncio.gather(mq_task, hyde_task)
search_queries = mq # 多查询:BM25 + 向量 都用
hyde_doc = hypo # 假设文档:向量 再用一次
rewritten_queries = mq
思想:MULTI_QUERY(召回面广)和 HYDE(语义准)的好处都要。
技术亮点:asyncio 并行 。两个都是独立的 LLM 调用,如果串行写就是 await a() 再 await b(),总耗时 = 两个 LLM 时间相加。这里用 asyncio.create_task 把两个协程同时 丢出去,asyncio.gather 等它们都完成,总耗时 ≈ 最慢的那个。代价是一次用两次 LLM 调用(_count_transform_calls 里计 2,见 pipeline.py:159-166)。
第四步:变换的结果怎么被下游用
看 pipeline.py:98-103:
# Stage 1: 查询变换
search_queries, hyde_doc, rewritten_queries = await self._query_transform(question)
# Stage 2: 检索(纯检索,零 LLM 调用)
all_docs = await self._retrieve(search_queries, hyde_doc)
在 _retrieve(pipeline.py:211-241)里:
- 若有
hyde_doc→ 先做一次分离式检索(BM25 用原问题,向量用假设文档) - 再对
search_queries里每个查询 都invoke一遍 - 所有结果按
hash(doc.page_content[:200])去重合并
而 rewritten_queries 则一路带到最后,装进 ChatResponse(pipeline.py:151)返回给前端展示------让用户看到"系统把我这句话理解成了哪几个问题",很贴心。
总结表格:怎么选
| 策略 | LLM 调用 | 检索次数 | 核心原理 | 解决哪个痛点 | 适用场景 |
|---|---|---|---|---|---|
NONE |
0 | 1 | 原样检索 | --- | 基线对比 / 追求最低延迟 |
MULTI_QUERY |
1 | ~4 | 一个问题 → 多个角度查询 | 痛点 1、2 | 通用,召回率优先 |
HYDE |
1 | 1 | 生成假设文档 → 向量检索 | 痛点 3 | 问题短、语义检索要更准 |
DECOMPOSE |
1 | ~5 | 复杂问题 → 多个子问题 | 痛点 2 | 复合型多领域问题 |
MULTI_QUERY_HYDE |
2(并行) | ~4+1 | 前两者的组合,asyncio 并行 | 1+2+3 | 要求最高,能承受延迟 |
一句话收尾:查询变换 = 在"花钱"(LLM 调用)和"提升召回"之间做权衡的一层。每次变换本质都是"调用一次 LLM,把用户的一句话翻译成检索系统更擅长吃的饲料"。
想动手验证的话,可以改 PipelineConfig(query_transform=QueryTransformStrategy.MULTI_QUERY) 跑一遍,对比 query_rewrite_ms 耗时和返回的 rewritten_queries,会很有体感。需要我帮你找调用 RAGPipeline 的入口在哪、怎么写这个对比实验吗?
Stage 2: _retrieve():混合检索
_retrieve() 函数是 RAG 管线 Stage 2 的核心操作,负责执行混合检索(Hybrid Retrieval,即关键词检索 BM25 + 语义向量检索 Vector)。
该函数最具特色的设计在于支持 HyDE 分离式查询(Split Query),并利用文本哈希集合(Hash Set)将多路检索到的文档进行内存去重与合并。
一、 函数代码执行逻辑梳理
输入 search_queries & hyde_doc
│
▼
初始化检索器 & 计时器开始 (t0)
retriever = get_hybrid_retriever(...)
all_docs = [], seen_contents = set()
│
▼
hyde_doc 是否存在?
/ \
[是] [否]
│ │
▼ │
触发 HyDE 分离式检索: │
search_with_split_queries( │
bm25_query=search_queries[0], │
vector_query=hyde_doc │
) │
│ │
▼ │
对 HyDE 检索文档进行哈希去重 │
写入 all_docs 与 seen_contents │
│ │
└───────────────┬───────────────┘
│
▼
遍历 search_queries 中的每个 q
│
▼
执行混合检索: retriever.invoke(q)
│
▼
对多查询文档逐个计算哈希值 key
若 key not in seen_contents:
seen_contents.add(key)
all_docs.append(doc)
│
▼
计算并记录耗时: self.metrics["retrieval_ms"]
│
▼
返回 all_docs
-
资源准备与初始化:
-
开启耗时统计
t0 = time.time()。 -
获取指定集合(如
laws和cases)的混合检索器retriever。 -
维护一个去重集合
seen_contents与最终文档列表all_docs。
-
-
分支一:HyDE 分离式检索(Split Query Strategy):
-
如果
hyde_doc存在,调用特殊的search_with_split_queries方法。 -
为何要分离? 关键词检索(BM25)需要准确的法律实体和术语(来自原始问题 search_queries[0]);而向量检索(Vector)更看重上下文语义相似度(来自模型生成的假想文档 hyde_doc)。将两者的 Query 拆开能最大化混合检索的效果。
-
-
分支二:常规多查询检索(Multi-Query Loop):
-
遍历
search_queries中的每一个改写或拆解后的子问题q。 -
使用 LangChain 标准接口
retriever.invoke(q)触发混合检索。
-
-
内存增量去重与合并:
-
每检索出批次文档,通过截取文本前 200 字符的
hash()值作为唯一识别 Key。 -
只有在
seen_contents中不存在该 Key 时才加入all_docs,保证了 HyDE 检索出的文档优先级更高(先加入),后续重复出现的文档被忽略。
-
二、 重点语法与高级工程细节
1. 高效内存去重:set 查找与 hash()
seen_contents: set[int] = set()
...
key = hash(doc.page_content[:200])
if key not in seen_contents:
seen_contents.add(key)
all_docs.append(doc)
-
语法点 :Python 中
set的底层是哈希表,in/not in检查的时间复杂度为 O(1)。 -
设计细节 :
doc.page_content[:200]取前 200 个字符进行哈希。-
为什么不取全文? 避免对长文本做全文
hash()带来的额外开销。 -
为什么不直接使用
doc对象?Document对象包含动态的metadata,即使文本相同,对象引用或元数据不同也无法直接比较,取固定前缀文本比对最稳妥。
-
2. LangChain Runnable 标准调用接口:retriever.invoke(q)
- 语法点 :LangChain 0.2+ / 0.3+ 规范中,所有的 Retriever、Model 和 Chain 统一继承自
Runnable抽象基类,检索操作统一推荐使用.invoke(input)(异步为.ainvoke(input)),替换了旧版的.get_relevant_documents()。
3. 混合检索中的"分离式查询"哲学 (Split Queries)
代码中的 search_with_split_queries 是针对 RAG 系统深度优化的设计:
-
传统做法直接将
hyde_doc既查 BM25 又查 Vector。但由于hyde_doc包含模型生成的假想法条和解释,用它做 BM25 会带来大量的干扰关键词,导致传统全文检索噪音变大。 -
分离式检索 精准解决了这一痛点:BM25 查精确词(原始提问),Vector 查高维语义(假设文档)。
1 get_hybrid_retriever():获取混合检索器(BM25+向量)
从应用开发工程师的视角来看,这种设计在 LangChain 框架和企业级 RAG 系统中非常标准,核心在于解决三个工程痛点:框架兼容性、检索精度提升 以及运行时弹性配置。
一、 核心架构设计意图
1. 继承 BaseRetriever:无缝契合 LangChain 生态
HybridRetriever 继承自 langchain_core.retrievers.BaseRetriever。
-
工程优势 :继承基类后,
HybridRetriever自动获得了 LangChain 标准的Runnable接口能力。 -
应用场景 :在主流程中可以像调用官方组件一样直接使用
.invoke(query)、.ainvoke(query),或者直接使用管道符|组装 LCEL 链(如retriever | prompt | llm)。
2. 混合检索(BM25 + Vector):解决单一检索方式的短板
在法律等专业领域,单一的检索策略都有致命缺陷:
-
纯向量检索(Vector):擅长理解意图和语义,但对精确数字、特定罪名(如"故意杀人罪"与"过失致人死亡罪")或特定法条编号的敏感度较差,容易召回语义相似但法条不匹配的内容。
-
纯关键词检索(BM25):对专有名词精确匹配极强,但遇到用户口语化表达或同义词替换时容易漏检(零召回)。
-
工程设计:采用混合检索,由 BM25 抓"精准词",向量抓"深层语义",二者结合能大幅提升 Top-K 召回率。
3. RRF (Reciprocal Rank Fusion) 融合与权重调优
代码中定义了 bm25_weight 和 vector_weight:
-
不同检索算法的原始得分(BM25 相似度分值 vs 向量余弦/欧氏距离)量级和分布完全不同,无法直接相加。
-
通过 RRF 算法将得分转化为排名倒数再乘以自定义权重,能够平滑融合两者的排序结果,使得同时在两边排名前列的文档获得更高的综合权重。
二、 代码语法与工程细节解析
1. Pydantic 兼容配置:arbitrary_types_allowed = True
-
语法点 :LangChain 的
BaseRetriever底层基于 Pydantic 数据模型构建。默认情况下,Pydantic 只允许常见的基础 Python 类型作为类属性。 -
工程作用 :代码中定义了
bm25_retriever: BM25ChineseRetriever这种自定义第三方类。如果不开启arbitrary_types_allowed = True,Pydantic 在实例化时会抛出类型校验错误。开启此选项允许模型接收并校验任意自定义对象类型。
Pydantic 数据模型是啥请见附录10
2. 统一工厂入口与默认兜底:get_hybrid_retriever
def get_hybrid_retriever(collection_names: list[str] | None = None) -> HybridRetriever:
names = collection_names or ["laws", "cases"]
return HybridRetriever(collection_names=names)
-
工程作用:封装工厂函数,降低上层模块(如 Pipeline)与底层检索器的耦合度。
-
默认值处理 :
names = collection_names or ["laws", "cases"]提供了安全兜底。上层如果不传具体数据库集合,默认直接检索法律文本(laws)和判例(cases)两大常用库。
3. 构造函数初始化与语料加载:self._load_bm25_corpus()
-
工程作用 :在
__init__中使用super().__init__(**kwargs)完成 Pydantic 属性赋值后,紧接着触发_load_bm25_corpus()。 -
运行时机制 :BM25 是基于内存倒排索引的算法,必须在初始化时将指定
collection_names中的文档文本加载到内存中并完成中文分词构建索引,确保后续执行retriever.invoke(q)时能实现毫秒级检索。
4.语法结构讲解
python
def get_hybrid_retriever(
collection_names: list[str] | None = None,
) -> HybridRetriever:
"""获取混合检索器"""
names = collection_names or ["laws", "cases"]
return HybridRetriever(collection_names=names)
-
collection_names: list[str] | None = None(参数定义与类型注解)-
类型注解(Type Hints):
-
list[str]:指定列表元素必须为字符串(Python 3.9+ 泛型语法)。 -
| None:联合类型操作符(Union Operator,Python 3.10+ 语法),等价于Optional[list[str]],表示该参数可以是字符串列表,也可以是None。
-
-
默认参数(Default Argument):
= None:将参数默认值设为None。这是 Python 的标准最佳实践(避免使用可变对象[]作为默认参数导致的共享引用 Bug)。
-
-
-> HybridRetriever(返回值类型注解)-
语法:
->用于声明函数返回值的类型。 -
作用:指明该函数执行完毕后将返回一个
HybridRetriever类的实例,便于 IDE 进行代码补全和类型检查(如 Mypy)。
-
names = collection_names or ["laws", "cases"](短路求值与默认值兜底)
-
短路求值(Short-circuit Evaluation):
-
如果传入了非空列表(如
["articles"]),collection_names为真值,names直接取该值。 -
如果传入
None或空列表[](逻辑假),or运算符将触发右侧评估,使names取默认值["laws", "cases"]。
-
-
作用:以极简的语法完成了"入参校验与默认回退"。
1.1 _load_bm25_corpus():从向量库加载文档+构建 BM25 索引
except Exception: continue 这种"全吞"写法,会让真正的 bug 无声消失。
load_bm25_corpus() 函数的核心目的在于:直接借用 ChromaDB 等向量数据库中已持久化的原始文本与元数据,在内存中动态重建 BM25 中文倒排索引,避免维护两套独立数据库的开销。
一、 函数代码执行逻辑梳理
遍历 collection_names (如 ["laws", "cases"])
│
▼
根据集合名获取向量库实例 store
store._collection.get(include=[...])
│
读取是否成功且含有文本?
/ \
[是] [否]
│ │
▼ ▼
zip(...) 打包文本与元数据 捕获 Exception
构建 Document(page_content, meta) 跳过当前集合 (continue)
追加至 all_docs 列表
│
└─────────────┬────────────┘
│
▼
更新 self.all_documents
│
all_docs 是否非空?
/ \
[是] [否]
│ │
▼ ▼
实例化 BM25ChineseRetriever 保持 self.bm25_retriever
建立内存中文倒排索引 为 None
-
多集合数据提取 :遍历指定的数据库集合名(如
laws法律条文库、cases判例库),使用底层store._collection.get()拉取存储在 ChromaDB 中的完整文档文本(documents)和元数据(metadatas)。 -
文本与元数据组装 :通过
zip()组合并行数组,精准还原为标准的 LangChainDocument对象并存入all_docs。 -
容错机制 :单个集合读取失败(如集合尚未创建或连接异常)会被
try...except捕获并continue,保证其他可用集合能正常加载。 -
BM25 索引构建 :若成功装载到文本,则调用
BM25ChineseRetriever初始化内存级别的 BM25 索引管理器(内部会自动触发中文分词与词频统计)。
二、 重点语法与高级工程细节
1. 内置函数 zip() 的经典协同应用
代码结构拆解
for doc_text, meta in zip(
result["documents"],
result["metadatas"] or [{}] * len(result["documents"])
):
all_docs.append(Document(page_content=doc_text, metadata=meta or {}))
-
result["documents"]与result["metadatas"](字典键值提取)-
语法:字典数据读取。
-
作用:通常用于获取向量数据库(如 ChromaDB/Milvus)返回的查询结果。
documents对应文本列表,metadatas对应元数据字典列表。
-
-
result["metadatas"] or [{}] * len(...)(外层列表级防御)-
短路求值 (
or) :如果result["metadatas"]为None或空列表[],表达式会自动执行or右侧的代码。 -
[{}] * len(...):获取文档数量 n,构造一个包含 n 个空字典的列表(如[{}, {}])。 -
作用:保证传给
zip()的第二个参数永远是一个与文档列表等长的序列 ,防止因为metadatas缺失导致zip()报TypeError或长短不一丢包。
-
-
zip(...)与for doc_text, meta in(多路打包与元组解包)- 语法:
zip()将两个可迭代对象按索引 1:1 打包成元组;for遍历通过解包将元素分别赋给doc_text(字符串)和meta(字典或None)。
- 语法:
-
metadata=meta or {}(内层元素级二次防御)-
短路求值 (
or) :如果metadatas列表虽然存在,但内部某个具体元素为None(例如:metadatas = [{"source": "A"}, None]),meta在当前循环中即为None。 -
meta or {}:若meta为None或{}, 则回退返回一个新的空字典{}。 -
作用:确保传入
Document构造函数的metadata属性绝对不会是None,符合 LangChain 等框架对Document类型的要求。
-
-
Document(page_content=..., metadata=...)(类实例化)-
语法:LangChain / LlamaIndex 等框架中文档对象的实例化。
-
作用:将纯文本和元数据封装为统一的文档对象格式。
-
-
all_docs.append(...)(列表追加)-
语法:列表方法调用。
-
作用:将实例化好的
Document对象逐一追加到汇总列表all_docs中。
-
2. 底层 API 绕过与高效导出:store._collection.get(...)
-
工程技巧 :没有通过 LangChain 标准检索接口去逐条检索,而是直接访问 ChromaDB 的 Python API
_collection.get(...)。 -
参数
include=["documents", "metadatas"]:显式指定只拉取文本和元数据,排除高维向量数据(embeddings),极大地节省了内存传输与序列化开销。
store._collection.get()到底是干嘛的请见附录12
3. 短路逻辑与默认值兜底:meta or {}
- 语法点 :在 Python 中,如果
meta是None,meta or {}会返回后者的空字典{}。这保障了构造Document(..., metadata=...)时传入的一定是合法的字典类型。
4. 条件装配与空安全检查
self.all_documents = all_docs
if all_docs:
self.bm25_retriever = BM25ChineseRetriever(documents=all_docs, k=self.k)
- 工程设计 :若向量库为空,
self.bm25_retriever将保留为None。在后续检索流程中,代码只需校验if self.bm25_retriever:即可安全跳过 BM25 步骤,杜绝了空索引导致的计算崩溃。
为啥要构建 BM25 索引?BM25 索引是啥?如何构建?请见附录11、附录12
1.1.1 BM25ChineseRetriever():基于 jieba 分词的 BM25 中文检索器
BM25ChineseRetriever 是将传统全文检索算法 BM25(Okapi算法) 集成到 LangChain 体系的核心组件。
它解决的核心问题是:BM25 算法是基于"词(Token)"计算相关度的,而原始中文文本是没有自然空格分隔的字符串。 该类利用 jieba 分词先将中文转化为词列表,再构建内存级 BM25 索引。
一、 函数与类代码执行逻辑梳理
┌─────────────────────────────────────────────────────────┐
│ 1. 初始化过程 __init__() │
└─────────────────────────────────────────────────────────┘
│
▼
接收文档列表 documents 与 top-k 值
│
▼
对所有文档文本逐篇执行 jieba.cut
生成词嵌套列表 tokenized_corpus: [['词1', '词2'], ...]
│
▼
tokenized_corpus 是否非空?
/ \
[是] [否]
│ │
▼ ▼
实例化 BM25Okapi 算法对象 self.bm25 为 None
计算逆文档频率 (IDF) 与词频 (TF)
│
└──────────────┬──────────────┘
│
┌──────────────────────────────────────────┴──────────────┐
│ 2. 检索执行过程 _get_relevant_documents() │
└─────────────────────────────────────────────────────────┘
│
▼
校验 self.bm25 / documents 是否有效
│
▼
对传入的 query 执行 jieba.cut
│
▼
self.bm25.get_scores(tokenized_query)
获得与全库文档对应的分数数组 scores
│
▼
高阶函数 key=lambda i: scores[i]
获得降序排列前 k 个原始文档索引 top_indices
│
▼
过滤掉得分小于等于 0 的文档,
返回最终的 Document 列表
二、 重点语法与算法实现细节
1. 列表推导式与嵌套解构
self.tokenized_corpus = [
list(jieba.cut(doc.page_content)) for doc in documents
]
-
语法点 :利用单行列表推导式处理批量数据,效率高于显式
for循环。 -
数据结构变化 :
documents原始类型为list[Document],经过转化后,tokenized_corpus的类型变为二维字符串列表list[list[str]],如[['刑法', '第二百', '条'], ['故意', '伤害']],这正是BM25Okapi库接收的标注格式。
2. LangChain 契约方法:_get_relevant_documents
-
工程规则 :继承
BaseRetriever时,开发者只需实现带下划线的内部私有方法_get_relevant_documents。 -
机制 :当外部调用公共接口
retriever.invoke(query)时,LangChain 基类会自动完成回调管理、追踪(Tracing)并调用内部的_get_relevant_documents。
3. Top-K 索引提取技巧:sorted + lambda 索引映射
top_indices = sorted(range(len(scores)), key=lambda i: scores[i], reverse=True)[: self.k]
这是一个极其经典且高效的 Python 数据处理范式:
-
range(len(scores)):生成与文档数量一致的索引序列[0, 1, 2, ..., N-1]。 -
key=lambda i: scores[i]:按照分数数组中对应索引的值作为排序依据。排序后得到的不是分数本身,而是分数从大到小对应的原始文档索引列表。 -
[: self.k]:切片截取得分最高的前 k 个文档下标。
4. 结果质量控制:推导式过滤
return [self.documents[i] for i in top_indices if scores[i] > 0]
- 工程意图 :如果输入的 query 包含的词在某些文档中完全没有出现,BM25 给出的打分可能为
0或负数。利用if scores[i] > 0过滤,确保返回给大模型上下文的都是至少存在关键词重合的相关文档,避免噪音污染。
2 对第一个问题做分离式检索search_with_split_queries()
BM25 使用一个查询,向量检索使用另一个查询------对原始问题用bm25关键字查询,对假设文档用vector查询。
search_with_split_queries() 是 HyDE(假设性文档嵌入)场景下的核心融合检索逻辑。
传统混合检索通常用同一个 query 同时查 BM25 和向量库。但 HyDE 的策略不同:
-
BM25(关键词检索) :非常依赖精确词汇,必须用用户的 原始问题(
bm25_query),避免 LLM 生成的假想文本带来关键词噪音。 -
Vector(语义检索) :擅长长文本语义匹配,使用 LLM 生成的 假设性答案/案例(
vector_query) 能在向量空间中更快逼近真实的目标文档。
代码通过 加权 RRF(Reciprocal Rank Fusion,倒数排名融合) 算法,将两路异构查询的结果归一化融合。
一、 函数代码执行逻辑梳理
输入 bm25_query (原始问题) & vector_query (HyDE假设文本)
│
▼
初始化累加哈希表 results_map = {}
设置 RRF 默认常数 rrf_k = 60
│
▼
┌──────────────────────────────────────────────────────────────────────────┐
│ 阶段一:BM25 检索 (原始问题) │
└──────────────────────────────────────────────────────────────────────────┘
│
▼
self.bm25_retriever.invoke(bm25_query)
│
▼
遍历结果,计算每个文档的 BM25-RRF 分数:
score = bm25_weight / (60 + rank + 1)
│
▼
根据 hash(doc.page_content[:200]) 写入/累加至 results_map
│
┌─────────────────────────────────────────┴────────────────────────────────┐
│ 阶段二:向量库检索 (HyDE 假设文本) │
└──────────────────────────────────────────────────────────────────────────┘
│
▼
遍历集合 collection_names (如 laws, cases)
│
▼
store.similarity_search(vector_query, k=self.k)
│
▼
遍历结果,计算每个文档的 Vector-RRF 分数:
score = vector_weight / (60 + rank + 1)
│
▼
根据 hash(doc.page_content[:200]) 写入/累加至 results_map
(遇到异常触发 try...except,记录日志并 continue)
│
┌─────────────────────────────────────────┴────────────────────────────────┐
│ 阶段三:RRF 综合得分排序与截取 │
└──────────────────────────────────────────────────────────────────────────┘
│
▼
按 RRF 最终总分降序排列:sorted(results_map.values())
│
▼
截取 Top-K 结果,提取并返回 list[Document]
二、 重点语法与算法细节
1. 加权 RRF 融合算法实现原理
RRF(Reciprocal Rank Fusion)是现代 RAG 系统中最通用的无监督融合算法。
它的标准公式为:
-
公式拆解:
-
r_m(d):文档 d 在算法 m(BM25 或 向量)中的排名(从 0 开始,所以代码中是
rank + 1)。 -
k:平滑常数(通常设为 60),防止排名靠前的文档权重过高。
-
w_m:检索源权重(代码中的
bm25_weight与vector_weight)。
-
-
工程逻辑:如果一个文档在 BM25 中排第 1,在向量检索中也排第 1,它的得分会累加;如果只在其中一方出现,则只保留一方的 RRF 贡献值。这完美解决了不同检索模型原始 Score 量级不一致的问题。
2. 哈希聚合并集:results_map: dict[int, dict]
if key in results_map:
results_map[key]["score"] += score
else:
results_map[key] = {"doc": doc, "score": score}
-
语法点 :利用字典实现 查找/更新时间复杂度为 O(1) 的多路召回合并。
-
数据结构设计 :字典的 Value 嵌套了一个包含
doc(文档对象)和score(当前累加分数)的小字典,兼顾了去重、分数累加与原对象保留。
3. 字典 .values() 的高阶排序与解构
sorted_results = sorted(results_map.values(), key=lambda x: x["score"], reverse=True)
return [item["doc"] for item in sorted_results[: self.k]]
-
results_map.values():直接取出字典中所有{"doc": ..., "score": ...}的小字典组合成可迭代对象,忽略无用的 Hash Key。 -
key=lambda x: x["score"]:指定按融合后的 RRF 总分作为排序依据。 -
列表推导式截取 :
[: self.k]只截取前 K 个最高分的元素,并解构取出item["doc"],将类型重新还原为 LangChain 标准的list[Document]。
4. 规范日志记录:logger.warning(..., exc_info=True)
-
工程细节 :在遍历向量库时,传入了
exc_info=True。 -
作用 :当某个向量集合抛出异常时,日志系统不仅会打印自定义文本
"分离式向量检索集合 laws 失败...",还会将底层的完整 Python 堆栈追踪信息(Stack Trace)写入日志,极大方便了生产环境中的故障排查。
2.1返回BM25检索结果:BM25ChineseRetriever.invoke()
bm25_results = self.bm25_retriever.invoke(bm25_query) # BM25 检索结果(原始查询)
这行代码的详解请见附录14
调用流程:
search_with_split_queries() ← 你的代码,[retriever.py:155]
└─ bm25_retriever.invoke(bm25_query) ← 你按了 F11
└─ BaseRetriever.invoke() ← 库代码,被 justMyCode 跳过不显示
├─ CallbackManager.configure / on_retriever_start ← 库里执行但不暂停
└─ self._get_relevant_documents() ← 调用子类实现
└─ BM25ChineseRetriever._get_relevant_documents() ← ★ 停在这里
所以步入bm25_results = self.bm25_retriever.invoke(bm25_query) 会直接跳转到BM25ChineseRetriever._get_relevant_documents()函数,如下:
python
def _get_relevant_documents(
self, query: str, *, run_manager: CallbackManagerForRetrieverRun
) -> list[Document]:
if not self.bm25 or not self.documents:
return []
tokenized_query = list(jieba.cut(query)) # 对查询进行 jieba 分词
scores = self.bm25.get_scores(tokenized_query) # 查询与各文档的 BM25 相似度分数
# 获取 top-k 索引
top_indices = sorted(range(len(scores)), key=lambda i: scores[i], reverse=True)[ # 按分数降序取 top-k 的文档索引
: self.k
]
return [self.documents[i] for i in top_indices if scores[i] > 0]
这段 _get_relevant_documents() 是前面 BM25ChineseRetriever 类的核心检索私有方法。
在前面的代码中,我们在 search_with_split_queries() 里面执行了 bm25_results = self.bm25_retriever.invoke(bm25_query)。由于继承了 LangChain 的 BaseRetriever,当你调用 public 的 .invoke() 时,LangChain 底层就会自动触发调用你写的这个 _get_relevant_documents() 方法。
一、 函数代码执行逻辑梳理
输入待检索的 query 字符串
│
▼
安全防护校验 (Safety Check)
self.bm25 或 self.documents 是否为空?
/ \
[是] [否]
│ │
▼ ▼
返回空列表 [] 使用 jieba 对 query 进行分词
tokenized_query = ['词1', '词2']
│
▼
计算 BM25 分数
self.bm25.get_scores(tokenized_query)
输出全库文档匹配得分数组 scores
│
▼
全局索引关联排序
按分数从高到低提取 Top-K 文档下标 top_indices
│
▼
有效性过滤与对象组装
滤除 scores <= 0 的无关联文档
返回最终的 list[Document]
-
安全校验:检查 BM25 索引或原始文档库是否未就绪,防止空指针异常。
-
查询词切分 :将输入的中文文本(如
bm25_query原始问题)拆解为词列表,保证与索引库的 Token 粒度对齐。 -
分值计算 :将分词后的 query 喂给
BM25Okapi索引,生成一个长度等于文档总数的相似度得分数组scores。 -
Top-K 下标提取:在不破坏原始文档位置索引的前提下,按得分排序找到前 K 个最相关的文档下标。
-
分值过滤与映射 :排除得分零分/负分的噪音文档,根据下标重新映射回
Document对象并返回。
二、 重点语法与工程细节
1. 强制关键字参数(Keyword-Only Arguments):*, run_manager
def _get_relevant_documents(
self, query: str, *, run_manager: CallbackManagerForRetrieverRun
) -> list[Document]:
-
语法点 :函数参数列表中的单星号
*表示分隔符 。*后面的所有参数(这里是run_manager)在被调用时必须通过关键字形式显式传递 (例如_get_relevant_documents("query", run_manager=mgr)),不允许使用位置参数。 -
工程作用 :符合 LangChain 的 API 规范。
run_manager用于 LangChain 的底层回调、链路追踪(Tracing)和日志记录,通过*强制规范了底层调用的签名约束。
2. 分词粒度对齐:list(jieba.cut(query))
-
工程规则:BM25 算法依赖的是准确的关键词重合度(Term Frequency)。
-
易错点 :构建索引时用的什么分词器,查询时就必须用完全相同 的分词器。这里严格使用与
__init__中相同的jieba.cut,确保了词表语义空间的一致性。
3. 关联索引的高效提取:sorted(range(...), key=...)
top_indices = sorted(range(len(scores)), key=lambda i: scores[i], reverse=True)[: self.k]
-
语法点:
-
range(len(scores))生成了一个下标迭代器[0, 1, 2, ..., N-1]。 -
key=lambda i: scores[i]将排序依据重定向到scores数组对应的数值上。
-
-
工程优势 : avoids 直接对
scores排序导致丢失原始文档位置的问题。这种写法能够在 O(N \\log N) 时间内优雅地完成"分数排序"与"原文档位置保留"。
4. 噪音文档防御:if scores[i] > 0
return [self.documents[i] for i in top_indices if scores[i] > 0]
-
工程意图:如果检索到的前 K 个文档中,有些文档的 BM25 匹配得分为 0(说明该文档中完全没有出现查询词中的任何 Token),直接剔除。
-
效果:避免把不相关的空泛文档塞进后续的 Prompt 中,降低大模型的幻觉率并节省 Token 消耗。
2.2 计算BM25检索贡献的 RRF 分数
python
score = self.bm25_weight / (rrf_k + rank + 1) # BM25 检索贡献的 RRF 分数
RRF 是什么?如何计算RRF?请见附录15
2.3 返回向量检索结果Chroma.similarity_search(vector_query, k=self.k)
python
vec_results = get_vectorstore(name).similarity_search(vector_query, k=self.k) # 向量检索结果(HyDE 文本)
这行代码是一个完整的两阶段链路:先把文本变成向量,再用向量在数据库里做最近邻搜索。我一步步拆给你看。
完整调用链总览
get_vectorstore(name).similarity_search(vector_query, k=self.k)
① get_vectorstore(name) → 返回 Chroma 实例(项目代码)
② Chroma.similarity_search() → 包装方法(langchain_community)
③ similarity_search_with_score() → 真正干活的方法
├─ ④ embed_query() → 文本→向量(调用本地 Ollama,一次 HTTP 请求)
└─ ⑤ __query_collection() → 把向量发给 chromadb
└─ ⑥ Collection.query() → chromadb Rust 内核做最近邻搜索(HNSW)
⑦ _results_to_docs_and_scores() → 把裸结果拼成 Document
⑧ 丢分数,只留文档 → list[Document]
逐层拆解
① get_vectorstore(name) ------ vectorstore.py:17
它返回一个 langchain_community.vectorstores.Chroma 对象。构造时关键参数:
collection_name="laws"或"cases"embedding_function = get_embeddings()→OllamaEmbeddings(model="bge-m3", base_url=Ollama地址)(embeddings.py:9)- 复用一个
chromadb.PersistentClient(数据在磁盘chroma_db目录)
也就是说 store 是 LangChain 对 chromadb collection 的一层包装,它知道"用哪个 embedding 模型"、"查哪个集合"。
② similarity_search(query, k) ------ 只是个"去掉分数"的壳
# langchain_community/vectorstores/chroma.py:333
def similarity_search(self, query, k=4, filter=None, **kwargs):
docs_and_scores = self.similarity_search_with_score(query, k, filter=filter, **kwargs)
return [doc for doc, _ in docs_and_scores] # 把分数丢掉,只返回文档
所以这行返回的 vec_results 类型是 list[Document],分数被丢弃了。
④ embed_query() ------ 文本 → 向量(这是最耗时的步骤)
# langchain_ollama/embeddings.py:305
def embed_query(self, text):
return self.embed_documents([text])[0]
# 293行 embed_documents → self._client.embed(model, texts, ...)["embeddings"]
它向你的本地 Ollama 服务 发一个 HTTP POST,请求 bge-m3 模型把 vector_query 这段文本编码成一条 1024 维的浮点向量 。注意:similarity_search 每调用一次,就发生一次真实的网络请求到 Ollama,这也是向量检索比 BM25 慢的主要原因。
⑤⑥ Collection.query() ------ 真正的"找邻居"发生在 chromadb Rust 内核
__query_collection 直接调用 self._collection.query(query_embeddings=[向量], n_results=k, ...),这是 chromadb 的 Collection.query(Collection.py:194)。它底层干三件事:
- 加载 HNSW 索引:集合里所有文档的向量早已建好一个 HNSW(近似最近邻)索引。
- 近似 K-近邻搜索 :拿查询向量在这个索引里找
k个"离它最近"的向量。距离度量这里用的是 L2 欧氏距离 ------因为你的collection_metadata没设hnsw:space,chromadb 默认就是"l2"(我在hnsw_params.py:56确认了默认值)。 - 回查 SQLite 取原文 :根据命中的向量 ID,从数据库读出对应的
documents(原文)、metadatas(元数据)、distances(距离值)。
返回的 QueryResult 是个"列表套列表"的字典(因为 chromadb 的 API 支持批量查询):
{
"documents": [["文档1原文", "文档2原文", ...]], # 共 k 条
"metadatas": [{"doc_type": "law", ...}, ...],
"distances": [[0.12, 0.35, ...]], # L2 距离,越小越相似
}
⑦ _results_to_docs_and_scores ------ 拼回 Document
# chroma.py:40
for result in zip(results["documents"][0], results["metadatas"][0], results["distances"][0]):
(Document(page_content=doc, metadata=meta), distance)
[0] 是因为你只发了一条查询,取批量的第一组结果。
返回值总结
vec_results = get_vectorstore(name).similarity_search(vector_query, k=self.k)
vec_results 是一个 list[Document] ,最多 k 条(k = settings.RETRIEVAL_TOP_K),按 L2 距离从小到大排序(最相似在前) 。每个 Document:
| 属性 | 内容 |
|---|---|
.page_content |
法条/案例原文(存进向量库时切好的 chunk) |
.metadata |
doc_type、source_file、law_name / effective_date(法条)或 case_number / case_title 等(案例) |
它和上一题 BM25 返回的 list[Document] 长得一样 ------这正是两个检索器能在 HybridRetriever 里做 RRF 融合的原因:两边都吐 list[Document],用 hash(doc.page_content[:200]) 去重、按位次累加分数。
和 BM25 对比(帮你串起两题)
| BM25(上一题) | 向量检索(本题) | |
|---|---|---|
| 检索依据 | jieba 词 + 词频/IDF 字面共现 | embedding 向量空间里的语义距离 |
| 成本 | 纯 CPU 数学,极快 | 一次 Ollama HTTP 请求(embedding)+ HNSW 搜索 |
| 能否命中"同义表达" | 不能("故意杀人"和"故意剥夺他人生命"不共词就得分低) | 能(语义相近的向量距离近) |
| 返回 | list[Document],按 BM25 分降序 |
list[Document],按 L2 距离升序 |
| 分数 | BM25 分(越大越相关) | L2 距离(越小越相关),被 similarity_search 丢弃了 |
所以你的项目把两者做 RRF 融合:BM25 兜住精确字面 (法条编号、法言法语),向量兜住语义泛化(换说法也能找到),互补盲区。
一个调试点提醒 :如果你在调试时想看"到底返回了什么",最直观的是打断点看 vec_results[0].page_content;如果想看每个文档的相似度分数(调试 RRF 权重合理性),把 similarity_search 换成 similarity_search_with_relevance_scores 就能拿到 (Document, 分数) 对------similarity_search 是特意把分数丢掉的。
2.4 计算向量检索贡献的 RRF 分数
python
score = self.vector_weight / (rrf_k + rank + 1) # 向量检索贡献的 RRF 分数
2.5 最终按 RRF 分数降序排列(top-10)
python
sorted_results = sorted(results_map.values(), key=lambda x: x["score"], reverse=True) # 按 RRF 分数降序排列
return [item["doc"] for item in sorted_results[: self.k]]
3 再对每个查询再分别使用常规的混合检索一遍retriever.invoke(q)并去重合并
即:对原问题做BM25关键字检索 + 向量相似度检索
async def _retrieve(self, search_queries, hyde_doc):
retriever = get_hybrid_retriever(...)
...
# ── A段:HyDE 分离检索(只有 hyde_doc 存在才走)──
if hyde_doc:
docs = retriever.search_with_split_queries(
bm25_query=search_queries[0], # ← 你问的"只取 [0]"
vector_query=hyde_doc,
)
...去重合并...
# ── B段:常规多查询检索(所有策略都会走)──
for q in search_queries:
docs = retriever.invoke(q) # ← 你问的"为啥还做这个"
...去重合并...
所以核心是:A 段是 HyDE 的"附加路径",B 段是"通用基线路径"。 两者是互补关系,不是重复关系。
先把 5 种策略各自跑什么列成表:
| 策略 | search_queries |
hyde_doc |
A段 split检索 | B段 invoke 循环 |
|---|---|---|---|---|
none |
[原问题] |
None | 跳过 | invoke(原问题) |
multi_query |
[改写1, 改写2, 改写3] |
None | 跳过 | 每个改写查询各 invoke 一次 |
decompose |
[子问题1,2,3] |
None | 跳过 | 每个子问题各 invoke 一次 |
hyde |
[原问题] |
假设文档 | BM25(原问题) + 向量(假设文档) | invoke(原问题) |
multi_query_hyde |
[改写1,2,3] |
假设文档 | BM25(改写1) + 向量(假设文档) | 三个改写各 invoke 一次 |
Q1:为啥还要做 B 段 retriever.invoke(q)?
因为 A 段只覆盖了"一个 BM25 查询 + 假设文档的向量",而 B 段要保证每个查询的完整混合检索都不缺席。 分开看:
-
MULTI_QUERY / DECOMPOSE 策略下 :
hyde_doc=None,A 段根本不执行。所有检索工作全靠 B 段------每个改写/子查询都要跑一遍完整的混合检索(BM25 用 q、向量也用 q)。B 段是唯一的检索路径,缺了它整个多查询策略就废了。 -
HYDE / MULTI_QUERY_HYDE 策略下 :A 段跑完,B 段仍然要跑 ,因为两者算的是不同的东西:
| 检索操作 | A段 split(BM25=原问题,向量=假设文档) | B段 invoke(原问题) |
|---|---|---|
| BM25 用哪个查询 | 原问题 | 原问题(重复,但结果被去重) |
| 向量用哪个查询 | 假设文档(语义匹配) | 原问题(原问题直接向量化) |
也就是说,A 段补的是"用假设文档做向量检索 "这一路(HyDE 的灵魂:假设文档与法条语体一致,语义更接近),B 段的 invoke(原问题) 补的是"用原问题直接做向量检索 "这一路。两条路结果用内容哈希去重合并 ,最终给重排阶段送去更多、更全的候选。即使 LLM 生成的假设文档跑偏了,B 段的常规检索也保证基本召回不丢------这是"双保险"。
Q2:为啥 A 段只取 search_queries[0] 做 BM25?
两个层面的原因:
① 结构原因:search_with_split_queries 的签名只接受两个查询。 看 retriever.py:143:
def search_with_split_queries(self, bm25_query: str, vector_query: str) -> list[Document]:
它一次只做一趟 分离检索,BM25 一个查询、向量一个查询,没有"BM25 传多个"的接口。所以管线只能从 search_queries 里挑一个,代码挑了 [0]。
② 语义原因:在纯 HYDE 策略下,search_queries[0] 恰好就是原问题。 看 pipeline.py:186-190:
elif strategy == QueryTransformStrategy.HYDE:
original, hypo = await hyde_transform(question)
search_queries = [original] # ← 只有 1 个元素
hyde_doc = hypo
search_queries = [original],所以 [0] 就是原问题------这正是 HyDE 的设计意图(注释也写了:bm25_query 为原始问题,vector_query 为假设文档 )。BM25 用原问题做精确的关键词匹配,向量用假设文档做语义匹配,取 [0] 是最合理的选择。
③ 其他查询没有被丢掉。 在 MULTI_QUERY_HYDE 下 search_queries 有多个元素,A 段只用 [0] 做 BM25,但第 2、3 个改写查询由 B 段 for q in search_queries 的循环各自 invoke 覆盖了------每个角度都跑了一遍完整混合检索。所以"只取 0"只是A 段内部的取舍,整体上没有一个查询被遗漏。
一句话总结
_retrieve() 是**"通用基线(B段:每个查询各跑一次完整混合检索)+ HyDE 附加(A段:BM25 用第一个查询 + 向量用假设文档的分离检索)"**的叠加设计。A 段只取 search_queries[0] 是因为分离检索接口一次只能收一个 BM25 查询,而 [0] 在纯 HyDE 下正好是原问题;B 段兜底保证了所有改写查询和"原问题向量检索"都不缺席,两条路结果去重合并、互为补充。
顺带一个小观察(不影响正确性):纯 HYDE 策略下,BM25(原问题) 会被算两遍(A 段一次、B 段的 invoke(原问题) 又算一次),靠内容哈希去重把重复文档滤掉了,所以只是多花了一点计算,结果不受影响------这是代码"通用路径 + 附加路径"统一设计的取舍。
为什么在纯 HYDE 策略下BM25(原问题) 会被算两遍?请见附录16
Stage 2.5: KG 查找(并入检索结果,增强刑事类问题的结构化知识)
1 _kg_lookup():知识图谱(Knowledge Graph)增强函数
从问题中提取罪名并在犯罪知识图谱中查找,返回 (实体列表, KG 文档列表)
_kg_lookup() 是 RAG 管线在 Stage 2.5 阶段执行的知识图谱(Knowledge Graph)增强函数。
在法律(尤其是刑事领域)问答中,非结构化的文本检索(向量/BM25)可能无法精准覆盖罪名的构成要件、量刑标准、加重情节 等确定性规则。通过图谱查找(KG Lookup),能够将结构化的法条知识作为补丁(kg_docs)缝合到上下文(Context)中。
一、 函数代码执行逻辑梳理
输入原始问题 question
│
▼
记录计时起点 t0 = time.time()
│
▼
┌────────────────────────────────────────┐
│ 尝试执行 (try) │
└────────────────────────────────────────┘
│
▼
动态延迟导入 (Lazy Import):
from app.services.kg_service import ...
│
▼
异步实体识别: await extract_crime_entities(question)
(混合策略: 字典/正则精准匹配 + LLM 语义识别)
│
▼
是否提取到实体 entities ?
/ \
[是] [否]
│ │
▼ ▼
执行图谱查询 赋值 docs = []
kg_lookup(entities)
│ │
└─────────────┬──────────────┘
│
▼
计算耗时写入 self.metrics["kg_lookup_ms"]
返回 (entities, docs)
│
┌───────────────────┴───────────────────┐
│ 捕获异常 (except) │
└───────────────────────────────────────┘
│
▼
计算耗时写入 self.metrics["kg_lookup_ms"]
记录 warning 日志并保留堆栈
安全降级:返回 ([], []),主流程不中断
二、 重点语法与工程细节
1. 动态延迟导入(Lazy Import)
from app.services.kg_service import extract_crime_entities, kg_lookup
-
语法点 :把
import语句写在函数体内,而不是文件最顶部(Top-level)。 -
工程作用:
-
加快启动速度 :如果系统配置了
self.config.use_kg = False,主程序加载时不会加载图谱服务的重型依赖或三元组模型。 -
避免循环依赖(Circular Import) :当管线类与
kg_service互相有引用关系时,局部导入能彻底规避 Python 模块循环引用崩溃的问题。
-
2. 类型提示与元组解包:tuple[list[str], list[Document]]
async def _kg_lookup(self, question: str) -> tuple[list[str], list[Document]]:
-
语法点:指定异步函数的返回值为固定格式的二元组:
-
第 1 项是罪名字符串列表(如
["故意伤害罪", "寻衅滋事罪"]); -
第 2 项是转化后的标准 LangChain
Document列表(方便与其他检索结果合并)。
-
-
调用端解包 :外部直接通过
kg_entities, kg_docs = await self._kg_lookup(question)一行代码完成优雅赋值。
3. 三元运算符的工程使用
docs = kg_lookup(entities) if entities else []
- 工程意图 :如果
entities是空列表[](Python 中视为假False),直接返回空列表[],跳过无意义的数据库或图谱网络查询,提高程序响应速度。
4. 容错设计(Graceful Degradation / Fallback)
except Exception:
self.metrics["kg_lookup_ms"] = round((time.time() - t0) * 1000, 1)
logger.warning("KG 查找失败,已跳过(不影响主流程)", exc_info=True)
return [], []
-
工程哲学 :知识图谱是增强项(Plugin),而不是阻断项(Core)。
-
处理机制 :不论是图谱服务连不上,还是实体抽取超速崩溃,
except块都会吞掉异常并记录 warning 日志,同时返回空数据([], []),保障上层的 RAG 管线能够平滑退回到"仅使用向量/BM25 检索"模式,不会直接给用户抛出 500 错误。
1.1 extract_crime_entities():提取罪名实体(先精确匹配,再 LLM 模糊匹配)
extract_crime_entities() 函数实现了一个高效且健壮的混合罪名实体提取策略(规则优先 + LLM 降级兜底 + 双向字符串归一化)。
在生产环境中,大模型调用有延迟且消耗 Token。该函数通过"能不调 LLM 就不调 LLM"的思想,优先采用规则精准匹配;在用户表达口语化时,再调度 LLM 进行意图提取并映射回图谱的标准罪名库。
一、 函数代码执行逻辑梳理
输入原始问题 question
│
▼
加载罪名图谱库 _load_crime_kg()
│
图谱数据是否存在?
/ \
[是] [否]
│ │
▼ ▼
┌─────────────────────────────────────────┐ 返回空列表 []
│ 阶段一:字符串包含校验 (规则快速通道) │
└─────────────────────────────────────────┘
│
▼
遍历 kg 中的每个标准罪名 crime_name
若 crime_name in question: 写入 matched
│
matched 是否非空?
/ \
[是] [否]
│ │
▼ ▼
直接返回 matched[:3] ┌─────────────────────────────────────────┐
(零延迟,0 Token消耗) │ 阶段二:LLM 复杂意图提取与模糊对齐 │
└─────────────────────────────────────────┘
│
▼
设置 temperature=0 (保证结果稳定)
链式调用: PROMPT | llm
异步执行: await chain.ainvoke(...)
│
▼
字符串解析: 逐行清洗与拆分
得到 LLM 提取出的 raw_names
│
raw_names 是否有效?
/ \
[是] [否/为"无"]
│ │
▼ ▼
双重匹配对齐 (Exact / Fuzzy): 返回空列表 []
1. 清洗首尾特殊字符/空格
2. 若 name 在 kg 中 -> 命中
3. 若不在, 检查子串互包含关系
(name in kn or kn in name)
│
▼
截取返回 result[:3]
│
┌───────────────────┴───────────────────┐
│ 异常处理 (except Exception) │
└───────────────────────────────────────┘
│
▼
记录 logger.warning,安全降级返回 []
二、 重点语法与高级工程细节
1. 规则优先与 Top-K 截断:matched[:3]
-
工程设计 :如果用户提问"张三涉嫌盗窃罪 和诈骗罪 怎么判?",因为"盗窃罪"和"诈骗罪"字符串直接存在于
question中,阶段一会在几毫秒内直接命中并返回,避开了 LLM 的开销。 -
[:3]切片保护:限制最多只取 3 个罪名,防止因过多的实体查询导致后续图谱检索膨胀。
2. LangChain 表达语言(LCEL)与异步调用:chain.ainvoke(...)
chain = KG_ENTITY_EXTRACT_PROMPT | llm
resp = await chain.ainvoke({"question": question})
-
语法点 :
|是 LCEL (LangChain Expression Language) 的管道符,将 Prompt 模板与 LLM 实例组合成一个可执行链。 -
ainvoke:异步执行方法,不阻塞 Python 的 asyncio 事件循环。由于 LLM 的网络 I/O 开销较大,必须使用await进行异步等待。
3. 严格的字符串清洗:strip("\"' \u3000")
name = name.strip("\"' \u3000")
-
语法点 :
strip()允许传入一个字符集合字符串。这里一次性清除了:-
双引号
" -
单引号
' -
半角空格
-
全角空格
\u3000
-
-
工程作用 :LLM 返回的罪名经常带有 Markdown 引用符号或格式化空格(如
"盗窃罪"),清洗后才能与 Python 字典的 Key 精确匹配。
4. 双向子串包含匹配(包含度容错)
if name in kn or kn in name:
result.append(kn)
break
-
工程逻辑:处理口语化与标准名称不一致的问题。
-
用户/LLM 提问比较长 (例如:
name="故意伤害他人罪"),而图谱标准罪名为kn="故意伤害罪"\\rightarrowkn in name判定为 True。 -
用户/LLM 提问比较短 (例如:
name="寻衅滋事"),而图谱标准罪名为kn="寻衅滋事罪"\\rightarrowname in kn判定为 True。
-
-
break关键字 :只要模糊匹配到图谱中的第一个标准罪名,立即退出内层kn循环,防止单个输入提取出重复的相似罪名。
1.1.1_load_crime_kg(): KG 数据加载
_load_crime_kg() 函数是一个典型的文本知识库解析与结构化提取函数,同时也是高并发场景下的标准性能优化范例。
它解决的核心问题是:将非结构化的 Markdown 或 TXT 文本知识库文件,以极高的效率解析为 Python 内存字典 {罪名: {概念, 构成, ...}},并使用 LRU 缓存避免重复磁盘 I/O。
一、 函数代码执行逻辑梳理
┌─────────────────────────────────────────────────────────┐
│ 阶段一:内存缓存检查与文件读取 (I/O 层) │
└─────────────────────────────────────────────────────────┘
│
▼
检查 @lru_cache 内存缓存中是否已加载?
/ \
[命中] [未命中]
│ │
▼ ▼
直接返回缓存字典 读取 settings.KG_DATA_PATH 文本
(文件不存在捕获 FileNotFoundError 返回 {})
│
┌────────────────────────────────────────────────┴────────┐
│ 阶段二:基于正则分隔条目 (分割层) │
└─────────────────────────────────────────────────────────┘
│
▼
re.split(r"(?:={3,})?\n*【", content)
按 "【" 符号切分为多个词条片段 entries
│
┌────────────────────────────────────────────────┴────────┐
│ 阶段三:遍历词条并正则提取字段 (解析层) │
└─────────────────────────────────────────────────────────┘
│
▼
遍历每一个 entry 文本片段
│
▼
re.match 提取罪名 (如 "故意伤害罪")
│
▼
re.search + re.DOTALL 跨行提取:
- 概念与定义
- 犯罪构成特征
- 量刑处罚
- 相关法条
- 类别(从前 100 字符内提取)
│
▼
组装为 sections 字典并存入 crime_map
│
┌────────────────────────────────────────────────┴────────┐
│ 阶段四:返回并写入 LRU 缓存 │
└─────────────────────────────────────────────────────────┘
│
▼
返回全局 crime_map 字典,
自动被 @lru_cache 缓存供后续使用
二、 重点语法与工程细节
1. 单例缓存装饰器:@lru_cache(maxsize=1)
from functools import lru_cache
@lru_cache(maxsize=1)
def _load_crime_kg() -> dict[str, dict]:
-
语法点 :
functools.lru_cache是 Python 内置的 最近最少使用(Least Recently Used)缓存装饰器 。maxsize=1表示只缓存最近一次函数调用的返回值。 -
工程作用 :由于该函数没有入参,
maxsize=1相当于实现了一个单例模式(Singleton) 。在服务启动后,无论该函数被调用多少万次,磁盘文件解析逻辑只会执行一次,后续调用直接从内存返回,完全消除了高并发下的磁盘 I/O 开销。
单例缓存装饰器:
@lru_cache(maxsize=1)有关介绍见附录17
2. 正则非捕获分组与前瞻断言:re.split & re.search
-
非捕获分组
(?:...):re.split(r"(?:={3,})?\n*【", content)?:告诉正则引擎"这里只匹配模式,但不要在 split 返回的列表中保留该分组文本 "。={3,}匹配 3 个以上的等号(如===),这样可以干净地把各个罪名模块切割开。 -
正则零宽断言(前瞻断言)
(?=...):re.search(r"概念与定义:\n(.*?)(?=\n犯罪构成特征:|\n认定与区分:|\n量刑处罚:|\n相关法条:|\Z)", entry, re.DOTALL)(?=\n犯罪构成特征:|...)属于正向前瞻断言(Positive Lookahead) 。意思是:匹配"概念与定义"后的内容,直到遇到下一个小标题或字符串末尾\Z为止,但不把下一个小标题包含到当前匹配结果中。
3. 跨行正则匹配标志:re.DOTALL
-
语法点 :默认情况下,正则表达式中的通配符
.不能匹配换行符\n。 -
关键作用 :法律法条或概念说明通常包含多段换行。传入
re.DOTALL标志后,.就可以匹配包含换行符在内的任意字符,确保使用(.*?)能完整抓取跨多行的正文文本。
4. 字符串限制扫描优化:entry[:100]
category_match = re.search(r"(([^)]+))", entry[:100])
- 工程技巧 :罪名的"类别"(如
(侵犯公民人身权利、民主权利罪))通常紧跟在罪名标题后面。通过只切片提取entry[:100]的前 100 个字符进行正则扫描,避免了对几千字的大文本段落进行无谓的全文本扫描,提升了解析性能。
1.2 按罪名查询图谱kg_lookup()
kg_lookup() 是知识图谱 RAG 流程的最后一环:根据前面提取到的标准罪名,从内存图谱缓存中读取对应的结构化字典,将其拼接重组为统一的 LangChain Document 格式。
这样做能够无缝将知识图谱(KG)的确定性规则与前面向量/BM25 检索出来的普通文档合并,送入 LLM 进行最终的 Prompt 上下文生成。
一、 函数代码执行逻辑梳理
输入罪名列表 crime_names (如 ["盗窃罪", "诈骗罪"])
│
▼
获取缓存的图谱字典 kg = _load_crime_kg()
初始化文档列表 docs = []
│
▼
遍历 crime_names 中的每个罪名 name
│
▼
在字典中查找 entry = kg.get(name)
/ \
[未找到] [已找到]
│ │
▼ ▼
跳过该罪名 (continue) 初始化拼接列表 parts = [f"【{name}】"]
│
▼
按顺序拼接字段并设置上限截断:
- 类别
- 概念与定义 (前 300 字)
- 犯罪构成 (前 500 字)
- 量刑处罚 (前 300 字)
- 相关法条 (前 300 字)
│
▼
拼接字符串 text = "\n".join(parts)
│
▼
组装 LangChain Document 对象
(含特定元数据 doc_type="kg")
追加至 docs 列表
│
└───────────────────┬───────────────────┘
│
▼
返回 docs 列表
二、 重点语法与工程细节
1. 安全字典取值:dict.get(key) 与 in 存在性检查
entry = kg.get(name)
if not entry:
continue
-
语法点 :使用
kg.get(name)而非kg[name]。若罪名不存在,前者返回None,后者会直接抛出KeyError导致崩盘。 -
防护作用 :配合
if "概念与定义" in entry:的字段级别检查,防范了不同罪名之间文本结构不完整(部分字段缺失)的问题。
2. 上下文长度调优与按需截断:[:300] / [:500]
parts.append(f"\n犯罪构成:\n{entry['犯罪构成'][:500]}")
-
工程考虑(Token 防膨胀) :图谱中的"犯罪构成"或"相关法条"可能长达数千字。在组装用于 Prompt 的上下文时,必须设置合理的字数上限(如构成要件取前 500 字,法条取前 300 字)。
-
作用:既保留了最关键的构成要件信息,又防止 KG 知识片段占用过长上下文,挤占主检索文档的 Token 配额。
3. 结构化文本拼接模式:parts 列表 + "\n".join(parts)
parts = [f"【{name}】"]
...
text = "\n".join(parts)
- 性能与易读性 :在 Python 中,多次使用
+拼接字符串会反复申请内存空间。先将子串放入列表,最后统一使用"\n".join(parts),既提高了代码的构建性能,又保证了生成的 Markdown 上下文格式排版清晰,利于 LLM 理解。
4. 标识元数据标记:doc_type: "kg"
metadata={
"doc_type": "kg",
"crime_name": name,
"category": entry.get("类别", ""),
"source_file": "犯罪知识图谱.txt",
}
-
工程作用 :为生成的
Document打上doc_type: "kg"的元数据标签。 -
后续价值 :在管道下游(如重排序 Stage 3、Prompt 渲染或引用来源溯源)时,程序可以根据
doc.metadata["doc_type"] == "kg"轻松识别出该文档来自于结构化知识图谱,从而为其配置更高置信度或特殊的展示样式。
Stage 3: 重排序(按策略精排,取前 top_k)
_rerank() 函数是 RAG 检索管线中的精排分发与熔断保护层。
它的核心职责是根据配置策略(如轻量关键词重排或 LLM 深度评分),对初筛获取的文档进行重新打分与排序,同时监控重排耗时,并在重排算法崩溃时触发无缝安全回退(Fallback),确保检索主流程的绝对可用性。
一、 函数代码执行逻辑梳理
输入用户问题 question 与候选文档列表 all_docs
│
▼
获取配置策略 strategy 与目标数量 top_k
│
▼
是否存在无效情况?
(strategy == NONE 或 all_docs 为空)
/ \
[是] [否]
│ │
▼ ▼
直接截取 all_docs[:top_k] 记录计时起点 t0 = time.time()
(零算法开销返回) 进入 try 块选择重排算法
│
┌─────────────────────────────────────────────┴─────────────────────────────────────────────┐
│ │ │
▼ ▼ ▼
[strategy == SIMPLE] [strategy == LLM] [其他未知策略]
│ │ │
▼ ▼ ▼
调用同步函数 simple_rerank 调用异步函数 await llm_rerank 直接截取前 top_k 项
(词频/交集等轻量重排) (LLM 大模型深度交互/打分) all_docs[:top_k]
│ │ │
└─────────────────────────────────────────────┬─────────────────────────────────────────────┘
│
▼
解包评分元组列表 (解构出 Document)
reranked = [doc for doc, _ in scored]
│
▼
计算并记录耗时 self.metrics["rerank_ms"]
│
▼
正常返回精排后的 reranked
│
┌───────────────────────────┴───────────────────────────┐
│ 异常捕获与熔断回退 (except Exception) │
└───────────────────────────────────────────────────────┘
│
▼
记录 logger.warning 日志 (打印 Stack Trace)
写入当前已消耗的毫秒数
降级安全返回原始列表截取 all_docs[:top_k]
二、 重点语法与工程细节
1. 枚举控制流:RerankStrategy 匹配
strategy = self.config.rerank_strategy
if strategy == RerankStrategy.NONE:
...
- 工程控制 :使用 Python 的
Enum(枚举类)统一管理策略,避免硬编码字符串(如"simple"或"llm")。这样能提高代码的强类型约束,防止拼写错误。
2. 混合异步调用与 await 条件等待
elif strategy == RerankStrategy.LLM:
scored = await llm_rerank(question, all_docs, top_k=top_k)
-
语法点 :
_rerank本身是一个async def异步函数。 -
工程策略:
-
当执行轻量的
simple_rerank时,它是一个普通的 CPU 密集/内存运算同步函数,直接调用; -
当执行
llm_rerank时,涉及到远端大模型网络 API 的 I/O 阻塞,使用await进行异步等待,避免卡死协程事件循环。
-
3. 列表推导式与元组解构:[doc for doc, _ in scored]
scored = await llm_rerank(...) # 返回格式通常为 [(Document, score), ...]
reranked = [doc for doc, _ in scored]
-
语法点 :使用下划线
_表示忽略不使用的变量(此处为浮点数得分score)。 -
工程作用 :重排函数底层的输出通常需要附带分数(用于调试或门限过滤),但管线上游只需要干净的
Document对象列表。这一行快速完成了解包与格式转换。
4. 性能指标监控与时间差计算:round((time.time() - t0) * 1000, 1)
self.metrics["rerank_ms"] = round((time.time() - t0) * 1000, 1)
-
计算逻辑 :
time.time()返回秒数,相减后乘以1000转化为毫秒(ms),round(..., 1)保留一位小数。 -
工程作用 :把监控指标打入
self.metrics字典,便于在链路追踪(Tracing)或响应头中回传重排耗时,方便排查性能瓶颈。
5. 系统级熔断与优雅降级(Graceful Degradation)
except Exception:
logger.warning("重排序失败,回退为直接截取 top_k", exc_info=True)
return all_docs[:top_k]
-
工程核心设计:在生产级 RAG 架构中,重排阶段最容易因网络超时、LLM 速率限制(Rate Limit)或内存溢出而报错。
-
保护机制 :通过捕获所有异常并配合
exc_info=True记录完整日志栈,同时强行退回到all_docs[:top_k]。这保证了系统"宁可重排质量下降,也绝不向前端抛出 500 错误"。
策略1:简单重排序simple_rerank()
simple_rerank() 函数实现了一个基于词项重叠率(Jaccard 相似度)与领域元数据命中加权的轻量级 CPU 精排算法。
在实际工程中,它不需要加载昂贵的神经网络模型,也不需要调远端 API,非常适合作为零成本、极低延迟(<5ms)的兜底重排策略。
一、 函数代码执行逻辑梳理
输入查询词 query、文档列表 documents 与截取数 top_k
│
▼
使用 jieba 将查询词切词并转为集合 query_tokens
(利用 set 自动去重,提取特征词)
│
▼
初始化打分列表 scored = []
│
▼
遍历 documents 中的每一个文档 doc
│
▼
1. 对文本内容切词转集合 doc_tokens
doc_tokens = set(jieba.cut(doc.page_content))
│
▼
2. 计算 Jaccard 相似度分值
- 交集:intersection = query_tokens & doc_tokens
- 并集:union = query_tokens | doc_tokens
- 基础分:jaccard = len(intersection) / len(union)
│
▼
3. 根据元数据(Metadata)匹配计算加成分 (bonus)
- 若法律名称 (law_name) 在 query 中: +0.2
- 若法条编号 (article_number) 在 query 中: +0.3
- 若指导性案例号 (guiding_number) 在 query 中: +0.2
│
▼
4. 计算最终得分 (jaccard + bonus)
以 (doc, final_score) 元组形式追加写入 scored 列表
│
▼
按得分降序排序: scored.sort(...)
│
▼
返回前 top_k 项元组列表 scored[:top_k]
二、 重点语法与工程细节
1. 集合运算(Set Operations)快速计算交并集
query_tokens = set(jieba.cut(query))
doc_tokens = set(jieba.cut(doc.page_content))
intersection = query_tokens & doc_tokens # 按位与运算符:计算交集
union = query_tokens | doc_tokens # 按位或运算符:计算并集
-
语法点 :Python 的
set(集合)重载了运算符:-
&相当于set.intersection(),提取两边都有的词项; -
|相当于set.union(),提取两边所有的词项(自动去重)。
-
-
工程优势:集合的交并集底层由 Hash Table 实现,运算复杂度为 O(\\min(N, M)),比在 List 中做循环匹配快了几个数量级。
2. Jaccard 相似度与除零保护
jaccard = len(intersection) / len(union) if union else 0.0
-
算法原理:Jaccard 相似度公式为:
-
语法点(三元表达式) :
val if condition else fallback。如果union为空集合(例如用户输入的都是停用词或空白),len(union)为 0,此时直接赋予基础分0.0,完美避开了 Python 的ZeroDivisionError(除以零异常)。
3. Lambda 隐式函数与自定义排序:sort(key=...)
scored.sort(key=lambda x: x[1], reverse=True)
-
语法点:
-
lambda x: x[1]:定义了一个匿名函数。输入的x是形如(Document, score)的二元组,x[1]提取出得分score; -
reverse=True:表示降序排列(分数越高排在越前面)。
-
-
工程作用 :就地(In-place)对
scored列表按分数重新排序,性能优于重新生成新列表的sorted()。
4. 业务规则加权(Rule-based Boosting)
article_num = meta.get("article_number", "")
if article_num and article_num in query:
bonus += 0.3
-
工程逻辑:纯基于词频匹配的算法容易忽略特定场景的强规则。
-
业务价值 :在法律场景下,如果用户提问中显式提到了"刑法第264条 ",那么元数据中包含了
article_number="第264条"的文档极有可能是用户真正需要的法条。在此处强行给它加权+0.3,能让它在重排后强行冲到最前列,体现了"规则 + 算法"结合的威力。
策略2:LLM 重排序(批量单次调用)llm_rerank
llm_rerank() 函数实现了典型的 "预筛选 + LLM 单次批量打分(Batch Scoring)" 高性能精排模式。
在工程实践中,如果直接对几十篇候选文档逐一调用 LLM 打分,会带来极高的延迟(几十秒)和 Token 成本。该函数通过先用 simple_rerank 粗筛、再拼入单次 Prompt 让 LLM 批量评分,在保证重排精度的同时将网络 RTT 降到了 1 次。
一、 函数代码执行逻辑梳理
输入用户问题 query、候选文档列表 documents、目标数 top_k
│
▼
┌────────────────────────────────────────────────────────────────────────┐
│ 阶段一:动态窗口预筛选(粗筛控制,防 Token 溢出与延迟) │
└────────────────────────────────────────────────────────────────────────┘
│
▼
动态计算预筛选阀值 pre_k = min(len(documents), top_k * 2, 8)
调用 simple_rerank(query, documents, top_k=pre_k)
提取前 pre_k 篇候选文档 candidates
│
candidates 是否为空?
/ \
[是] [否]
│ │
▼ ▼
返回空列表 [] ┌────────────────────────────────────────┐
│ 阶段二:批量 Prompt 构建 │
└────────────────────────────────────────┘
│
▼
遍历 candidates 列表 (索引从 1 开始):
- 截取正文前 300 字
- 短路运算提取元数据标签 label
- 拼接为 "[文本i] label\n text"
│
▼
用 "\n\n".join() 合并为 doc_list
填充 _BATCH_RERANK_PROMPT 生成 prompt
│
┌──────────────────────────────────────────────────┴─────────────────────┐
│ 阶段三:LLM 单次批量评分与容错熔断 │
└────────────────────────────────────────────────────────────────────────┘
│
▼
异步调用 LLM: resp = await llm.ainvoke(prompt)
解析出分数列表 scores = _parse_batch_scores(...)
│
LLM 评分是否成功抛出 Exception?
/ \
[报错] [成功]
│ │
▼ ▼
记录 logger.warning 使用 zip(candidates, scores)
降级回退返回预筛选结果 按得分降序排序 sorted.sort(...)
return pre_scored[:top_k] 返回 top_k 项:scored[:top_k]
二、 重点语法与工程细节
1. 组合截断机制:min(len(documents), top_k * 2, 8)
pre_k = min(len(documents), top_k * 2, 8)
-
语法点 :
min()接收多个数值参数,返回其中的最小值。 -
工程作用 :三重保护机制:
-
不超过文档实际总数(
len(documents)); -
按照目标数翻倍取候选(
top_k * 2); -
设置硬上限 8 篇 ,防止即使
top_k设置很大时把过长文本一次性压给 LLM,严格控制了 LLM 的延迟与 Token 开销。
-
2. 枚举带索引遍历与起始值指定:enumerate(candidates, 1)
for i, doc in enumerate(candidates, 1):
-
语法点 :
enumerate(iterable, start=0)用于在遍历时同时获取索引与元素。设置start=1后,循环中的i直接从 1 开始计次(1, 2, 3...)。 -
工程对齐 :Prompt 中要求 LLM 按照
1: 评分,2: 评分的格式输出,因此文本序号从 1 开始与 Prompt 的预期格式严格对齐,方便后续正则或按行解析。
3. 链式短路逻辑(Truthy/Falsy 级联取值)
label = meta.get("law_name", "") or meta.get("guiding_number", "") or meta.get("source_file", "")
-
语法点 :Python 中的
or属于短路运算符,从左到右返回第一个真值(非空字符串)。 -
工程作用 :文档元数据可能多样化。优先使用法律名称(
law_name),如果没有则退而求其次使用指导案例号(guiding_number),最后兜底使用源文件名(source_file)。一行代码优雅完成了优先级兜底。
4. 字符串批量填充:str.format()
doc_list = "\n\n".join(doc_parts)
prompt = _BATCH_RERANK_PROMPT.format(query=query, doc_list=doc_list)
-
语法点 :利用模板字符串中的
{query}和{doc_list}占位符,通过.format()注入变量。 -
解析技巧 : Prompt 中如果有不需要被替换的裸花括号
{},必须双写转义为{``{}}(如代码中没有,则正常写单个{}即可)。
5. 聚合函数:zip(candidates, scores)
scored = list(zip(candidates, scores))
-
语法点 :
zip()函数将两个可迭代对象(列表 A[doc1, doc2]和 列表 B[score1, score2])中对应的元素按顺序打包成一个个元组(doc1, score1)。 -
工程作用 :将打分前的
Document列表与解析出的浮点数/整数scores一一对齐,方便后续直接使用.sort(key=lambda x: x[1])进行整体降序重排序。
6. 两级降级保护机制(Failover to Simple Rerank)
except Exception:
logger.warning("LLM 重排序失败,回退为简单重排结果", exc_info=True)
return pre_scored[:top_k]
-
工程设计:由于网络抖动、LLM 返回格式乱序或 API 格式校验失败,LLM 重排阶段是高风险区。
-
降级策略 :直接返回阶段一已经计算好的
pre_scored[:top_k]。既没有浪费前面轻量重排的计算成果,又避免了系统直接报错崩溃。
Stage 4: 生成(选择生成策略并调用 LLM 产出答案)
_generate() 函数是 RAG 检索管线中的生成层(Stage 4)与生成后反思校验层。
它的核心职责是:根据配置的策略(标准问答、思维链 CoT、结构化回答或自我反思 Self-Reflect),选用对应的 Prompt 模板,调用 LLM 生成最终的法律回答;若启用了自我反思机制,则还会对初答进行幻觉校验与纠错。
一、 函数代码执行逻辑梳理
输入用户问题 question 与重排后的文档列表 docs
│
▼
记录生成起始时间 t0
组装上下文 context = build_context(docs)
获取生成策略 strategy
│
▼
┌────────────────────────────────────────────────────────────────────────┐
│ 阶段一:动态选择 Prompt 模板与生成初答 │
└────────────────────────────────────────────────────────────────────────┘
│
▼
根据 strategy 策略匹配对应的 Prompt 模板:
- COT (思维链) -> LEGAL_COT_PROMPT
- STRUCTURED (结构化) -> LEGAL_STRUCTURED_PROMPT
- 其他 (包含 SELF_REFLECT) -> LEGAL_QA_PROMPT
│
▼
构建并异步执行 LCEL 链:chain = prompt | llm
response = await chain.ainvoke({"context": context, "question": question})
提取初步回答 answer = response.content,初始化 was_corrected = False
│
┌────────────────────────────────────────────────────────────────────────┐
│ 阶段二:自我反思校验与修正分流 (Self-Correction Branch) │
└────────────────────────────────────────────────────────────────────────┘
│
策略是否为 GenerationStrategy.SELF_REFLECT ?
/ \
[是] [否]
│ │
▼ ▼
1. 计算并写入 initial_generation_ms 耗时 1. 计算整体生成耗时 generation_ms
2. 记录反思计时 t_reflect 2. 返回 (answer, was_corrected)
3. 延迟导入 self_reflect_and_correct 元组
4. 异步调用自我反思与纠错函数:
await self_reflect_and_correct(...)
5. 写入 self_reflect_ms 耗时
6. 返回 (修正后的 answer, was_corrected)
二、 重点语法与工程细节
1. 函数局部延迟导入(Lazy Import)
from app.services.self_reflect import self_reflect_and_correct
-
语法点 :将
import显式放在条件分支代码块中,而不是放在文件最上方。 -
工程机制 :如果系统配置的生成策略不是
SELF_REFLECT,程序完全不需要解包与加载反思服务的模块依赖,既节省了服务初始化资源,也成功解耦了主流程与反思组件,避免了循环引用(Circular Import)。
2. 多阶段细粒度耗时监控(Granular Telemetry)
# 自我反思模式下:分段统计初生成与反思的时间
gen_ms = round((time.time() - t0) * 1000, 1)
self.metrics["generation_ms"] = gen_ms
t_reflect = time.time()
...
self.metrics["self_reflect_ms"] = round((time.time() - t_reflect) * 1000, 1)
- 工程作用 :反思机制由于引入了额外的 LLM 验证轮次,耗时通常显著高于单次生成。通过用
t0和t_reflect两个时间戳独立计算generation_ms与self_reflect_ms,可以在 APM 监控面板上清晰地评估"反思步骤究竟增加了多少延迟"。
3. 策略模式(Strategy Pattern)驱动 Prompt 动态路由
if strategy == GenerationStrategy.COT:
prompt = LEGAL_COT_PROMPT
elif strategy == GenerationStrategy.STRUCTURED:
prompt = LEGAL_STRUCTURED_PROMPT
else:
prompt = LEGAL_QA_PROMPT
- 工程设计 :解耦了生成管道与具体的 Prompt 逻辑。新增任何生成范式(如法律意见书格式、庭审流程格式),只需要在
GenerationStrategy枚举中添加项并在此处扩展分支即可,上游代码完全无需变更。
4. 元组状态标记:tuple[str, bool]
async def _generate(...) -> tuple[str, bool]:
...
return answer, was_corrected
- 工程价值 :不仅返回字符串类型的
answer,还顺带返回布尔值was_corrected。这让下游模块(如 API 响应体、日志或前端 UI)可以感知到"该答案是否经历了自我纠错",从而为用户打上如"已通过自我反思核验"的置信度标签。
1 将文档拼装为受限长度的上下文build_context()
build_context() 函数是 RAG 检索管线中负责上下文预算控制与类型优先级排序的核心函数。
在本地部署中小参数量模型(如 8B 模型)时,上下文窗口(Context Window)非常紧张。该函数通过优先级重排(KG > 法条 > 案例)与字符级硬上限截断,确保最关键、确定性最高的法律规则能优先塞入 Prompt 中,同时防止超过 Token 预算引发溢出。
一、 函数代码执行逻辑梳理
输入候选文档列表 docs,上下文字符最大限制 max_length (默认 4000)
│
▼
docs 是否为空列表?
/ \
[是] [否]
│ │
▼ ▼
返回 "未找到相关参考资料。" 定义优先级权重字典 PRIORITY:
- kg: 0 (最高)
- law/statute: 1 (次高)
- case: 2 (中等)
- 其他类型: 默认 3 (最低)
│
▼
按优先级字典进行升序排序
sorted_docs = sorted(docs, key=...)
│
▼
初始化容器 parts = [] 与计数器 total_len = 0
│
▼
遍历 sorted_docs 列表 (索引从 1 开始):
1. 调用 format_source_display 格式化来源标签
2. 拼接为 "[来源i] 标签\n正文"
│
(total_len + len(segment) > max_length) ?
/ \
[超限] [未超限]
│ │
▼ ▼
立即跳出循环 (break) 将 segment 追加至 parts
不加入当前文档 更新 total_len += len(segment)
│ │
└────────────────────┬───────────────────────┘
│
▼
返回以换行分隔的最终字符串
"\n".join(parts)
二、 重点语法与工程细节
1. 基于字典查表的默认权重排序:dict.get(key, default)
PRIORITY = {"kg": 0, "law": 1, "statute": 1, "case": 2}
sorted_docs = sorted(docs, key=lambda d: PRIORITY.get(d.metadata.get("doc_type", ""), 3))
-
语法点:
-
d.metadata.get("doc_type", ""):从文档元数据中安全提取doc_type,若不存在则返回空字符串""; -
PRIORITY.get(..., 3):在字典中查找对应类型的优先级权重。如果doc_type未在字典中注册,则返回默认值3(优先级最低)。
-
-
算法效果 :
sorted()默认按升序排列。权重值越小(0 < 1 < 2 < 3),文档在排序后的列表中越靠前,实现了"知识图谱 > 法律法规 > 司法案例 > 其他"的绝对优先级序列。
2. 滑动累计截断与安全预算判定(Budget Allocation)
if total_len + len(segment) > max_length:
break
-
工程技巧 :在将字符串追加到
parts之前,先进行预判(Look-ahead Check)。 -
防溢出机制 :避免直接截断单个文档正文导致半句话或语法破损,而是采取完整文档粒度的保护拦截 。一旦当前文档加上后会超越
max_length上限,直接退出循环,放弃拼接后续所有低优先级的文档。
3. 带有基数偏移的索引遍历:enumerate(..., 1)
for i, doc in enumerate(sorted_docs, 1):
segment = f"[来源{i}] {source_label}\n{doc.page_content}\n"
-
语法点 :
enumerate(sorted_docs, 1)从 1 开始编号。 -
工程作用 :为最终 Prompt 中的每一个上下文段落打上标准化的可参考标签(例如
[来源1]、[来源2])。这为上游 Prompt 提示 LLM"请根据 来源i 进行引用回答"奠定了格式化基础。
4. 高效字符串列表拼接:"\n".join(parts)
return "\n".join(parts)
- 语法点与性能 :避免在循环体内使用
context_str += segment形式的频繁字符串拷贝。维护parts列表并在末尾执行一次join,在 Python 解释器内部只需要一次性分配内存,保证了运行效率。
2 选择 prompt策略
链式推理模板
python
LEGAL_COT_PROMPT = ChatPromptTemplate.from_messages([
("system", """你是一个专业的中国法律顾问AI助手。请根据提供的参考资料,按以下步骤进行推理后回答用户的法律问题。
推理步骤:
1. **识别法律问题**:明确用户提问涉及的法律领域和核心问题
2. **查找适用法律**:从参考资料中找出适用的法律条文和案例
3. **分析构成要件**:逐一分析法律要件是否满足
4. **得出结论**:基于分析给出明确结论
5. **提示注意事项**:指出可能的例外情况或需要关注的要点
要求:
- 回答必须基于提供的参考资料,不要编造法律条文
- 每个推理步骤都要明确标注
- 引用具体的法律名称和条文编号
- 如果参考资料中没有相关信息,请诚实说明
参考资料:
{context}"""),
("human", "{question}"),
])
结构化法律回答模板
python
LEGAL_STRUCTURED_PROMPT = ChatPromptTemplate.from_messages([
("system", """你是一个专业的中国法律顾问AI助手。请根据提供的参考资料,以结构化格式回答用户的法律问题。
请严格按照以下格式输出:
## 法律结论
(简要回答用户的核心问题,1-2句话)
## 适用法律
(列出适用的法律法规和具体条文编号)
## 详细分析
(基于参考资料的详细法律分析)
## 注意事项
(特殊情况、例外规定或实务建议)
要求:
- 回答必须基于提供的参考资料,不要编造法律条文
- 引用具体的法律名称和条文编号
- 如果参考资料中没有相关信息,请诚实说明
参考资料:
{context}"""),
("human", "{question}"),
])
标准问答模板
python
LEGAL_QA_PROMPT = ChatPromptTemplate.from_messages([
("system", """你是一个专业的中国法律顾问AI助手。请根据提供的法律条文和指导案例,准确回答用户的法律问题。
要求:
1. 回答必须基于提供的参考资料,不要编造法律条文
2. 引用具体的法律名称和条文编号
3. 如果参考资料中没有相关信息,请诚实说明
4. 语言简洁专业,使用法律术语
5. 必要时区分不同情况分别作答
参考资料:
{context}"""),
("human", "{question}"),
])
附录:
1、Chroma Vs Milvus向量数据库

一、这个项目为什么选 Chroma------先看它的真实场景
回顾这个项目的数据规模,你就能判断选型合不合理:
- ~59,000 个 chunk(18 万条案例只导入了 1 万,还有 5000 上限)
- 单机、单 GPU、本地部署,Ollama 就在同一台机器上
- 个人/教学级并发,甚至没做并发压测
在这个量级下,向量库的竞争点根本不是"能不能扛住十亿向量",而是:装起来多快、跑起来多省事、会不会成为 Agent 应用里最脆的一个环节。Chroma 完全踩中:
- 进程内嵌入式 ---
pip install chromadb后Chroma(collection_name=..., persist_directory=...)直接用,不启动任何独立服务。代码里 vectorstore.py 就 27 行,get_vectorstore()按 collection 缓存实例,零配置。 - 自带持久化和元数据过滤 --- 落盘就是
chroma_db/下那个 sqlite 文件,不需要外挂 MinIO、etcd 这些存储。而legal_chunker.py里大量用doc_type/law_name/article_number元数据做过滤和展示,Chroma 的 metadata 过滤够用。 - Python-first --- 和 LangChain 的
langchain_community.vectorstores.Chroma是同一生态,API 语义直通,没有任何阻抗。
而 Milvus 在这个场景是负资产:为了跑一个 6 万条向量的库,你得起一整套集群组件,光部署和运维就超过项目其余部分的工作量。
二、两者本质区别
一句话概括:Chroma 是一个"嵌进你 Python 进程的向量库",Milvus 是一个"需要独立部署的分布式数据库服务"。这不是档位差异,是架构形态差异。
| 维度 | Chroma | Milvus |
|---|---|---|
| 定位 | 嵌入式 / Python-first 轻量库 | 云原生分布式向量数据库 |
| 部署 | pip install 即用,进程内运行(新版 1.x 也提供 server 模式) |
组件化集群:proxy / query node / data node / index node + etcd(元数据) + MinIO/S3(存储) + Pulsar/Kafka(消息) |
| 数据规模 | 适合万~百万级(本地/small 场景) | 面向十亿级、水平扩展 |
| 并发 | 低,单写为主(SQLite 写锁) | 高 QPS、分布式并行查询 |
| 索引能力 | 内置 HNSW(封装程度高,不可细调) | HNSW / IVF_FLAT / IVF_SQ8 / IVF_PQ / DiskANN / SCANN / GPU 索引,可按数据形态选 |
| 检索能力 | 基础向量相似度 + 简单元数据过滤 | 稠密+稀疏混合检索(内置 BM25/全文检索)、partition 分区、字段级标量过滤、多租户 RBAC、可调一致性等级 |
| 运维负担 | 约等于零 | 需要懂分布式存储/消息队列,通常上 k8s |
| 上手到产出 | 半小时 | 半天到数天(含环境搭建) |
| 适合场景 | 原型、本地 RAG、教学、嵌入式/桌面应用、中小规模 | 生产环境、大规模高并发、企业多租户、需要混合检索的严肃 RAG |
一个常见的补充认知:FAISS 不是数据库,是库 ------内存态、不持久化、无元数据过滤,所以不在"数据库选型"这个对比里;而 pgvector 是"不想引第三方组件"时的折中(向量存进 Postgres),适合已有 Postgres 的系统。
三、Agent 开发工程师视角的选型框架
选向量库本质是回答 5 个问题,和选任何存储一样:
- 数据量多大? 万级 → Chroma;百万级+ → 认真考虑 Milvus/Qdrant/云服务
- 并发多高? 自己一个人调式/低并发 → Chroma;面向用户的线上服务 → Milvus
- 部署环境在哪? 单机脚本/本地工具 → Chroma;k8s 集群 → Milvus 才是主场
- 检索能力要求? 只要 top-k 相似度 + 基础过滤 → Chroma;需要混合检索(稀疏+稠密)、复杂过滤、多租户 → Milvus 才是必需
- 你愿意为"运维"付多少成本? 有没有人维护集群,决定了上面所有答案
对 Agent 应用开发者,还要加一条思考:向量库只是你的 Agent/RAG 架构里的"记忆层"一环 。它不应该是你最费神的部分------你的精力应该在检索策略 、Prompt 、工具编排上。所以在架构初期用 Chroma 快速把链路跑通、验证检索质量,是极正确的工程决策。
另外注意一个细节:这个项目用 LangChain 的 Chroma 封装,将来数据量真涨上去了要迁移到 Milvus,只需要把 vectorstore.py 和 retriever.py 里的 langchain_community.vectorstores.Chroma 换成 langchain_milvus 的对应类,上层 RAG 管线、分块、prompt 几乎零改动------这就是用 LangChain 抽象层带来的可迁移性,也是为什么这类教程项目敢先在 Chroma 上快速迭代。
总结成一张决策速查:
- 教学/原型/本地/嵌入式/数据 < 50 万条 → Chroma(现在这个项目,选对)
- 生产/高并发/十亿级/需要混合检索/有运维团队 → Milvus
- 不想引新组件、已有 Postgres → pgvector
- 纯算法验证、不在意持久化 → FAISS
2、异步事件循环
一、为什么这里必须用 asyncio.run(main())
直接说结论:await 只能在事件循环里执行,而 main() 是 async def,你不给它一个事件循环,它就只是一块「没通电的代码」。
debug_one_query.py:43-65 里实际发生了什么:
if __name__ == "__main__":
asyncio.run(main())
main()返回的不是结果,而是一个协程对象(coroutine) 。async def函数被调用时不会执行函数体,只是创建了一个可挂起/恢复的状态机。asyncio.run(main())做的事:创建一个全新的事件循环 → 把main()作为任务投进去 → 驱动循环一直转,直到main()返回 → 关闭并清理这个循环。- 如果没有这行,直接
main(),什么都不会跑,还会收到RuntimeWarning: coroutine 'main' was never awaited的警告。
你可能会想:「那我自己手动 loop.run_until_complete(main()) 也行?」------当然行,但 asyncio.run() 是 Python 3.7+ 推荐的唯一正确入口,因为它自带清理(关闭 loop、回收所有 pending task),不容易泄漏。官方文档甚至说:这个函数永远不应该在一个已经在运行的循环里被调用。
二、异步事件循环基础知识点(复习)
1. 核心三件套:协程 / await / 事件循环
| 概念 | 本质 | 类比 |
|---|---|---|
协程 async def f() |
一个可挂起的函数,调用后不执行,返回协程对象 | 一份「菜谱」,需要人照做 |
await |
把控制权交还给事件循环,让出 CPU | 菜做到一半「等水烧开」→ 先去做别的菜 |
事件循环 asyncio.run() |
一个永不停歇的调度器(除非没任务了) | 厨房里的厨师长,统筹安排 |
事件循环就是一条无限循环的调度代码,反复问三件事:
- 有没有就绪的协程可以恢复?(把 await 断点续上)
- 有没有到期的定时器 ?(
asyncio.sleep到期了) - 有没有I/O 就绪的事件?(socket/HTTP 响应到了)
这行代码概括了它的全部工作:
while 还有待执行的任务 or 定时器 or 监听中的I/O:
执行就绪的协程直到遇到 await
处理到期定时器 / 就绪的I/O,把对应的协程恢复
2. 为什么 await 不能在普通函数里、必须在事件循环里?
因为 await 的语义是「挂起自己,把控制权交出去」 。你得先有一个「接收控制权的人」(事件循环),await 才有意义。
def sync_func():
await asyncio.sleep(1) # ❌ SyntaxError: 'await' outside async function
3. 单线程协作式并发(重点理解)
异步 ≠ 多线程。 整个程序默认只有一个线程、一个事件循环 。所谓「并发」是协作式 的:每个协程自觉在 await 处让出,循环再调度别的协程跑。
这也是为什么在这个脚本里异步特别合适------debug_one_query.py:49 的 await get_llm().ainvoke("你好") 是网络 I/O 等待(HTTP 请求到 Ollama):
线程只有一个,但时间线是这样被"塞满"的:
主线程: [发送请求→等响应........→拿到结果] ← 等待期间线程闲着
异步版: [发送请求→(让出)→处理别的→响应到了→恢复]
单线程没浪费,等待的时间用来干别的活了
pipeline 里第 192 行 asyncio.gather(mq_task, hyde_task) 就是证据:两个 LLM 调用同时发起、一起等,线程只忙一件"等"的事。
4. 阻塞 vs 挂起 ------ 异步代码的头号天敌
这是最容易踩的坑,复习时重点记:
await asyncio.sleep(1) # ✅ 挂起,让出控制权,事件循环还能转
time.sleep(1) # ❌ 阻塞,整个事件循环停摆,所有协程都卡住
因为 time.sleep 是同步阻塞 ,它把唯一的线程直接睡死,事件循环转不了,其他协程全都「被连坐」。同理,同步的 I/O(requests.get)也绝不能出现在 async 代码里 ,要用 httpx.AsyncClient / aiohttp 或把同步调用丢到线程池(asyncio.to_thread)。
⚠️ 顺带一个提醒:你的 pipeline 里第 223 行
retriever.invoke(q)是同步调用 (向量库查询),它嵌在 async 函数里。好在查询很快、不常是瓶颈,但如果哪天真觉得卡,可以用await asyncio.to_thread(retriever.invoke, q)把它挪出主循环。
5. 事件循环的生命周期与一些常识
- 每个线程只能有一个正在运行的循环 ;
asyncio.run一次性创建→用完→关闭。 - 任务(Task) :
asyncio.create_task(coro)把协程「丢进循环调度队列」,实现真正的并发;await一个协程是串行,create_task+gather才是并行等待。 - 同一线程里不能嵌套第二个循环 :所以在 FastAPI 里你不用(也不该)手动调
asyncio.run(),FastAPI 自己已经跑着一个 loop 了------这也是为什么 debug_one_query.py 和run_integration_test.py这种独立脚本 才需要asyncio.run,而 API 层不需要。
三、把这几块拼回你的脚本
if __name__ == "__main__":
asyncio.run(main()) # ① 建循环 → ② 把 main() 投进去 → ③ 驱动循环 → ④ 收尾
async def main():
await get_llm().ainvoke("你好") # 挂起点1:等 Ollama 响应(I/O)
get_embeddings().embed_query("测试") # 同步的,不用 await
resp = await pipe.execute(QUESTION) # 挂起点2:进入 pipeline,里面又是一串 await
VSCode 里 F11 单步跟进去时,你会发现每一步 await 的返回都「像是瞬间就完成了」------那其实是事件循环把「等待」折叠进了这个调试线程,这也是协程调试和普通函数调试观感上最大的区别。
一句话总结:asyncio.run(main()) = 给异步代码接上电源。没有事件循环,await 无从谈起;有了它,单线程也能靠「让出---调度」把大量 I/O 等待时间叠在一起用。
异步编程请跳转:
3、模块级单例缓存模式+工厂函数模式
一句话定位
vectorstore.py 做的是:用「工厂函数」做创建入口,用「模块级字典」做实例缓存,两件事合起来,实现"每个 collection 全局只有一个实例"的效果。
python
_store_cache: dict[str, Chroma] = {} # ① 模块级缓存容器
def get_vectorstore(collection_name: str) -> Chroma: # ② 工厂函数
if collection_name in _store_cache: # ③ 先查缓存
return _store_cache[collection_name]
store = Chroma(...) # ④ 未命中才创建
_store_cache[collection_name] = store # ⑤ 创建后回填
return store
模式一:工厂函数模式(Factory Function)
核心思想:不让调用方直接 new 对象,而是通过一个函数来拿对象。
在你的代码里,等价于:不让调用方自己写 Chroma(collection_name=..., embedding_function=..., persist_directory=...) 这一长串初始化,而是统一调 get_vectorstore("laws")。
这样做拿到了什么?
- 封装复杂性 ------Chroma 需要 3 个参数、还要联动
settings和get_embeddings()。这些细节全部收敛到一个函数里,调用方只需要知道"给我 collection 名,还我向量库"。 - 统一的变更点 ------以后想给 Chroma 加参数(比如
hnsw:space)、换 persist 目录、加日志,只改这一个函数,全项目生效。 - 可以随时"插缓存逻辑" ------工厂函数天然是加缓存的完美位置(后面模式二正是这么干的)。如果调用方各自
new,缓存就没法做了。
判断依据:你搜索一下项目里调用处,全都是 get_vectorstore("laws") 这种写法,没人直接 Chroma(...)------这就是工厂模式生效了。
模式二:模块级单例缓存模式
核心思想:昂贵资源只创建一次,之后全局复用。
先澄清一个精确的叫法:这不是 经典意义上的"单例"(经典单例 = 全局只有一个实例,谁拿都是同一个)。这是按 key 键控的多例注册表(registry) ------因为你有 laws、cases 两个 collection,每个 key 各有一个共享实例。每个 key 内部是单例的,所以叫"per-key singleton / 多例单例缓存"都行。
三个关键设计点:
① 缓存挂在模块级(module-level),决定了它的生命周期。
_store_cache: dict[str, Chroma] = {}
- 模块级(即不放在函数内) = 在
import时创建,存活到进程结束。 - 对比局部变量:如果缓存写在函数局部,函数一返回就没了,缓存就失效了。
- 对比类属性/全局单例类:模块级更简单直接,Python 里
import本身就会天然保证模块只加载一次,所以这个 dict 天然是进程级的单例。
② 为什么这种资源值得做单例?
因为创建 Chroma 实例是昂贵操作:
- 要打开并加载
persist_directory下的数据文件(把向量索引读进内存) - 要建立内部客户端/连接
- 它本身是有状态、可复用的对象(后续查询复用已加载的索引)
如果每次请求都新建一个,等于每个请求都重新读一遍磁盘索引------纯浪费。而且 Chroma 实例内部可能还持有连接池,重复创建会导致资源泄漏。
③ 线程/并发安全性。
在 FastAPI 里,事件循环是单线程的,get_vectorstore 在协程里被调用时不会有并发问题;而且服务启动后缓存就是只读命中了 ,所以这个字典既不需要锁,也不需要用 threading.local。如果你用多线程跑它,才需要考虑加锁------这是写这种模式时唯一要留意的点。
这段话我没理解,进一步解释请见附录4
两个模式是怎么组合的
get_vectorstore("laws")
│
▼
查缓存字典 ──命中──→ 直接返回缓存实例(零成本)
│
未命中
▼
走工厂创建 Chroma(昂贵:读磁盘)
│
▼
回填缓存字典
│
▼
返回实例
关键洞察:工厂模式提供了"拦截点",单例缓存模式提供了"复用机制",二者缺一不可。 没有工厂,缓存没地方放;没有缓存,工厂只是白白多包了一层。
这同时解释了 embeddings.py 里的 get_embeddings()------vectorstore.py:18 用 get_embeddings() 拿到的 BGE-M3 实例,也是同一个模式(embedding 模型加载到 GPU 更贵,更必须单例)。两个缓存模式通过工厂函数互相引用、层层复用------这就是模块化项目里常见的"单例叠单例"结构。
**app.core.embeddings.get_embeddings()**函数如下:
python
_embed_cache: dict[str, OllamaEmbeddings] = {} # 按模型名缓存 Embedding 实例
def get_embeddings(model: str | None = None) -> OllamaEmbeddings:
_model = model or settings.EMBEDDING_MODEL # 未指定则使用配置默认模型(bge-m3)
if _model not in _embed_cache: # 未命中缓存才创建新实例
_embed_cache[_model] = OllamaEmbeddings(
model=_model,
base_url=settings.OLLAMA_BASE_URL, # 本地 Ollama 服务地址
)
return _embed_cache[_model] # 返回缓存的 Embedding 实例
对比:为什么不用 @lru_cache?
functools.lru_cache 也能做键控缓存,但作者选择手写 dict,理由是:
python
@lru_cache
def get_vectorstore(collection_name: str) -> Chroma: ...
lru_cache 无法在函数体外主动清空(只能 cache_clear(),但那会顺带丢弃所有 collection)。而这里需要 reset_store_cache() 这个细粒度清空操作:
python
def reset_store_cache():
_store_cache.clear() # 清空后,下次 get_vectorstore 会重新从磁盘加载
这个函数是给重建索引 场景用的(索引重新生成后,旧缓存里是旧数据,必须失效),配合工厂模式"缓存未命中就走重新创建"的逻辑,就实现了缓存失效机制 。这是手写 dict 相比 lru_cache 的优势------缓存的生命周期管理权在自己手里。
总结对比表
| 模式 | 解决什么问题 | 在本文件的体现 |
|---|---|---|
| 工厂函数 | 封装创建逻辑、统一变更点、让"替换/加缓存"成为可能 | get_vectorstore() 作为唯一创建入口 |
| 模块级单例缓存 | 昂贵资源进程级复用,避免重复加载 | _store_cache 字典 + 命中/回填逻辑 |
| 组合效果 | 每个 collection 全局一个实例,按需懒加载 | laws、cases 各一个共享实例 |
| 配套的失效机制 | 数据变更时让缓存作废 | reset_store_cache() 清空字典 |
一句话记法:工厂管"怎么造",缓存管"造一次",模块级管"活多久",reset 管"什么时候作废"。 四件事合起来,就是生产环境里管理昂贵资源的标准姿势。
4、线程/并发安全性进一步解释
先建立一个核心直觉
并发问题(race condition)的本质是:两个执行流同时访问同一份可变数据。
那么"会不会出问题",完全取决于一个问题------你的代码里,检查→创建→插入这一段,会不会被中途打断?
check → create → insert ← 这一段如果是"一气呵成"的,就安全
下面看两种环境下,这一段的命运完全不同。
第一层:FastAPI 事件循环里,为什么"打不断"
1. 单线程,一次只跑一段代码
FastAPI 跑起来后,整个进程里跑协程的就是那一个线程 。任何时刻,事件循环要么在执行某一个 协程的某一段,要么在 await 处等待。
关键机制:协程只在 await 这一行才切换。 没有 await 的普通代码,会一口气执行到底,中途绝对不会被换走。
2. 而 get_vectorstore()恰好是"没有 await 的同步函数"
这是整件事的题眼,你注意看:
python
def get_vectorstore(collection_name: str) -> Chroma: # 不是 async def!
if collection_name in _store_cache: # 查
return _store_cache[collection_name]
store = Chroma(...) # 建(同步的)
_store_cache[collection_name] = store # 插
return store
它内部没有 await 。所以哪怕它是从 async 代码里被调用的,事件循环也没机会打断它------从 if 判断到 插入缓存,这一整段是原子性执行完的。
对比一下,如果它是这么写的,就危险了:
async def get_vectorstore(...): if ...: return ... store = await Chroma.async_create(...) # ❌ 这里让出了控制权! _store_cache[...] = store # 回来时可能已被别人改过中间一旦有
await,切换窗口就出现了。
3. 用"时间线"看
两个协程 A、B 几乎同时调用它,在事件循环里的真实时间线是这样的:
线程执行: [A 执行 get_vectorstore 一口气跑完 查→建→插] [B 执行 get_vectorstore 一口气跑完]
↑ 只有一个线程,永远串行,不可能叠在一起
因为只有一个线程,A 跑的时候 B 根本不可能在跑。两个执行流物理上无法同时出现------没有并行,自然没有竞争。这是"单线程"带来的最朴素的安全保证。
第二层:换成多线程,为什么就出问题了
多线程时代,情况彻底变了:两个线程是真的可以同时跑的(多核 CPU 上并行执行)。
1. 你以为 GIL 能保护你?不能完全保护
CPython 确实有 GIL(全局解释器锁),但它的真相是:GIL 只保证"单条字节码"是原子的,不保证"检查→创建→插入"这个复合操作是原子的。
解释器会在任意两条字节码之间(约每 5ms,或遇到 I/O 时)切换线程。更狠的是,Chroma(...) 构造时会做磁盘 I/O ------而 I/O 会主动释放 GIL,等于告诉别的线程"这会儿你来跑"。
2. 经典 TOCTOU 竞争
于是冷启动时两个线程同时撞进来:
线程 A: 查缓存 → 未命中 ← 撞车点!
线程 B: 查缓存 → 未命中 ← B 也在这一刻查
线程 A: Chroma(...) 创建实例A (I/O 时 GIL 释放,B 趁机继续跑)
线程 B: Chroma(...) 创建实例B ← B 没看到 A 已经建了
线程 A: 插入 dict → 实例A
线程 B: 插入 dict → 实例B ← 覆盖掉 A,A 被"孤儿化"
结果:同一个 collection 被加载了两遍 (磁盘读了两次、内存两份模型副本),还丢了引用。这就是"检查时间"和"使用时间"之间出现空档导致的 race------time-of-check to time-of-use,TOCTOU。
这个 bug 的可怕之处在于:它不是每次都发生,取决于线程撞车的运气,属于最难排查的偶发性问题。
第三层:为什么"服务启动后只读命中"就完全安全了
作者特意强调这点,因为它把风险窗口说得非常清楚:
- 冷启动阶段(第一次调用):有"检查→创建→插入"的写操作序列 → 这才是唯一的风险窗口。
- 热启动之后 :
get_vectorstore只剩一条路------return _store_cache[collection_name]。纯读操作。
而 "读一个字典"是原子操作------一次取一个引用,要么取到要么取不到,不可能读到半个对象。不管单线程还是多线程,纯读共享数据都不会有竞争。
所以风险窗口 = "第一次建索引 + 并发命中"同时出现 ,而生产服务通常是启动时预热(或顺序加载)完再对外服务,这个窗口基本碰不上。作者的意思就是:"风险理论存在,但在这个场景下实际打不开,所以不加锁是划算的。"
如何理解冷启动与热启动?见附录5
第四层:为什么"不需要 threading.local"------这其实是个精妙点
你可能会想:"既然怕并发,那用 threading.local 让每个线程各存一份不就行了吗?"
恰恰相反------threading.local 在这里不但没用,还会毁掉这个模式的设计目标。
threading.local 的语义是:每个线程拿到自己独立的副本,互不相干。
threading.local() → 线程A 有一份缓存 / 线程B 有另一份缓存(彼此看不见)
而我们这个模式的目标是什么?------让全局共享同一个 Chroma 实例,只加载一次、只占一份内存、所有请求复用。
threading.local 会让两个线程各自加载一份 Chroma------省内存的目标直接没了,而且实例之间数据不同步,比不加锁更糟。
threading.local的正确使用场景是:每个线程持有不该共享的资源(比如旧式 web 框架里,每个请求线程各持一个数据库连接,因为连接对象自身不是线程安全的)。
所以作者这句话的完整意思是:单线程场景下连"给每个线程分隔离副本"这种成本都不需要------一个全局共享的普通 dict 就是最合适的选择。
第五层:这个设计真正妙的地方
把前面的全串起来,你会看到一个很漂亮的自洽结构:
| 多线程同步服务器(如 Flask) | 单线程异步服务器(FastAPI) | |
|---|---|---|
| 并发方式 | 线程真并行 | 协程在 await 处交错 |
| 共享可变状态 | 危险,要加锁 | 同步代码段内天然安全 |
| 昂贵资源 | 常用 threading.local 隔离 |
直接共享单例 |
| 结论 | 需要锁 | 不需要锁,也不需要隔离 |
FastAPI 的异步单线程 + 共享单例,是一场完美匹配 :你既拿到了并发(大量请求同时在 await 等 I/O,不互相阻塞),又拿到了安全(真正操作共享数据的同步代码段从不被打断)。用一句话总结:
异步把"并发性"放在了
await处,把"排他性"留给了同步代码段------单例模式恰好活在同步代码段里,于是两者互不冲突。
延伸:如果真的变成多线程,怎么补课?
如果你哪天确实要多线程调用(比如用 ThreadPoolExecutor 跑向量检索),补丁方式有两种,从朴素到优雅:
python
# 方式一:加锁(简单粗暴)
_lock = threading.Lock()
def get_vectorstore(name: str) -> Chroma:
with _lock: # 把"查→建→插"整体锁住
if name in _store_cache:
return _store_cache[name]
store = Chroma(...)
_store_cache[name] = store
return store
# 方式二:double-checked(先无锁查,命中就免锁;只有未命中才进锁)
def get_vectorstore(name: str) -> Chroma:
store = _store_cache.get(name)
if store is not None:
return store
with _lock:
if name not in _store_cache: # 进锁后二次确认
_store_cache[name] = Chroma(...)
return _store_cache[name]
方式二里"先无锁查"正是利用了"读是原子"的性质------平时热路径零锁开销,只有冷启动那一小段才进锁,这就是把前面讲的所有原理都落到了代码上。
一句话总结这段结论的底层逻辑: 单线程 = 无并行 = 同步代码段原子 = 共享安全;多线程 = 并行 = 复合操作可被打断 = TOCTOU 竞争;而"启动后只读"把唯一的风险窗口也关闭了,所以锁和 threading.local 都是多余。
如果你感兴趣,我可以再往下挖一层:为什么 GIL 不是万能盾牌(IO 释放、字节码粒度切换的具体机制),这块讲清楚了,你对所有 Python 并发坑都会有"免疫"般的理解。
5、冷启动与热启动的底层原理
一、一句话定义
- 冷启动 :资源还没就位,第一次用到时现造------要付全部初始化代价。
- 热启动 :资源已经在内存/显存里躺着,拿来即用------代价趋近于零。
冷和热之间差的,就是「把资源准备好」这件事的代价。 下一节拆这个代价。
二、统一底层原理:代价的本质 = 把数据搬到更快的介质 + 初始化
计算机的底层原理可以用一张「存储层次金字塔」说清:
速度 容量 代价
极快 极小
CPU 寄存器 ▲ ▼ ▲ 贵(搬一次都要钱)
L1/L2/L3 缓存 │ │
内存 RAM │ 你的"热"在这 │
显存 VRAM │ │
磁盘/SSD ▼ ▲ ▼ 便宜(大而慢)
/ 网络远端 │
▼
一条铁律:计算机的"时间开销"几乎都来自数据在层次之间移动,以及目标层上的初始化。 具体到一个系统,冷启动时你在为四件事买单:
- 搬运------把模型权重/索引数据从磁盘读进内存或显存(I/O,最慢的一步)。
- 转换------加载时反序列化、量化、构建索引结构(CPU 计算)。
- 分配------申请内存/显存、分配 KV cache(LLM 推理的缓存区)。
- 连接------建立网络连接、初始化客户端(如 Chroma 客户端连后端)。
热启动时这四件事一件都不用做 ,因为第一项和后面三项的产物都还在。这就是全部原理------冷热之差 = 初始化代价之差。
三、你的项目里的三层冷/热启动
对照刚才读的三个文件,每一层都是同一个模式,但"资源"不同、代价的量级也不同:
层 1:向量库索引(vectorstore.py)
store = Chroma(collection_name=..., persist_directory=...) # 冷:要读磁盘里的索引文件
- 冷 :第一次
get_vectorstore("laws")→ 把persist_directory下的 HNSW 索引、collection 元数据从磁盘读进内存,构建好查询结构。 - 热 :
_store_cache命中 → 直接返回内存里的实例,微秒级。 - 量级:小数据集几十毫秒,大索引几秒。加载的是你的法律数据。
层 2:Embedding 模型 BGE-M3(embeddings.py)
_embed_cache[_model] = OllamaEmbeddings(...) # 注意:这行其实很便宜!
这里有个关键洞察,值得专门点出来 :get_embeddings() 里 OllamaEmbeddings(...) 创建的只是一个瘦客户端封装对象(存几个配置参数),创建它本身几乎不花钱。
真正的冷启动发生在之后的第一次 embed_query("测试") 上 ------那一刻才会把 bge-m3 模型权重从磁盘加载进显存 。加载的是模型,几百 MB 到几 GB 的权重文件。
层 3:LLM qwen3:8b(llm.py)
和层 2 同理:ChatOllama(model=..., temperature=...) 只创建封装对象,便宜。
真正的冷启动在第一次 ainvoke() :Ollama 收到推理请求 → 发现模型不在显存 → 读权重(qwen3:8b 约 5GB)→ 量化 → 载入 VRAM → 分配 KV cache 。这正是 debug_one_query.py:45 注释里写的 10~30 秒。
⚠️ 所以你会看到一个"错位":三个
get_xxx()缓存解决的是封装对象 的重复创建;而真正的冷启动代价,落在每个对象第一次被实际调用的时候。缓存代码把「建对象」变成 O(1),但「模型加载」这步躲不掉------这就是为什么需要预热。
四、debug 脚本里的预热 = 手动把"冷"转成"热"
现在回头看预热那两行,它的意义就完全清楚了:
print("预热本地模型...")
await get_llm().ainvoke("你好") # 触发 qwen3:8b 首次加载(10-30s 花在这)
get_embeddings().embed_query("测试") # 触发 bge-m3 首次加载
print("预热完成\n") # 此刻两层都"热"了
为什么要在打断点之前预热? 因为你是来调试 RAG 管线逻辑 的,不是来等模型加载的。如果不预热,你会在 pipe.execute() 里的每一步 await 处体验 10~30 秒的"断点假死"------你会误以为是代码卡死了,其实是模型冷启动。预热把它集中付掉,之后的调试步进才是纯推理速度。
这和生产环境是同构的:服务启动后第一请求总是最慢的,后续请求才进入稳定延迟------这就是"第一个请求会超时"现象的根因。
五、时间数量级对比(把这个背下来就掌握了冷热的"分量")
| 层 | 冷启动(首次) | 热启动(之后) | 差了几个数量级 |
|---|---|---|---|
封装对象创建(get_llm() 等) |
微秒级 | 微秒级 | 基本没差别 |
| 向量库索引加载 | 几十 ms ~ 几 s | 微秒级 | 10³ ~ 10⁶ 倍 |
| Embedding 模型加载 | 秒级 | 推理时约几 ms | 10² ~ 10³ 倍 |
| LLM 模型加载 | 10~30 秒 | 推理时约几百 ms | 10² 倍 |
看出规律了吗?越大的资源,冷热差距越悬殊。 所以缓存/预热策略要优先照顾最贵的层------你的项目里 LLM 那层最值得预热,也是脚本里唯一显式 await 预热的原因。
六、这套设计的选择与代价(顺带理解架构意图)
你的项目用的是懒加载 (Lazy):get_xxx() 第一次被调用才加载。它的好处是启动快、不用提前浪费显存;代价就是第一个请求必须替所有层买单。
生产项目应对冷启动的三种常见策略,供你对照:
- 预热端点(本项目 debug 脚本的做法)------服务启动后主动触发一次推理,把冷变热。
- 常驻进程------服务长跑不重启,冷启动只发生一次,之后永远热(热缓存、热模型、热索引)。
- 预加载------启动时就显式加载所有模型和索引(快启动换掉重启的代价)。
而 reset_store_cache() / clear_llm_cache() 这种函数的存在,意味着系统认为"变冷"也是一种需要的状态------重建索引、切换模型后,缓存作废,下次请求重新走一遍冷启动,让新数据/新模型接管。
一句话总结:冷启动 = 把资源从慢介质搬进快介质 + 完成初始化(搬运、转换、分配、连接);热启动 = 资源已就位,直接使用。你项目里的向量库、embedding、LLM 三层各自有冷热,而 debug 脚本的预热代码,就是在调试前手动把所有层一次性推到"热"状态。
6、面向对象编程中的"重用与扩展"
python
class LegalArticleSplitter(RecursiveCharacterTextSplitter):
def __init__(self, chunk_size: int = 512, chunk_overlap: int = 64, **kwargs):
...
super().__init__(
separators=separators,
chunk_size=chunk_size,
chunk_overlap=chunk_overlap,
is_separator_regex=True,
keep_separator=True,
**kwargs,
)
这段代码"省"的根源是:父类的切分逻辑不是一个写死的算法,而是把"在哪些地方切"做成了一份可配置的数据(separators) 。子类只覆写 __init__,本质上是往父类已经设计好的参数洞里塞了法律专属的配置------算法一行没改,行为却完全定制了。
底层机制一:init 只是个普通方法,覆写就是"换了个新版本"
首先要破除一个错觉:__init__ 不是特殊语法,它只是一个名字特殊(双下划线)的普通方法 ,由解释器在你写 LegalArticleSplitter(...) 时自动调用。
创建对象时解释器的内部流程是:
LegalArticleSplitter(chunk_size=512, ...)
│
▼
type.__call__ (类型对象的调用协议)
│
├─► __new__(cls) ← 分配一块内存,得到实例 self(子类没覆写,用继承的)
│
└─► __init__(self, ...) ← 用传参初始化这块内存(子类覆写了这个版本)
所以"覆写 __init__"的准确含义是:LegalArticleSplitter 这个类自己带了一个 __init__ 的实现,它覆盖了父类的同名方法。 至于覆盖之后里面怎么做------它可以完全自己写,也可以像这里一样调用父类的版本(下面第三层讲)。
底层机制二:继承的本质 = 方法查找链(MRO)
类里"继承方法"不是复制代码,而是查找时往上翻。Python 维护一个方法解析顺序(MRO):
LegalArticleSplitter → RecursiveCharacterTextSplitter → TextSplitter → BaseDocumentTransformer → object
当你调用 splitter.split_documents(docs) 时,解释器做的事:
- 先在
LegalArticleSplitter.__dict__里找split_documents→ 没有 (它只定义了__init__) - 沿 MRO 上溯到
RecursiveCharacterTextSplitter.__dict__→ 没有 - 再到
TextSplitter.__dict__→ 找到 ,绑定到self上执行
这就是"重用"的底层原理:子类没写的任何方法,都会顺着这条链找到父类的版本拿来用。 LegalArticleSplitter 里只存在一个方法(__init__),其余 split_text、create_documents、_merge_splits 全是从链上"借"来的------父类几百行切分逻辑,一个字节都不用重写。
底层机制三:super().init(...) ------ 不调用会当场崩溃
这是最容易理解错的一行。子类的 __init__ 执行到 legal_chunker.py:33:
super().__init__(
separators=separators,
chunk_size=chunk_size,
...
)
super() 返回一个沿着 MRO 定位到父类 的代理对象,super().__init__(...) 就是调用 RecursiveCharacterTextSplitter.__init__。父类的 __init__ 干的事,是把传参变成实例属性:
# 父类 init 内部大致是:
self._separators = separators # ← 这份法律正则,被存在实例上
self._chunk_size = chunk_size
self._chunk_overlap = chunk_overlap
self._is_separator_regex = True
self._keep_separator = True
关键:如果注释掉这行 super().__init__(),这些实例属性一个都不会被创建。 之后任何继承来的方法一读 self._separators,立刻抛 AttributeError: 'LegalArticleSplitter' object has no attribute '_separators'。
所以 super().__init__(...) 的含义是:"我先用自己的代码准备配置,然后让父类的初始化逻辑把这个实例完整地建起来。" 这正是 OOP 里"子类扩展父类初始化,但不破坏父类初始化"的标准姿势。
底层机制四(最关键):为什么只喂配置,行为就变了?
这是整段代码最精妙的地方,也是"模板方法模式"(Template Method)的教科书案例。
看父类 RecursiveCharacterTextSplitter 的设计哲学:它的核心算法 _split_text 不硬编码"按什么切" ,而是读实例属性 self._separators,按优先级尝试:
# 父类 _split_text 的简化逻辑(关键就这几步):
def _split_text(self, text, separators):
for sep in separators: # ① 从头到尾找
if re.search(sep, text): # 第一个"能匹配"的分隔符
splits = re.split(sep, text) # ② 用它切一刀
...
new_seps = 优先级更低的部分 # ③ 剩下的分隔符递归去切每段
return [piece for s in splits for piece in self._split_text(s, new_seps)]
return [text] # ④ 一个都匹配不上,整段兜底
看明白了吗------父类把算法写死(递归、优先级、兜底),把"切点长什么样"留成一个洞(separators 参数)。 子类 LegalArticleSplitter 干的唯一一件事,就是往这个洞里填了 9 条法律条文的正则:
第X编 → 第X章 → 第X节 → 第X条 → X、 → (X) → (n) → n. → 换行
(注释写的"优先级从高到低"就是上面 ① 遍历的顺序:先试最粗的分隔符"编",切完再递归用"章"切段内,一路细化到"条"。)
这就是"扩展"的底层原理:父类留好了可替换的参数点(钩子),子类提供参数值,算法骨架完全复用。
类比:父类是一台通用切纸机,上面有个"分隔规则"旋钮。子类没有重造机器,只是把旋钮拧到了"法律条文"档位------机器(算法)原封不动,产出的纸张(chunk)却完全不同。
把四层串起来:走一遍完整调用链
splitter = LegalArticleSplitter(chunk_size=512, chunk_overlap=64)
chunks = splitter.create_documents([刑法文本])
| 步骤 | 解释器做了什么 | 机制 |
|---|---|---|
① LegalArticleSplitter(...) |
调子类的 __init__ |
覆写生效 |
② 第 22 行构造 separators 列表 |
一份法律正则配置 | 子类自己的逻辑 |
③ super().__init__(...) |
沿 MRO 调父类 init,把配置写入实例属性 self._separators |
数据注入 |
④ create_documents(...) |
MRO 上溯,找到 TextSplitter.create_documents |
方法继承 |
⑤ 内部调 self._split_text(text, self._separators) |
找到 RecursiveCharacterTextSplitter._split_text,读到的是法律正则 |
配置被算法消费 |
第 ⑤ 步是整个魔法所在:数据是子类注入的,算法是父类借来的,二者在 self 这个实例上汇合。 这就是"覆写 __init__ 完成全部定制"之所以可行的根本原因。
结论:怎么理解"重用与扩展"
- 重用(Reuse) :父类约 800 行的切分算法(正则优先级、递归拆分、
chunk_size合并、overlap 计算、Document 转换)全部免费继承,没写一行。 - 扩展(Extension) :只写了 ~20 行,覆写
__init__,提供了一份"法律文本专属配置"。
而文件里的 LegalCaseSplitter 完美证明了这套设计的复用价值:同一个父类、同一个模式,只换一份正则列表,就得到了一个"按裁判要旨/基本案情/裁判理由切分"的全新分块器。 父类写一次,配置写 N 份,就得到 N 个定制化工具------这才是"重用与扩展"真正的威力所在。
一句话总结底层原理:__init__ 只是普通方法,覆写它就是替换了初始化逻辑;继承是 MRO 方法查找,没写的方法都从父类链上借;super().__init__ 把子类配置写入实例属性;而父类"参数化"的设计让"改配置"就等于"改行为"------于是 20 行覆写,就完成了一个高度定制的法律分块器。
7、高级异步并发
python
mq_task = asyncio.create_task(multi_query_rewrite(question))
hyde_task = asyncio.create_task(hyde_transform(question))
mq, (_, hypo) = await asyncio.gather(mq_task, hyde_task)
asyncio.create_task 负责把任务排队开工(不等它做完就返回) ,asyncio.gather 负责等所有任务都做完,再按传入顺序把结果打包返回。两者搭配,两个互不依赖的 LLM 调用就能同时发出,总耗时从「两者之和」变成「两者的最大值」。
1. 先建立心智模型:单线程 + 事件循环(Event Loop)
asyncio 不是多线程。它全程只有一个线程在跑,靠一个叫「事件循环」的调度器来实现并发。
先看厨房做饭的类比。只有一个灶台(单线程),你要同时做两件事:
| 做法 | 过程 | 总耗时 |
|---|---|---|
| 串行 | 先煮饭,傻等 20 分钟熟;再烧水,傻等 5 分钟开 | 25 分钟 |
| 并行(asyncio 思路) | 饭下锅点火,水壶同时点火;两个炉子一起烧,你只需等最慢的 | 20 分钟 |
关键点:煮饭时你不需要一直握着锅 。饭在锅里自己熟的那 20 分钟,你的手是空的------asyncio 就是让「等待的时间」空出来去做别的事。
对应到 Python 的几个概念:
- 协程(coroutine) :用
async def定义的函数。调用它并不会执行函数体,只是返回一个「协程对象」,相当于一张还没开始做的菜谱。 await:挂起当前协程,把控制权交还给事件循环,说「我在等 XX 返回,你先去忙别的」。结果回来再继续。- 事件循环:一个无限循环,反复问「哪个协程的等待完成了?」让它们继续跑。
- I/O 等待 :
await的一般都是这类东西------网络请求、读写文件、调用 LLM。等待期间不占 CPU,这就是并发能省时间的根源。
注意一个容易混淆的点:并发(concurrency)≠ 并行(parallelism)。单线程下,同一时刻只有一个协程在执行代码;只是多个 I/O 等待「重叠」了,把空转的时间挤了出来。
2. 逐行拆解你的代码
mq_task = asyncio.create_task(multi_query_rewrite(question)) # ①
hyde_task = asyncio.create_task(hyde_transform(question)) # ②
mq, (_, hypo) = await asyncio.gather(mq_task, hyde_task) # ③
① multi_query_rewrite(question) 先调用这个 async 函数------得到一个协程对象,函数体一行都没执行。
② asyncio.create_task(coro) 把协程包装成一个 Task,并立刻登记到事件循环的调度队列里 ,让它开始「排队开工」。create_task 立刻返回 Task 对象,不等待它完成。这就是并发启动的关键。
对比一下三种启动方式:
r = await coro # 启动 + 阻塞等待这一个完成
await c1; await c2 # 串行:等完一个再启动下一个
t = asyncio.create_task(c1) # 启动,不等,立刻返回 Task
③ await asyncio.gather(mq_task, hyde_task) 挂起当前协程,同时等待两个 Task 全部完成 ,然后 gather 按传入顺序返回一个结果列表。
由于两个函数的返回类型,gather 的结果类型是: [list[str], tuple[str, str | None]]
把它拆开看,gather 那一行完全等价于:
results = await asyncio.gather(mq_task, hyde_task)
mq = results[0] # 多查询列表
hypo = results[1][1] # hyde 返回的元组里取第 2 个元素
再写回原代码:
mq←results[0]=multi_query_rewrite的多查询列表(_, hypo)←results[1]= 解构hyde_transform返回的元组(original, hypothetical_doc)_= 原始问题(丢弃 ,用_表示「我不要这个值」)hypo= 假设文档(向量检索要用的)
3. 解构赋值(Unpacking)详解
Python 允许一次把可迭代对象拆给多个变量,这就是解构赋值:
a, b = [1, 2] # a=1, b=2
a, (b, c) = [1, [2, 3]] # 嵌套解构:a=1, b=2, c=3
你代码里的 mq, (_, hypo) = ... 就是嵌套解构 :外层拆 [结果1, 结果2],内层 (_, hypo) 再拆第二个结果(hyde 的元组)。
_ 不是什么特殊语法,它只是一个普通变量名,惯例 用来表示「这个位置的值我故意不要」。你可以把它换成 original 也完全能跑,只是 _ 让读者一眼看懂意图。
4. 为什么能省时间?串行 vs 并行的时序对比
串行写法(把等待一个接一个排着):
T0 ────────→ multi_query 请求发出
T0 ──T1───→ 第一个 LLM 返回(期间 CPU 空转)
T1 ──T2───→ hyde 请求这时才发出
T1 ──T1+T2→ 第二个 LLM 返回
总耗时 = T1 + T2
并行写法(你的代码):
T0 ──→ multi_query 请求发出
T0 ──→ hyde 请求同时发出 ← 关键:两个请求同时发出
T0 ──max(T1,T2)──→ 两个都返回
总耗时 = max(T1, T2)
因为两次调用互不依赖,而等待网络返回时 CPU 是空闲的,所以能重叠。
顺带一提:pipeline.py:155-156 里 _count_transform_calls 对 MULTI_QUERY_HYDE 返回 2,注释是「并行但各调一次」------意思是并行只省等待时间,不省 LLM 调用次数(照样各调一次,共 2 次)。
5. 一个可直接运行的最小示例
建议自己跑一遍,直观感受串行 vs 并行的耗时差异:
import asyncio
import time
async def llm_call(name: str, delay: float) -> str:
print(f"[{time.strftime('%H:%M:%S')}] {name} 请求发出")
await asyncio.sleep(delay) # 模拟 LLM 网络等待
return f"{name} 的结果"
async def main():
# 串行:一个等另一个
t0 = time.perf_counter()
r1 = await llm_call("多查询", 2)
r2 = await llm_call("HyDE", 2)
print(f"串行总耗时: {time.perf_counter()-t0:.1f}s -> {r1} / {r2}")
# 并行:同时发出
t0 = time.perf_counter()
t1 = asyncio.create_task(llm_call("多查询", 2))
t2 = asyncio.create_task(llm_call("HyDE", 2))
r1, r2 = await asyncio.gather(t1, t2)
print(f"并行总耗时: {time.perf_counter()-t0:.1f}s -> {r1} / {r2}")
asyncio.run(main())
输出大致是:
[12:00:01] 多查询 请求发出
[12:00:03] 多查询 的结果
[12:00:03] HyDE 请求发出
[12:00:05] HyDE 的结果
串行总耗时: 4.0s
[12:00:05] 多查询 请求发出
[12:00:05] HyDE 请求发出 ← 两个几乎同时发出
[12:00:07] HyDE 的结果 ← HyDE 反而先完成
[12:00:07] 多查询 的结果
并行总耗时: 2.0s
注意两件事:
- 并行那次,两个「请求发出」几乎同时打印------说明两个任务同时开工了。
- 明明 HyDE 先返回,
gather的结果却仍是[多查询, HyDE]------结果顺序 = 传入顺序,与完成先后无关。
6. 常见坑(对照你的代码看)
- 忘了
await:asyncio.gather(...)不 await,它只是个协程对象,任务没跑完程序可能就结束了。 create_task后必须等待 :如果创建了 Task 却不放进gather/await,Task 可能被垃圾回收,Python 会抛RuntimeWarning: Task was destroyed but it is pending。所以create_task和await gather(...)永远是配套出现的。create_task只能在运行中的事件循环里调用 ,所以必须写在 async 函数内部(_query_transform就是 async 函数,没问题)。不能在模块顶层直接create_task。- 并发适合 I/O 密集 (LLM 调用、HTTP、数据库),不适合 CPU 密集(纯计算)------单线程下纯计算并发不会加速,反而有调度开销。
gather的异常行为 :默认只要有一个任务抛异常,整个gather就抛异常(已完成的照常返回)。如果想让异常作为结果值返回而不是中断,用return_exceptions=True。- 如果想知道哪个任务先完成 (而不是按传入顺序等全部),用
asyncio.as_completed或asyncio.wait------但你的场景「要两个结果一起用」,gather正合适。
7. 延伸:和你项目里其他地方的呼应
你的 _query_transform(pipeline.py:159-198)返回 (search_queries, hyde_doc, rewritten_queries),所以 MULTI_QUERY_HYDE 分支里解构出的 mq 和 hypo 最终被存进 search_queries 和 hyde_doc,供 Stage 2 检索 使用------hyde_doc 走向量检索、search_queries[0] 走 BM25。整个流程里,查询变换阶段是唯一「并行」优化过的点,其他阶段(检索、重排、生成)数据存在依赖,必须串行。
如果还想深入,下一层可以看 asyncio 的底层调度机制(Future 与 Task 的关系、asyncio.wait_for 超时控制),需要的话我可以继续展开。
8、哈希指纹 + set 查重
1. 它解决什么问题:多查询检索会"撞车"
看 pipeline.py:186-228 这段,MULTI_QUERY_HYDE 策略下会生成多个查询去检索:
- 假设问题 → 改写出了 3 个查询(
search_queries) - 外加一个
hyde_doc假设文档做向量检索
然后对每个查询 都调一次 retriever.invoke(q)。问题来了:不同查询很可能检索到同一个文档。
举个例子,用户问「打人怎么判」:
search_queries = [
"故意伤害罪 量刑标准", # 查询1
"故意伤害 指导案例", # 查询2
"故意伤害罪 司法解释", # 查询3
]
查询1 返回: 刑法第234条, 案例A
查询2 返回: 刑法第234条, 案例B ← 刑法234条 又出现了
查询3 返回: 案例B, 最高法解释 ← 案例B 又出现了
直接拼起来: 刑法234, 案例A, 刑法234, 案例B, 案例B, 最高法解释 (6条,2条是重复的)
如果不处理,all_docs 里就塞满了重复文档。后果是:
- 浪费上下文窗口 ------build_context 有 4000 字符预算,本地 8B 模型上下文只有 8192 tokens,重复文档挤掉真正有用的内容;
- 误导重排序------同一个文档被算多遍;
- 回答里来源列表重复。
所以需要「相同的文档只保留一份」。
2. 逐行拆解这段代码
all_docs: list[Document] = [] # 去重后的结果
seen_contents: set[int] = set() # 记录"见过的指纹"
for q in search_queries: # 对每个查询
docs = retriever.invoke(q) # 检索一批文档
for doc in docs: # 逐个检查
key = hash(doc.page_content[:200]) # ① 算"指纹"
if key not in seen_contents: # ② O(1) 查重
seen_contents.add(key) # ③ 记住这个指纹
all_docs.append(doc) # ④ 首次见到 → 保留
它的核心逻辑是 「见过的指纹不再要」:
| 步骤 | 代码 | 在做什么 |
|---|---|---|
| ① | hash(doc.page_content[:200]) |
给这篇文档算一个指纹(一个整数) |
| ② | key not in seen_contents |
这个指纹以前见过吗? |
| ③ | seen_contents.add(key) |
没见过 → 把指纹记进「黑名单/记录本」 |
| ④ | all_docs.append(doc) |
没见过 → 才保留这篇文档 |
如果指纹已经在 seen_contents 里 → 说明这篇文档之前来过 → 直接跳过,不 append。
3. 为什么要 hash + 切片,而不是直接比较?
为什么用 hash()?
hash(x) 是 Python 内置函数,把任意内容映射成一个整数。它有两个关键特性:
- 相等的内容 → 相等的哈希 。所以同一篇文档(内容相同)算出来的
key一定相同,重复的一定会被识别出来; - 算得极快。它只是把字符串内容滚一遍算个数字,不做逐字符比对。
而 seen_contents 是一个 set(集合),Python 的 set 底层是哈希表,判断「某元素在不在集合里」平均只需要 O(1) ------它内部就是直接算 hash(待查值) 然后跳到对应的桶看一眼。
所以整个流程的复杂度是:
- 每个文档:一次
hash计算(O(200),很短)+ 一次集合查重(O(1)) - 总共 n 个文档:约 O(n)
为什么直接比较不行?
「把文档和已有的逐个比」是 O(n\^2) 的:
# 朴素写法(很慢):每个新文档都要和已保留的每个文档全文比对
for new_doc in docs:
if all(d.page_content != new_doc.page_content for d in all_docs): # 全文比较
all_docs.append(new_doc)
全文比较每对比一次都要逐字符扫整篇法律条文(几千字),文档一多就是灾难。而 set 查重 O(1),两者天差地别。
为什么要 [:200] 截断?
法律条文的 page_content 可能几千字。用全部内容算哈希:
- 更慢(要滚完整个字符串);
- 其实没必要------前 200 个字符通常已经足够区分两篇不同文档(标题 + 开头)。
所以用 page_content[:200] 当指纹输入,又快又够用。这是一个刻意的取舍:用「极小概率看走眼」换「明显更快的速度」。
4. 跟跑一遍具体例子
接着上面 3 个查询的例子,模拟 set 的状态变化:
seen_contents = {} # 初始空集合
doc = 刑法第234条 key = hash("刑法第一百三十四条...")
key 在 seen 里吗? → 否 → add(key), append
seen = {K1} all_docs = [刑法234]
doc = 案例A key = hash("【案例】张某故意伤害...")
key 在 seen 里吗? → 否 → add(key), append
seen = {K1, K2} all_docs = [刑法234, 案例A]
doc = 刑法第234条(又来了) key = hash("刑法第一百三十四条...") ← 内容一样,指纹一样
key 在 seen 里吗? → 是! → 直接跳过,不 append!
doc = 案例B key = hash("【案例】李某...")
→ 否 → add(key), append
seen = {K1, K2, K3} all_docs = [刑法234, 案例A, 案例B]
doc = 案例B(又来了) key = 同上的 K3
key 在 seen 里吗? → 是! → 跳过
doc = 最高法解释 → 否 → add, append
最终 all_docs = [刑法234, 案例A, 案例B, 最高法解释] ← 去重完成
关键就在同一篇文档重复出现时,hash 一定算出同一个整数,第二次就能被 set 精准拦住。
5. KG 融合处的变体(同样套路)
execute 里的 KG 融合 是同一套思路,只是写法略有不同:
existing_keys = {hash(d.page_content[:200]) for d in all_docs} # 先把已收集的文档全部算成指纹集合
for kd in kg_docs:
key = hash(kd.page_content[:200])
if key not in existing_keys: # KG 文档和已有文档重复 → 不要
all_docs.insert(0, kd) # 不重复 → 插到最前面(KG 优先级最高)
existing_keys.add(key) # 记住
- 相同点:都是「算指纹 → 查集合 → 重复就跳过」;
- 不同点 :这里用集合推导式 一次性把
all_docs全部转成指纹集合({hash(...) for d in all_docs}),而不是边遍历边 add; - 行为差异 :不重复的 KG 文档用
insert(0, kd)插到最前面 ,这样在后续 build_context 的优先级排序 里,KG 结构化知识能排到法律条文、案例之前。
6. 需要知道的三个边界(潜在坑)
- 200 字符截断的代价 :如果两篇内容不同的文档恰好前 200 个字符一样,它们会被误判为重复,其中一篇被丢弃。这是故意接受的取舍------法律文档前 200 字通常包含标题/条号,足以区分。如果项目里真有这种极端情况,可以截更长或改成全文哈希。
hash()的碰撞 :两个不同的字符串理论上可能哈希到同一个整数(64 位哈希,碰撞概率极低)。碰撞的后果是误删(假重复),不会造成错误保留。文档数量只有几十篇时,可忽略。hash()跨进程不稳定 :Python 默认启用了哈希随机化(PYTHONHASHSEED),同一个字符串在不同进程里 hash 值可能不同 。这里完全没问题------因为seen_contents只在本次请求内存活、用一次就扔,从不持久化。只要别把这些整数存进数据库或跨请求复用就行。
一句话总结:这段代码用「前 200 字符的哈希指纹 + set 查重」,以接近 O(1) 的单次代价,保证每个文档在结果里只出现一次------防止多查询检索时同一个法律条文/案例被重复灌进上下文。
9、字典解包运算符
python
metrics=StageMetrics(**self.metrics)
StageMetrics(**self.metrics) 里的 **,作用是把 self.metrics 这个字典拆散展开 :字典里的每一对「键 → 值」,都变成一个 键=值 的关键字参数。上面那一行完全等价于手写这 10 个参数:
metrics=StageMetrics(
query_rewrite_ms=None,
retrieval_ms=0,
rerank_ms=None,
generation_ms=0,
total_ms=0,
kg_lookup_ms=None,
self_reflect_ms=None,
was_corrected=False,
llm_calls=0,
llm_calls_saved=0,
)
1. 背景:这里有两个不同的东西
self.metrics 是一个字典 (dict),在管线执行过程中被逐步填充:
self.metrics: dict = {
"query_rewrite_ms": None, # 初始占位
"retrieval_ms": 0,
... # 共 10 个键
}
# 之后管线各阶段往里写值:
self.metrics["query_rewrite_ms"] = round(...) # 查询变换结束
self.metrics["retrieval_ms"] = round(...) # 检索结束
self.metrics["total_ms"] = round(...) # 最结尾
StageMetrics 是一个 Pydantic 数据模型,它规定了「每个字段应该是什么类型、默认值是多少」:
class StageMetrics(BaseModel):
query_rewrite_ms: float | None = None
retrieval_ms: float = 0
llm_calls: int = 0
...
问题来了 :Pydantic 模型的实例化方式是关键字参数 ------StageMetrics(query_rewrite_ms=12.5, ...)。但手里的数据是字典 ------{"query_rewrite_ms": 12.5, ...}。这两者结构很像(键名 = 字段名),但语法上不能直接互相替代。** 就是那个「转换桥梁」。
2. ** 到底在做什么:「拉平」的准确含义
字典是「键 → 值」这样一层嵌套结构 。** 的作用是把这一层「外壳」拆掉,变成一列平铺的、带名字的参数。
d = {"query_rewrite_ms": None, "retrieval_ms": 0}
# 用了 **:
FakeModel(**d)
# 等价于没用什么神秘魔法,就是:
FakeModel(query_rewrite_ms=None, retrieval_ms=0)
你引用的解释里那句「拉平 」,指的就是去掉 dict 的键值结构,展开成 键=值 的平铺序列 ;「以关键字参数的形式 」就是指展开后的 键=值,这正是函数调用时关键字参数(keyword argument)的写法。
逐层看 metrics=StageMetrics(**self.metrics)
self.metrics
# → {"query_rewrite_ms": None, "retrieval_ms": 0, ..., "llm_calls_saved": 0}
StageMetrics(**self.metrics)
# → StageMetrics(query_rewrite_ms=None, retrieval_ms=0, ..., llm_calls_saved=0)
# (第一层 * 把 dict 拆成 键=值 对)
metrics=StageMetrics(...)
# → 作为 ChatResponse 的 metrics 字段传入
ChatResponse 里的 metrics: StageMetrics 字段(schemas.py:101)要求一个 StageMetrics 实例,这一行正好把它构造出来。
3. 两个星号运算符: 与 ** 别混淆*
Python 有两个「展开」运算符,经常一起出现:
| 运算符 | 展开的对象 | 展开成 | 示例 |
|---|---|---|---|
* |
序列(list / tuple) | 位置参数 | f(*[1,2,3]) ≡ f(1,2,3) |
** |
字典(dict) | 关键字参数 | f(**{"a":1}) ≡ f(a=1) |
记忆法:一个星号 * 对应「按位置」,两个星号 ** 对应「带名字」。管道式地讲------* 把列表的元素一个个按顺序塞进去;** 把字典的键值对变成 名字=值 塞进去。
顺带说,这里还有个常见的用法是合并字典 (同一个 **,不同场景):
merged = {**dict_a, **dict_b} # Python 3.5+,把两个字典摊开再合并
**4. 函数定义里的 kwargs:同一个符号的反向操作
注意 ** 在调用 和定义两个位置,方向正好相反:
-
调用时 :
f(**d)------ 把 dict 展开成关键字参数(本文的主角); -
定义时 :
def f(**kwargs)------ 把调用方传进来的多余关键字参数收集成一个 dict。def f(a, **kwargs):
print(kwargs) # 收集所有没被 a 接住的关键字参数f(a=1, b=2, c=3) # 输出 {"b": 2, "c": 3}
它们本质是同一枚硬币的两面:一边是把 dict 摊开,一边是把散落的关键字参数收拢成 dict。
5. 为什么项目要这样写(不直接传参的好处)
这段代码的价值在于:self.metrics 是在管线执行过程中逐步更新的,用字典可以「随时按需改某一个字段」:
self.metrics["retrieval_ms"] = ... # Stage 2 结束后写
self.metrics["rerank_ms"] = ... # Stage 3 结束后写
最后构建 StageMetrics 时,** 一把梭把整个 dict 转过去。如果不这么写,就得在文件末尾手打 10 个关键字参数、且字段名还要和 dict 键名保持同步------重复且容易漏 。字典增量收集 + ** 一次投喂,是「运行中动态组装 → 结尾一次性固化」的常见套路。
6. Pydantic 在这一行里做了什么
StageMetrics(**self.metrics) 执行时,Pydantic 做三件事:
- 实例化------用展开后的关键字参数构造模型对象;
- 类型校验(验证) ------每个值对照字段的类型注解检查。比如
query_rewrite_ms: float | None传了个字符串"abc",会抛ValidationError;传3(int)给retrieval_ms: float,Pydantic 通常会自动转成3.0; - 默认值兜底 ------展开后缺了哪个字段(dict 里没有那个键),就用模型里写的默认值(如
llm_calls: int = 0)。
这就是你引用解释里「类型校验与实例化」的含义。
7. 规则与坑
**的键必须是字符串 ,否则抛TypeError: keywords must be strings。- 键名必须和模型字段名完全一致 。好在
self.metrics的 10 个键就是照着StageMetrics的 10 个字段起的(pipeline.py:75-86),一一对应。 - 拼错键名的隐形坑 :Pydantic v2 默认
extra='ignore'------如果 dict 里混进一个模型没有的键,它不会报错,而是悄悄忽略。好处是健壮,坏处是拼错字段名时你察觉不到,字段就一直用默认值。 **必须写在位置参数和普通关键字参数之后 (f(1, a=2, **d)合法,f(**d, 1)语法错误)。**展开的键不能和前面已显式写的关键字参数重复,否则报错。
再串一遍 :self.metrics 是管线运行中一点点填起来的字典 → 行尾用 StageMetrics(**self.metrics) 把字典「拉平」成 10 个关键字参数 → Pydantic 逐个校验类型、补默认值、实例化 → 得到带完整指标的对象塞进 ChatResponse.metrics。字典是「过程态」,模型是「结果态」,** 是两者之间的转换器。
10、Pydantic 数据模型
arbitrary_types_allowed = True 是给 Pydantic 的一句话指令:「遇到你不认识的第三方类字段,别抛错,原样存下就行(顶多查一下类型对不对)。」
它不是为了「多一种校验能力」,而是为了关掉 Pydantic 默认的「我不认识就不干」的报错行为。
1. 第一性原理:Pydantic 模型的本质是「带校验的容器」
普通 Python 类:
class Box:
def __init__(self, x):
self.x = x # Python 不检查 x 是什么,塞进去就行
Pydantic 模型:
class Box(BaseModel):
x: int # 声明式:告诉 Pydantic「x 必须/应该是 int」
区别在于创建对象时:
Box(x="abc") # 普通类:成功。Pydantic:抛 ValidationError
Box(x="3") # 普通类:存字符串。Pydantic:自动转成 int 3
也就是说,Pydantic 在 __init__ 里对每一个字段 跑一遍「校验器」(validator),检查类型、做转换、查约束。这是它区别于普通类的全部本质。
2. 第二层原理:Pydantic 靠一张「校验器注册表」工作
Pydantic 怎么知道某个字段该怎么校验?靠内部一张 「类型 → 校验函数」的映射表:
| 字段类型 | Pydantic 认识吗? | 原因 |
|---|---|---|
int、str、bool |
✅ | 有内置校验器 |
list[str]、dict[str, int] |
✅ | 标准容器,递归校验 |
Document(LangChain 的) |
✅ | Document 自己就是 Pydantic 模型 |
BM25Okapi(rank_bm25 的) |
❌ | 普通第三方类,注册表里没有 |
关键哲学("显式优于隐式"):Pydantic 对不认识的类型,宁可拒绝,也不猜测。 它不知道 BM25Okapi 该接受什么字段、该做什么转换,所以默认直接报错,逼你显式表态。
类比海关查验:
- 已知类别(int、str、Document)→ 开箱检查,甚至能换算单位(类型转换);
- 黑盒类别(BM25Okapi)→ 海关没法开箱检查内容,默认直接拒收;
arbitrary_types_allowed = True→ 海关下令:「对黑盒货物只查标签(isinstance),不查内容,放行。」
3. 具体到你的代码:bm25: BM25Okapi | None 为什么会触发
看 retriever.py:12-21:
class BM25ChineseRetriever(BaseRetriever):
documents: list[Document] = []
tokenized_corpus: list[list[str]] = []
bm25: BM25Okapi | None = None # ← 问题在这
k: int = 10
class Config:
arbitrary_types_allowed = True
BM25Okapi来自rank_bm25库(requirements.txt:9),是一个普通的 Python 类,不是 Pydantic 模型------Pydantic 的注册表里没有它。BM25ChineseRetriever继承自 LangChain 的BaseRetriever,而BaseRetriever基于 Pydantic 模型。所以这个类的注解字段全部要经过 Pydantic 处理。
这里有一个非常反直觉的点,正是理解本题的关键:
bm25 字段带默认值 None,而且构造时根本不从外部传值 (它是 retriever.py:32 内部 self.bm25 = BM25Okapi(self.tokenized_corpus) 自己构建的)------但它依然会触发报错。
为什么?因为 Pydantic 是声明式 的:它在类定义时 就要根据所有注解,为每一个字段 预先构建完整的校验 schema。不管你将来传不传、怎么传,只要注解里出现了注册表外的类型,schema 构建就失败(Pydantic v2 会抛 PydanticSchemaGenerationError: Unable to generate pydantic-core schema ... Set arbitrary_types_allowed=True ...)。
所以开关的真正作用,是在 schema 构建那一刻豁免这类字段的深入校验。
4. 开关到底翻转了什么
默认(arbitrary_types_allowed = False) |
开启后 | |
|---|---|---|
| 遇到注册表外的类型 | 构建 schema 时抛错 | 正常接受 |
| 校验级别 | --- | 降级为 isinstance 检查(类型对就行,不查内部) |
| 类比 | 海关对黑盒货物拒收 | 海关对黑盒只查标签放行 |
注意它没有关闭所有校验 :开启了之后,bm25: BM25Okapi | None 这个字段如果将来被赋了一个 BM25Okapi 之外的东西,Pydantic 仍然会报 isinstance 校验失败。只是不再要求它认识这个类内部结构。
5. 为什么两个类都写了这个开关?
- retriever.py:21
BM25ChineseRetriever:必须写 。它直接持有bm25: BM25Okapi,注解里就有任意类型。 - retriever.py:59
HybridRetriever:双保险 。它持有的bm25_retriever: BM25ChineseRetriever | None本身是 Pydantic 模型,按理可识别;但该模型内部嵌套着任意类型,不同 Pydantic 版本在递归生成嵌套 schema 时行为有差异。写上零成本,还能避免「层级传递」时的意外报错------这是 Pydantic 项目的常见防御性写法。
6. 版本写法差异(你的项目踩在两种风格之间)
你的 requirements.txt:11 写的是 pydantic>=2.0,但代码用的是 Pydantic v1 风格 的 class Config(Pydantic v2 仍兼容这种写法,只是会提示弃用)。v2 官方推荐这样写:
from pydantic import ConfigDict
class BM25ChineseRetriever(BaseRetriever):
model_config = ConfigDict(arbitrary_types_allowed=True)
bm25: BM25Okapi | None = None
原因:LangChain 生态长期基于 Pydantic v1,大量组件源码(包括 BaseRetriever)都是 v1 的 class Config 风格,所以项目跟随了这种写法。
最后把链条串一遍 :Pydantic 是「带校验的容器」,靠一张类型→校验器注册表工作 → BM25Okapi 这类第三方普通类不在注册表里,且由于 Pydantic 声明式地在类定义时就要给每个注解字段建 schema,所以即使 bm25 只内部赋值、从不外部传入,也会触发「无法生成 schema」的报错 → arbitrary_types_allowed = True 在 schema 构建时豁免这类字段,把它降级为只做 isinstance 检查 → 两个类都加上,是为了让持有/嵌套任意类型的每一层都安然通过。
11、构建 BM25 索引
把已经存进 Chroma 向量库的全部文档读出来 ,用 jieba 分词后在内存里构建一个 BM25 索引 ,存进
self.bm25_retriever,供后面做"关键词检索"用。
它属于一个叫 混合检索(Hybrid Retrieval) 的系统,系统的整体结构是:
┌─────────────────────────────┐
│ 用户问题 query │
└──────────────┬──────────────┘
│
┌─────────────────┴─────────────────┐
▼ ▼
向量检索(稠密 dense) BM25 检索(稀疏 sparse)
embedding → 余弦相似度 jieba 分词 → 关键词打分
└─────────────────┬─────────────────┘
▼
RRF 融合排序
▼
返回 top-k 文档
两条检索通道各有所长,_load_bm25_corpus 负责把**右边这条通道的"检索字典"**建好。
第一步:逐行拆解这个函数
def _load_bm25_corpus(self):
"""从向量库加载文档构建 BM25 索引"""
all_docs = [] # ① 空列表,准备收集所有文档
for name in self.collection_names: # ② 默认 ["laws", "cases"],遍历每个集合
try:
store = get_vectorstore(name) # ③ 打开对应集合的 Chroma 向量库
result = store._collection.get(include=["documents", "metadatas"]) # ④ 关键
if result and result["documents"]: # ⑤ 如果有文档
for doc_text, meta in zip( # ⑥ 把每篇原文 + 元数据打包
result["documents"], result["metadatas"] or [{}] * len(result["documents"])
):
all_docs.append( # ⑦ 组装成 langchain 的 Document
Document(page_content=doc_text, metadata=meta or {})
)
except Exception:
continue # ⑧ 某个集合打不开就跳过,不影响其他集合
self.all_documents = all_docs # ⑨ 保存全部文档(后面 RRF 融合时也会用到)
if all_docs:
self.bm25_retriever = BM25ChineseRetriever(documents=all_docs, k=self.k) # ⑩ 真正建索引
逐行解释几个重点:
- ③
get_vectorstore(name)(vectorstore.py:14):从缓存里拿(或新建)名为laws/cases的 Chroma 向量库实例。 - ④ 这是全函数最关键的一行 :
store._collection是 Chroma 底层集合对象,.get()不传条件时会把集合里所有原始数据 读出来,include指定只要哪些字段。注意------这一步完全没有做 embedding 相似度计算 ,它只是"把存进去的原文原样取出来"。所以这个函数读的是数据,不是"检索结果"。 - ⑥
zip的作用 :把"文档原文"和"它对应的元数据"一对一对地打包,因为result里这两个是分开的两个列表。result["metadatas"] or [{}] * len(...)是防御:万一某些文档没存元数据(返回None),就用空字典{}占位,避免 zip 错位。 - ⑩ 真正干活的地方 :调用
BM25ChineseRetriever,这才是构建索引的实体。
第二步:BM25ChineseRetriever 到底干了什么
它才是在retriever.py:12。__init__ 里只有三行,却是核心:
self.documents = documents # 保存原始文档(检索时按索引返回)
self.tokenized_corpus = [ # 分词语料库:把每篇文档切成词列表
list(jieba.cut(doc.page_content)) for doc in documents
]
if self.tokenized_corpus:
self.bm25 = BM25Okapi(self.tokenized_corpus) # 真正构建 BM25 索引
这里的逻辑链是:
jieba.cut("被告人王某盗窃财物")→["被告人", "王某", "盗窃", "财物"](中文要先分词,这是中文 BM25 和英文最大的区别)- 于是
tokenized_corpus就变成[["被告人","王某",...], ["抢劫",...], ...]------一个"每篇文档 = 一串词"的列表 BM25Okapi(...)拿到这个分词列表后,会统计出:- 倒排索引:每个词 → 它出现在哪些文档
- 每个词的 IDF(稀有程度)
- 每篇文档的长度(用于长度归一化)
这三大统计量就是"索引"。构建完之后,查询时直接用,不再扫全量文档。
第三步:BM25 是什么?
BM25 = Best Matching 25 ,是 1994 年提出的一个经典关键词检索(稀疏检索)排序算法,至今仍是搜索引擎和 RAG 的标配(Elasticsearch 的默认打分就是它)。
它的思路:给"查询里的每个词"和"每篇文档"算一个相关性得分,然后求和。核心打分公式(不用背,理解三个直觉就行):
score(文档D, 查询Q) = Σ [ IDF(qi) × TF加权 ]
三个直觉:
| 概念 | 作用 | 类比 |
|---|---|---|
| IDF | 词越稀有,命中越值钱。"的""了"这种常见词权重极低,"第二百六十四条"这种稀有词权重极高 | 物以稀为贵 |
| 词频饱和 | 一个词出现 1 次和出现 50 次,分数不是 50 倍关系,后面出现越多收益递减(用参数 k1 控制) |
同样一个词,看到 1 次很兴奋,看到 50 次边际收益趋近于 0 |
| 长度归一化 | 同样出现 3 次"盗窃",在 100 字短文里比在 10000 字长文里更有意义,长文档要惩罚(用参数 b 控制) |
短文本里出现 = 密度高,更相关 |
打个比方:BM25 就像一个只认字面、不认语义的老式图书馆管理员。你问"第二百六十四条",他会翻卡片目录精确找到写有这个编号的书;但他不懂"非法占有他人财物"和"盗窃"是同一个意思。
对比一下两种检索:
| 向量检索(dense) | BM25(sparse) | |
|---|---|---|
| 依据 | embedding 向量 + 语义相似度 | 字面关键词 + 统计打分 |
| 优点 | 懂语义,"iPhone"能配"苹果手机" | 精确匹配,"刑法第二百六十四条"能精确命中 |
| 缺点 | 精确编号、生僻专有名词容易"失真" | 换个说法就匹配不上 |
| 速度/成本 | 需要 embedding 模型计算 | 纯统计,毫秒级、零模型开销 |
第四步:为什么"需要"它?------两个问题
问题一:为什么检索系统里要同时有向量检索和 BM25?
因为单一检索会漏。这是法律领域的 RAG(legal_rag),法律文本里有大量精确编号 ("第二百六十四条")、法条名称 、案号。这种精确词:
- 向量检索可能把它 embedding 成"噪音",相似度得分不高 → 漏召回
- BM25 靠字面精确命中 → 稳稳召回
反过来,用户用大白话问"借钱不还怎么办",BM25 匹配不上任何精确词,向量检索却能从语义上找到民间借贷相关的法条。
两者互补 ,再用 RRF(Reciprocal Rank Fusion) 把两路结果融合:见 retriever.py:85-121。基本逻辑是:文档在两路检索里的排名 越靠前,得分越高(weight / (rrf_k + rank + 1)),两路都命中的文档分数叠加,最终按总分取 top-k。这样"字面命中 + 语义命中"的文档就会排在前面。
问题二:为什么 BM25 的语料要"从向量库里加载",而不是直接拿原始文档?
这是数据源统一的设计:
- 系统的流程是"文档导入 → embedding → 存进 Chroma",Chroma 是唯一的数据源。
- 构建 BM25 时,从 Chroma 把原文读出来重建索引,就不用再维护一份独立的原始文档库,避免"两处数据不一致"。
- 代价是这个函数在
__init__里调用(retriever.py:65),也就是说每次启动时会把全量文档读出来 + 全量 jieba 分词,这是一个较重的一次性操作,只在内存里做一次。
完整调用链回顾
启动 HybridRetriever
└─ __init__ → _load_bm25_corpus()
├─ get_vectorstore("laws") / get_vectorstore("cases") # 打开向量库
├─ _collection.get(...) # 读出全部原文
└─ BM25ChineseRetriever(documents)
├─ jieba 分词全部文档 → tokenized_corpus
└─ BM25Okapi(tokenized_corpus) # 建好索引,存内存
用户提问时
└─ _get_relevant_documents(query)
├─ BM25 通道:bm25.get_scores(jieba分词后的query) → top-k
├─ 向量通道:similarity_search(query) → top-k
└─ RRF 融合 → 排序 → 返回最终文档
一句话收尾:这个函数 = 把向量库当数据源,趁启动时把"关键词检索用的词典"提前建好放内存,好让 BM25 通道在查询时瞬间打分。
12、BM25是啥?

现在很多人搭RAG,即检索增强生成,把精力全砸在向量数据库和embedding模型上。结果召回的内容乱七八糟,大模型答非所问。问题往往不在生成,而在检索那一环。BM25是传统检索里最经典、最扛打的算法。今天咱们就把它的原理、关键参数和真实局限一次讲透。
先说BM25是什么,它本质上是给文档打分 的公式,你输入一个查询,它算出每篇文档跟查询有多相关 。然后按分数排序,取前几名喂给大模型。它属于稀疏检索 ,靠的是词频TF 和逆文档频率IDF,不靠语义。换句话说,它不看意思,只看字面重合度。

你可能觉得这很原始,但恰恰是这种原始,让它在很多场景下比向量检索还稳。为什么重要?因为RAG的瓶颈经常在召回精度 。向量检索擅长找意思相近、但字面不同的内容,比如你说"猫咪生病了",它能匹配到"宠物健康问题",但向量检索有个毛病,容易召回过宽,把不相关、但语义沾边的也拉进来。而且对专有名词、产品、型号、代码片段这种精准匹配效果很差。
BM25正好互补,它严格按词相匹配来打分。你搜"iphone 15电池续航",它绝不会因为"苹果手机"语义相近,就把旧款机型排前面。所以成熟的RAG系统通常是BM25和向量检索双路召回,再合并重排。你只上向量等于少了一条腿。

**那BM25具体怎么工作?**核心就三件事。
第一,词频TF,一个词在文档里出现的越多,文档分越高,但不是线性增长,而是有边际递减。
第二,逆文档频率IDF,一个词在越少文档里出现,它越有区分度,权重越高。比如"了"这种词,几乎每篇都有,权重就低。
第三,文档长度归一化,一篇长文里出现一次关键词和一篇短文里出现一次含金量完全不同,所以要按长度打折。
BM25有两个关键参数k1和b 。k1控制词频的饱和速度 ,默认1.2左右,调大说明你更看重复词。b控制长度惩罚力度 ,默认0.75,调大说明你更惩罚长文档。这两个值直接影响你检索的精准度。

真实场景里怎么用?给你个典型例子。假设你有个企业知识库,里面全是技术文档,用户问"red is内存满了怎么办?"向量检索可能召回一堆关于缓存策略、内存优化的泛泛文章。而BM25会精确命中那些标题或正文里同时出现"red is内存满"的文档。如果你把k1调高,它会更偏向那些反复提到"内存满的故障报告"。如果你把b调低,长文档就不吃亏,适合那些详细操作手册。
参数不是摆设,是调优杠杆。但BM25不是万能的,它最大的死穴是词汇鸿沟 ,也就是同义词和语义改写。用户说"怎么给车胎打气?"文档里写的是"轮胎充气",BM25匹配不上,因为它不认语义。另外,它对拼写错误、中英文混写也很敏感 。所以正确的姿势是用BM25做精确匹配和专有名词召回 ,用向量检索做语义兜底 ,最后用重排序模型把两路结果合并打分,这已经是工业级RAG的标准架构了。

最后给你一个判断标准:若知识库内容以技术规范、产品手册、代码问答为主,则B25的权重应占较高比例,甚至可单独使用。若内容以开放对话、新闻摘要、创意写作、向量检索为主,则以BM R5作为辅助。不要盲目迷信新工具,也不要抛弃老方法,只有真正掌握BM R5,你的rig才能真正站得住脚。
13、SQLite 报错: too many SQL variables
在Stage 2: 检索中初始化HybridRetriever时,_load_bm25_corpus函数从向量库加载文档构建 BM25 索引过程中,result = store._collection.get(include="documents", "metadatas")这行代码经过debug测试发现,当name=laws时,为啥返回result为空?但当name=cases时,返回result不为空。
一、直接结论:laws 不是没数据,是 get() 报错被静默吞掉了
我先复现了你 debug 的场景,关键证据如下:
===== laws =====
collection.count() = 38762 ← 集合里明明有 38762 条记录!
result = store._collection.get(...)
→ chromadb.errors.InternalError:
Error executing plan: ... too many SQL variables ← 这行抛异常了
再看 cases:
===== cases =====
collection.count() = 100 ← 只有 100 条
get() → 正常返回 ← 没到上限,成功
我也直接查了 Chroma 落盘的 sqlite 文件(chroma_db/chroma.sqlite3),laws 集合里确实存着 38762 条 embedding 和完整元数据。数据一直在库里 ,问题出在 get() 这个调用本身------它抛了一个 too many SQL variables 异常,而这个异常在 _load_bm25_corpus 里被 except Exception: continue 无声无息地吞掉了,于是看起来就像是"laws 返回为空"。
二、store._collection.get() 到底是干嘛的
先拆这一层层的对象:
store = get_vectorstore(name) # langchain 的 Chroma 包装类
store._collection # 它内部持有的"原始 chromadb Collection"对象
store._collection.get(...) # 直接调用 chromadb 原生集合的 get 方法
_collection是下划线开头的私有属性------正常情况下不该直接碰,这里是"绕过 langchain 封装,直接操作底层 chromadb"。.get()是 chromadb Collection 的按条件读取接口 :把集合里存的原始记录 取出来。它不做向量相似度搜索、不计算 embedding,纯数据库读操作。
它的完整签名(重要参数):
collection.get(
ids=None, # 指定要哪几条(传 id 列表)
where=None, # 按元数据过滤,如 {"doc_type": "law"}
limit=None, # 每次最多取几条(分页!)
offset=None, # 从第几条开始(分页!)
where_document=None, # 按文档正文全文检索过滤
include=["documents", "metadatas"], # 要返回哪些字段
)
返回一个 dict,结构是:
{
"ids": [...], # 每条记录的 id
"documents": [...], # 文档正文(include 里要了才有)
"metadatas": [...], # 每条记录的元数据(include 里要了才有)
"embeddings": [...], # 向量(include 里要了才有,一般不要,太大)
}
和 .query() 的区别一句话:.query(query_embeddings) 是"拿向量算相似度找最近邻",.get() 是"把记录原样取回来" 。_load_bm25_corpus 需要的是原文全文 → 用 get()。
三、为什么 laws 会报 too many SQL variables
这行代码的完整调用链是:
store._collection.get(include=["documents", "metadatas"])
↓ chromadb Rust 内核
数据库执行 SQL 查询
↓
SQLite 报错: too many SQL variables
核心机制是 SQLite 的"绑定变量上限":
- SQLite 每次执行一条 SQL 时,语句里的占位符(
?问号)数量有硬性上限。老版本默认 999 个 ,3.32 之后默认 32766 个(你这个环境是 sqlite 3.53.2,上限就是 32766)。 - chromadb 的
get()在没传ids时,内部会把集合里所有记录的 id 收集起来 ,然后拼成一条形如WHERE id IN (?, ?, ?, ...)的 SQL------38762 个 id 就是 38762 个绑定变量。 - 38762 > 32766 → SQLite 直接拒绝执行 → 抛
too many SQL variables。 cases只有 100 条,远低于 32766 → 正常通过。
所以这个 bug 的本质是:get() 一次性拉 3 万+条记录,撞上了 SQLite 的变量数上限。 数据量小(比如样例阶段只有几百条)没事,导入全量法律条文(38762 块)后就炸了------这也解释了为什么 cases 没事而 laws 有事:laws 恰好超过了上限。
四、为什么表现出来是"laws 为空"
回到 retriever.py:70-80:
for name in self.collection_names:
try:
store = get_vectorstore(name)
result = store._collection.get(include=["documents", "metadatas"]) # ← laws 在这里抛异常
if result and result["documents"]:
...
except Exception:
continue # ← 异常被吞掉,静默跳到下一个集合
laws 的 get() 抛异常 → except Exception: continue → laws 一整批文档都被跳过 。最后 all_docs 里只有 cases 的 100 条文档,BM25 索引只拿 100 条案例建出来。法律条文一条都没进 BM25 索引------而法律条文才是这个系统的主语料,后果很严重。
这也是一个编码教训:
except Exception: continue这种"全吞"写法,会让真正的 bug 无声消失。当时应该至少打一条日志。
五、修复方案:给 get() 加分页
chromadb 的 get() 原生支持 limit + offset 分页,每次只取一小批(比如 1000 条),就不会撞上限了。我已经在你环境里验证:分页后 laws 能正常取回文档和完整元数据(全量验证在后台跑,马上确认)。
修正后的 _load_bm25_corpus:
def _load_bm25_corpus(self):
"""从向量库加载文档构建 BM25 索引"""
all_docs = []
BATCH = 1000 # 分页大小:避免一次性 get 全部记录,撞 SQLite 绑定变量上限
for name in self.collection_names:
try:
store = get_vectorstore(name)
offset = 0
while True:
# 分页读取:limit=1000,offset 逐批推进
result = store._collection.get(
include=["documents", "metadatas"],
limit=BATCH,
offset=offset,
)
docs = result.get("documents") or []
if not docs: # 没有更多数据,退出循环
break
metas = result.get("metadatas") or [{}] * len(docs)
for doc_text, meta in zip(docs, metas):
all_docs.append(Document(page_content=doc_text, metadata=meta or {}))
offset += BATCH
if len(docs) < BATCH: # 最后一批不足 BATCH 条,说明取完了
break
except Exception as e:
# 至少打个日志,避免 bug 再被静默吞掉
print(f"[warn] 加载集合 {name} 失败: {e}")
continue
self.all_documents = all_docs
if all_docs:
self.bm25_retriever = BM25ChineseRetriever(documents=all_docs, k=self.k)
改动点就三个:
limit=BATCH(1000 条一批)+offset逐批推进,循环拉取;- 两个退出条件:
docs为空(异常保险)或len(docs) < BATCH(正常到最后一页); except里加一行print,让问题以后能露出来。
六、其它可选方案(了解即可)
| 方案 | 说明 | 评价 |
|---|---|---|
| 分页 limit/offset | 上面这个 | ✅ 标准做法,已验证 |
按 where 条件分批 |
用 {"doc_type": "law"} 等元数据切块 |
可行,但字段值可能不均衡 |
| 抬高 SQLite 上限 | 重新编译 sqlite / 调 SQLITE_MAX_VARIABLE_NUMBER |
治标不治本,且 chromadb 用自带内核,不好改 |
| 不从向量库读、直接读源文件 | 像 import_data.py 那样从 data/laws_slight/ 读 |
绕开问题,但破坏"向量库为唯一数据源"的设计 |
现在整个问题链已经闭环:
- 现象 :
name=laws时get()"返回为空"; - 真相 :不是空,是
get()一次性拉 38762 条记录,撞上 SQLite 绑定变量上限(32766),抛too many SQL variables; - 为什么 cases 没事:只有 100 条,远低于上限;
- 为什么看着像空 :
except Exception: continue把异常吞了; - 修复 :
get(limit=1000, offset=...)分页循环,已验证取回全部 38762 条。
14、BM25ChineseRetriever.invoke("故意杀人罪的量刑标准是什么?")
为什么我在调试HybridRetriever.search_with_split_queries()函数中bm25_results = self.bm25_retriever.invoke(bm25_query)这行代码时,点击步入竟直接跳到了BM25ChineseRetriever._get_relevant_documents()函数中,请你教会我这里面的原理。
另外教会我BM25ChineseRetriever.invoke("故意杀人罪的量刑标准是什么?")这个函数到底在做什么?返回值是啥?
问题一:为什么 F11 步入 invoke() 直接跳到了 _get_relevant_documents()?
1. 关键在于:BM25ChineseRetriever 根本没定义 invoke()
看你的 retriever.py 第 15-48 行,BM25ChineseRetriever 只实现了:
__init__()------ 用 jieba 分词构建BM25Okapi索引_get_relevant_documents()------ 真正的检索逻辑
invoke() 是继承 来的,来自 langchain_core.retrievers.BaseRetriever(第 178-234 行)。你调用 bm25_retriever.invoke(query) 时,实际执行的是这段库代码:
# langchain_core/retrievers.py 中的 BaseRetriever.invoke()
def invoke(self, input, config=None, **kwargs):
...
callback_manager = CallbackManager.configure(...) # ① 构建回调管理器(tracing/LangSmith)
run_manager = callback_manager.on_retriever_start(...) # ② 通知"检索开始"
try:
if self._new_arg_supported: # ③ 检测子类签名是否支持 run_manager
result = self._get_relevant_documents( # ④ ★ 调用子类的核心逻辑
input, run_manager=run_manager, ...
)
...
run_manager.on_retriever_end(result) # ⑤ 通知"检索结束"
return result
这就是经典的模板方法模式 :invoke() 负责统一的"包装工作"(配置、回调、日志),然后把真正干活的部分委托给子类实现的下划线方法 _get_relevant_documents()。
2. 为什么调试器"看不见" invoke 的代码体?
因为 BaseRetriever.invoke() 位于 site-packages(库代码),而你的 launch.json 里两个调试配置都开着 "justMyCode": true。
VS Code 的 Python 调试器默认开启 "Just My Code"(仅我的代码) :调试时它会跳过所有不属于你工程目录的帧 (也就是 site-packages 里的 langchain_core 代码)。所以当你 F11 步入 invoke() 时,调试器执行了 invoke 里的那几行库代码,但不停留在它们上面,而是"穿透"到调用栈里第一个属于你自己代码的帧 ------也就是 BM25ChineseRetriever._get_relevant_documents() 的第一行 retriever.py:40。
3. 完整的调用链
search_with_split_queries() ← 你的代码,[retriever.py:155]
└─ bm25_retriever.invoke(bm25_query) ← 你按了 F11
└─ BaseRetriever.invoke() ← 库代码,被 justMyCode 跳过不显示
├─ CallbackManager.configure / on_retriever_start ← 库里执行但不暂停
└─ self._get_relevant_documents() ← 调用子类实现
└─ BM25ChineseRetriever._get_relevant_documents() ← ★ 停在这里
想亲眼看到 invoke() 的内部,两种办法:
- 把 launch.json 里的
"justMyCode": true改成false,再 F11 就能逐行看invoke了; - 或者直接打开
D:\python\Anaconda\envs\langchain1.2\Lib\site-packages\langchain_core\retrievers.py,在invoke里打断点(库文件也能打断点)。
顺带一提:search_with_split_queries() 是个普通方法 ,不是 _get_relevant_documents 的重写------所以它可以直接手动调用,而 hybrid_retriever.invoke(query) 才会走 HybridRetriever._get_relevant_documents()(RRF 那条路)。
问题二:BM25ChineseRetriever.invoke("故意杀人罪的量刑标准是什么?") 在做什么?
执行流程
invoke() 经过问题一讲的模板方法包装后,落到 retriever.py:37-48 的 _get_relevant_documents(),共 5 步:
| 步骤 | 代码 | 作用 |
|---|---|---|
| ① 守卫 | if not self.bm25 or not self.documents: return [] |
索引没建好就返回空列表 |
| ② 分词 | tokenized_query = list(jieba.cut(query)) |
把查询切成词:['故意杀人罪', '的', '量刑', '标准', '是', '什么', '?'] |
| ③ 打分 | scores = self.bm25.get_scores(tokenized_query) |
返回一个长度=语料库文档总数的数组,每篇文档一个 BM25 相似度分数 |
| ④ 取 top-k | 按分数降序排索引,取前 k 个 | k 默认 10(由 HybridRetriever 构建时传入 settings.RETRIEVAL_TOP_K) |
| ⑤ 过滤返回 | [self.documents[i] for i in top_indices if scores[i] > 0] |
只保留得分>0 的文档 |
BM25 分数怎么算的:每篇文档的词频(TF)+ 逆文档频率(IDF)加权和。查询词在一篇文档里出现越多、且在整个语料里越稀有,得分越高------是"字面共现"相关度,不是概率。
返回值
返回值是一个 list[Document] (langchain_core.documents.Document),特点:
- 元素内容 :每个
Document有.page_content(法条/案例原文,从 Chroma 向量库读出来的文本)和.metadata(字典,含doc_type="law"/"case"、source_file、law_name、effective_date等,见 legal_chunker.py:73)。 - 排序 :严格按 BM25 分数从高到低(因为第 ④ 步就是按分数降序排的)。
- 数量:最多 k 条;但如果 top-k 里混进了得分恰好为 0 的文档(可能是负 IDF 导致的),会被第 ⑤ 步过滤掉,所以实际可能少于 k 条。
我实测的一个小实验(3 篇文档模拟)
用你项目的 conda 环境(langchain1.2)真实跑了一遍:
查询分词: ['故意杀人罪', '的', '量刑', '标准', '是', '什么', '?']
各文档BM25得分:
文档0 故意杀人罪条文 → 0.5489 ← 最高,因为命中"故意杀人罪"这一整词
文档2 盗窃罪条文 → 0.0677
文档1 过失致人死亡罪条文 → 0.0592
返回顺序: [故意杀人罪条文, 盗窃罪条文, 过失致人死亡罪条文]
注意一个有意思的细节:盗窃罪条文得分居然比"过失致人死亡罪"高 。原因是两者都共享 "是 / 的 / 行为 / 有期徒刑" 这类高频词,它们词频贡献了分数。这正好暴露了 BM25 的局限------纯字面匹配、无语义,它不知道"故意杀人"和"过失致人死亡"是两个不同的罪名。所以你的项目才要做混合检索:BM25(稀疏,抓字面)+ 向量检索(稠密,抓语义)再 RRF 融合,互补各自的盲区。
一句话总结 :invoke() 是模板方法入口,负责回调/tracing,最后把活委托给子类的 _get_relevant_documents()------你的 F11 因为 justMyCode: true 跳过了库代码,直接停在了用户代码帧上;而 invoke("故意杀人罪的量刑标准是什么?") 返回的是按 BM25 相关性排序的、最多 k 条的法条/案例 Document 列表。
15、RRF计算
本项目是手写的 RRF (没装 langchain.retrievers),但公式和 LangChain 内置的 EnsembleRetriever 以及经典论文《Reciprocal Rank Fusion outperforms Condorcet and individual Rank Learning Methods》(Cormack 2009) 是同一套。下面完整讲透。
一、RRF 是什么(一句话)
RRF(Reciprocal Rank Fusion,倒数排名融合) :不用原始分数,而用文档在每个检索结果里的排名位次 ,把位次换算成一个分数,再跨来源相加,得到融合后的总排序。
它的核心信条:"排名位置"是跨检索方法可比的,"原始分数"不是。
二、为什么要 RRF?------ 因为两边的分数根本没法直接比
回顾前两题你看到的两个检索器的输出:
| 来源 | 分数 | 方向 | 量级 |
|---|---|---|---|
| BM25 | BM25 得分 | 越大越相关 | 和语料库规模有关,可以到 0~20+ |
| 向量 | L2 距离 | 越小越相关 | 通常在 0~2 |
问题来了:BM25得5分 和 向量距离0.5 谁更相关?没有可比性------单位不同、方向相反、分布不同。你不可能直接把两个分数加起来做融合。
RRF 的解决办法:把分数全扔掉,只看"排第几名" 。因为无论 BM25 的分数是 5 还是 20,它输出的顺序(谁是第 1、谁是第 2)是稳定可比的。这就是 RRF 存在的全部理由。
三、公式拆解
score = self.bm25_weight / (rrf_k + rank + 1) # 该文档在 BM25 结果里排第几 → 一个"贡献分"
score = self.vector_weight / (rrf_k + rank + 1) # 该文档在向量结果里排第几 → 另一个"贡献分"
| 符号 | 含义 | 本项目取值 |
|---|---|---|
rank |
文档在这个检索结果里的位次 (0 开始,来自 enumerate) |
BM25:按得分降序;向量:按距离升序 |
+1 |
把 0 基位次转成 1 基排名(第 1 名贡献最大) | 恒为 +1 |
rrf_k |
平滑常数(论文里的 k,控制曲线陡峭程度) | 60 (retriever.py:109 写死) |
bm25_weight |
该来源的权重,可调 | 0.5 (config.py:44) |
vector_weight |
该来源的权重,可调 | 0.5 (config.py:45) |
rrf_k=60 和 +1 的用意
+1 是因为 enumerate 从 0 开始,要转成"第 1 名"才能让首位贡献最大。rrf_k 决定排名曲线的陡峭程度:
- rrf_k 越小(如 5) :第 1 名
0.5/6=0.083、第 2 名0.5/7=0.071------相邻名次差距大,"单个来源排第 1"就很有话语权。 - rrf_k 越大(如 60) :第 1 名
0.5/61=0.0082、第 2 名0.5/62=0.0081------差距极小,曲线很"平",融合非常民主:文档只要被多个来源都认到,累计起来就能超过"只在单个来源排第一"。这也是论文选 60 的原因。
四、RRF 分数到底怎么一步步算出来的(手把手走一遍)
以 retriever.py:108-141 的算法为例,分 4 步:
第 1 步:准备一个空字典
results_map: dict[str, dict] = {} # content_hash -> {doc, score}
rrf_k = 60
第 2 步:BM25 检索,按位次贡献累加
bm25_results = self.bm25_retriever.invoke(query) # 得到按 BM25 分降序排好的列表
for rank, doc in enumerate(bm25_results): # rank = 0, 1, 2, ...
key = hash(doc.page_content[:200]) # 内容去重键
score = self.bm25_weight / (rrf_k + rank + 1) # ← 只看位次,不看 BM25 原始分
if key in results_map:
results_map[key]["score"] += score # 命中过就累加
else:
results_map[key] = {"doc": doc, "score": score}
第 3 步:向量检索,同样的逻辑再累加一次
vec_results = get_vectorstore(name).similarity_search(query, k=self.k)
for rank, doc in enumerate(vec_results): # rank = 0, 1, 2, ...
score = self.vector_weight / (rrf_k + rank + 1) # 同样只看位次
... # 同一 key 则累加
第 4 步:按总分降序排序,取前 k 条
sorted_results = sorted(results_map.values(), key=lambda x: x["score"], reverse=True)
return [item["doc"] for item in sorted_results[:self.k]]
具体数值例子
假设两个检索器各返回 3 篇(rrf_k=60,权重各 0.5):
BM25 排名: X(第1) Y(第2) Z(第3)
向量排名: Y(第1) Z(第2) W(第3)
| 文档 | BM25 贡献 0.5/(60+rank+1) |
向量贡献 0.5/(60+rank+1) |
融合总分 |
|---|---|---|---|
| X | 0.5/61 = 0.00820 | --- | 0.00820 |
| Y | 0.5/62 = 0.00806 | 0.5/61 = 0.00820 | 0.01626 |
| Z | 0.5/63 = 0.00794 | 0.5/62 = 0.00806 | 0.01600 |
| W | --- | 0.5/63 = 0.00794 | 0.00794 |
最终排序:Y > Z > X > W
注意看这个结果里 RRF 的精髓:
- X 在 BM25 排第 1,但总分只排第 3------因为它只被一个来源认到,只有一份贡献。
- Y、Z 被两个检索器都认到 ,两份贡献累加,总分几乎翻倍,直接反超单来源的第 1 名。
- 这说明 RRF 融合后的排序天然偏向"两个检索器一致认可的文档 ",而不是"某个检索器极度偏爱但另一个完全不认的文档"。这正是混合检索想要的效果------取交集共识,而不是各自的最爱。
五、回到你的代码:bm25_weight / vector_weight 的作用
纯 RRF 公式是 1/(rrf_k + rank + 1),本项目在上面乘了一个权重,等于在说:
BM25 的每名贡献打 0.5 折,向量的每名贡献也打 0.5 折。
当前都是 0.5,所以两个来源对等。你可以改 config 来调偏重:
- 想让精确关键词 更重要 →
BM25_WEIGHT=0.7, VECTOR_WEIGHT=0.3 - 想让语义泛化更重要 → 反过来
权重只影响"两个来源谁更强势",而 rrf_k 决定"排名 vs 共识的取舍"------这是 RRF 的两个独立调节旋钮。
小结
RRF 分数 = 把文档在"各检索结果中的排名位次"按
权重/(60+位次)换算成分数,再把多个来源的分数累加,最后按总分排序取 top-k。
它用"排名"取代了不可比的原始分数(BM25 得分 vs L2 距离),让不同检索方法能公平地"投票"。两个方法都投了票的文档会拿到两份分数累加,从而在最终排序里胜出------这就是混合检索融合的核心思想。
16、在纯 HYDE 策略下BM25(原问题) 会被算两遍
先明确:分离式检索只在 hyde_doc 存在时才执行
if hyde_doc: # ← 只有 HYDE / MULTI_QUERY_HYDE 才进这里
docs = retriever.search_with_split_queries(bm25_query=search_queries[0], vector_query=hyde_doc)
for q in search_queries: # ← 这个循环永远执行
docs = retriever.invoke(q)
所以"重复检索"的疑问只存在于 HYDE 系列策略。NONE / MULTI_QUERY / DECOMPOSE 根本没有 A 段,不存在重复。
以纯 HYDE 为例,把两段到底算了什么列出来
设 search_queries = [原问题],hyde_doc = 假设文档。两段实际执行了 5 次子检索:
| 检索操作 | A段 split(原问题, 假设文档) | B段 invoke(原问题) |
|---|---|---|
| BM25(原问题) | ✅ 第 1 遍 | ✅ 第 2 遍(重复!) |
| vector(假设文档) laws+cases | ✅ | ❌ |
| vector(原问题) laws+cases | ❌ | ✅ |
所以你的判断对了一半,关键是哪部分重复了、哪部分没重复:
- BM25(原问题) 确实算了两遍 ------ 这是真实的冗余。
- 向量部分完全没重复 ------ A 段向量用的是假设文档 ,B 段向量用的是原问题,是两条互补的路。
而且 BM25 算两遍的结果是一模一样 的(同一个 retriever、同一个查询、同一个 k),第二遍算出来的 10 篇文档在第一遍时就被 seen_contents 去重掉了,一条都不会多。所以冗余只影响性能,不影响正确性。
那为什么还要这么设计?
这是"通用路径 + HyDE 附加路径"叠加的设计,宁可多算,不可漏召。 拆开看:
-
B 段是通用底座,缺了它整个管线就废了 。
MULTI_QUERY/DECOMPOSE策略全靠 B 段的循环逐个查询检索,A 段根本不参与。代码不可能只为 HYDE 写一套,只为别的策略写另一套------统一走 B 段最省心。 -
B 段的
invoke(原问题)补了 A 段没有的"原问题向量检索" 。A 段的向量只认假设文档,如果 LLM 生成的假设文档质量差(这是 HyDE 的已知风险),A 段就抓瞎了。B 段用原问题直接向量化检索,等于兜底:假设文档再跑偏,标准检索也能保住基本召回。 -
给重排阶段送更多候选。A 段和 B 段返回的 top-10 集合不同(RRF 融合的对象不同),去重合并后候选可能到 ~20 篇,重排器(simple/llm)再从中精选 top-5,候选越多最终越稳。
那"只取 search_queries0"又为什么?
因为 search_with_split_queries(bm25_query, vector_query) 的签名只接受一个 BM25 查询 (retriever.py:143)------它设计成"一趟分离检索:BM25 用 A、向量用 B"。纯 HYDE 下 search_queries = [原问题],[0] 就是原问题,正好符合 HyDE 的意图(BM25 精确匹配原问题,向量语义匹配假设文档)。至于 MULTI_QUERY_HYDE 里第 2、3 个改写查询------它们根本没进 A 段,而是由 B 段 for q in search_queries 循环逐个 invoke 覆盖了,一个都没丢。
一句话回答你的核心疑问
是的,纯 HYDE 时
BM25(原问题)被算了两遍,但只有这一处重复,且被去重吸收,结果不受影响。 这是"通用检索循环(兜底、覆盖所有查询)+ HyDE 附加分离检索(补语义一路)"叠加设计的代价------代码简单、统一、不会漏召回,代价就是 HYDE 场景多算一遍 BM25(在内存里对几万篇文档做一次打分,比向量检索那两趟 Ollama HTTP 便宜得多)。
如果你确实介意这遍冗余,可以做一个小的优化:在 hyde_doc 分支里,若 len(search_queries) == 1,B 段跳过对 search_queries[0] 的 invoke,改为直接用 get_vectorstore(name).similarity_search(原问题, k=...) 补上"原问题向量检索"这一路,这样 BM25 就只算一遍了。但这是优化,不是修 bug------现在的代码是对的。
16、单例缓存装饰器:@lru_cache(maxsize=1)
@lru_cache(maxsize=1) 是 Python 内置的缓存装饰器 ,意思是:给这个函数加一层"记忆"。同一个参数的调用,只真正执行函数体一次 ,之后的调用直接返回之前算好的结果。对你这个函数来说,就是------犯罪知识图谱文件只在第一次调用时读一次、解析一次,后面所有调用都白嫖第一次的结果。
第一步:先把这行语法拆开
@lru_cache(maxsize=1)
def _load_crime_kg() -> dict[str, dict]:
...
这三件套分别是什么:
| 部分 | 含义 |
|---|---|
@ |
装饰器语法 。Python 在定义这个函数的时候 (即 import 该模块时)执行一次。它等价于在函数定义完之后加一行:_load_crime_kg = lru_cache(maxsize=1)(_load_crime_kg) |
lru_cache(...) |
来自标准库 functools,会返回一个新的"包装函数",用这个包装函数替换 掉原来的 _load_crime_kg |
maxsize=1 |
缓存最多存 1 个结果。超出就按 LRU(Least Recently Used,最近最少使用)策略淘汰最旧的 |
关键点:_load_crime_kg 这个名字在定义之后,指向的已经不是你自己写的那个函数了,而是一个"带缓存的壳"。你自己写的原函数体被藏在壳里。
第二步:加上注解之后,函数内部到底发生了什么变化
函数体本身一行都没变------你写的打开文件、正则解析、返回 dict 的代码,第一次调用时照样逐行执行。
变的是调用流程 。带缓存之后,每次调用 _load_crime_kg() 实际走的是这个壳的逻辑:
调用 _load_crime_kg()
│
├─ 壳先拿本次调用的参数(这里参数是空元组 ())去缓存里查
│
├─ 命中了吗?
│ ├─ 命中 → 直接返回缓存里的 dict,函数体【不执行】,结束
│ └─ 没命中 → 执行你写的函数体,把结果 dict 存进缓存,返回
│
下一次调用 _load_crime_kg() ──► 重复上面流程
在你这个函数上的具体体现,对比一下:
没加注解时(每次调用都重来一遍):
kg = _load_crime_kg() # 第 1 次:读文件 + 正则解析整个文件
kg = _load_crime_kg() # 第 2 次:又读文件 + 解析一遍
kg = _load_crime_kg() # 第 3 次:再读文件 + 再解析一遍
加了注解后:
kg = _load_crime_kg() # 第 1 次:真正执行函数体(读文件 + 解析),结果存入缓存
kg = _load_crime_kg() # 第 2 次:直接返回缓存,函数体不执行
kg = _load_crime_kg() # 第 3 次:同上
为什么这值得做?因为 kg_service.py:81 和 kg_service.py:128 都调用了它。每次用户问一个问题,就可能走两次 _load_crime_kg()。犯罪知识图谱文件不小,每次重新读盘 + 全量正则解析很浪费。缓存让这个开销在整个进程生命周期里只发生一次。
第三步:几个你可能会踩的坑(看完才算真懂)
1. 它是个"全局单例",文件改了也不会重新加载
缓存的 dict 存放在壳的全局缓存里,跟函数定义所在模块同生命周期。如果运行过程中你改了 犯罪知识图谱.txt,_load_crime_kg() 依然返回旧的 解析结果。要手动清缓存就调 _load_crime_kg.cache_clear()------你的测试代码里恰好就用了这个:
# test_kg.py 第 11 行
_load_crime_kg.cache_clear()
kg = _load_crime_kg() # 清掉后,下一次调用才会真正重新读文件
2. 返回的是同一个对象(引用共享)
每次返回的是同一个 dict 对象 ,不是副本。如果哪个调用方 kg["xx"] = ... 改动了它,所有后续调用方看到的都是改动后的版本。这个函数目前只有读操作,所以没问题。
3. 缓存的"键"是参数
LRU 缓存的键是每次调用的实参 。这个函数无参,所有调用共享同一个键 (),所以 maxsize=1 恰好够用。如果函数有参数(比如 _load_crime_kg(path)),那么不同路径会各自缓存一份,maxsize=1 就会导致频繁淘汰重建------那种情况才需要考虑调大。
4. 为什么是 1 而不是默认值
lru_cache 不写 maxsize 时默认是 128。对一个无参函数,存 1 个和存 128 个效果完全一样(反正只有一个键),写 maxsize=1 是在明确表达:"我只需要缓存一个结果"。
第四步:一个 10 行小实验,自己验证
你可以复制这段代码跑一下,直接看到"函数体只执行了一次":
from functools import lru_cache
@lru_cache(maxsize=1)
def load():
print("函数体真正执行了,正在读文件/解析......")
return {"盗窃罪": "秘密窃取"}
print(load()) # 第一次:打印那行提示 + 返回 dict
print(load()) # 第二次:没有打印提示,直接返回 dict ← 这就是缓存生效了
# 还能看统计信息
print(load.cache_info()) # CacheInfo(hits=1, misses=1, maxsize=1, currsize=1)
cache_info() 里的 hits=1 就是"有 1 次调用没有执行函数体直接命中缓存了",misses=1 是"只有 1 次真正执行了"。
小结
@lru_cache(maxsize=1)= 给函数加记忆,最多记 1 个结果- 加了之后,函数体代码本身不变 ,变的是它执行的次数:第一次真跑,之后全返回缓存
- 代价是数据不更新 (文件改了不重读),想重读就调
cache_clear() - 你这段代码用它很合适:知识图谱是"几乎不变"的静态数据,加载成本高但调用频繁,正好是缓存的最佳场景