【FDE系列】阶段3:Day 71:高级检索 — 查询改写与 HyDE

📚前言

📒FDE系列内容总纲:

【大纲】FDE 前沿部署工程师学习系列教程-CSDN博客

🚄前置课程列表:

见文档结尾附录。


🚀阶段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 分钟)

  1. 打开 data/cleaned/manual_a3.clean.md,人工挑出 10 个手册里的标准术语(如"料筒超温""锁模力""液压油泄漏""射出量")。

  2. 为这 10 个术语各配 2-3 个一线口语说法(可以去问现场师傅,或者自己按常识编,比如"烫得厉害"→"料筒超温")。

  3. 写进 config/glossary.json,重启服务。

  4. 量化收益 :用第六节的对比脚本,把 raw 和 rewrite 两档各跑一遍,记录 Recall@5。要求:术语表贡献的增益要单独列一列 (提示:把 rewrite() 的 use_llm 参数设成 False,就是纯 L0 的效果)。

  5. 把三行结果(raw / L0-only / L0+L1)写进 doc/week11/rewrite_eval.md,附一句结论:术语表值不值得继续投入人力维护?

练习 2:给 HyDE 做一次"禁区验证"(约 50 分钟)

  1. 造 5 道精确数值题(如"液压阀拆卸扭矩是多少""液压油更换周期是多少小时""冷却水压力停机阈值是多少"),每道题都标注正确的 chunk id 和正确数值。

  2. 分别用 strategy="raw"、strategy="hyde"、strategy="rewrite" 各跑一遍,记录 Recall@5。

  3. 核心观察 :把 HyDE 生成的假想文档打印出来,数一数里面出现了几个编造的数字(对比手册里的真实数值)。

  4. 改 HYDE_SYSTEM 提示词,加上"若提问涉及具体数值或型号,则原文复述提问中的数值,禁止自行给出数字",再跑一遍,对比 Recall 与"编造数字个数"。

  5. 把前后两版提示词和结果写进 doc/week11/hyde_ab.md,结论写成一句话:什么类型的问题应该强制禁用 HyDE? (提示:答案会变成第六节 kb/flags.py 里的一条路由规则)

练习 3:给自纠闭环写单测 + 造一个"必然拒答"的用例(约 55 分钟)

  1. 用 pytest 给 rrf() 写 3 个单测:① 单路输入返回顺序不变;② 两路都含同一 id 时该 id 排在只被一路命中的 id 前面;③ top_n 截断生效。

  2. 给 grade_contexts() 写 2 个单测:① 模型返回非法 JSON 时全部降级为 ambiguous(可用 monkeypatch 模拟 _client 异常);② 返回数组长度不足时自动补齐。

  3. 造一个必然拒答的用例 :问一个手册里绝对没有的问题(如"注塑机能做咖啡吗"),跑 advanced_answer(q, strategy="auto", max_retry=1),验证三件事 :refused=True、retries<=1、trace 里能看出完整的升级路径(rewrite → hyde → multi → 拒答)。

  4. 压测开关 :把 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 治理)

阶段五:行业实战与认证

相关推荐
天空之城--1 小时前
Android一周动态:Android 18首次官宣、Compose Material3 1.4转正(5趋势+5资讯)
android·人工智能·flutter·架构·android jetpack
ZzT1 小时前
Claude 干活的时候,在终端里打砖块
人工智能·ai编程·claude
AI日报派送佬1 小时前
2026年10月3日AI行业日报|系统权限安全全面收紧,AI算力与模型工程化落地提速
人工智能·openai·英伟达·ai日报·智能体技术·ai安全管控·ai开源生态
Ai-_Man1 小时前
请问豆包的智能体聊天记录该怎么弄
开发语言·前端·javascript·人工智能·小程序·ecmascript·电脑
richard_yuu1 小时前
OpenCV 实战第 2 篇:cv::Mat 内存模型,引用计数、ROI 与连续性
人工智能·opencv·计算机视觉
艺杯羹1 小时前
Anthropic突袭发布Claude Code Mods!用TypeScript编写智能体中间件:告别AI误删与失控
人工智能·typescript·系统架构
Yolanda_20221 小时前
18.神经网络-卷积层
人工智能·神经网络·cnn
合米AI SOP系统1 小时前
螺丝细小难识别?合米科技 AI SOP 视觉依靠动作识别攻克微小工件核验难题.
人工智能·科技·计算机视觉
IT_陈寒1 小时前
SpringBoot自动配置把我坑惨了:这些隐式规则要小心
前端·人工智能·后端