【AI Agent案例开发项目01解读】

Teeeeen/legal_rag --- 先把它的 RAG 管线读懂

项目地址:GitHub - Teeeeen/legal_rag: 面向中国法律领域的轻量化本地 RAG 系统,基于 LangChain、Qwen3、BGE-M3 与 ChromaDB,支持可插拔检索、重排与生成策略 · GitHub

面向中国法律领域 的本地 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 做的事:

  1. 创建 FastAPI 实例 (第 8 行),并配置文档地址 /docs(Swagger UI,浏览器打开就能调试接口)
  2. 挂 CORS 中间件(第 17 行):允许前端(Vue 的 5173 端口)跨域调用
  3. 注册三个路由模块 (第 26-28 行),统一加 /api 前缀:
路由 功能 文件
/api/chat 问答接口(核心)、保存/下载问答记录 app/api/chat.py
/api/knowledge 知识库:上传文档、删除、重建索引、统计 app/api/knowledge.py
/api/performance 性能测试、基准测试、生成报告 app/api/performance.py
  1. startup 预热钩子(第 31-50 行):启动时主动把 Ollama 的 LLM 和 Embedding 模型加载进显存。因为本地模型冷启动要 10-30 秒,预热后用户首次提问就不必等。
  2. //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.pyget_llm() → 返回 ChatOllama(qwen3:8b) 实例(按 model+temperature 缓存)
  • embeddings.pyget_embeddings()OllamaEmbeddings(bge-m3)
  • vectorstore.pyget_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,产出答案

每道工序都通过策略枚举QueryTransformStrategyRerankStrategyGenerationStrategy)自由组合,所以前端能任意开关"多查询""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:8bbge-m3 两个模型),并导入过法律/案例数据(scripts/ 下的脚本),否则 /health 会提示模型预热失败。

几个值得记住的"坑"与决策

  • 全本地 Ollama 而非 API → 法律数据"不出域"(隐私)
  • ChromaDB 而非 Milvus/FAISS → 嵌入式零配置
  • 内存字典 而非 Neo4j → 需求只是键值查询,O(1) 够用
  • config.py 启动时把 localhost 写进 NO_PROXYWindows 系统代理(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() → 分别实例化 lawscases 两个向量数据库集合。

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__))))
  1. __file__:Python 内置变量,代表当前执行的 Python 脚本文件的相对或绝对路径(取决于运行时如何调用)。

  2. os.path.abspath(__file__) :将 __file__ 转换为标准且规范的绝对路径 (例如 /Users/username/project/src/main.py),消除相对路径带来的干扰。

  3. 内层 os.path.dirname(...) :获取上述绝对路径的父级目录 (即当前文件所在的文件夹路径,例如 /Users/username/project/src)。

  4. 外层 os.path.dirname(...) :再次嵌套一层 dirname,获取父级目录的父级目录 (即当前文件所在文件夹的上一级目录,如 /Users/username/project)。

  5. 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...elsetry...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) = 12000range(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)问题。

核心语法解析

  1. 多异常元组捕获 (Tuple of Exceptions)

    except (UnicodeDecodeError, UnicodeError):
    continue

  • 语法拆解

    • except 后跟用圆括号 () 包裹的元组,可以同时捕获多种指定的异常类型

    • UnicodeDecodeErrorUnicodeError 的子类,这里同时显式捕获,确保只要是解码问题就会被拦截,而不会误拦截其他的异常(例如文件权限不足 PermissionError)。

  • 教学点 :捕获异常时应尽可能精细(Specific),切忌写成盲目的 except Exception:。如果因"文件找不到"或"磁盘损坏"报错,应该让程序报错抛出,而不是误以为是"编码错误"而继续 continue

2. 显式抛出业务异常 (raise)

复制代码
raise ValueError(f"无法解码: {filepath}")
  • 语法拆解

    • raise 关键字用于主动抛出一个内置或自定义的异常对象。

    • ValueError 表示传入的参数/数据不符合预期(即给定的路径对应的文件内容无法用常见中文编码解析)。

  • 教学点 :当函数无法完成其承诺的功能(读取文本)时,显式抛出带有明确上下文(如文件路径 filepath)的异常,比静默返回 None 或空字符串要安全得多。这能让调用方(如 import_laws 中的 try...except)第一时间获取到报错详情并打印日志。

