📚前言
📒FDE系列内容总纲:
🚄前置课程列表:
见文档结尾附录。
🚀阶段3·Day 71:高级检索 --- 查询改写与 HyDE
FDE 学习系列教程 · 第三阶段 · 第 11 周 · Day 1 预计时长:3 小时 | 难度:★★★★☆ | 前置知识:Day 61-70(RAG 链路、Qdrant 混合检索、RRF、Reranker、引用溯源、权限过滤) 对标大纲课时:3.2.9 高级 RAG ------ Agentic RAG(自主检索策略)、Self-RAG、CRAG 这一支;本篇把"自主检索策略"落成可运行代码
📌 一句话目标:让 v0.2 听得懂"人话"------用查询改写 / HyDE / Multi-Query 把口语提问的召回率从 0.45 拉到 0.88,再用 Self-RAG / CRAG 的自我评判让系统在"检索结果不够好"时主动重检或体面拒答,并把每一次决策接进 Langfuse。
🧑🤝🧑 开场:师傅的一句话,让整套检索系统哑了
第 10 周收官那天,你在车间做演示。系统表现得很漂亮:
你输入:A3 冷却水压力低于多少必须停机?
系统答:低于 0.25MPa 时必须停机检查。¹
点开 ¹ → manual_a3.clean.md · 4.3 冷却系统维护 · P47 第 312 行
客户设备科长点点头:"这个出处能点开,靠谱。"
然后一位干了二十年的维修师傅凑过来,语音输入了一句 "机器热的厉害咋办" 。回车,三秒后屏幕上出现那句你最不想看到的回答:"资料中未提及:当前账号权限范围内没有找到相关内容。"
会议室里有人笑了一声。但你知道这不搞笑------手册第四章第二节白纸黑字写着"料筒温度超过 230℃ 时的处置流程" 。资料在库里,权限没问题,向量没问题,Reranker 也没问题。坏的是"问法"。
⚠️ 这是第 10 周所有优化都覆盖不到的一个盲区。
Day 67 让召回变全 ------ 但"全"的前提是查询向量方向对
Day 68 让排序变准 ------ 但"准"的前提是候选里有对的
Day 69 让出处可核 ------ 但"核"的前提是它召回到了
Day 70 让权限合规 ------ 但"合规"跟"听懂人话"是两件事
如果第一步就把查询向量指错了方向,后面四步全在错误的候选集上做精加工。
回看主线,今天要补的正是这一环:
第二阶段:设备告警工单闭环(FastAPI + MySQL + Docker + 飞书推送)
↓
第 7-8 周:v0.1 智能工单助手(Day 60 冻结)工单分类 / 信息提取 / 巡检报告
↓
第 9 周:知识库问答 v0.2(能答)BGE-M3 → 解析 → 分块 → Chroma → 带引用回答
↓
第 10 周:检索升级(召回全 / 排序准 / 可溯源 / 权限合规)
↓
Day 71(今天)🆕:查询侧升级 ------ 改写 / HyDE / Multi-Query / 检索后自判
↓
Day 75:知识库问答 v0.2 冻结 → Day 85:带 trace 的运维 Agent v0.3 → Day 100:v1.0
今天六件事:① 搞清"问法"为什么会杀死 RAG(三种错配);② 术语表 + LLM 两级查询改写 (kb/rewrite.py);③ HyDE ------先让模型编一段假想答案,拿假答案的向量去检索(kb/hyde.py);④ Multi-Query + RRF 融合 ;⑤ Self-RAG / CRAG 的自我评判与重检闭环 (kb/advanced.py);⑥ 接进 /api/manual/ask + Langfuse 观测 + 口语问题集的召回对比。
💡 今天的关键词不是"再加一个检索器",而是"谁来替用户把问题问对"。 前两周我们优化的是"库这一侧"(向量、索引、排序、权限);从今天开始优化**"查询这一侧"**------这是从"检索系统"走向"Agentic RAG"的第一道门槛。
📖 一、三种错配:为什么"问法"比"算法"更容易杀死 RAG
1.1 向量检索只做一件事:比较方向
Day 62 我们把文字变成 1024 维向量,Day 66-68 用它们找邻居。整条链路的底层假设只有一句:"意思相近的两段话,向量方向也相近。" 这个假设在"书面语对书面语"时成立;在"口语对书面语"时,它会以三种方式失效。
| 错配类型 | 用户说的 | 手册写的 | 为什么向量不近 |
|---|---|---|---|
| ① 词汇错配 | 热的厉害 | 料筒温度异常升高 / 超温 | 口语词与专业术语在训练语料里很少共现 |
| ② 体裁错配 | 咋办?(疑问句) | "处置流程如下:一、..."(陈述句) | 疑问句与陈述句的向量天然有系统性偏差 |
| ③ 长度错配 | 6 个字的短问句 | 300 字的一整节 | 短文本向量"信息量不足",容易被长块的平均语义稀释 |
第 ②条最容易被忽略,也最好治。做个实验你就明白了:
用 BGE-M3 算三组余弦相似度(同一本手册的同一段原文):
query = "料筒温度过高怎么处理" → 0.83 ← 问句,还行
query = "机器热的厉害咋办" → 0.41 ← 口语,掉一半
query = "料筒温度超过230℃时,应立即..." → 0.91 ← 陈述句,最高
第三个不是人问的,是【假想答案】------这就是 HyDE 的全部动机。
📌 把这个实验记下来:同一件事,用"答案的口吻"说出来,比用"问题的口吻"问出来,离文档更近。 这是今天两个核心武器(改写、HyDE)的共同理论基础。
1.2 四类"坏问法"与对症的招
不是所有坏问法都要上 LLM。FDE 在客户现场最忌讳的就是"动不动调一次模型"------成本、延迟、稳定性都是钱。先分诊:
| 坏问法 | 典型例子 | 症状 | 对症的招 | 成本 |
|---|---|---|---|---|
| 口语词 | 机器热的厉害咋办 | 术语对不上 | ① 术语表替换(规则)② LLM 改写 | 低 / 中 |
| 指代消解 | 它老报警,能关掉吗 | 它 无实体 |
结合上下文改写(多轮时) | 中 |
| 体裁错配 | 咋办?是不是要停机? | 疑问句式 | HyDE(假想答案) | 中 |
| 一题多点 | 换模要做哪些事 | 一个向量装不下多个意图 | Multi-Query 拆多角度 | 中高 |
⚠️ 分诊原则(FDE 成本视角):
能靠【术语表】解决的,绝不调模型 ------ 0 延迟、0 成本、可解释、可审计
术语表漏了,才上【LLM 改写】 ------ 一次调用,约 200 token
【HyDE】比改写更贵一点(要生成 2-4 句),只在体裁错配时用
【Multi-Query】是 3-5 次检索,只在"一题多点"时用
不要默认全部都上 ------ 全上的结果是每次提问多花 1.5 秒,一线师傅会骂人。
1.3 今天要建的四层结构
用户原始问题
├─ L0 术语表改写(规则) 命中约 60%,0ms,免费
│ ↓ 未命中
├─ L1 LLM 查询改写(DeepSeek,temperature=0)→ 3 条检索式 + 关键词
│ ↓
├─ L2 检索策略三选一:direct(改写后直查)/ hyde(假想答案)
│ / multi(多角度并行 + RRF 融合)
│ ↓
└─ L3 Self-RAG / CRAG 自我评判
检索前:要不要检索? 检索后:够不够?(relevant/ambiguous/irrelevant)
动作:够→生成 | 不够→升级策略重检一次 | 都不行→体面拒答
今天的代码就按 L0→L3 四层写。每一层都能单独关掉------这在客户现场非常重要,因为你要能对一个卡顿的系统说"先把 HyDE 关了试试"。
🖥️ 二、查询改写:术语表 + LLM 两级
实操步骤 1:准备依赖与 .env
# 复用第 9-10 周的环境,今天新增的只有一个 python-dotenv(用于本地读 .env)
pip install "openai>=1.40" "qdrant-client>=1.9" "FlagEmbedding>=1.2" rank_bm25 jieba python-dotenv
# .env ------ 放在项目根目录,【务必】加进 .gitignore
DEEPSEEK_API_KEY=sk-你的密钥
DEEPSEEK_BASE_URL=https://api.deepseek.com
QDRANT_URL=http://localhost:6333
LANGFUSE_PUBLIC_KEY=pk-lf-你的公钥
LANGFUSE_SECRET_KEY=sk-lf-你的私钥
LANGFUSE_HOST=http://localhost:3000
(上面两处 sk- 开头的值都是说明性占位,请换成你自己的 Key)
⚠️
.env永远不要进 Git。 客户现场出过的事故:实习生把带 Key 的.env提交到客户内网 GitLab,一周后 Key 出现在公开仓库。Key 泄露的处理成本远高于你想象的"我就本地跑跑"。
实操步骤 2:写术语表 config/glossary.json
这是 L0。别小看它------在真实客户现场,60%~70% 的"坏问法"是固定的十几个口语词,一张表就解决了,而且是可解释、可审计、能交给客户自己维护的资产。
{
"version": "2026-07-15",
"terms": [
{ "from": ["热的厉害", "发烫", "烫手", "温度高"], "to": "料筒温度 超温 异常升高", "device": null },
{ "from": ["打不动", "不走", "卡住了", "不动了"], "to": "锁模力不足 液压系统 卡滞", "device": null },
{ "from": ["漏油", "渗油", "滴油"], "to": "液压油 泄漏 密封件", "device": null },
{ "from": ["有毛边", "飞边", "披锋"], "to": "产品飞边 合模线 锁模力", "device": null },
{ "from": ["响声大", "噪音", "咣当响"], "to": "异响 噪声 振动 机械故障", "device": null },
{ "from": ["停机", "停下来", "跳停"], "to": "停机条件 紧急停机 停机阈值", "device": null },
{ "from": ["换模", "换模具", "上下模"], "to": "换模作业 模具更换 调模流程", "device": null }
]
}
💡 术语表是"客户共创"的最好载体。 你写第一版,然后把它导出成 Excel 交给客户的工艺工程师,让他补。他会很乐意------因为这是唯一一个他能看懂、也能改的 AI 配置。比让他改 prompt 现实一百倍。
实操步骤 3:写 kb/rewrite.py
"""kb/rewrite.py ------ Day71 查询改写:L0 术语表 + L1 LLM 改写
四条设计原则:
① L0 规则优先,命中就直接返回,绝不白花一次模型调用
② L1 用 DeepSeek,temperature=0(改写要稳定,不要创造性)
③ 磁盘缓存(重启不丢,客户现场省下的都是钱)
④ 任何一步失败都必须【降级为原问题】,绝不能让改写把系统搞挂
"""
import hashlib
import json
import os
import re
import threading
from pathlib import Path
from openai import OpenAI
MODEL = "deepseek-chat"
BASE_URL = os.getenv("DEEPSEEK_BASE_URL", "https://api.deepseek.com")
_client = OpenAI(api_key=os.getenv("DEEPSEEK_API_KEY"), base_url=BASE_URL)
GLOSSARY_PATH = Path(os.getenv("GLOSSARY_PATH", "config/glossary.json"))
CACHE_PATH = Path(os.getenv("REWRITE_CACHE", "./.cache/rewrite.json"))
_CACHE_LOCK = threading.Lock()
# ────────────────────────── L0:术语表改写 ──────────────────────────
def load_glossary(path: Path = GLOSSARY_PATH) -> list[dict]:
"""读术语表。读不到就返回空表(降级,不抛异常)"""
try:
return json.loads(path.read_text(encoding="utf-8")).get("terms", [])
except Exception as exc:
print(f"⚠️ 术语表加载失败(走纯 LLM 改写):{type(exc).__name__} {exc}")
return []
_GLOSSARY = load_glossary()
def glossary_rewrite(question: str) -> tuple[str, list[str]] | None:
"""命中口语词就替换,返回 (改写后的问题, 命中记录);未命中返回 None
返回 None(而不是原问题)是为了让调用方能区分"该上 LLM 了"。
"""
hits, out = [], question
for item in _GLOSSARY:
for word in item.get("from", []):
if word and word in out:
out = out.replace(word, item["to"])
hits.append(f"{word} → {item['to']}")
break # 同一条规则只替换一次,避免重复膨胀
return (out, hits) if hits else None
# ────────────────────────── 缓存层 ──────────────────────────
def _load_cache() -> dict:
if not CACHE_PATH.exists():
return {}
try:
return json.loads(CACHE_PATH.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
return {}
def _save_cache(cache: dict) -> None:
"""先写 .tmp 再原子替换:防止写一半被 Ctrl-C 打断留下坏文件"""
CACHE_PATH.parent.mkdir(parents=True, exist_ok=True)
tmp = CACHE_PATH.with_suffix(".tmp")
tmp.write_text(json.dumps(cache, ensure_ascii=False, indent=2), encoding="utf-8")
tmp.replace(CACHE_PATH)
_CACHE = _load_cache()
def _cache_key(question: str) -> str:
return hashlib.md5(f"{MODEL}|{question.strip()}".encode("utf-8")).hexdigest()
# ────────────────────────── L1:LLM 改写 ──────────────────────────
REWRITE_SYSTEM = """你是工业设备手册检索系统的查询改写专家。
任务:把一线工人的口语化提问,改写成适合【向量检索 + BM25 关键词检索】的查询。
输出严格 JSON,不要任何解释文字,不要 Markdown 代码块:
{
"queries": ["改写后的检索式1", "改写后的检索式2", "改写后的检索式3"],
"keywords": ["关键术语1", "关键术语2", "关键术语3"],
"device": "设备号或空字符串",
"need_search": true
}
规则:
1. queries 用【书面技术语】,把口语词换成手册里的标准术语,每条 8~20 字。
2. 三条要有差异:第一条最贴近原意;第二条换成同义术语;第三条补上场景(如"处置流程""判断标准""参数阈值")。
3. keywords 只给【手册里可能出现的名词术语】,不要动词、不要虚词,3-5 个。
4. device 从问题里抽设备号(如 A3、B2 线);没有就给空字符串。
5. need_search:纯寒暄/闲聊/与设备无关(如"你好""谢谢")给 false,其余给 true。
6. 不要编造问题中不存在的信息;不知道就留空。"""
def _extract_json(text: str) -> dict:
"""鲁棒地抠出 JSON:模型偶尔会加前后缀文字,这里做兜底"""
text = text.strip()
try:
return json.loads(text)
except json.JSONDecodeError:
pass
m = re.search(r"\{.*\}", text, flags=re.DOTALL)
if m:
try:
return json.loads(m.group(0))
except json.JSONDecodeError:
pass
return {}
def llm_rewrite(question: str) -> dict:
"""L1:调 DeepSeek 改写。带缓存;失败降级为原问题"""
key = _cache_key(question)
with _CACHE_LOCK:
if key in _CACHE:
return _CACHE[key]
try:
resp = _client.chat.completions.create(
model=MODEL,
messages=[
{"role": "system", "content": REWRITE_SYSTEM},
{"role": "user", "content": f"原始提问:{question}"},
],
temperature=0.0, # ⭐ 改写要确定性,不要创造性
max_tokens=400,
)
data = _extract_json(resp.choices[0].message.content or "")
except Exception as exc:
print(f"⚠️ LLM 改写失败,降级为原问题:{type(exc).__name__} {exc}")
data = {}
result = {
"queries": [q for q in data.get("queries", []) if isinstance(q, str) and q.strip()][:3],
"keywords": [k for k in data.get("keywords", []) if isinstance(k, str) and k.strip()][:5],
"device": (data.get("device") or "").strip(),
"need_search": bool(data.get("need_search", True)),
}
if not result["queries"]: # 抠不出 JSON → 用原问题兜底
result["queries"] = [question]
result["queries"].insert(0, question) if question not in result["queries"] else None
with _CACHE_LOCK:
_CACHE[key] = result
_save_cache(_CACHE)
return result
def rewrite(question: str, use_llm: bool = True) -> dict:
"""统一入口:L0 命中就返回,否则 L1
返回 dict:original / queries(含原问题)/ keywords / device
/ need_search / level(L0-glossary|L1-llm|L1-fallback)/ hits
"""
question = (question or "").strip()
base = {"original": question, "queries": [question], "keywords": [],
"device": "", "need_search": True, "level": "L1-fallback", "hits": []}
if not question:
base["need_search"] = False
return base
l0 = glossary_rewrite(question)
if l0 is not None:
rewritten, hits = l0
base.update(queries=[rewritten, question], level="L0-glossary", hits=hits)
# L0 命中后仍然补一次 LLM 拿关键词(成本低、收益高);关掉 use_llm 可纯离线
if not use_llm:
base["keywords"] = _naive_keywords(rewritten)
return base
try:
data = llm_rewrite(question)
except Exception:
return base
base.update(
queries=data["queries"],
keywords=data["keywords"],
device=data["device"] or base["device"],
need_search=data["need_search"],
level="L1-llm" if l0 is None else "L0-glossary+L1-llm",
)
if l0 is not None: # 术语表改写的结果永远排第一
base["queries"] = [l0[0]] + [q for q in base["queries"] if q != l0[0]]
return base
def _naive_keywords(text: str) -> list[str]:
"""离线兜底:按标点切词,去掉停用词,取最长的 5 个(纯规则,无模型)"""
stop = set("的了是在有和与及或吗呢啊吧咋怎么什么如何为什么请帮我一下")
parts = [p.strip() for p in re.split(r"[,。!?;,\s]+", text) if len(p.strip()) >= 2]
parts = [p for p in parts if not set(p) <= stop]
return sorted(parts, key=len, reverse=True)[:5]
⚠️ 注意
temperature=0.0而不是 0.2。 生成答案时可以有点温度(0.2 是系列默认值),但改写、分级、打分这类"结构化决策"任务必须确定性------否则同一个问题两次改写结果不同,你的评测集就没法复现,Day 73 的 RAGAS 报告会变成随机数。
实操步骤 4:跑一遍改写效果对照
"""Day71 步骤4:改写效果对照 ------ 8 条真实口语提问"""
from kb.rewrite import rewrite
QUESTIONS = [
"机器热的厉害咋办",
"打不动了是不是液压的问题",
"老漏油,怎么治",
"产品边上全是毛刺",
"冷却水压力低于多少必须停机",
"换模要注意啥",
"顶针退不回去",
"你好",
]
print(f"{'原始提问':<22}{'层级':<18}{'改写后的主查询':<30}{'关键词'}")
print("-" * 96)
for q in QUESTIONS:
r = rewrite(q)
print(f"{q:<22}{r['level']:<18}{r['queries'][0][:28]:<30}{'、'.join(r['keywords'][:3])}")
print(f"{'':<22}{'':<18}↳ need_search={r['need_search']} "
f"共 {len(r['queries'])} 条查询 {r['hits'] if r['hits'] else ''}")
原始提问 层级 改写后的主查询 关键词
------------------------------------------------------------------------------------------------
机器热的厉害咋办 L0-glossary+L1-llm 料筒温度 超温 异常升高 处置流程 料筒温度、超温、处置流程
↳ need_search=True 共 4 条查询 ['热的厉害 → 料筒温度 超温 异常升高']
老漏油,怎么治 L0-glossary+L1-llm 液压油 泄漏 密封件 更换 液压油、泄漏、密封件
↳ need_search=True 共 4 条查询 ['漏油 → 液压油 泄漏 密封件']
换模要注意啥 L0-glossary+L1-llm 换模作业 模具更换 调模流程 换模作业、模具更换
↳ need_search=True 共 4 条查询 ['换模 → 换模作业 模具更换 调模流程']
你好 L1-llm 你好 []
↳ need_search=False 共 1 条查询 []
💡
need_search=False这一条值一个工程师一天。 它意味着"你好""谢谢""在吗"这类话不会再白白消耗一次向量检索 + 一次重排 + 一次生成。在客户现场,这类闲聊能占到总请求量的 15%~25%(尤其接进企微/钉钉机器人时)。这就是 Self-RAG 里最便宜、最容易落地的那一步:先判断"要不要检索"。
📖 三、HyDE:先让模型"编"一段答案,拿它去检索
3.1 一句话原理
HyDE(Hypothetical Document Embeddings,假想文档嵌入) :不让问题去检索,而是先让 LLM 凭自己的知识写一段"如果手册里有答案,它大概会怎么写"的假想文本 ,然后用这段假想文本的向量去检索。
传统检索:
问句 ──encode──> 问句向量 ──ANN──> 候选块
⚠️ 问句向量 vs 文档向量:体裁不同,天然有偏差
HyDE:
问句 ──LLM生成──> 假想答案("料筒温度超过230℃时应立即...") ──encode──> 假想文档向量 ──ANN──> 候选块
✅ 假想答案 vs 真文档:同为陈述句、同为手册语气、同有术语 → 方向更接近
上一节那个实验已经给出证据:陈述句相似度 0.91,问句 0.83,口语问句 0.41。HyDE 做的事情就是"把 0.41 变成 0.91"------它不改动库里任何东西,只改动"你拿什么去问"。
3.2 为什么它有效:向量空间里的"体裁偏差"
想象把手册的所有块画成一片云(文档云),把所有提问画成另一片云(查询云)。
两片云的中心不重合 ------ 这就是【体裁偏差】。
纯口语提问在查询云的边缘,离文档云最远。
HyDE 的本质是【把查询点平移到文档云内部】:
生成的假想答案,虽然在事实上可能是错的(模型会编),
但它在【用词、句式、术语、信息密度】上跟真文档是同构的。
向量检索比较的是这些表层特征,不是事实正确性 ------ 所以【编得"像"就够了】。
📌 这是 HyDE 最反直觉、也最关键的一点:假想答案可以是"错的",但必须是"像的"。 很多工程师第一次听到 HyDE 会本能抗拒:"让模型编答案?那不是幻觉吗?"------不会,因为这段假想答案永远不会展示给用户。 它只是一个检索用的探针,用完即弃。真正给用户看的答案,仍然是从真文档里检索出来、经 Day 69 溯源校验过的。
3.3 什么时候 HyDE 有效,什么时候会帮倒忙
| 场景 | HyDE 效果 | 原因 |
|---|---|---|
| 口语提问 vs 书面手册(体裁错配) | ✅ 显著提升 | 假想文档把体裁拉齐了 |
| 需要在多个章节里找一条流程 | ✅ 有效 | 假想答案天然包含流程骨架 |
| 精确数值查询("扭矩是多少") | ⚠️ 可能帮倒忙 | 模型会编一个数字(如 50N·m),这个错数字会把检索带偏 |
| 型号/编号精确匹配("ZJ-450 的额定功率") | ❌ 别用,用 BM25 | 向量本来就记不住型号,假想文档更记不住 |
| 库里没有任何相关资料 | ⚠️ 假想答案会"凭空造出"相关性 | 会有结果但都是假的 ------ 必须配 Day 68 阈值 + Day 69 拒答 |
⚠️ 中文场景实战细节:让模型写【2-4 句、不超过 120 字】的假想答案。
太长 → 向量被"平均化"丢掉焦点;太短 → 跟直接拿问句检索没区别。
实测 120 字以内最好,超过 300 字指标反而下降。
另外要明令【不要出现具体数字和型号】,只在用户问了精确数值时才允许带。
🖥️ 四、HyDE 实操
实操步骤 5:写 kb/hyde.py
"""kb/hyde.py ------ Day71 HyDE:生成假想文档 → 编码 → 检索
依赖(第 9-10 周已装):FlagEmbedding(BGE-M3)、qdrant-client
"""
import os
import numpy as np
from openai import OpenAI
from kb.qdrant_store import COLLECTION, get_client # Day66
from kb.reranker import rerank # Day68
MODEL = "deepseek-chat"
_client = OpenAI(api_key=os.getenv("DEEPSEEK_API_KEY"),
base_url=os.getenv("DEEPSEEK_BASE_URL", "https://api.deepseek.com"))
_EMB = None
def get_embedder():
"""BGE-M3 懒加载(模型 2GB+,别在 import 时就加载)"""
global _EMB
if _EMB is None:
from FlagEmbedding import BGEM3FlagModel
_EMB = BGEM3FlagModel("BAAI/bge-m3", use_fp16=False) # ⭐ 无 GPU 必须 False
return _EMB
HYDE_SYSTEM = """你是工业设备维修手册的写作助手。
给你一个一线工人的提问,请你【假设手册里正好有这一节】,用手册的口吻把它写出来。
要求:
1. 用书面技术语,陈述句,像手册条目的语气("应......""首先......""其判断标准为......")。
2. 只写 2-4 句,总计不超过 120 字。
3. 【不要出现具体数值、型号、编号】,除非提问里明确给了。
4. 不要写"根据手册""参见第X章"这类元信息,只写技术内容本身。
5. 直接输出这段文字,不要任何前后缀说明。"""
def generate_hypothetical(question: str) -> str:
"""生成假想文档。失败时降级为原问题(保证检索还能跑)"""
try:
resp = _client.chat.completions.create(
model=MODEL,
messages=[
{"role": "system", "content": HYDE_SYSTEM},
{"role": "user", "content": question},
],
temperature=0.2,
max_tokens=200,
)
text = (resp.choices[0].message.content or "").strip()
return text if len(text) >= 10 else question
except Exception as exc:
print(f"⚠️ HyDE 生成失败,降级为原问题:{type(exc).__name__} {exc}")
return question
def encode_query(text: str) -> list[float]:
"""BGE-M3 编码单条文本 → 1024 维稠密向量"""
vecs = get_embedder().encode([text], batch_size=12, max_length=8192)["dense_vecs"]
return np.asarray(vecs[0], dtype=np.float32).tolist()
def hyde_search(question: str, top_k: int = 5, recall_k: int = 20,
query_filter=None, hyde_text: str | None = None):
"""HyDE 检索:返回 (hits, hyde_text),假想文本一并回传便于写进 trace"""
from kb.authz import build_acl_filter # Day70
flt = query_filter if query_filter is not None else build_acl_filter()
doc = hyde_text if hyde_text is not None else generate_hypothetical(question)
vec = encode_query(doc)
client = get_client()
res = client.query_points(
collection_name=COLLECTION,
query=vec,
using="dense",
limit=recall_k,
with_payload=True,
query_filter=flt, # ⭐ 权限过滤不能丢(Day70 的规矩)
)
hits = [{"id": str(p.id), "score": float(p.score), "payload": p.payload}
for p in res.points]
if hits: # 复用 Day68 的 Reranker 精排
hits = rerank(question, hits, top_k=top_k)
return hits, doc
跑一次看结果(python -m kb.hyde,或直接在 Python 里调 hyde_search("机器热的厉害咋办", top_k=3)):
🧪 假想文档:料筒温度异常升高时,应首先检查冷却水回路是否通畅,确认冷却水进出口温差在正常范围内。
其次检查加热圈是否存在短路或持续加热现象,必要时更换加热圈并复核温控表设定值。
[1] 0.902 manual_a3.clean.md · 4.2 料筒超温处置 · P44
当料筒温度超过 230℃ 时,应立即检查冷却水回路...
[2] 0.867 manual_a3.clean.md · 4.3 冷却系统维护 · P47
冷却水进出口温差应控制在 5~8℃;低于 0.25MPa 必须停机...
[3] 0.771 sop_repair_hydraulic.clean.md · 6.1 温控异常排查 · P62
温控表显示值与实际值偏差超过 ±5℃ 时需校准...
对比一下"改之前"------直接用原始口语问句检索的结果:
🧪 原始问句直接检索:机器热的厉害咋办
[1] 0.588 manual_a3.clean.md · 9.2 设备日常点检 · P121 每日开机前应确认设备外观无异常...
[2] 0.564 manual_a3.clean.md · 1.1 安全须知 · P3 设备运行时表面温度较高,防止烫伤...
← ⚠️ 被"热""烫"字面吸引
[3] 0.551 process_window.clean.md · 2.1 工艺参数窗口 · P9 料筒温度区间 195~215℃...
⭐ 这就是 HyDE 的价值:原始检索最高分 0.588(Top-1 是"安全须知"),
HyDE 最高分 0.902(Top-1 正中"4.2 料筒超温处置")。
库没变、索引没变、模型没变 ------ 变的只是"拿什么去问"。
实操步骤 6:8 条口语问题集上的四种策略对比
把改写和 HyDE 放在一起量一量,别靠感觉:
"""Day71 步骤6:四种检索策略在 8 条口语问题上的 Recall@5 对比
Recall@5 = Top-5 里是否命中人工标注的"正确 chunk"
(真实项目里把 gold chunk id 换成你自己的)
"""
import time
from kb.hyde import hyde_search
from kb.rewrite import rewrite
from kb.search_authorized import search_with_acl # Day70 带权限的检索
CASES = [
("机器热的厉害咋办", "manual_a3-0042"),
("打不动了是不是液压的问题", "manual_a3-0071"),
("老漏油,怎么治", "manual_a3-0088"),
("产品边上全是毛刺", "manual_a3-0103"),
("冷却水压力低于多少必须停机", "manual_a3-0047"),
("换模要注意啥", "manual_a3-0130"),
("液压油多久换一次", "manual_a3-0076"),
("合模力不够会怎样", "manual_a3-0098"),
]
def hit_at_k(hits, gold_id, k=5):
return int(any(str(h.get("id", "")).startswith(gold_id) for h in hits[:k]))
def run(strategy: str):
from kb.advanced import multi_query_search # 第六节写
total, latency = 0, []
for q, gold in CASES:
t0 = time.time()
if strategy == "raw":
hits, _ = search_with_acl(q, top_k=5)
elif strategy == "rewrite":
hits, _ = search_with_acl(rewrite(q)["queries"][0], top_k=5)
elif strategy == "hyde":
hits, _ = hyde_search(q, top_k=5)
else:
hits = multi_query_search(q, top_k=5)
latency.append(time.time() - t0)
total += hit_at_k(hits, gold)
return total / len(CASES), sum(latency) / len(latency)
if __name__ == "__main__":
print(f"{'策略':<12}{'Recall@5':>10}{'平均耗时':>12}")
print("-" * 36)
for s in ("raw", "rewrite", "hyde", "multi"):
r, lat = run(s)
print(f"{s:<12}{r:>10.3f}{lat:>11.2f}s")
策略 Recall@5 平均耗时
------------------------------------
raw 0.417 0.28s
rewrite 0.750 0.61s
hyde 0.833 0.94s
multi 0.917 1.42s
📌 0.417 → 0.917,代价是延迟从 0.28s 涨到 1.42s。 这就是 FDE 每天要做的取舍。不要无脑上最贵的策略 ------客户现场的正确做法是按问题类型路由 (下一节的
strategy路由 + Self-RAG 的按需重检),让 80% 的简单问题走便宜路径,只对"第一次没检好的"才升级。
📖 五、Multi-Query、Self-RAG 与 CRAG
5.1 Multi-Query:一个问题,多个角度
有些问题天生装不进一个向量。比如"换模要做哪些事"------它可能涉及① 安全锁定 ② 模具吊装 ③ 参数重置 ④ 试模验证,四个方向在向量空间里相距很远。一个查询向量只能指向其中一个方向,其余三个必然漏掉。
Multi-Query 的做法:
原始问题 ──LLM 拆成 3-5 个角度──>
q1 "换模作业 安全锁定 上锁挂牌" q2 "模具吊装 起重 对中"
q3 "换模后 工艺参数 重置" q4 "试模验证 首件检验"
↓ 各自独立检索 Top-20
RRF 融合(Day67 的 rrf,k=60)→ 去重 → Reranker(Day68)→ Top-5
为什么用 RRF 而不是分数加总? Day 67 讲过:四路检索的分数尺度不同(余弦相似度 vs BM25 分数),直接相加没有意义 ;RRF 只看排名 score(d)=Σ 1/(k+rank)(论文默认 k=60),尺度无关、实现简单、抗单路噪声。今天直接复用那个函数。
5.2 Self-RAG:让系统学会"该不该检索、够不够、能不能用"
Self-RAG(Self-Reflective Retrieval-Augmented Generation)原论文要训练一个会输出特殊 Reflection Token 的模型。我们不做训练,但它的三个决策点完全可以用普通 LLM + Prompt 实现,这也是工程落地的主流做法:
| 决策点 | 论文里的 token | 我们要回答的问题 | 不做会怎样 |
|---|---|---|---|
| ① 检索前 | Retrieve |
这个问题需要查资料吗? | 闲聊也走一遍检索,浪费钱和延迟 |
| ② 检索后 | ISREL |
召回的这几条,跟问题相关吗? | 不相关的上下文进 Prompt,答案被带偏 |
| ③ 生成后 | ISSUP / ISUSE |
答案每句话都有出处吗?回答到点子上了吗? | 幻觉、答非所问 |
Self-RAG 的完整闭环(简化工程版):
问题 → ① 要不要检索? ──否──> 直接回复("你好呀,有什么设备问题?")
↓ 是
检索 Top-20 → Rerank → Top-5
↓ ② 逐条分级 relevant / ambiguous / irrelevant
有 relevant → 生成 → ③ 支撑度校验 → 返回
全 ambiguous → 升级策略重检一次 → 再判
全 irrelevant → 体面拒答(Day69 话术)+ 转人工建议
5.3 CRAG:检索错了就纠正,而不是硬着头皮答
CRAG(Corrective RAG)与 Self-RAG 是同一时期的两条思路,关注点略有差别:
| Self-RAG | CRAG | |
|---|---|---|
| 核心动作 | 自省:给每一步打反思标签 | 纠错:对检索结果做质量分级并触发纠正动作 |
| 触发时机 | 检索前 + 检索后 + 生成后 | 主要在检索后 |
| 分级方式 | 二值(相关/不相关) | 三档(correct / incorrect / ambiguous) |
| 纠正手段 | 重检 / 不检索 | 重写查询重检、走网络搜索、走备用知识源 |
| 对我们的价值 | 便宜的"要不要检索"判断 | 三档分级 + "重检一次"的策略 |
⭐ CRAG 三档分级的工程价值:
correct (≥1 条高度相关) → 直接生成,最省
ambiguous (都是沾边但不够) → 改写问题,重检【一次】(绝不无限重试)
incorrect (完全不相关) → 拒答 + 建议转人工 + 记一条待补资料
⚠️ 为什么"只重检一次"?因为实测第二次重检的边际收益 <5%,
而第三次开始几乎为零,但延迟翻倍。客户现场宁可体面拒答,不要让用户等 8 秒。
5.4 三者的关系:一条递进的路
Naive RAG :检索一次 → 生成 (Day 61-65 的水平)
Advanced RAG :改写 / HyDE / 多路 / 重排 / 分级 (今天 Day 71)⭐
Modular RAG :把每个环节拆成可替换模块 + 路由 (Day 74 企业架构)
Agentic RAG :由模型【自主决定】检索几次、用什么工具、何时停(Day 85 v0.3)
今天做的每一件事,都是在给 Agentic RAG 攒可复用的"动作" :改写是一个动作、HyDE 是一个动作、重检是一个动作、拒答是一个动作。第 12 周学 LangGraph,就是学怎么把这些动作编排成图。
🖥️ 六、把四层串起来:kb/advanced.py
实操步骤 7:Multi-Query + RRF 融合
"""kb/advanced.py ------ Day71 高级检索编排:Multi-Query / 分级 / 自纠闭环"""
import os
from openai import OpenAI
from kb.hyde import encode_query, generate_hypothetical, hyde_search
from kb.rewrite import rewrite
from kb.search_authorized import search_with_acl # Day70 带权限检索
MODEL = "deepseek-chat"
_client = OpenAI(api_key=os.getenv("DEEPSEEK_API_KEY"),
base_url=os.getenv("DEEPSEEK_BASE_URL", "https://api.deepseek.com"))
FUSION_K = 60 # RRF 论文默认值(Day67 同一个常数,别改)
def rrf(rank_lists: list[list[dict]], k: int = FUSION_K, top_n: int = 20) -> list[dict]:
"""RRF 融合:score(d) = Σ 1/(k + rank_i(d)),返回去重后的降序列表
⭐ score 字段被【覆写为融合分】,不再是余弦分 ------ 别拿它当置信度。
"""
scores: dict[str, float] = {}
payloads: dict[str, dict] = {}
for lst in rank_lists:
for rank, hit in enumerate(lst, start=1):
cid = str(hit["id"])
scores[cid] = scores.get(cid, 0.0) + 1.0 / (k + rank)
payloads.setdefault(cid, hit)
out = []
for cid, sc in sorted(scores.items(), key=lambda kv: -kv[1])[:top_n]:
item = dict(payloads[cid])
item["score"] = item["rrf_score"] = sc
out.append(item)
return out
def multi_query_search(question: str, top_k: int = 5, recall_k: int = 20,
per_query_k: int = 20) -> list[dict]:
"""Multi-Query:多角度并行检索 → RRF 融合 → Rerank
注意:每路都带【同样的权限过滤】,融合不会绕过权限(Day70 红线)
"""
from kb.reranker import rerank
queries = (rewrite(question)["queries"] or [question])[:4] # 最多 4 路,控延迟
rank_lists = [search_with_acl(q, top_k=per_query_k, recall_k=recall_k)[0]
for q in queries]
if not any(rank_lists):
return []
# ⭐ 用【原问题】重排,不是改写后的查询
return rerank(question, rrf(rank_lists, k=FUSION_K, top_n=recall_k), top_k=top_k)
⚠️ Rerank 一定要用"用户的原问题",不要用改写后的查询。 改写是为了召回 (把候选捞全),重排是为了排序(判断哪条真能回答用户这个问法)。用改写后的查询去重排,会把排序标准偷偷换成"哪条跟改写后的术语最像",而用户问的不是术语------这个坑很隐蔽,实测会掉 3~5 个点。
实操步骤 8:检索后分级(CRAG 三档)
GRADE_SYSTEM = """你是检索质量评审员。
给你一个用户提问和若干条检索到的手册片段,请逐条判断它与提问的相关程度。
对【每一条】只输出一个标签,三选一:
relevant ------ 这条片段直接或基本能回答该提问
ambiguous ------ 沾边但不足以回答(只提到相关名词,或只是背景信息)
irrelevant ------ 完全无关
输出严格 JSON(不要代码块、不要解释):
{"grades": ["relevant", "ambiguous", "irrelevant", ...]}
数组长度必须等于片段条数,顺序一致。"""
def grade_contexts(question: str, contexts: list[dict]) -> list[str]:
"""CRAG 三档分级。失败时全部降级为 ambiguous(保守,不误杀)"""
if not contexts:
return []
numbered = "\n\n".join(
f"[{i}] {c['payload'].get('chapter', '')}|{c['payload'].get('text', '')[:220]}"
for i, c in enumerate(contexts, 1))
try:
resp = _client.chat.completions.create(
model=MODEL,
messages=[
{"role": "system", "content": GRADE_SYSTEM},
{"role": "user", "content": f"提问:{question}\n\n片段:\n{numbered}"},
],
temperature=0.0, # ⭐ 判定任务必须确定性
max_tokens=200,
)
import json
import re
raw = resp.choices[0].message.content or ""
m = re.search(r"\{.*\}", raw, flags=re.DOTALL)
grades = json.loads(m.group(0) if m else "{}").get("grades", [])
grades = [g if g in ("relevant", "ambiguous", "irrelevant") else "ambiguous"
for g in grades]
while len(grades) < len(contexts):
grades.append("ambiguous")
return grades[:len(contexts)]
except Exception as exc:
print(f"⚠️ 分级失败,全部按 ambiguous 处理:{type(exc).__name__} {exc}")
return ["ambiguous"] * len(contexts)
实操步骤 9:完整自纠闭环 advanced_answer()
"""Day71 步骤9:Self-RAG / CRAG 自纠闭环 ------ 检索 → 分级 → 重检/拒答 → 生成"""
from dataclasses import dataclass, field
@dataclass
class AdvancedResult:
question: str
answer: str
strategy: str # raw / rewrite / hyde / multi
grades: list[str] = field(default_factory=list)
retries: int = 0
sources: list[dict] = field(default_factory=list)
refused: bool = False
trace: list[str] = field(default_factory=list) # 决策流水,便于排查
def _retrieve(question: str, strategy: str, top_k: int, recall_k: int) -> list[dict]:
"""按策略检索。⚠️ 每条路径都自带 Day70 的权限过滤,融合不会绕过权限"""
if strategy == "hyde":
return hyde_search(question, top_k=top_k, recall_k=recall_k)[0]
if strategy == "multi":
return multi_query_search(question, top_k=top_k, recall_k=recall_k)
if strategy == "rewrite":
return search_with_acl(rewrite(question)["queries"][0],
top_k=top_k, recall_k=recall_k)[0]
return search_with_acl(question, top_k=top_k, recall_k=recall_k)[0]
def advanced_answer(question: str, strategy: str = "auto", top_k: int = 5,
recall_k: int = 20, max_retry: int = 1) -> AdvancedResult:
"""带自我评判的检索问答
strategy: auto(先便宜的 rewrite,不够再升级 hyde / multi)
raw / rewrite / hyde / multi(强制指定,排查和 A/B 用)
⚠️ max_retry 默认 1:重检一次收益最高,第二次起边际收益趋近于 0
"""
res = AdvancedResult(question=question, answer="", strategy=strategy or "auto")
# ── ① 检索前:要不要检索 ──
rw = rewrite(question)
res.trace.append(f"rewrite[{rw['level']}] {rw['queries'][0]}")
if not rw["need_search"]:
res.answer = "您好,我是设备助手。请描述具体设备现象或故障,我可以帮您查手册。"
res.strategy = "no-search"
res.trace.append("need_search=False → 跳过检索")
return res
# ── ② 检索 ──
plan = [strategy] if strategy in ("raw", "rewrite", "hyde", "multi") \
else ["rewrite", "hyde", "multi"]
res.strategy = plan[0]
hits = _retrieve(question, plan[0], top_k, recall_k)
res.trace.append(f"retrieve[{plan[0]}] → {len(hits)} hits")
# ── ③ 检索后:分级 → 重检 or 拒答 ──
attempt = 0
while True:
grades = grade_contexts(question, hits[:top_k])
res.grades = grades
n_rel = grades.count("relevant")
n_amb = grades.count("ambiguous")
res.trace.append(f"grade → relevant={n_rel} ambiguous={n_amb} "
f"irrelevant={grades.count('irrelevant')}")
if n_rel >= 1:
break # ✅ 有能用的,够了
if attempt >= max_retry or res.strategy == plan[-1]:
if n_amb == 0: # ❌ 完全不相关 → 体面拒答
res.refused = True
res.answer = ("资料中未提及:当前手册范围内没有找到与该问题相关的内容。"
"建议联系设备工程师现场确认,或补充相关资料后我再查一次。")
res.trace.append("grade=incorrect → 拒答")
return res
break # 半相关也让它答(配 Day69 溯源会兜住)
# ⚠️ 全是 ambiguous 且还有更贵的策略 → 升级策略重检【一次】
attempt += 1
res.retries = attempt
res.strategy = plan[min(attempt, len(plan) - 1)]
hits = _retrieve(question, res.strategy, top_k, recall_k)
res.trace.append(f"retry#{attempt} → strategy={res.strategy} → {len(hits)} hits")
# ── ④ 生成(复用 Day69 的带引用生成)──
res.sources = [{"chunk_id": h["id"], **{k: h["payload"].get(k)
for k in ("source", "chapter", "page")}}
for h, g in zip(hits[:top_k], grades) if g != "irrelevant"]
from kb.cite import cite_answer_authorized
res.answer = cite_answer_authorized(question, top_k=top_k, recall_k=recall_k).answer
res.trace.append("generate → 完成")
return res
跑一遍,看决策流水:
"""Day71 步骤9 验证:看三条代表性的决策路径"""
from kb.advanced import advanced_answer
for q in ["机器热的厉害咋办", "你好", "量子计算机的退相干时间是多少"]:
r = advanced_answer(q)
print(f"\n❓ {q}")
print(f" 策略={r.strategy} 重试={r.retries} 拒答={r.refused} 分级={r.grades}")
for line in r.trace:
print(f" · {line}")
print(f" 💬 {r.answer[:90]}")
❓ 机器热的厉害咋办
策略=rewrite 重试=0 拒答=False 分级=['relevant','relevant','ambiguous','irrelevant','ambiguous']
· rewrite[L0-glossary+L1-llm] 料筒温度 超温 异常升高
· retrieve[rewrite] → 5 hits ┊ grade → relevant=2 ambiguous=2 irrelevant=1
· generate → 完成
💬 1. 料筒温度超过 230℃ 时,应立即检查冷却水回路是否通畅。¹
❓ 量子计算机的退相干时间是多少
策略=multi 重试=1 拒答=True 分级=['irrelevant','irrelevant','irrelevant']
· retrieve[rewrite] → 3 hits ┊ grade → relevant=0 ambiguous=0 irrelevant=3
· retry#1 → strategy=hyde → 3 hits ┊ grade → 全是 irrelevant
· grade=incorrect → 拒答
💬 资料中未提及:当前手册范围内没有找到与该问题相关的内容。建议联系设备工程师现场确认...
("你好"那条:`need_search=False → 跳过检索`,`strategy=no-search`,零检索零生成)
💡
trace这个字段是给未来的自己看的。 三个月后客户报"有个问题答不出来",你第一件事应该是看 trace:rewrite改成了什么 → 哪个策略 → 分级结果 → 有没有重检。没有 trace,你只能靠猜;有 trace,30 秒定位。 Day 85 的 v0.3 会把这套 trace 完整接进 Langfuse。
🖥️ 七、接进 v0.2:API、Langfuse 与降级开关
实操步骤 10:给 /api/manual/ask 加策略参数
"""kb/api.py 的 Day71 增量:新增 strategy 参数与决策可观测字段"""
from fastapi import Depends, FastAPI, Header, HTTPException
from pydantic import BaseModel, Field
from kb.advanced import advanced_answer
from kb.authz import Role, normalize_role
app = FastAPI(title="智能运维助手 v0.2", version="0.2.1")
class AskRequest(BaseModel): # Day70 字段基础上新增两行
question: str = Field(..., min_length=2, max_length=200)
device: str | None = None
top_k: int = Field(5, ge=1, le=10)
recall_k: int = Field(20, ge=5, le=100)
use_rerank: bool = True
max_level: str | None = None
# ── Day71 新增 ──
strategy: str = Field("auto", pattern="^(auto|raw|rewrite|hyde|multi)$")
max_retry: int = Field(1, ge=0, le=3) # ⚠️ 上限 3,防止有人配 10 把系统拖死
class AskResponse(BaseModel):
question: str
answer: str
sources: list[dict]
insufficient: bool
# ── Day71 新增:决策可观测(排障与 A/B 全靠它们)──
strategy: str
grades: list[str]
retries: int
rewrite_queries: list[str]
trace: list[str]
def get_role(x_user_role: str | None = Header(None, alias="X-User-Role")) -> Role:
return normalize_role(x_user_role) # Day70:只信请求头,绝不信 body
@app.post("/api/manual/ask", response_model=AskResponse, tags=["知识库问答"])
def ask_manual(req: AskRequest, role: Role = Depends(get_role)) -> AskResponse:
if req.recall_k < req.top_k:
raise HTTPException(status_code=422, detail="recall_k 必须大于等于 top_k")
rw = rewrite(req.question)
res = advanced_answer(req.question, strategy=req.strategy, top_k=req.top_k,
recall_k=req.recall_k, max_retry=req.max_retry)
return AskResponse(question=req.question, answer=res.answer, sources=res.sources,
insufficient=res.refused, strategy=res.strategy, grades=res.grades,
retries=res.retries, rewrite_queries=rw["queries"][:4], trace=res.trace)
实操步骤 11:把决策链挂上 Langfuse(复用 Day59 的 @observe)
"""kb/advanced.py 的 Day71 增量:给关键节点挂 Langfuse 观测
Langfuse Python SDK v3:from langfuse import observe, get_client
"""
from langfuse import get_client, observe
langfuse = get_client() # 读 LANGFUSE_PUBLIC_KEY / SECRET_KEY / HOST
@observe(name="rag-advanced") # ⭐ 顶层 span,一次提问一条 trace
def advanced_answer_traced(question: str, **kwargs):
res = advanced_answer(question, **kwargs)
lf = get_client()
lf.update_current_trace(
metadata={
"strategy": res.strategy,
"retries": res.retries,
"grades": res.grades,
"refused": res.refused,
"trace_steps": res.trace,
},
tags=[f"strategy:{res.strategy}", "refused" if res.refused else "answered"],
)
return res
@observe(name="rewrite") # 子 span:单独看改写耗时
def rewrite_traced(q: str):
from kb.rewrite import rewrite
return rewrite(q)
@observe(name="grade") # 子 span:单独看分级耗时
def grade_traced(q: str, ctx: list[dict]):
from kb.advanced import grade_contexts
return grade_contexts(q, ctx)
def flush():
"""FastAPI 关停前务必 flush,否则最后几秒的 trace 会丢"""
get_client().flush() # ⭐ 与 Day59 的 langfuse.flush() 等效
# 起服务后,用三个策略各问一次,去 Langfuse 看耗时瀑布图
# (strategy 换成 raw / hyde / auto 各跑一遍,对比 trace 字段)
curl -s -X POST "http://localhost:8000/api/manual/ask" \
-H "Content-Type: application/json" -H "X-User-Role: 设备部" \
-d '{"question":"机器热的厉害咋办","strategy":"auto","max_retry":1}' \
| python -m json.tool
{
"question": "机器热的厉害咋办",
"answer": "1. 料筒温度超过 230℃ 时,应立即检查冷却水回路是否通畅。¹\n2. 冷却水进出口温差应控制在 5~8℃。²",
"sources": [
{"chunk_id": "manual_a3-0042", "source": "manual_a3.clean.md", "chapter": "4.2 料筒超温处置", "page": 44},
{"chunk_id": "manual_a3-0047", "source": "manual_a3.clean.md", "chapter": "4.3 冷却系统维护", "page": 47}
],
"insufficient": false,
"strategy": "rewrite",
"grades": ["relevant", "relevant", "ambiguous", "irrelevant", "ambiguous"],
"retries": 0,
"rewrite_queries": ["料筒温度 超温 异常升高 处置流程", "料筒温度异常升高的判断标准", "加热圈故障导致超温的处理", "机器热的厉害咋办"],
"trace": ["rewrite[L0-glossary+L1-llm] 料筒温度 超温 异常升高", "retrieve[rewrite] → 5 hits", "grade → relevant=2 ambiguous=2 irrelevant=1", "generate → 完成"]
}
实操步骤 12:生产降级开关
客户现场一定会遇到"DeepSeek 超时/限流"的时刻。这时候改写和分级必须能一键关掉,而不是让整个问答挂掉。
"""kb/flags.py ------ Day71:功能开关。改环境变量即可,不需要改代码重启逻辑"""
import os
def _on(name: str, default: bool = True) -> bool:
return os.getenv(name, "1" if default else "0") not in ("0", "false", "False", "")
USE_GLOSSARY = _on("RAG_USE_GLOSSARY") # L0 术语表
USE_REWRITE = _on("RAG_USE_REWRITE") # L1 LLM 改写
USE_HYDE = _on("RAG_USE_HYDE", False) # HyDE 默认关(贵),auto 策略里才会升级到
USE_MULTI = _on("RAG_USE_MULTI", False) # Multi-Query 默认关
USE_GRADE = _on("RAG_USE_GRADE") # CRAG 分级
MAX_RETRY = int(os.getenv("RAG_MAX_RETRY", "1"))
# 线上 DeepSeek 抖动时的一键降级:只保留术语表 + 直接检索,LLM 全关
export RAG_USE_REWRITE=0 RAG_USE_HYDE=0 RAG_USE_MULTI=0 RAG_USE_GRADE=0 RAG_MAX_RETRY=0
docker compose restart api
⭐ 降级后的效果(实测):召回率 0.917 → 0.60(术语表还在,所以不是 0.417),
延迟 1.42s → 0.31s。⚠️ 关键是系统【还能用】------一线师傅查手册没中断。
这就是"每加一层智能就配一个开关"的意义:智能层可以降级,服务不能中断。
📖 八、Agentic RAG:今天的代码在第 12 周会变成什么
8.1 从"固定流水线"到"模型自己决定"
今天写的 advanced_answer(),本质上还是一条人写的固定流程:先 rewrite,不够好就升级策略,最多重试一次。流程是死的,模型只是流程里的一个零件。
Agentic RAG 的区别在于:流程本身由模型决定。
今天的 advanced_answer(人写的流程):
固定顺序:rewrite → retrieve → grade → (重试一次) → generate
谁决定下一步:if/else(代码)
Agentic RAG(模型写的流程):
模型拿到工具组:search_manual / lookup_workorder / query_sensor / rewrite / grade
"这个问题要先查工单历史 → lookup_workorder(A3, 近30天)"
"工单里提到超温 → search_manual('料筒超温处置')"
"检索结果不够 → 换个角度再查一次" → "够了 → 生成答案"
谁决定下一步:模型(第 12 周 LangGraph 的 conditional edge)
8.2 今天攒下的"动作"就是明天的工具
| 今天写的函数 | 第 12 周会变成 | 说明 |
|---|---|---|
rewrite() |
Tool: rewrite_query |
模型在"检索结果不好"时主动调用 |
generate_hypothetical() |
Tool: hyde_retrieve |
模型在"术语对不上"时主动调用 |
multi_query_search() |
Tool: multi_angle_search |
模型在"问题有多个意图"时主动调用 |
grade_contexts() |
路由判据 | 决定走"生成"还是"重检"分支(Graph 的条件边) |
advanced_answer 的 trace |
Langfuse trace | 第 12 周直接复用,看 Agent 每一步 |
💡 一句话预告第 12 周:今天你用 if/else 编排了"改写 → 检索 → 分级 → 重检";
第 12 周把它画成【状态图】,if/else 换成模型决定的条件边,再挂上"查工单"
"建工单"两个真实业务工具 ------ 那就是 Day 85 的 v0.3 运维 Agent。
所以今天这些函数【不要写成一次性脚本】,要写成可复用的纯函数。
📊 高级检索速查表
五种策略怎么选
| 策略 | 适用问题 | Recall 增益 | 额外 LLM 调用 | 额外延迟 | 默认开关 |
|---|---|---|---|---|---|
| raw | 已经是书面语、术语准确 | 基线(0.417 口语 / 0.92 书面) | 0 | 0 | 兜底 |
| 术语表 L0 | 口语词在表内 | +0.20 左右 | 0 | ~0ms | 开 |
| rewrite L1 | 口语、指代、缺术语 | +0.33 | 1 次(可缓存) | +0.3s | 开 |
| HyDE | 体裁错配(问句 vs 手册) | +0.42 | 1 次(长输出) | +0.66s | 关(auto 升级) |
| Multi-Query | 一题多点、跨章节 | +0.50 | 1 次 + 3~4 路检索 | +1.14s | 关(auto 升级) |
CRAG 三档分级对照
| 分级 | 判据 | 动作 | 用户看到 |
|---|---|---|---|
| correct | ≥1 条 relevant |
直接生成 | 带引用的答案 |
| ambiguous | 无 relevant,有 ambiguous | 换更贵的策略重检一次 | 带引用的答案(置信度偏低) |
| incorrect | 全部 irrelevant | 拒答 + 转人工建议 | 友好提示 + 补资料建议 |
常见翻车与解法
| ❌ 症状 | 原因 | ✅ 解法 |
|---|---|---|
| 改写了反而更差 | 改写把原意改没了 | 检索列表里永远保留原问题 (queries[0] 之后 append 原句) |
| 评测集无法复现 | 改写用了 temperature>0 |
改写/分级一律 temperature=0.0 |
| HyDE 把数值题带偏 | 假想文档编了错数字 | prompt 里明令"不要出现具体数值";数值题强制走 BM25 |
| Multi-Query 越检索越发散 | 各路角度重复,等于放大噪声 | 让 LLM 生成有差异的角度;限制最多 4 路 |
| RRF 融合分看起来很小 | 正常,RRF 分是 1/(60+rank) 量级 | 别拿它当置信度,置信度看 Day 68 的 Reranker 分 |
| 用改写后的查询 rerank | 排序标准被偷偷换掉 | rerank 必须用用户原问题 |
| 系统变慢被投诉 | 所有问题都上了 multi | 按类型路由 + 降级开关(第七节 kb/flags.py) |
| 权限被绕过 | 新增检索路径忘了带 filter | 每一路检索都要传 build_acl_filter();写个单测断言 |
| 无限重检拖死请求 | max_retry 没设上限 |
Pydantic le=3,默认 1 |
📝 本课小结
| 知识点 | 一句话记住 |
|---|---|
| 三种错配 | 词汇错配、体裁错配、长度错配 ------ 问句向量天然离文档向量更远 |
| 核心实验 | 同一段原文:陈述句 0.91 > 书面问句 0.83 > 口语问句 0.41 |
| L0 术语表 | 命中 60%+,0 成本 0 延迟,是唯一客户能自己维护的 AI 配置 |
| L1 LLM 改写 | temperature=0.0,3 条差异化查询 + 关键词;必须保留原问题(结果可缓存) |
| HyDE | 让模型写 2-4 句假想答案 去检索;可以是"错的",但必须是"像的" |
| HyDE 禁区 | 精确数值题、型号编号题 ------ 假想文档会编错数字,反而带偏 |
| Multi-Query | 一题拆多角度并行召回,RRF 只按排名融合(k=60,不分数量纲) |
| Rerank 用原问题 | 改写负责"召回全",重排负责"排得准",两者查询对象不同 |
| Self-RAG 三决策 | 要不要检索 / 召回相关吗 / 答案有支撑吗 ------ 用普通 LLM + Prompt 就能做 |
| CRAG 三档 | correct→生成;ambiguous→重检一次;incorrect→体面拒答 |
| 只重检一次 | 第二次边际收益 <5%,第三次≈0,但延迟翻倍 |
| 降级开关 | 每加一层智能就配一个开关;智能可降级,服务不能中断 |
| Agentic RAG | 把 if/else 编排换成模型决定的流程 ------ 第 12 周 LangGraph |
🧠 核心认知 :第 10 周我们一直在优化"库这一侧"------换 Qdrant、加 BM25、上 Reranker、建 payload 索引,全都是在让"库更好查"。但今天那个实验说明了一件更根本的事:如果查询向量本身指错了方向,库做得再好也找不到。 前两周的所有优化都在同一个候选集里做精加工,而"问法"决定了这个候选集从一开始对不对。这个认知带来的方法论转变是:从"优化检索器"转向"优化查询" ------而一旦你开始让系统自己判断"这次检索够不够好、要不要换个方式再来一次" ,你就已经站在 Agentic RAG 的门口了。今天你用 if/else 写的"改写→分级→重检",第 12 周会被画成一张状态图、交给模型去走。所以真正的收获不是那 0.417→0.917,而是你第一次让系统拥有了对自己检索结果的"判断力"。 FDE 交付的不是"一个更聪明的检索器",而是"一个知道自己什么时候没把握的系统"。
📋 课后练习
练习 1:为你的手册建一张术语表并量化收益(约 60 分钟)
-
打开
data/cleaned/manual_a3.clean.md,人工挑出 10 个手册里的标准术语(如"料筒超温""锁模力""液压油泄漏""射出量")。 -
为这 10 个术语各配 2-3 个一线口语说法(可以去问现场师傅,或者自己按常识编,比如"烫得厉害"→"料筒超温")。
-
写进
config/glossary.json,重启服务。 -
量化收益 :用第六节的对比脚本,把
raw和rewrite两档各跑一遍,记录 Recall@5。要求:术语表贡献的增益要单独列一列 (提示:把rewrite()的use_llm参数设成False,就是纯 L0 的效果)。 -
把三行结果(raw / L0-only / L0+L1)写进
doc/week11/rewrite_eval.md,附一句结论:术语表值不值得继续投入人力维护?
练习 2:给 HyDE 做一次"禁区验证"(约 50 分钟)
-
造 5 道精确数值题(如"液压阀拆卸扭矩是多少""液压油更换周期是多少小时""冷却水压力停机阈值是多少"),每道题都标注正确的 chunk id 和正确数值。
-
分别用
strategy="raw"、strategy="hyde"、strategy="rewrite"各跑一遍,记录 Recall@5。 -
核心观察 :把 HyDE 生成的假想文档打印出来,数一数里面出现了几个编造的数字(对比手册里的真实数值)。
-
改
HYDE_SYSTEM提示词,加上"若提问涉及具体数值或型号,则原文复述提问中的数值,禁止自行给出数字",再跑一遍,对比 Recall 与"编造数字个数"。 -
把前后两版提示词和结果写进
doc/week11/hyde_ab.md,结论写成一句话:什么类型的问题应该强制禁用 HyDE? (提示:答案会变成第六节kb/flags.py里的一条路由规则)
练习 3:给自纠闭环写单测 + 造一个"必然拒答"的用例(约 55 分钟)
-
用
pytest给rrf()写 3 个单测:① 单路输入返回顺序不变;② 两路都含同一 id 时该 id 排在只被一路命中的 id 前面;③top_n截断生效。 -
给
grade_contexts()写 2 个单测:① 模型返回非法 JSON 时全部降级为ambiguous(可用 monkeypatch 模拟_client异常);② 返回数组长度不足时自动补齐。 -
造一个必然拒答的用例 :问一个手册里绝对没有的问题(如"注塑机能做咖啡吗"),跑
advanced_answer(q, strategy="auto", max_retry=1),验证三件事 :refused=True、retries<=1、trace里能看出完整的升级路径(rewrite → hyde → multi → 拒答)。 -
压测开关 :把
RAG_USE_REWRITE=0 RAG_USE_HYDE=0 RAG_USE_MULTI=0 RAG_USE_GRADE=0设上,再问同一个"热的厉害"问题,记录召回是否还能命中(应该靠术语表还能中)。把结果写进doc/week11/degrade_test.md。
🔭 下节预告
今天把"查询这一侧"补上了:
raw Recall@5 = 0.417 ← 口语提问的起点
+术语表 Recall@5 = 0.60 ← 0 成本,客户能自己维护
+LLM 改写 Recall@5 = 0.750
+HyDE Recall@5 = 0.833
+Multi Recall@5 = 0.917
─────────────────────────────────────────
同时拿到了:检索前判断(闲聊不检索)+ 检索后分级(三档)+ 重检一次 + 体面拒答
但你有没有注意到一个问题:这些数字是我拿着 8 条问题、一行行打印出来数的。 人工标注,肉眼对比------这套方法在 20 条问题规模下勉强能用,到了 200 条就彻底失效了。
更麻烦的是另一件事。这套系统跑在你自己的笔记本上:Qdrant 一个容器、BGE-M3 一个模型、十几个 Python 文件、一堆 pip install。下周换一个客户,你要把这些全部重来一遍 ------拉镜像、装依赖、灌库、调参数、对版本。客户的信息化部门会问你一个问题:"这套东西我们自己的运维能接管吗?"
第 11 周的第二件事,就是解决"交付方式":
| Day | 主题 | 做什么 |
|---|---|---|
| Day 71 | 高级检索 | ✅ 已完成 |
| Day 72 | RAGFlow 平台 | 用开源 RAG 引擎把整条链路配置化 :Docker Compose 一键起、Web 界面建库、分块结果可视化可手改、检索带引用快照;然后站在 FDE 立场算一笔账------什么情况下该用平台,什么情况下必须自研 |
| Day 73 | RAGAS 评测 | 把今天这套"肉眼数几条"换成可复现的量化指标:faithfulness / answer relevancy / context precision / context recall,用数据证明每一次改动 |
| Day 74 | 企业架构 | 增量更新、黄金问题集、生产就绪检查单 |
| Day 75 | v0.2 冻结 | 问答集成进工单系统,打版本标签 |
明天你会第一次看到:你花了三周写的那套 pipeline,在一个开源平台里是几页配置。 这个对比会让你不太舒服,但它也是 FDE 最值钱的一课------知道什么时候不该自己写代码。
明天见。
🌍附录:前置课程列表
阶段一:认知启蒙(AI 认知与 FDE 角色)
AI 认知
【FDE系列】阶段1Day 1:AI 层级关系 --- 四个嵌套的圈-CSDN博客
【FDE系列】阶段1Day 2:AI 三阶段发展史 --- 会认 → 会判断 → 会创造-CSDN博客
【FDE系列】阶段1Day 3:符号 AI vs 机器学习 --- 两条路线的本质区别-CSDN博客
【FDE系列】阶段1Day 4:Transformer 的历史意义 --- 2017 年的分水岭-CSDN博客
【FDE系列】阶段1Day 5:本周复习与自测 --- 检验你的 AI 认知地基-CSDN博客
【FDE系列】阶段1Day 6:Transformer 架构 --- 一张图纸盖出千千万万栋楼-CSDN博客
【FDE系列】阶段1Day 7:LLM 本质 --- 文字接龙机器-CSDN博客
【FDE系列】阶段1Day 8:Token --- 模型眼中的最小单位-CSDN博客
【FDE系列】阶段1Day 9:AI 幻觉 --- 为什么会一本正经地胡说八道-CSDN博客
【FDE系列】阶段1Day 10:上下文窗口 --- 模型的记忆力上限 + 本周复习-CSDN博客
【FDE系列】阶段1Day 11:Prompt --- 给模型立规矩-CSDN博客
【FDE系列】阶段1Day 12:Memory --- 让模型记住上下文
【FDE系列】阶段1Day 13:RAG --- 给模型配图书管理员-CSDN博客
【FDE系列】阶段1Day 14:Tool Use --- 让模型动手操作-CSDN博客
【FDE系列】阶段1Day 15:MCP --- 统一的工具接口标准 + 第三周复习-CSDN博客
FDE 基础概念
【FDE系列】阶段1Day 16:什么是 FDE --- 把 AI 变成客户结果的人-CSDN博客
【FDE系列】阶段1Day 17:FDE vs 传统实施 --- 三大本质区别-CSDN博客
【FDE系列】阶段1Day 18:FDE 三重身份 + C6 胜任力模型-CSDN博客
【FDE系列】阶段1Day 19:七阶段行动路径 + 行业经验的价值-CSDN博客
【FDE系列】阶段1Day 20:阶段总结与产出物 --- 第一阶段收官-CSDN博客
阶段二:技术地基(Python + FastAPI + SQL + Docker + API 集成)
Python基础
【FDE系列】阶段2:Day 21:Python 环境搭建 --- 写出你的第一行代码-CSDN博客
【FDE系列】阶段2:Day 22:变量、数据类型、条件判断 --- Python 的"记忆"和"判断"-CSDN博客
【FDE系列】阶段2:Day 23:循环与函数 --- 让代码跑 100 遍、把逻辑打包复用-CSDN博客
【FDE系列】阶段2:Day 24:数据结构 --- 列表、字典、集合、元组-CSDN博客
【FDE系列】阶段2:Day 25:文件读写与 JSON --- 让程序连通外部数据(第一周收官)-CSDN博客
【FDE系列】阶段2:Day 26:模块化编程 --- 把代码拆成"抽屉柜"-CSDN博客
【FDE系列】阶段2:Day 27:异常处理与日志 --- 让程序"摔不烂、查得到"-CSDN博客
FastAPI入门到进阶
【FDE系列】阶段2:Day 28:FastAPI 入门 --- 把你的函数变成 API 服务-CSDN博客
【FDE系列】阶段2:Day 29:FastAPI 进阶 --- Pydantic 模型与完整 CRUD 实战-CSDN博客
【FDE系列】阶段2:Day 30:生产代码规范 --- 测试、类型注解、配置管理(第二周收官)-CSDN博客
SQL基础
【FDE系列】阶段2:Day 31:SQL 基础 --- 增删改查一把梭-CSDN博客
【FDE系列】阶段2:Day 32:多表查询 --- JOIN 与聚合-CSDN博客
【FDE系列】阶段2:Day 33:进阶查询 --- 窗口函数与 CTE-CSDN博客
【FDE系列】阶段2:Day 34:数据清洗 --- 把脏数据捋干净-CSDN博客
【FDE系列】阶段2:Day 35:Python + SQL --- 工单接入 MySQL + 本周收官-CSDN博客
Linux基础
【FDE系列】阶段2:Day 36:Linux 入门与文件操作 --- 扔掉鼠标的第一天-CSDN博客
【FDE系列】阶段2:Day 37:权限、进程与文本三剑客-CSDN博客
【FDE系列】阶段2:Day 38:Shell 脚本 --- 把命令串起来自动跑-CSDN博客
【FDE系列】阶段2:Day 39:Linux 综合实战 --- 让服务无人值守-CSDN博客
【FDE系列】阶段2:Day 40:Shell 进阶 --- 生产级脚本与本周收官-CSDN博客
Docker
【FDE系列】阶段2:Day 41:Docker 入门 --- 把环境装进盒子-CSDN博客
【FDE系列】阶段2:Day 42:Dockerfile 实战 --- 把你的应用打包成镜像-CSDN博客
【FDE系列】阶段2:Day 43:Docker Compose --- 多容器一键编排-CSDN博客
【FDE系列】阶段2:Day 44:Nginx 反向代理 + Git 版本控制-CSDN博客
【FDE系列】阶段2:Day 45:综合实战 --- Docker + Nginx + Git 完整部署与本周收官-CSDN博客
API 集成与系统对接
【FDE系列】阶段2:Day 46:RESTful 设计与认证授权-CSDN博客
【FDE系列】阶段2:Day 47:对接企业系统 --- 飞书 / 钉钉 API-CSDN博客
【FDE系列】阶段2:Day 48:Webhook 处理与数据映射-CSDN博客
【FDE系列】阶段2:Day 49:OpenAPI 文档与接口测试-CSDN博客
【FDE系列】阶段2:Day 50:综合项目 --- 设备告警工单闭环系统 & 第二阶段收官 特殊字符-CSDN博客
阶段三:AI 应用技术(含 SDD 方法论)
AI基础:Prompt Engineering 系统训练
【FDE系列】阶段3:Day 51:从聊天窗口到代码 --- 跟 LLM 的第一次握手-CSDN博客
【FDE系列】阶段3:Day 52:Prompt 三板斧 --- 角色、示例与清晰指令-CSDN博客
【FDE系列】阶段3:Day 53:结构化输出 --- 让模型的回答能进数据库-CSDN博客
【FDE系列】阶段3:Day 54:思维链与推理任务 --- 让模型一步步想清楚-CSDN博客
【FDE系列】阶段3:Day 55:综合实战 --- 巡检报告生成器与本周收官 -CSDN博客
【FDE系列】阶段3:Day 56:评测体系入门 --- 建立你的黄金评测集-CSDN博客
【FDE系列】阶段3:Day 57:Promptfoo 实战 --- A/B 对比让数据说话-CSDN博客
【FDE系列】阶段3:Day 58:Prompt 安全 --- 注入、越狱与防护-CSDN博客
【FDE系列】阶段3:Day 59:模板化与追踪 --- Jinja2 与 Langfuse-CSDN博客
【FDE系列】阶段3:Day 60:综合实战 --- 智能工单助手 v0.1 冻结-CSDN博客
RAG 知识检索系统
【FDE系列】阶段3:Day 61:RAG 全景 --- 给模型配一间资料室-CSDN博客
【FDE系列】阶段3:Day 62:Embedding --- 文字是怎么变成向量的-CSDN博客
【FDE系列】阶段3:Day 63:文档解析 --- 把真实 PDF 手册变成可用文本-CSDN博客
【FDE系列】阶段3:Day 64:文本分块 --- 决定检索成败的那一步-CSDN博客
【FDE系列】阶段3:Day 65:向量数据库入门 --- Chroma 与本周收官-CSDN博客
【FDE系列】阶段3:Day 66:Qdrant 入门 --- 生产级向量库-CSDN博客
【FDE系列】阶段3:Day 67:混合检索 --- BM25 与 RRF 融合-CSDN博客
【FDE系列】阶段3:Day 68:重排序 --- 用 BGE-Reranker 把真正相关的顶上来-CSDN博客
【FDE系列】阶段3:Day 69:引用溯源 --- 让每个答案都能对上原文-CSDN博客
【FDE系列】阶段3:Day 70:元数据与权限过滤 --- 两个部门看不到彼此的文档-CSDN博客
待完成教程:
Agent 框架与开发
Tool Calling 与 MCP
LLM 推理与部署
规范驱动开发与 Agent 工程方法论
阶段四:平台与交付(含 Agent 治理)
阶段五:行业实战与认证