是整个法律 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 & 架构排错要点:

  1. 差异化 Chunk Size 的合理性

    • 法律(Law) :条文通常精炼(如"第一百条 ..."),采用标准的 512 字符块大小,能精准定位到具体法条,避免检索噪声。

    • 案例(Case) :判决书/案情描述往往篇幅冗长,需要更大的上下文(chunk_size * 2 = 1024)来容纳完整的因果逻辑和案情事实。

  2. 上下文标头注入(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)。优点有两个:

    1. 避免循环导入(Circular Import) :如果 app.config 在初始化时也间接依赖了 legal_chunker,在顶部导入会导致 Python 报错。

    2. 性能优化:只有当代码真正运行到这个分支时才会去加载配置模块,减少不必要的初始化开销。

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"
  1. 限定检索窗口(text[:500] / text[:2000]

    • 性能控制:法律文件(如《民法典》)可能长达几十万字。如果在全文本上执行复杂的正则表达式搜索,耗时会随文本长度呈非线性增长。

    • 精确度提升:法律名称通常出现在文首标题,生效日期通常出现在前言、文首文号或颁布说明中。限制在文首窗口搜索,既大幅提升了正则匹配效率,又避免了误匹配正文中引用的其他法律名称或历史日期。

  2. 多重正则优先级(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 打上精准的元数据有两个巨大的检索与重排优势

  1. 精确过滤(Metadata Filtering) :用户搜索"盗窃罪量刑标准"时,数据库可以先筛选出 article_number = "264"section_type = "裁判结果" 的数据块,直接排除无关的"诉讼程序"或"被告人身份"文本。

  2. 重构上下文 :利用 chunk_index 可以轻松拼接相邻的上下文 Chunk(如加载前后的第 i-1i+1 块)。

业务处理逻辑流:

复制代码
[传入单个 Chunk & 父级元数据]
          │
          ├── 1. 字典解包合并 parent_metadata,注入 chunk_index
          │
          ├── 2. 正则抽取章节信息 ──> 匹配 "第X章 标题"
          │
          ├── 3. 正则抽取条号数字 ──> 匹配 "第X条" 中的 X 写入 article_number
          │
          └── 4. 案情段落归一化 ──> 前 50 字检索关键词,映射为标准板块(裁判要旨/基本案情/裁判结果/裁判理由)
  • 关键词范围限制 (text[:50])

    • 设计意图 :判决书中常出现"本院在基本案情 审查中认为..."这样的描述。如果全局搜索关键词,非段落头部的正文讨论会造成误判。限制在 前 50 个字符 内匹配,确保只有当该块是该段落的起始开头 时,才打上 section_type 标签。
  • 映射归一化(Normalisation via Dictionary)

    • 不同的法院判决书用词不一(有的叫"裁判要旨",有的叫"裁判要点";有的写"裁判理由",有的写"法院认为")。通过 section_types 字典,将多种别名统一归一化为标准的 4 类,极大地降低了后续检索过滤的复杂度。

二、 核心语法解析

  1. 字典解包合并 (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 头部
  1. 解决 Embedding 的孤岛问题

    • 未增强前 :Chunk 正文为 "犯前款罪的,处三年以下有期徒刑..."(Embedding 模型不知道这是哪部法律、哪一条,相似度计算效果差)。

    • 增强后 :Chunk 变成了 [法律名称: 中华人民共和国刑法 | 条号: 第264条]\n犯前款罪的,处三年以下有期徒刑...(Embedding 模型能完美捕获《刑法》和第 264 条的向量特征)。

  2. 安全防护(无脏数据前缀)

    • 通过 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 向量库
  1. 向量数据库批次上限(Batch Size Overhead)

    • 为什么需要 _batch_add_documents 许多向量数据库(如 ChromaDB、Milvus 等)底层有 API 单次写入记录数的限制(例如 ChromaDB 单次请求最多包含约 5461 个向量,否则会直接抛出 BatchSizeError)。

    • 通过设置 batch_size=5000 并使用列表切片分批写入,避免了内存溢出(OOM)和数据库请求超限。

  2. 错误隔离(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) = 12000batch_size = 5000range(0, 12000, 5000) 会产生三个 i 值:0500010000

    • 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 (收尾刷盘)
  1. 流式处理(Streaming)避免内存暴涨(OOM)

    • JSONL 文件往往高达数 GB,如果一次性 f.readlines()json.load(f) 加载,内存会瞬间爆满。

    • 设计亮点 :代码采用 for line_no, line in enumerate(f, 1) 遍历文件句柄,内存中同一时刻只保留当前行的字符串,对海量数据极其友好。

  2. 尾部清算(Tail Flush)机制

    • 在文件内循环结束后,必须包含 if batch_chunks: 判断。如果最后一个批次只有 45 个 Chunk(未达到 200 个的写入阈值),如果不做尾部清算,这 45 个 Chunk 就会在内存中被丢弃,造成数据丢失
  3. 全局上限截断(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、引号未闭合等),而不是粗暴地使用 generic except 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)]
  1. 元数据扁平化归一化(Flattening & Normalization)

    • 在向量数据库中(如 ChromaDB),metadata 字典通常不支持存入嵌套列表 (例如 ['盗窃罪', '抢劫罪'])。直接存入会导致数据库报错。

    • 设计亮点 :代码将列表统一通过 ";".join(...) 转为以中文分号间隔的单层字符串,确保兼容性。

  2. 多源兼容与兜底(Schema Robustness)

    • 某些预处理管道会提前将 meta 中的 accusation 提至顶层(扁平格式)。代码中 if "accusation" in obj and not extra_meta.get("accusation") 巧妙解决了"嵌套结构 vs 顶层结构"的数据异构冲突。
  3. 安全数据元组返回 (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] 包含两重安全校验:

      1. key in obj:确保字典中存在该键(避免 KeyError)。

      2. 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 判断为假,提升了代码容错率。

步骤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,它直接构造 PipelineConfigRAGPipeline.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() 协程。

异步事件循环的基础知识点复习请见附录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 实例
  1. 参数解析与默认值兜底 :首先对输入的 modeltemperature 进行校验。如果调用方没有显式传参,则自动读取全局配置 settings 中的默认参数。

  2. 组合唯一缓存键 :把确定好的 _model_temp 组合成一个不可变元组 cache_key,以此代表一种特定的 LLM 配置。

  3. 缓存命中判断与延迟加载(Lazy Initialization)

    • 未命中 :如果 _llm_cache 字典里还没有对应的 cache_key,才真正实例化 ChatOllama,并填入 Ollama 服务地址(base_url)和上下文长度(num_ctx)等固定配置,存入字典。

    • 命中:直接跳过创建步骤。

  4. 返回复用实例:直接返回字典中已有的对象。

二、 核心语法详解

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) 。若 modelNone 或空字符串 ""(假值/Falsy),表达式会直接取右侧的 settings.LLM_MODEL
  • 温度参数兜底_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 实例
  1. 缺省模型获取 :检查入参 model。若调用时没有传参,则自动读取 settings.EMBEDDING_MODEL(如默认的 bge-m3)。

  2. 字典缓存检索 :以字符串 _model 为键,在全局字典 _embed_cache 中查找对应的向量模型对象。

  3. 延迟初始化(Lazy Initialization)

    • 未命中 :实例化 OllamaEmbeddings,配置模型名称与 Ollama 服务基准地址(base_url),并写入 _embed_cache

    • 命中:跳过创建流程。

  4. 返回对象 :直接返回可进行文本向量化操作(如 .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_taskasyncio.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
  • 作用

    1. 加快启动速度:避免在模块初始化时就加载耗时较长的依赖库(如重型图算法、NLP 模型)。

    2. 避免循环依赖(Circular Import) :当多个服务之间存在交叉引用时,将 import 延迟到函数执行时触发可彻底避免此问题。

5. 解包运算符与字典动态构建:StageMetrics(**self.metrics)

在生成返回值时:

python 复制代码
metrics=StageMetrics(**self.metrics)
  • 语法点** 为字典解包运算符。它会将 self.metrics 字典中的键值对拉平,并以关键字参数的形式传给 StageMetrics Pydantic 数据模型进行类型校验与实例化。

字典解包运算符详解请见附录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)
  1. 零开销快速退出(Short-circuiting) :优先检查策略是否为 NONE。如果是,直接返回原问题,跳过后续所有初始化和耗时统计。

  2. 策略分发(Strategy Pattern)

    • MULTI_QUERY:调用大模型将单句提问泛化改写为多角度同义问题,提升检索召回率。

    • HYDE(假设性文档嵌入):让大模型先盲写一份"假想答案",后续用这份假想答案去向量库查真实文献。

    • DECOMPOSE:把多条件或复杂的逻辑问题拆解为多个独立子问题。

    • MULTI_QUERY_HYDE :融合策略,利用异步并发同时触发 MULTI_QUERYHYDE

  3. 指标埋点与归一化输出 :计算该阶段总毫秒数写入 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,直接提取目标字符串 hypohyde_doc

4. 函数级延迟导入(Lazy Import)

HYDEDECOMPOSE 分支内部可以看到 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)
  1. 特定温度的 LLM 示例获取 :调用 get_llm(temperature=settings.HYDE_TEMPERATURE)。HyDE 通常需要模型具有一定的创造力来生成假设文档,因此其温度(Temperature)设置一般略高于严格问答时的温度。

  2. 异步链式调用 :利用 LCEL 表达式 HYDE_PROMPT | llm 将 Prompt 模板与大模型绑定,并使用 await chain.ainvoke(...) 异步生成假设文档。

  3. 输出清洗与质量校验

    • .strip() 去除生成文本前后的空格和换行符。

    • 校验长度 len(hypothetical_doc) < 10:若生成内容太短(如模型只回复了"不知道"或输出异常空文本),判定为无效生成,回退为 None

  4. 防御性编程(异常兜底) :整个流程包裹在 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.00.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)里:

  1. 若有 hyde_doc → 先做一次分离式检索(BM25 用原问题,向量用假设文档)
  2. 再对 search_queries每个查询invoke 一遍
  3. 所有结果按 hash(doc.page_content[:200]) 去重合并

rewritten_queries 则一路带到最后,装进 ChatResponsepipeline.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
  1. 资源准备与初始化

    • 开启耗时统计 t0 = time.time()

    • 获取指定集合(如 lawscases)的混合检索器 retriever

    • 维护一个去重集合 seen_contents 与最终文档列表 all_docs

  2. 分支一:HyDE 分离式检索(Split Query Strategy)

    • 如果 hyde_doc 存在,调用特殊的 search_with_split_queries 方法。

    • 为何要分离? 关键词检索(BM25)需要准确的法律实体和术语(来自原始问题 search_queries[0]);而向量检索(Vector)更看重上下文语义相似度(来自模型生成的假想文档 hyde_doc)。将两者的 Query 拆开能最大化混合检索的效果。

  3. 分支二:常规多查询检索(Multi-Query Loop)

    • 遍历 search_queries 中的每一个改写或拆解后的子问题 q

    • 使用 LangChain 标准接口 retriever.invoke(q) 触发混合检索。

  4. 内存增量去重与合并

    • 每检索出批次文档,通过截取文本前 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_weightvector_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
  1. 多集合数据提取 :遍历指定的数据库集合名(如 laws 法律条文库、cases 判例库),使用底层 store._collection.get() 拉取存储在 ChromaDB 中的完整文档文本(documents)和元数据(metadatas)。

  2. 文本与元数据组装 :通过 zip() 组合并行数组,精准还原为标准的 LangChain Document 对象并存入 all_docs

  3. 容错机制 :单个集合读取失败(如集合尚未创建或连接异常)会被 try...except 捕获并 continue,保证其他可用集合能正常加载。

  4. 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 {}))
  1. result["documents"]result["metadatas"](字典键值提取)

    • 语法:字典数据读取。

    • 作用:通常用于获取向量数据库(如 ChromaDB/Milvus)返回的查询结果。documents 对应文本列表,metadatas 对应元数据字典列表。

  2. result["metadatas"] or [{}] * len(...)(外层列表级防御)

    • 短路求值 (or) :如果 result["metadatas"]None 或空列表 [],表达式会自动执行 or 右侧的代码。

    • [{}] * len(...) :获取文档数量 n,构造一个包含 n 个空字典的列表(如 [{}, {}])。

    • 作用:保证传给 zip() 的第二个参数永远是一个与文档列表等长的序列 ,防止因为 metadatas 缺失导致 zip()TypeError 或长短不一丢包。

  3. zip(...)for doc_text, meta in(多路打包与元组解包)

    • 语法:zip() 将两个可迭代对象按索引 1:1 打包成元组;for 遍历通过解包将元素分别赋给 doc_text(字符串)和 meta(字典或 None)。
  4. metadata=meta or {}(内层元素级二次防御)

    • 短路求值 (or) :如果 metadatas 列表虽然存在,但内部某个具体元素为 None(例如:metadatas = [{"source": "A"}, None]),meta 在当前循环中即为 None

    • meta or {} :若 metaNone{}, 则回退返回一个新的空字典 {}

    • 作用:确保传入 Document 构造函数的 metadata 属性绝对不会是 None ,符合 LangChain 等框架对 Document 类型的要求。

  5. Document(page_content=..., metadata=...)(类实例化)

    • 语法:LangChain / LlamaIndex 等框架中文档对象的实例化。

    • 作用:将纯文本和元数据封装为统一的文档对象格式。

  6. 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 中,如果 metaNonemeta 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_weightvector_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]
  1. 安全校验:检查 BM25 索引或原始文档库是否未就绪,防止空指针异常。

  2. 查询词切分 :将输入的中文文本(如 bm25_query 原始问题)拆解为词列表,保证与索引库的 Token 粒度对齐。

  3. 分值计算 :将分词后的 query 喂给 BM25Okapi 索引,生成一个长度等于文档总数的相似度得分数组 scores

  4. Top-K 下标提取:在不破坏原始文档位置索引的前提下,按得分排序找到前 K 个最相关的文档下标。

  5. 分值过滤与映射 :排除得分零分/负分的噪音文档,根据下标重新映射回 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.queryCollection.py:194)。它底层干三件事:

  1. 加载 HNSW 索引:集合里所有文档的向量早已建好一个 HNSW(近似最近邻)索引。
  2. 近似 K-近邻搜索 :拿查询向量在这个索引里找 k 个"离它最近"的向量。距离度量这里用的是 L2 欧氏距离 ------因为你的 collection_metadata 没设 hnsw:space,chromadb 默认就是 "l2"(我在 hnsw_params.py:56 确认了默认值)。
  3. 回查 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_typesource_filelaw_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="故意伤害罪" \\rightarrow kn in name 判定为 True。

    • 用户/LLM 提问比较短 (例如:name="寻衅滋事"),而图谱标准罪名为 kn="寻衅滋事罪" \\rightarrow name 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() 接收多个数值参数,返回其中的最小值。

  • 工程作用三重保护机制

    1. 不超过文档实际总数(len(documents));

    2. 按照目标数翻倍取候选(top_k * 2);

    3. 设置硬上限 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 验证轮次,耗时通常显著高于单次生成。通过用 t0t_reflect 两个时间戳独立计算 generation_msself_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 完全踩中:

  1. 进程内嵌入式 --- pip install chromadbChroma(collection_name=..., persist_directory=...) 直接用,不启动任何独立服务。代码里 vectorstore.py 就 27 行,get_vectorstore() 按 collection 缓存实例,零配置。
  2. 自带持久化和元数据过滤 --- 落盘就是 chroma_db/ 下那个 sqlite 文件,不需要外挂 MinIO、etcd 这些存储。而 legal_chunker.py 里大量用 doc_type/law_name/article_number 元数据做过滤和展示,Chroma 的 metadata 过滤够用。
  3. 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 个问题,和选任何存储一样:

  1. 数据量多大? 万级 → Chroma;百万级+ → 认真考虑 Milvus/Qdrant/云服务
  2. 并发多高? 自己一个人调式/低并发 → Chroma;面向用户的线上服务 → Milvus
  3. 部署环境在哪? 单机脚本/本地工具 → Chroma;k8s 集群 → Milvus 才是主场
  4. 检索能力要求? 只要 top-k 相似度 + 基础过滤 → Chroma;需要混合检索(稀疏+稠密)、复杂过滤、多租户 → Milvus 才是必需
  5. 你愿意为"运维"付多少成本? 有没有人维护集群,决定了上面所有答案

对 Agent 应用开发者,还要加一条思考:向量库只是你的 Agent/RAG 架构里的"记忆层"一环 。它不应该是你最费神的部分------你的精力应该在检索策略Prompt工具编排上。所以在架构初期用 Chroma 快速把链路跑通、验证检索质量,是极正确的工程决策。

另外注意一个细节:这个项目用 LangChain 的 Chroma 封装,将来数据量真涨上去了要迁移到 Milvus,只需要把 vectorstore.pyretriever.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())
  1. main() 返回的不是结果,而是一个协程对象(coroutine)async def 函数被调用时不会执行函数体,只是创建了一个可挂起/恢复的状态机。
  2. asyncio.run(main()) 做的事:创建一个全新的事件循环 → 把 main() 作为任务投进去 → 驱动循环一直转,直到 main() 返回 → 关闭并清理这个循环。
  3. 如果没有这行,直接 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 等待时间叠在一起用。

异步编程请跳转:

Python异步编程入门案例-CSDN博客

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")

这样做拿到了什么?

  1. 封装复杂性 ------Chroma 需要 3 个参数、还要联动 settingsget_embeddings()。这些细节全部收敛到一个函数里,调用方只需要知道"给我 collection 名,还我向量库"。
  2. 统一的变更点 ------以后想给 Chroma 加参数(比如 hnsw:space)、换 persist 目录、加日志,只改这一个函数,全项目生效。
  3. 可以随时"插缓存逻辑" ------工厂函数天然是加缓存的完美位置(后面模式二正是这么干的)。如果调用方各自 new,缓存就没法做了。

判断依据:你搜索一下项目里调用处,全都是 get_vectorstore("laws") 这种写法,没人直接 Chroma(...)------这就是工厂模式生效了。

模式二:模块级单例缓存模式

核心思想:昂贵资源只创建一次,之后全局复用。

先澄清一个精确的叫法:这不是 经典意义上的"单例"(经典单例 = 全局只有一个实例,谁拿都是同一个)。这是按 key 键控的多例注册表(registry) ------因为你有 lawscases 两个 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 全局一个实例,按需懒加载 lawscases 各一个共享实例
配套的失效机制 数据变更时让缓存作废 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    ▼         ▲    ▼ 便宜(大而慢)
       / 网络远端   │
                   ▼

一条铁律:计算机的"时间开销"几乎都来自数据在层次之间移动,以及目标层上的初始化。 具体到一个系统,冷启动时你在为四件事买单:

  1. 搬运------把模型权重/索引数据从磁盘读进内存或显存(I/O,最慢的一步)。
  2. 转换------加载时反序列化、量化、构建索引结构(CPU 计算)。
  3. 分配------申请内存/显存、分配 KV cache(LLM 推理的缓存区)。
  4. 连接------建立网络连接、初始化客户端(如 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() 第一次被调用才加载。它的好处是启动快、不用提前浪费显存;代价就是第一个请求必须替所有层买单

生产项目应对冷启动的三种常见策略,供你对照:

  1. 预热端点(本项目 debug 脚本的做法)------服务启动后主动触发一次推理,把冷变热。
  2. 常驻进程------服务长跑不重启,冷启动只发生一次,之后永远热(热缓存、热模型、热索引)。
  3. 预加载------启动时就显式加载所有模型和索引(快启动换掉重启的代价)。

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) 时,解释器做的事:

  1. 先在 LegalArticleSplitter.__dict__ 里找 split_documents没有 (它只定义了 __init__
  2. 沿 MRO 上溯到 RecursiveCharacterTextSplitter.__dict__ → 没有
  3. 再到 TextSplitter.__dict__找到 ,绑定到 self 上执行

这就是"重用"的底层原理:子类没写的任何方法,都会顺着这条链找到父类的版本拿来用。 LegalArticleSplitter 里只存在一个方法(__init__),其余 split_textcreate_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 个元素

再写回原代码:

  • mqresults[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_callsMULTI_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

注意两件事:

  1. 并行那次,两个「请求发出」几乎同时打印------说明两个任务同时开工了。
  2. 明明 HyDE 先返回,gather 的结果却仍是 [多查询, HyDE]------结果顺序 = 传入顺序,与完成先后无关

6. 常见坑(对照你的代码看)

  1. 忘了 awaitasyncio.gather(...) 不 await,它只是个协程对象,任务没跑完程序可能就结束了。
  2. create_task 后必须等待 :如果创建了 Task 却不放进 gather/await,Task 可能被垃圾回收,Python 会抛 RuntimeWarning: Task was destroyed but it is pending。所以 create_taskawait gather(...) 永远是配套出现的。
  3. create_task 只能在运行中的事件循环里调用 ,所以必须写在 async 函数内部(_query_transform 就是 async 函数,没问题)。不能在模块顶层直接 create_task
  4. 并发适合 I/O 密集 (LLM 调用、HTTP、数据库),不适合 CPU 密集(纯计算)------单线程下纯计算并发不会加速,反而有调度开销。
  5. gather 的异常行为 :默认只要有一个任务抛异常,整个 gather 就抛异常(已完成的照常返回)。如果想让异常作为结果值返回而不是中断,用 return_exceptions=True
  6. 如果想知道哪个任务先完成 (而不是按传入顺序等全部),用 asyncio.as_completedasyncio.wait------但你的场景「要两个结果一起用」,gather 正合适。

7. 延伸:和你项目里其他地方的呼应

你的 _query_transform(pipeline.py:159-198)返回 (search_queries, hyde_doc, rewritten_queries),所以 MULTI_QUERY_HYDE 分支里解构出的 mqhypo 最终被存进 search_querieshyde_doc,供 Stage 2 检索 使用------hyde_doc 走向量检索、search_queries[0] 走 BM25。整个流程里,查询变换阶段是唯一「并行」优化过的点,其他阶段(检索、重排、生成)数据存在依赖,必须串行。

如果还想深入,下一层可以看 asyncio 的底层调度机制(FutureTask 的关系、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 里就塞满了重复文档。后果是:

  1. 浪费上下文窗口 ------build_context 有 4000 字符预算,本地 8B 模型上下文只有 8192 tokens,重复文档挤掉真正有用的内容;
  2. 误导重排序------同一个文档被算多遍;
  3. 回答里来源列表重复

所以需要「相同的文档只保留一份」。


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. 需要知道的三个边界(潜在坑)

  1. 200 字符截断的代价 :如果两篇内容不同的文档恰好前 200 个字符一样,它们会被误判为重复,其中一篇被丢弃。这是故意接受的取舍------法律文档前 200 字通常包含标题/条号,足以区分。如果项目里真有这种极端情况,可以截更长或改成全文哈希。
  2. hash() 的碰撞 :两个不同的字符串理论上可能哈希到同一个整数(64 位哈希,碰撞概率极低)。碰撞的后果是误删(假重复),不会造成错误保留。文档数量只有几十篇时,可忽略。
  3. 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 做三件事:

  1. 实例化------用展开后的关键字参数构造模型对象;
  2. 类型校验(验证) ------每个值对照字段的类型注解检查。比如 query_rewrite_ms: float | None 传了个字符串 "abc",会抛 ValidationError;传 3(int)给 retrieval_ms: float,Pydantic 通常会自动转成 3.0
  3. 默认值兜底 ------展开后缺了哪个字段(dict 里没有那个键),就用模型里写的默认值(如 llm_calls: int = 0)。

这就是你引用解释里「类型校验与实例化」的含义。


7. 规则与坑

  1. ** 的键必须是字符串 ,否则抛 TypeError: keywords must be strings
  2. 键名必须和模型字段名完全一致 。好在 self.metrics 的 10 个键就是照着 StageMetrics 的 10 个字段起的(pipeline.py:75-86),一一对应。
  3. 拼错键名的隐形坑 :Pydantic v2 默认 extra='ignore'------如果 dict 里混进一个模型没有的键,它不会报错,而是悄悄忽略。好处是健壮,坏处是拼错字段名时你察觉不到,字段就一直用默认值。
  4. ** 必须写在位置参数和普通关键字参数之后f(1, a=2, **d) 合法,f(**d, 1) 语法错误)。
  5. ** 展开的键不能和前面已显式写的关键字参数重复,否则报错。

再串一遍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 认识吗? 原因
intstrbool 有内置校验器
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 索引

这里的逻辑链是:

  1. jieba.cut("被告人王某盗窃财物")["被告人", "王某", "盗窃", "财物"](中文要先分词,这是中文 BM25 和英文最大的区别)
  2. 于是 tokenized_corpus 就变成 [["被告人","王某",...], ["抢劫",...], ...]------一个"每篇文档 = 一串词"的列表
  3. 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 的语料要"从向量库里加载",而不是直接拿原始文档?

这是数据源统一的设计:

  1. 系统的流程是"文档导入 → embedding → 存进 Chroma",Chroma 是唯一的数据源
  2. 构建 BM25 时,从 Chroma 把原文读出来重建索引,就不用再维护一份独立的原始文档库,避免"两处数据不一致"。
  3. 代价是这个函数在 __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和bk1控制词频的饱和速度 ,默认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          # ← 异常被吞掉,静默跳到下一个集合

lawsget() 抛异常 → except Exception: continuelaws 一整批文档都被跳过 。最后 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)

改动点就三个:

  1. limit=BATCH(1000 条一批)+ offset 逐批推进,循环拉取;
  2. 两个退出条件:docs 为空(异常保险)或 len(docs) < BATCH(正常到最后一页);
  3. except 里加一行 print,让问题以后能露出来。

六、其它可选方案(了解即可)

方案 说明 评价
分页 limit/offset 上面这个 ✅ 标准做法,已验证
where 条件分批 {"doc_type": "law"} 等元数据切块 可行,但字段值可能不均衡
抬高 SQLite 上限 重新编译 sqlite / 调 SQLITE_MAX_VARIABLE_NUMBER 治标不治本,且 chromadb 用自带内核,不好改
不从向量库读、直接读源文件 import_data.py 那样从 data/laws_slight/ 绕开问题,但破坏"向量库为唯一数据源"的设计

现在整个问题链已经闭环:

  1. 现象name=lawsget() "返回为空";
  2. 真相 :不是空,是 get() 一次性拉 38762 条记录,撞上 SQLite 绑定变量上限(32766),抛 too many SQL variables
  3. 为什么 cases 没事:只有 100 条,远低于上限;
  4. 为什么看着像空except Exception: continue 把异常吞了;
  5. 修复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_filelaw_nameeffective_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,控制曲线陡峭程度) 60retriever.py:109 写死)
bm25_weight 该来源的权重,可调 0.5config.py:44
vector_weight 该来源的权重,可调 0.5config.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 附加路径"叠加的设计,宁可多算,不可漏召。 拆开看:

  1. B 段是通用底座,缺了它整个管线就废了MULTI_QUERY / DECOMPOSE 策略全靠 B 段的循环逐个查询检索,A 段根本不参与。代码不可能只为 HYDE 写一套,只为别的策略写另一套------统一走 B 段最省心。

  2. B 段的 invoke(原问题) 补了 A 段没有的"原问题向量检索" 。A 段的向量只认假设文档,如果 LLM 生成的假设文档质量差(这是 HyDE 的已知风险),A 段就抓瞎了。B 段用原问题直接向量化检索,等于兜底:假设文档再跑偏,标准检索也能保住基本召回。

  3. 给重排阶段送更多候选。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()
  • 你这段代码用它很合适:知识图谱是"几乎不变"的静态数据,加载成本高但调用频繁,正好是缓存的最佳场景
相关推荐
青 春 记 忆19 分钟前
零基础入门python43:Django接口测试与库存边界
python·django·后端开发
Patrick在香港25 分钟前
Python 拉取 C&SD 官方 API:香港 2022 年已跨过“超老龄线“,而抚养比正在爬回 1961
android·c语言·python·数据分析·时序数据库·数据可视化·香港
2601_9623824327 分钟前
Python零基础入门,看完直接上手写代码
python·机器学习·编程语言·数据科学·入门教程
信誓旦旦的程序猿27 分钟前
【零依赖量化数据实战 #25】北交所技术指标与基本面
java·人工智能·python·股票数据api·股票数据·股票数据api接口·股票api数据接口
2601_9669496530 分钟前
Python 量化开发为什么适合使用金融数据 SDK?从数据获取到策略研究的工程化实践
开发语言·python·数据分析·量化交易·股票数据·quantdash
科技小E30 分钟前
训完怎么带走?AI模型私有化部署平台DLTM模型导出ONNX/PyTorch与离线部署跑遍产线边缘
人工智能·pytorch·python
晓窗科技31 分钟前
专业的AI基座公司
人工智能·python
花间相见32 分钟前
【LangChain组件02】—— create_agent函数详解
python