从输入到决策:意图识别在 AI 架构中的定位与应用 — 第八章《知识检索 RAG-2》

0 阅读35分钟

五、意图感知的检索 Query 构造

要解决什么问题

这是本章最关键的设计点,也是本系列文章和通用 RAG 教程的最大区别。

通用 RAG 直接拿用户原文去检索。但在我们的架构中,用户输入已经经过了前六章的处理,我们已经知道了意图、实体、情绪。利用这些信息构造检索 query,比直接用原文检索效果好得多:

用户原文:"我上周买的那个耳机 好像不能降噪啊 咋回事"

直接用原文检索:
  → 模糊、口语化,可能命中不相关的文档

利用意图识别结果构造 query:
  → 意图: 售后服务/功能异常
  → 实体: {product: "蓝牙耳机", feature: "降噪"}
  → 构造的检索 query: "蓝牙耳机 降噪功能 故障排除 使用方法"
  → 精准命中产品手册中"降噪"章节 + FAQ 中"降噪不工作怎么办"

实现

class IntentAwareQueryBuilder:
    """
    意图感知的检索 Query 构造器。

    根据前六章的意图识别结果,构造更精准的检索 query 和过滤条件。
    核心思路:不同意图需要不同的知识来源和检索策略。
    """

    # 意图 → 知识来源映射
    # 不同意图应该在不同类型的文档中搜索
    INTENT_DOC_TYPE_MAP = {
        "售前咨询/功能咨询":   ["product_manual", "faq"],
        "售前咨询/价格咨询":   ["price_list", "promotion", "faq"],
        "售前咨询/对比推荐":   ["product_manual", "comparison"],
        "售后服务/退款":       ["refund_policy", "faq"],
        "售后服务/换货":       ["exchange_policy", "faq"],
        "售后服务/物流查询":   ["logistics_faq", "faq"],
        "售后服务/维修":       ["warranty_policy", "repair_guide", "faq"],
        "投诉建议/服务投诉":   ["complaint_process", "faq"],
        "投诉建议/产品投诉":   ["complaint_process", "quality_policy", "faq"],
        "账号问题/登录异常":   ["account_faq", "faq"],
        "账号问题/密码重置":   ["account_faq", "faq"],
    }

    # 意图 → 检索 query 扩展模板
    # 在用户原文基础上,追加和意图相关的关键词,提升召回率
    INTENT_QUERY_EXPANSION = {
        "售前咨询/功能咨询":  "{product} {feature} 功能 参数 支持",
        "售前咨询/价格咨询":  "{product} 价格 优惠 折扣 活动",
        "售后服务/退款":      "{product} 退款 退货 退款政策 退款流程 退款条件",
        "售后服务/换货":      "{product} 换货 更换 换货流程 换货条件",
        "售后服务/维修":      "{product} {issue} 维修 保修 故障 解决方法",
        "投诉建议/产品投诉":  "{product} {issue} 质量问题 投诉处理",
    }

    def build(self, state: dict) -> dict:
        """
        根据意图识别结果构造检索参数。

        输入: 前六章输出的 state(包含意图、实体、脱敏后的文本)
        输出: {
            "queries": [检索 query 列表],     # 可能有多条,用于多路召回
            "filters": {metadata 过滤条件},    # 限定搜索范围
            "top_k": 检索数量,
        }
        """
        l1 = state.get("l1_intent", "")
        l2 = state.get("l2_intent", "")
        intent_key = f"{l1}/{l2}"
        entities = state.get("filled_slots", {})
        user_text = state.get("pii_masked_input", state.get("processed_input", ""))

        # 1. 构造主检索 query
        queries = self._build_queries(intent_key, entities, user_text)

        # 2. 构造 metadata 过滤条件
        filters = self._build_filters(intent_key, entities)

        # 3. 确定 top_k
        # 简单咨询类少检索几条(答案集中),复杂问题多检索几条
        top_k = 5 if "咨询" in l2 else 8

        return {
            "queries": queries,
            "filters": filters,
            "top_k": top_k,
        }

    def _build_queries(
        self,
        intent_key: str,
        entities: dict,
        user_text: str,
    ) -> list[str]:
        """
        构造检索 query。返回多条 query 用于多路召回。

        策略:
        - query_1: 用户原文(保留语义完整性)
        - query_2: 意图 + 实体组合(精准匹配)
        - query_3: 意图扩展 query(提升召回)
        """
        queries = []

        # query_1: 用户原文(永远保留,兜底用)
        queries.append(user_text)

        # query_2: 实体组合 query
        # 把所有非空实体拼在一起,去掉口语化的干扰
        entity_parts = []
        for key in ['product', 'feature', 'issue_summary', 'order_id']:
            val = entities.get(key, "")
            if val:
                entity_parts.append(str(val))
        if entity_parts:
            queries.append(' '.join(entity_parts))

        # query_3: 意图扩展 query
        template = self.INTENT_QUERY_EXPANSION.get(intent_key)
        if template:
            expanded = template.format(
                product=entities.get("product", ""),
                feature=entities.get("feature", ""),
                issue=entities.get("issue_summary", ""),
            ).strip()
            # 去掉未填充的占位符残留
            expanded = re.sub(r'\s+', ' ', expanded).strip()
            if expanded:
                queries.append(expanded)

        return queries

    def _build_filters(self, intent_key: str, entities: dict) -> dict | None:
        """
        构造 metadata 过滤条件。

        作用:限定检索范围,避免"退款政策"的问题命中"产品手册"。
        """
        doc_types = self.INTENT_DOC_TYPE_MAP.get(intent_key)

        if not doc_types:
            return None

        if len(doc_types) == 1:
            return {"doc_type": doc_types[0]}
        else:
            # Chroma 的多值过滤用 $in
            return {"doc_type": {"$in": doc_types}}

进阶 Query 变换技术

上面的意图感知 Query 构造适用于大多数场景。但对于复杂或模糊的用户问题,还需要更高级的 Query 变换技术。

HyDE(Hypothetical Document Embeddings)

核心思路:让 LLM 先假装回答用户的问题,然后用这个假设性答案的 embedding 去检索,而不是用问题本身。

为什么有效?
  → 用户问题是"问句",知识库里的文档是"陈述句"
  → 问句和陈述句的 embedding 天然存在语义鸿沟
  → HyDE 生成的假设性答案是陈述句,和知识库文档在同一个语义空间
  → 用陈述句检索陈述句,匹配度更高
HYDE_PROMPT = """根据以下用户问题,写一段可能的回答(100字以内)。
不需要确保准确,只需要包含可能相关的关键信息。

用户问题:{question}

可能的回答:"""


def generate_hyde_query(question: str, llm) -> str:
    """
    HyDE: 用 LLM 生成假设性答案,作为检索 query。

    注意:
    - 用轻量模型(gpt-4o-mini / claude-haiku),不值得用大模型
    - 有 200~500ms 额外延迟,只在用户问题模糊时启用
    - 生成的答案不需要准确,只需要"方向对"
    """
    response = llm.invoke([
        {"role": "user", "content": HYDE_PROMPT.format(question=question)},
    ])
    return response.content.strip()


# 使用示例
# 用户问:"耳机怎么连不上啊"(模糊,缺乏具体信息)
# HyDE 生成:"蓝牙耳机连接失败可能是因为未进入配对模式、蓝牙未打开、
#            设备距离过远或已连接其他设备。请长按耳机电源键3秒进入配对模式..."
# → 用这段"假设性答案"做 embedding 检索,能精准命中故障排除文档

Query Decomposition(问题拆解)

复杂问题包含多个子问题,拆解后分别检索效果更好:

DECOMPOSE_PROMPT = """将用户问题拆解为 1~3 个独立的子问题。每个子问题独立检索都能找到有用信息。
如果问题本身已经很简单,直接返回原问题。

用户问题:{question}

输出格式(严格 JSON 数组):
["子问题1", "子问题2", ...]"""


def decompose_query(question: str, llm) -> list[str]:
    """
    将复杂问题拆解为子问题。

    示例:
    输入: "蓝牙耳机降噪好不好,和有线耳机比哪个值"
    输出: ["蓝牙耳机的降噪效果怎么样", "蓝牙耳机和有线耳机的对比"]
    → 分别检索,各自命中不同的文档
    """
    import json
    response = llm.invoke([
        {"role": "user", "content": DECOMPOSE_PROMPT.format(question=question)},
    ])
    try:
        return json.loads(response.content)
    except (json.JSONDecodeError, TypeError):
        return [question]  # 解析失败,返回原问题

Step-back Prompting(抽象化检索)

把具体问题抽象一级后检索,适用于答案在更上层概念中的场景:

def step_back_query(question: str, entities: dict) -> str:
    """
    将具体问题抽象化。

    示例:
    "ORD-456 能退款吗"   → "退款的条件和流程是什么"
    "蓝牙耳机Pro能防水吗" → "蓝牙耳机Pro的产品规格参数"

    不需要 LLM,用规则模板即可(基于意图)。
    """
    # 具体订单问题 → 抽象到政策层面
    if entities.get("order_id"):
        return question.replace(entities["order_id"], "订单")

    return question

何时启用哪种技术

技术额外延迟适用场景启用条件
意图感知(默认)0ms所有请求始终启用
HyDE200~500ms模糊问题、低置信度confidence < 0.6 时启用
Query Decomposition200~500ms复杂/多子问题检测到多个问号或并列连词时
Step-back0ms具体→抽象的场景意图为政策咨询类时
# 在 IntentAwareQueryBuilder.build() 中集成
def build(self, state: dict) -> dict:
    # ... 原有逻辑 ...
    queries = self._build_queries(intent_key, entities, user_text)

    # 低置信度时启用 HyDE
    if state.get("confidence", 1.0) < 0.6 and guard_llm is not None:
        hyde_answer = generate_hyde_query(user_text, guard_llm)
        queries.append(hyde_answer)

    # 检测到复杂问题时启用 Decomposition
    if any(kw in user_text for kw in ["和", "还是", "对比", "区别", "以及"]):
        sub_queries = decompose_query(user_text, guard_llm)
        queries.extend(sub_queries)

    return {"queries": queries, "filters": filters, "top_k": top_k}

为什么不直接用用户原文检索

方式用户输入检索 query效果
原文直接检索"那个耳机 好像不能降噪啊 咋回事"同左"咋回事"、"好像"都是噪音词,干扰检索
意图感知检索同上"蓝牙耳机 降噪功能 故障排除 使用方法"精准命中故障排除文档
多路召回同上原文 + 实体组合 + 意图扩展三路互补,召回率最高
HyDE同上"蓝牙耳机降噪不工作可能是因为..."假设性答案与知识库文档语义更接近

六、混合检索(向量 + 关键词)

要解决什么问题

纯向量检索有一个致命弱点:对精确匹配不敏感

用户问"ORD-456 的物流状态",向量检索可能把"ORD-123 的物流状态"排在更前面(因为语义更相似),但用户要的是 ORD-456 这个特定订单。

关键词检索(BM25)擅长精确匹配,但不懂语义。两者结合是目前业界的最佳实践。

实现

import math
from collections import Counter


# ─────────────────────────────────────────────
# 中文分词(生产必须用 jieba,不能用 bigram)
# ─────────────────────────────────────────────

# pip install jieba
import jieba
import jieba.analyse

# 加载业务自定义词典(产品名、品牌名等不能被切错的词)
# 格式:每行 "词语 词频 词性",如 "蓝牙耳机 10000 n"
# jieba.load_userdict("config/custom_dict.txt")

# 停用词表("的"、"了"、"是" 等高频无意义词,不参与检索)
STOP_WORDS = set()
# 生产环境从文件加载:
# with open("config/stopwords.txt", "r") as f:
#     STOP_WORDS = {line.strip() for line in f if line.strip()}
# 内置基础停用词:
STOP_WORDS.update({
    "的", "了", "在", "是", "我", "有", "和", "就", "不", "人", "都", "一",
    "一个", "上", "也", "很", "到", "说", "要", "去", "你", "会", "着",
    "没有", "看", "好", "自己", "这", "他", "她", "它", "吗", "吧", "啊",
    "呢", "哦", "嗯", "呀", "哈", "么", "那", "这个", "那个",
    "什么", "怎么", "怎么样", "为什么", "可以", "能", "请问", "请",
})


def tokenize_chinese(text: str) -> list[str]:
    """
    中文分词 + 停用词过滤。

    为什么必须用 jieba 而不是 bigram?
    → bigram "蓝牙耳机" → ["蓝牙","牙耳","耳机"],"牙耳"是噪音
    → jieba  "蓝牙耳机" → ["蓝牙","耳机"],精确分词
    → bigram "主动降噪" → ["主动","动降","降噪"],"动降"会引入误匹配
    → jieba  "主动降噪" → ["主动","降噪"],语义正确
    """
    words = jieba.lcut(text.lower())
    # 过滤停用词和单字符(单字符在中文 BM25 中几乎没有区分度)
    return [w for w in words if w not in STOP_WORDS and len(w.strip()) > 1]


class BM25:
    """
    BM25 关键词检索(jieba 分词版)。

    为什么不用 Elasticsearch?
    → 电商客服知识库通常几千到几万条 chunk,内存 BM25 就够用
    → 引入 ES 会增加运维复杂度,不划算
    → 如果知识库规模超过 10 万条,建议用 ES 或 Meilisearch
    """

    def __init__(self, k1: float = 1.5, b: float = 0.75):
        self.k1 = k1
        self.b = b
        self.corpus = []        # 分词后的文档列表
        self.doc_ids = []       # 对应的 chunk_id
        self.doc_contents = []  # 原文
        self.doc_len = []       # 每篇文档的长度
        self.avg_dl = 0         # 平均文档长度
        self.df = {}            # 文档频率
        self.N = 0              # 文档总数

    def index(self, chunks: list[TextChunk]):
        """构建 BM25 索引"""
        self.corpus = []
        self.doc_ids = []
        self.doc_contents = []
        self.doc_len = []
        self.df = Counter()

        for chunk in chunks:
            tokens = tokenize_chinese(chunk.content)
            self.corpus.append(tokens)
            self.doc_ids.append(chunk.chunk_id)
            self.doc_contents.append(chunk.content)
            self.doc_len.append(len(tokens))

            unique_tokens = set(tokens)
            for token in unique_tokens:
                self.df[token] += 1

        self.N = len(self.corpus)
        self.avg_dl = sum(self.doc_len) / self.N if self.N > 0 else 1

    def search(self, query: str, top_k: int = 10) -> list[dict]:
        """BM25 检索"""
        query_tokens = tokenize_chinese(query)
        scores = []

        for i, doc_tokens in enumerate(self.corpus):
            score = self._score(query_tokens, doc_tokens, self.doc_len[i])
            if score > 0:
                scores.append((i, score))

        scores.sort(key=lambda x: x[1], reverse=True)

        return [
            {
                "chunk_id": self.doc_ids[idx],
                "content": self.doc_contents[idx],
                "score": round(score, 4),
            }
            for idx, score in scores[:top_k]
        ]

    def _score(self, query_tokens: list[str], doc_tokens: list[str], doc_len: int) -> float:
        """计算单篇文档的 BM25 分数"""
        doc_tf = Counter(doc_tokens)
        score = 0.0

        for token in query_tokens:
            if token not in doc_tf:
                continue

            tf = doc_tf[token]
            df = self.df.get(token, 0)

            # IDF:在越少文档中出现的词,权重越高
            idf = math.log((self.N - df + 0.5) / (df + 0.5) + 1)
            # TF 归一化:长文档中的词频不应该占优势
            tf_norm = (tf * (self.k1 + 1)) / (
                tf + self.k1 * (1 - self.b + self.b * doc_len / self.avg_dl)
            )

            score += idf * tf_norm

        return score


class HybridRetriever:
    """
    混合检索器:向量检索 + BM25 关键词检索。

    两路检索的结果用 RRF(Reciprocal Rank Fusion)合并。
    RRF 的优点:不需要对两路检索的分数做归一化(它们的分数量纲完全不同),
    只用排名来融合。
    """

    def __init__(
        self,
        vector_store: VectorStore,
        bm25: BM25,
        embedding_service: EmbeddingService,
        vector_weight: float = 0.6,    # 向量检索权重
        keyword_weight: float = 0.4,   # 关键词检索权重
        rrf_k: int = 60,              # RRF 平滑参数(标准值 60)
    ):
        self.vector_store = vector_store
        self.bm25 = bm25
        self.embedding_service = embedding_service
        self.vector_weight = vector_weight
        self.keyword_weight = keyword_weight
        self.rrf_k = rrf_k

    def search(
        self,
        queries: list[str],
        top_k: int = 5,
        filters: dict | None = None,
    ) -> list[dict]:
        """
        混合检索。

        步骤:
        1. 每条 query 分别做向量检索和关键词检索
        2. 所有结果用 RRF 融合
        3. 返回 top_k 结果
        """
        all_vector_results = []
        all_bm25_results = []

        for query in queries:
            # 向量检索
            query_emb = self.embedding_service.embed_query(query)
            vec_results = self.vector_store.search(
                query_embedding=query_emb,
                top_k=top_k * 2,  # 多召回一些,后续 RRF 融合时择优
                where=filters,
            )
            all_vector_results.extend(vec_results)

            # 关键词检索
            bm25_results = self.bm25.search(query, top_k=top_k * 2)
            all_bm25_results.extend(bm25_results)

        # RRF 融合
        merged = self._rrf_merge(all_vector_results, all_bm25_results)

        return merged[:top_k]

    def _rrf_merge(
        self,
        vector_results: list[dict],
        bm25_results: list[dict],
    ) -> list[dict]:
        """
        Reciprocal Rank Fusion(倒数排名融合)。

        公式:RRF_score(d) = Σ weight / (k + rank(d))

        为什么用 RRF 而不是简单加权?
        → 向量检索的 score 是 0~1 的余弦相似度
        → BM25 的 score 是无上界的 TF-IDF 分数
        → 两者量纲完全不同,直接加权没有意义
        → RRF 只用排名(第1名、第2名...),不依赖绝对分数
        """
        rrf_scores = {}   # chunk_id → RRF 分数
        content_map = {}  # chunk_id → content
        metadata_map = {} # chunk_id → metadata

        # 向量检索结果的 RRF 贡献
        # 先按 score 排序(去重:同一 chunk 被多个 query 检索到时取最高分)
        vec_dedup = {}
        for r in vector_results:
            cid = r["chunk_id"]
            if cid not in vec_dedup or r["score"] > vec_dedup[cid]["score"]:
                vec_dedup[cid] = r

        vec_sorted = sorted(vec_dedup.values(), key=lambda x: x["score"], reverse=True)
        for rank, r in enumerate(vec_sorted, 1):
            cid = r["chunk_id"]
            rrf_scores[cid] = rrf_scores.get(cid, 0) + self.vector_weight / (self.rrf_k + rank)
            content_map[cid] = r["content"]
            metadata_map[cid] = r.get("metadata", {})

        # BM25 结果的 RRF 贡献
        bm25_dedup = {}
        for r in bm25_results:
            cid = r["chunk_id"]
            if cid not in bm25_dedup or r["score"] > bm25_dedup[cid]["score"]:
                bm25_dedup[cid] = r

        bm25_sorted = sorted(bm25_dedup.values(), key=lambda x: x["score"], reverse=True)
        for rank, r in enumerate(bm25_sorted, 1):
            cid = r["chunk_id"]
            rrf_scores[cid] = rrf_scores.get(cid, 0) + self.keyword_weight / (self.rrf_k + rank)
            content_map.setdefault(cid, r["content"])

        # 按 RRF 分数排序
        sorted_ids = sorted(rrf_scores.keys(), key=lambda cid: rrf_scores[cid], reverse=True)

        return [
            {
                "chunk_id": cid,
                "content": content_map[cid],
                "score": round(rrf_scores[cid], 6),
                "metadata": metadata_map.get(cid, {}),
            }
            for cid in sorted_ids
        ]

七、Rerank 重排序

要解决什么问题

混合检索返回的 top-10 结果中,排名不一定准确。原因:

  • Embedding 模型是双编码器(bi-encoder),query 和 document 独立编码后算余弦相似度,无法捕捉 query 和 document 之间的细粒度交互
  • BM25 是词袋模型,完全不理解语义

Rerank 用交叉编码器(cross-encoder)对候选文档精排。交叉编码器把 query 和 document 拼在一起送入模型,能理解两者之间的细粒度语义关系,准确率远高于双编码器。

代价是 — 每个 query-document 对都要过一次模型。所以只对候选集(10~20 条)做 rerank,不能对全库做。

全库(10000+ chunks)
    │
    ▼ 混合检索(快,粗排)
候选集(10~20 条)
    │
    ▼ Rerank(慢,精排)
最终结果(3~5 条)

实现

class Reranker:
    """
    交叉编码器重排序。

    推荐模型:
    - bge-reranker-v2-m3(开源,中英文效果好,本地部署)
    - Cohere Rerank API(云端,效果最好,有免费额度)
    - bge-reranker-large(开源,中文专优)
    """

    def __init__(self, model_name: str = "BAAI/bge-reranker-v2-m3"):
        """
        初始化 Reranker。

        使用 HuggingFace 的 CrossEncoder。
        如果用 Cohere API,替换 rerank 方法即可。
        """
        from sentence_transformers import CrossEncoder
        self.model = CrossEncoder(model_name, max_length=512)

    def rerank(
        self,
        query: str,
        candidates: list[dict],
        top_k: int = 5,
        score_threshold: float = 0.3,
    ) -> list[dict]:
        """
        对候选文档重排序。

        参数:
        - query: 用户查询
        - candidates: 混合检索返回的候选列表
        - top_k: 最终返回的文档数
        - score_threshold: 低于此分数的文档丢弃(排除不相关的结果)

        返回: 重排后的 top_k 文档,每个文档新增 rerank_score 字段
        """
        if not candidates:
            return []

        # 构造 query-document 对
        # 注意:CrossEncoder 有 max_length 限制(默认 512 tokens)
        # query + document 超过 max_length 会被静默截断,导致信息丢失
        # 策略:优先保留 query 完整,截断 document 的末尾
        max_doc_chars = 1500  # 约 500 tokens,留 ~12 tokens 给 query
        pairs = []
        for c in candidates:
            doc_text = c["content"]
            if len(doc_text) > max_doc_chars:
                doc_text = doc_text[:max_doc_chars] + "..."
            pairs.append((query, doc_text))

        # 批量计算相关性分数
        scores = self.model.predict(pairs)

        # 将分数附加到候选文档上
        for i, score in enumerate(scores):
            candidates[i]["rerank_score"] = float(score)

        # 按 rerank_score 降序排列
        candidates.sort(key=lambda x: x["rerank_score"], reverse=True)

        # 过滤低分文档
        filtered = [c for c in candidates if c["rerank_score"] >= score_threshold]

        return filtered[:top_k]

Rerank 的效果提升有多大

以电商客服场景实测数据为例(非精确值,仅供参考):

检索方式Top-3 命中率Top-5 命中率
纯向量检索~65%~78%
混合检索(向量 + BM25)~75%~85%
混合检索 + Rerank~88%~93%

Rerank 的提升主要来自:把混合检索中"排在第 4~10 名的真正相关文档"提到前 3 名。

是否必须用 Rerank

场景是否需要理由
FAQ 检索(问题匹配)不一定FAQ 本身就是短文本,向量检索精度够高
产品手册检索推荐chunk 长度差异大,排序不稳定
政策文件检索推荐多个条款可能语义相近但只有一个真正适用
延迟敏感场景看情况本地部署 reranker ~50ms;API 调用 ~200ms

MMR 多样性去重

Rerank 解决了排序问题,但还有一个问题:返回的 5 条结果可能高度相似,浪费 context window

问题:
  检索"蓝牙耳机降噪"返回 5 条结果,其中 3 条来自同一章节,内容几乎重复:
  [1] "蓝牙耳机 > 降噪 > 参数说明""降噪深度 -38dB..."
  [2] "蓝牙耳机 > 降噪 > 功能介绍""本产品支持主动降噪,深度达到..."
  [3] "蓝牙耳机 > 降噪 > FAQ""降噪功能支持 -38dB..."
  → 这 3 条说的是同一件事,浪费了 context 空间

期望:
  [1] 降噪参数(-38dB,主动降噪)
  [2] 降噪使用教程(如何开启)
  [3] 降噪常见问题(不工作时怎么办)
  → 3 条覆盖不同方面,LLM 能给出更全面的回答

MMR(Maximum Marginal Relevance)在相关性和多样性之间做平衡:

import numpy as np


def mmr_rerank(
    query_embedding: list[float],
    candidates: list[dict],
    candidate_embeddings: list[list[float]],
    top_k: int = 5,
    lambda_param: float = 0.7,
) -> list[dict]:
    """
    MMR 多样性重排序。

    公式:MMR = λ * sim(query, doc) - (1-λ) * max(sim(doc, selected_doc))

    参数:
    - lambda_param: 0~1 之间
      → 1.0 = 纯相关性(退化为普通排序)
      → 0.0 = 纯多样性(选最不一样的)
      → 0.7 = 生产推荐值(偏重相关性,适度多样)
    """
    if not candidates:
        return []

    query_emb = np.array(query_embedding)
    doc_embs = np.array(candidate_embeddings)

    # 计算 query 和每个 doc 的相似度
    query_sims = np.dot(doc_embs, query_emb) / (
        np.linalg.norm(doc_embs, axis=1) * np.linalg.norm(query_emb) + 1e-8
    )

    selected_indices = []
    remaining = list(range(len(candidates)))

    for _ in range(min(top_k, len(candidates))):
        if not remaining:
            break

        mmr_scores = []
        for idx in remaining:
            relevance = query_sims[idx]

            # 计算和已选文档的最大相似度
            if selected_indices:
                selected_embs = doc_embs[selected_indices]
                doc_sims = np.dot(selected_embs, doc_embs[idx]) / (
                    np.linalg.norm(selected_embs, axis=1) * np.linalg.norm(doc_embs[idx]) + 1e-8
                )
                max_sim_to_selected = np.max(doc_sims)
            else:
                max_sim_to_selected = 0

            # MMR 分数 = 相关性 - 冗余度
            mmr = lambda_param * relevance - (1 - lambda_param) * max_sim_to_selected
            mmr_scores.append((idx, mmr))

        # 选 MMR 分数最高的
        best_idx = max(mmr_scores, key=lambda x: x[1])[0]
        selected_indices.append(best_idx)
        remaining.remove(best_idx)

    return [candidates[i] for i in selected_indices]

生产中的简化替代方案:如果不想引入 MMR 的复杂度,可以用规则去重 — 同一个 doc_idheading_path 最多保留 2 条:

def deduplicate_by_source(candidates: list[dict], max_per_source: int = 2) -> list[dict]:
    """简单的来源去重:同一章节最多保留 max_per_source 条"""
    source_count = {}
    result = []
    for c in candidates:
        source = c.get("metadata", {}).get("heading_path", c.get("chunk_id", ""))
        source_count[source] = source_count.get(source, 0) + 1
        if source_count[source] <= max_per_source:
            result.append(c)
    return result

八、两层缓存架构

要解决什么问题

电商客服场景中,大量用户问的是语义相同但措辞不同的问题:

"怎么办理会员?"      ─┐
"会员办理的流程"      ├── 同一个问题,3 种说法
"我想开通会员怎么弄"  ─┘

如果每次都走完 向量检索 → BM25 → RRF 融合 → Rerank → LLM 生成 全链路,重复的计算浪费大量时间和成本。

两层缓存的设计

用户提问
    │
    ▼
┌──────────────────────────────────────────────────┐
│  第二层缓存:LLM 回答缓存(第十章实现)            │
│  query 语义匹配 → 命中 → 直接返回最终回答          │
│  跳过:向量检索 + Rerank + Prompt 组装 + LLM 生成  │
│  省时:~1000ms    省钱:省 LLM 调用费              │
│                                                    │
│  未命中 ↓                                          │
├──────────────────────────────────────────────────┤
│  第一层缓存:检索结果缓存(本章实现)               │
│  query 语义匹配 → 命中 → 返回缓存的 Rerank 结果    │
│  跳过:向量检索 + BM25 + RRF + Rerank              │
│  省时:~200ms     仍需走 Prompt 组装 + LLM          │
│                                                    │
│  未命中 ↓                                          │
├──────────────────────────────────────────────────┤
│  完整检索链路                                       │
│  向量检索 → BM25 → RRF → Rerank → 写入缓存        │
└──────────────────────────────────────────────────┘
场景无缓存只有检索缓存两层都有
"蓝牙耳机降噪好吗"(第 2 个人问)检索 150ms + LLM 800ms检索 0ms + LLM 800ms直接返回 0ms
"我的订单到哪了"检索 150ms + LLM 800ms同左同左(不可缓存)
知识类请求成本100%~70%~20%

缓存 key 的核心问题:不同措辞怎么命中同一份缓存

用文本哈希做 key 是不行的

"怎么办理会员?"   → MD5 → "a3f8c1..."  → 查 Redis → 命中
"会员办理的流程"  → MD5 → "b7d2e9..."  → 查 Redis → 未命中 ❌

→ 同一个问题,换了个说法就命不中,缓存形同虚设

正确做法:用 Embedding 向量做语义匹配

"怎么办理会员?"   → Bi-Encoder → 向量 [0.12, 0.34, ...]  ──┐
                                                             ├── 余弦相似度 = 0.97 → 命中 ✅
"会员办理的流程"  → Bi-Encoder → 向量 [0.11, 0.33, ...]  ──┘

→ 语义相近的 query,向量也相近,可以共享缓存

检索结果缓存实现

import time
import numpy as np


class RetrievalCache:
    """
    基于语义相似度的检索结果缓存。

    不是用文本哈希做 key,而是用 embedding 向量做语义匹配。
    "怎么办理会员" 和 "会员办理的流程" 的 embedding 相似度 > 0.95,
    可以共享同一份缓存结果。

    缓存的是什么:
    → Rerank 之后的 top-K chunk 列表(已经过精排、过滤、去重的最终结果)
    → 不是原始的向量检索结果(那些还没排序,缓存价值低)

    不缓存的情况:
    → 实时数据类意图(物流查询、库存查询)→ 答案随时变化
    → 有订单号等用户特定实体的请求 → 每个用户不同
    → 空结果 → 缓存空结果会导致后续用户也拿不到答案
    """

    def __init__(
        self,
        embedding_service,
        similarity_threshold: float = 0.95,
        ttl_seconds: int = 1800,         # 30 分钟过期
        max_entries: int = 5000,
    ):
        self.embedding_service = embedding_service
        self.threshold = similarity_threshold
        self.ttl = ttl_seconds
        self.max_entries = max_entries
        # 生产环境用 Redis + 向量搜索(Redis Stack / RediSearch)
        # 这里用内存演示核心逻辑
        self.entries: list[dict] = []

    def get(self, query: str, intent: str) -> list[dict] | None:
        """
        查缓存。

        注意:query 的 embedding 无论缓存是否命中都要算,
        因为即使缓存未命中,后续向量检索也需要这个 embedding。
        所以缓存查询不会带来额外的 embedding 计算成本。
        """
        if not self._is_cacheable_intent(intent):
            return None

        if not self.entries:
            return None

        query_emb = np.array(self.embedding_service.embed_query(query))
        now = time.time()

        best_score = 0
        best_results = None

        for entry in self.entries:
            # 过期检查
            if now - entry["created_at"] > self.ttl:
                continue
            # 意图必须匹配(退款和会员不能共享缓存)
            if entry["intent"] != intent:
                continue

            cached_emb = np.array(entry["embedding"])
            score = np.dot(query_emb, cached_emb) / (
                np.linalg.norm(query_emb) * np.linalg.norm(cached_emb) + 1e-8
            )

            if score > best_score:
                best_score = score
                best_results = entry["results"]

        if best_score >= self.threshold:
            return best_results
        return None

    def put(self, query: str, intent: str, results: list[dict]):
        """写缓存(Rerank 之后调用)"""
        if not self._is_cacheable_intent(intent):
            return
        if not results:
            return  # 空结果不缓存

        query_emb = self.embedding_service.embed_query(query)
        self.entries.append({
            "embedding": query_emb,
            "intent": intent,
            "results": results,
            "created_at": time.time(),
        })

        # LRU 淘汰
        if len(self.entries) > self.max_entries:
            self.entries = self.entries[-self.max_entries:]

    def invalidate_all(self):
        """知识库更新后清空全部缓存"""
        count = len(self.entries)
        self.entries = []
        return count

    def invalidate_by_intent(self, intent: str):
        """按意图清空(如退款政策更新后只清退款相关缓存)"""
        before = len(self.entries)
        self.entries = [e for e in self.entries if e["intent"] != intent]
        return before - len(self.entries)

    def clear_expired(self):
        """定时清理过期条目"""
        now = time.time()
        before = len(self.entries)
        self.entries = [e for e in self.entries if now - e["created_at"] <= self.ttl]
        return before - len(self.entries)

    def _is_cacheable_intent(self, intent: str) -> bool:
        """
        判断该意图是否可以使用缓存。

        不可缓存的意图(涉及实时数据或用户特定操作):
        """
        non_cacheable = {
            "售后服务/物流查询",   # 物流状态实时变化
            "售后服务/退款",       # 操作类,每次不同
            "售后服务/换货",       # 操作类
            "售前咨询/库存查询",   # 库存实时变化
        }
        return intent not in non_cacheable

轻量替代方案:意图 + 实体组合做 key

如果不想引入向量匹配的复杂度,可以利用前面章节已经做好的意图识别和实体提取:

def build_simple_cache_key(state: dict) -> str:
    """
    用意图 + 实体组合做缓存 key(轻量方案)。

    "怎么办理会员?"   → 意图: 会员服务/办理 + 实体: {} → key = "会员服务/办理:"
    "会员办理的流程"  → 意图: 会员服务/办理 + 实体: {} → key = "会员服务/办理:"
    → 同一个 key ✅ 命中

    优点:简单,不需要向量计算
    缺点:实体稍有差异就命不中("蓝牙耳机" vs "耳机")
    适用:意图体系清晰、实体标准化做得好的场景
    """
    intent = f"{state.get('l1_intent', '')}/{state.get('l2_intent', '')}"
    slots = state.get("filled_slots", {})
    slot_str = ":".join(f"{k}={v}" for k, v in sorted(slots.items()) if v)
    return f"rag_cache:{intent}:{slot_str}"

两种 key 方案对比

测试 case向量语义匹配意图+实体组合
"怎么办理会员" vs "会员办理流程"✅ 命中(向量相似度 0.97)✅ 命中(意图相同,无实体)
"蓝牙耳机多少钱" vs "耳机价格"✅ 命中(向量相似度 0.96)❌ 未命中("蓝牙耳机" ≠ "耳机")
"蓝牙耳机降噪" vs "退款政策"❌ 未命中(不相关)❌ 未命中(意图不同)
实现复杂度高(需向量计算+遍历匹配)低(字符串拼接+Redis GET)
额外延迟0ms(embedding 本来就要算)0ms
推荐场景生产环境快速验证 / 实体标准化做得好时

九、在线查询全流程:从用户 Query 到最终输出

设计思路:缓存优先、按需改写

核心目标是能省则省 — 如果原始 query 就能命中缓存,就没必要花钱调 LLM 做改写。只有缓存未命中时才启动 LLM 改写,改写后再尝试一次缓存匹配,仍然未命中才进入完整的深度检索链路。

为什么不每次都先改写?
─────────────────────────────────────
                        每次都改写(方案 A)     先查缓存再改写(方案 B)
─────────────────────────────────────
缓存命中时的 LLM 调用     1 次(改写)            0 次 ✅
缓存命中时的延迟          ~200ms(改写)+ 查缓存   ~15ms(embedding + 查缓存)✅
缓存未命中时的 LLM 调用   1 次(改写)            1 次(改写)
缓存命中率               略高(改写后更规范)      略低(原始 query 匹配)
─────────────────────────────────────
结论:高并发场景下,方案 B 的性价比更高
     缓存命中率 60% 时,方案 B 能省掉 60% 的 LLM 改写调用

全流程架构图

用户原始 Query + 前六章的意图识别结果
    │
    ▼
╔══════════════════════════════════════════════════════════════╗
║  阶段零:RAG 准入判断                                         ║
║                                                              ║
║  并非所有 query 都需要走 RAG 检索。                            ║
║  根据意图识别结果和 query 特征,决定是否进入检索流程。          ║
║                                                              ║
║  跳过 RAG 的情况:                                            ║
║  · 闲聊意图("你好""谢谢")→ 直接走 LLM 生成               ║
║  · 纯工具调用意图("查一下我的订单")→ 走 API/工具调用         ║
║  · 已被安全护栏拦截 → 不应到达此处                             ║
║  · 纯数学/计算类问题 → LLM 直接推理或调用计算工具              ║
║  · 上下文足够的多轮追问("上面那个再详细说说")                ║
║    → 对话历史中已有知识上下文,无需重复检索                     ║
║                                                              ║
║  进入 RAG 的情况:                                            ║
║  · 知识咨询类(功能咨询、价格咨询、对比推荐)                  ║
║  · 政策查询类(退款政策、换货条件、保修范围)                   ║
║  · 故障排查类(功能异常、使用问题)                             ║
║  · 混合意图中包含知识需求的子意图                               ║
╚══════════════════════════════════════════════════════════════╝
    │                          │
  需要 RAG ✅              不需要 RAG ❌
    │                          │
    │                          ▼
    │                   ┌──────────────────────────┐
    │                   │ 跳过全部检索阶段           │
    │                   │ knowledge_context = ""    │
    │                   │ 直接进入 Prompt 拼接       │
    │                   │ 或路由到工具调用节点        │
    │                   └──────────────────────────┘
    │                          │
    ▼                          │
╔══════════════════════════════════════════════════════════════╗
║  阶段一:原始 Query Embedding 向量化                          ║
║                                                              ║
║  直接将用户原始 query 转化为向量                               ║
║  此向量用于第一次缓存匹配                                     ║
║  成本极低:Embedding 调用约 10~30ms,$0.00002/次              ║
╚══════════════════════════════════════════════════════════════╝
    │
    │  原始 query embedding 向量
    ▼
╔══════════════════════════════════════════════════════════════╗
║  阶段二:Redis 语义缓存查找(第一次)                          ║
║                                                              ║
║  用原始 query 向量在 Redis 中做余弦相似度匹配                  ║
║  同时校验意图一致(退款和会员不能共享缓存)                     ║
║  阈值:相似度 ≥ 0.95 视为命中                                 ║
╚══════════════════════════════════════════════════════════════╝
    │                          │
    │                          │
  命中 ✅                    未命中 ❌
    │                          │
    │                          ▼
    │               ╔══════════════════════════════════════╗
    │               ║  阶段三:LLM Query 改写               ║
    │               ║                                      ║
    │               ║  原始 query 没命中缓存,说明可能       ║
    │               ║  太口语化或太模糊,需要 LLM 改写       ║
    │               ║                                      ║
    │               ║  处理:补全省略信息、去口语化、         ║
    │               ║  修正错别字、消除指代歧义               ║
    │               ║                                      ║
    │               ║  "会员咋弄啊"                         ║
    │               ║  → "如何办理会员?办理流程是什么?"    ║
    │               ╚══════════════════════════════════════╝
    │                          │
    │                          │  改写后的 Query
    │                          ▼
    │               ╔══════════════════════════════════════╗
    │               ║  阶段四:改写后 Query Embedding 向量化 ║
    │               ║                                      ║
    │               ║  将改写后的 query 转化为新的向量       ║
    │               ╚══════════════════════════════════════╝
    │                          │
    │                          │  改写后 query embedding
    │                          ▼
    │               ╔══════════════════════════════════════╗
    │               ║  阶段五:Redis 语义缓存查找(第二次)  ║
    │               ║                                      ║
    │               ║  用改写后的向量再查一次缓存            ║
    │               ║  改写后的 query 更规范,               ║
    │               ║  可能命中之前其他用户改写后写入的缓存  ║
    │               ╚══════════════════════════════════════╝
    │                          │                │
    │                        命中 ✅          未命中 ❌
    │                          │                │
    ▼                          ▼                ▼
┌──────────────────────┐                ╔══════════════════════════════╗
│ 取出缓存的 Rerank 结果 │                ║  阶段六:深度检索策略          ║
│                      │                ║                              ║
│ 跳过后续检索阶段       │                ║  根据 query 特征选择策略:     ║
│ 直接进入 Prompt 拼接   │                ║  · HyDE                      ║
│                      │                ║  · Query Decomposition       ║
│                      │                ║  · Step-back Prompting       ║
│                      │                ║  · 混合检索(向量 + BM25)     ║
│                      │                ║                              ║
│                      │                ║  详见下方"策略选择与路由"       ║
└──────────────────────┘                ╚══════════════════════════════╝
    │                                           │
    │                                           │  候选 chunks(未排序)
    │                                           ▼
    │                                   ╔══════════════════════════════╗
    │                                   ║  阶段七:Rerank 重排序        ║
    │                                   ║                              ║
    │                                   ║  用交叉编码器精排              ║
    │                                   ║  (bge-reranker-v2-m3)       ║
    │                                   ║  取 top-K 结果               ║
    │                                   ╚══════════════════════════════╝
    │                                           │
    │                                           │  排序后的 top-K chunks
    │                                           ▼
    │                                   ╔══════════════════════════════╗
    │                                   ║  阶段八:写入 Redis 缓存      ║
    │                                   ║                              ║
    │                                   ║  key:  改写后 query embedding ║
    │                                   ║  value: Rerank 后的 chunks   ║
    │                                   ║  TTL:  30 分钟(可配置)      ║
    │                                   ║  意图:  辅助匹配字段          ║
    │                                   ╚══════════════════════════════╝
    │                                           │
    ▼                                           ▼
╔══════════════════════════════════════════════════════════════╗
║  阶段九:Prompt 拼接 + LLM 生成最终回答                       ║
║                                                              ║
║  将 Rerank 后的 chunks 作为 context 嵌入 Prompt               ║
║  结合意图、实体、对话历史等信息                                 ║
║  调用业务 LLM 生成最终回答                                     ║
╚══════════════════════════════════════════════════════════════╝
    │
    ▼
  输出给用户

三条路径对比

路径 ⓪(直接跳过): 准入判断 → 不需要 RAG → 跳过全部检索 → Prompt + LLM / 工具调用
                    省掉:embedding + 缓存 + 改写 + 检索 + Rerank,全部省掉
                    耗时:~0ms(准入判断是纯规则,无计算开销)

路径 A(最快): 准入判断 → 需要 RAG → embedding → 缓存命中 → Prompt + LLM
               省掉:LLM 改写 + 深度检索 + Rerank
               耗时:~15ms(到拿到 chunks)

路径 B(中等): 准入判断 → 需要 RAG → 缓存未命中 → LLM 改写 → 再次 embedding
               → 缓存命中 → Prompt + LLM
               省掉:深度检索 + Rerank
               耗时:~200ms(到拿到 chunks)

路径 C(完整): 准入判断 → 需要 RAG → 缓存未命中 → LLM 改写 → 缓存仍未命中
               → 深度检索 → Rerank → 写缓存 → Prompt + LLM
               耗时:~500ms(到拿到 chunks)

阶段零详解:RAG 准入判断

这是整个流程的守门员,在任何 embedding 或检索操作之前,先判断这个 query 到底需不需要走 RAG。判断依据完全来自前六章已有的意图识别结果,不需要额外的 LLM 调用,零成本。

为什么需要准入判断

没有准入判断时的问题:
─────────────────────────────────────
用户: "你好"                    → embedding → 缓存查找 → 检索 → 全白费
用户: "帮我查一下订单12345"      → embedding → 缓存查找 → 检索 → 找不到(答案在 API 里)
用户: "1+1等于多少"             → embedding → 缓存查找 → 检索 → 找不到(LLM 直接能答)
用户: "刚才那个再详细说说"       → embedding → 缓存查找 → 检索 → 重复检索(上轮已有知识)

每次白跑一趟:
  - 浪费 embedding 计算(10~30ms)
  - 浪费 Redis 查找(1~5ms)
  - 如果缓存未命中还浪费 LLM 改写 + 检索 + Rerank(300~600ms)
  - 更重要的是:检索到不相关的内容塞进 Prompt,反而干扰 LLM 回答质量

怎么判断一个 query 该走 RAG 还是走 API?

这个判断不是第八章自己在做的,而是前面几章已经给出了全部依据,第八章只是读取这些现成信息做分流:

用户: "帮我查一下订单 ORD-456 到哪了"
         │
         ▼
┌─ 第三章:意图分类 ────────────────────────────┐
│  模型输出: L1 = "售后服务", L2 = "物流查询"     │
│  confidence = 0.95                            │
│  → 系统已经知道用户想"查物流"                   │
└───────────────────────────────────────────────┘
         │
         ▼
┌─ 第四章:实体提取与槽位填充 ──────────────────────┐
│  filled_slots = { "order_id": "ORD-456" }       │
│  → 系统已经知道要查的是哪个订单                   │
└───────────────────────────────────────────────┘
         │
         ▼
┌─ 第九章:工具注册表(tools.yaml)────────────────┐
│  intent_tool_map:                                │
│    "售后服务/物流查询":                            │
│      required: [query_logistics]  ← 有工具映射    │
│      action: null                                │
│                                                  │
│  "售前咨询/功能咨询":                              │
│      required: []                 ← 无工具映射    │
│      action: null                                │
│                                                  │
│  → 有 required 工具的走工具调用                    │
│  → 没有 required 工具的走 RAG                     │
└───────────────────────────────────────────────┘
         │
         ▼
┌─ 第八章:RAG 准入判断(阶段零)─────────────────────┐
│  读取工具注册表:                                    │
│    "售后服务/物流查询" 有 required 工具              │
│    → need_rag = False, skip_to = "tool_call"       │
│                                                    │
│  如果是 "售前咨询/功能咨询":                        │
│    没有 required 工具                               │
│    → need_rag = True, 进入 RAG 检索流程             │
└───────────────────────────────────────────────┘

核心思路:第九章的 intent_tool_map 是唯一的数据源(Single Source of Truth),第八章直接读取它,不维护自己的映射表。

实现

class RAGGatekeeper:
    """
    RAG 准入判断器。

    根据前六章的意图识别结果 + 第九章的工具注册表,决定当前请求是否走 RAG。
    完全基于规则,不需要 LLM 调用,零成本零延迟。

    判断逻辑的数据来源:
    ┌──────────────────┬──────────────────────────────┐
    │ 判断依据          │ 来自哪一章                     │
    ├──────────────────┼──────────────────────────────┤
    │ 意图分类结果       │ 第三章(意图分类)             │
    │ 槽位/实体信息     │ 第四章(实体提取与槽位填充)    │
    │ 意图置信度        │ 第六章(置信度决策路由)        │
    │ 意图→工具映射     │ 第九章(工具注册表 tools.yaml) │
    │ 对话历史/上轮上下文 │ 第一章(对话历史管理)         │
    └──────────────────┴──────────────────────────────┘
    """

    def __init__(self, tool_registry: "ToolRegistry"):
        """
        接收第九章的工具注册表实例。

        不再硬编码 RAG_SKIP_INTENTS = {"物流查询": "tool_call", ...}
        而是直接查工具注册表:有工具的走工具,没工具的走 RAG。
        新增工具时只改 tools.yaml,这里自动生效。
        """
        self.tool_registry = tool_registry

    def should_retrieve(self, state: dict) -> dict:
        """
        判断当前请求是否需要走 RAG 检索。

        Returns:
            {
                "need_rag": True/False,
                "reason": "判断原因(调试用)",
                "skip_to": None / "llm_direct" / "tool_call",
                    # None = 走 RAG
                    # "llm_direct" = 跳过 RAG,LLM 直接回答
                    # "tool_call" = 跳过 RAG,走工具调用节点
            }
        """
        l1 = state.get("l1_intent", "")
        l2 = state.get("l2_intent", "")
        intent_key = f"{l1}/{l2}"
        confidence = state.get("confidence", 0)

        # ── 检查一:已被安全护栏拦截 ──
        if state.get("is_blocked"):
            return {
                "need_rag": False,
                "reason": "已被安全护栏拦截",
                "skip_to": "blocked",
            }

        # ── 检查二:闲聊意图 ──
        if l1 == "闲聊":
            return {
                "need_rag": False,
                "reason": "闲聊意图,LLM 直接回答",
                "skip_to": "llm_direct",
            }

        # ── 检查三:查工具注册表,判断是否为工具调用类意图 ──
        # 直接读取第九章的 intent_tool_map,不维护自己的映射
        tool_mapping = self.tool_registry.get_tools_for_intent(l1, l2)

        if tool_mapping and tool_mapping.required:
            # 该意图有必须调用的工具
            if tool_mapping.action:
                # 有 action(退款、换货等操作类)→ 纯工具调用,不需要 RAG
                return {
                    "need_rag": False,
                    "reason": f"意图 {intent_key} 是操作类意图,"
                              f"需要调用工具 {tool_mapping.required} "
                              f"并执行 {tool_mapping.action}",
                    "skip_to": "tool_call",
                }
            else:
                # 纯查询类工具(物流查询、库存查询)→ 也不需要 RAG
                # 答案在 API 的实时数据里,不在知识库的静态文档里
                return {
                    "need_rag": False,
                    "reason": f"意图 {intent_key} 是查询类意图,"
                              f"需要调用工具 {tool_mapping.required}",
                    "skip_to": "tool_call",
                }

        # ── 检查四:上下文充足的多轮追问 ──
        # 如果对话历史中已有知识上下文,且用户只是追问("详细说说"、"还有呢")
        # 则无需重复检索,复用上一轮的知识上下文
        if self._is_followup_with_context(state):
            return {
                "need_rag": False,
                "reason": "多轮追问,对话历史中已有知识上下文,无需重复检索",
                "skip_to": "llm_direct",
            }

        # ── 检查五:低置信度兜底 ──
        # 意图不明确时,走 RAG 检索兜底(比 LLM 直接回答更安全)
        if confidence < 0.5:
            return {
                "need_rag": True,
                "reason": f"意图置信度 {confidence:.2f} 过低,走 RAG 兜底",
                "skip_to": None,
            }

        # ── 默认:走 RAG ──
        # 走到这里的意图:
        #   - 工具注册表里没有对应工具(或 required 为空)
        #   - 不是闲聊
        #   - 不是多轮追问
        # 说明答案大概率在知识库里,走 RAG 检索
        return {
            "need_rag": True,
            "reason": f"意图 {intent_key} 无对应工具映射,走 RAG 检索",
            "skip_to": None,
        }

    def _is_followup_with_context(self, state: dict) -> bool:
        """
        判断是否为"上下文充足的多轮追问"。

        条件(同时满足):
        1. 上一轮 state 中有非空的 knowledge_context
        2. 当前 query 是追问性质(短句 + 追问关键词)
        3. 当前意图与上一轮意图相同

        示例:
        - 上轮: "蓝牙耳机降噪怎么样" → 检索了降噪相关知识
        - 本轮: "续航呢" → 追问,但话题变了(降噪→续航),需要重新检索
        - 本轮: "再详细说说" → 追问,话题没变,复用上轮知识
        """
        prev_context = state.get("knowledge_context", "")
        if not prev_context:
            return False

        query = state.get("processed_input", "")
        followup_markers = [
            "详细说说", "再说说", "具体说说", "展开说说",
            "还有呢", "还有吗", "其他呢", "继续",
            "什么意思", "怎么理解", "能解释一下吗",
        ]

        # 短句 + 追问关键词 → 大概率是追问
        is_short = len(query) < 15
        has_marker = any(m in query for m in followup_markers)

        if not (is_short and has_marker):
            return False

        # 追问的意图应该和上一轮一致
        prev_intent = state.get("prev_l1_intent", "")
        curr_intent = state.get("l1_intent", "")

        return prev_intent == curr_intent and prev_intent != ""

为什么不硬编码两份映射

反面示例(之前的写法,有维护隐患):
─────────────────────────────────────

第八章 RAGGatekeeper 里硬编码:
    RAG_SKIP_INTENTS = {
        "售后服务/物流查询": "tool_call",
        "售后服务/退款": "tool_call",
        ...
    }

第九章 tools.yaml 里也配了:
    intent_tool_map:
      "售后服务/物流查询":
        required: [query_logistics]
      "售后服务/退款":
        required: [query_order]
        action: submit_refund

问题:
  1. 新增一个"售后服务/催单"意图 + 对应工具
      改了 tools.yaml,忘了改 RAGGatekeeper  催单请求白跑一趟 RAG
  2. 下线一个工具(比如库存查询 API 下线)
      改了 tools.yaml,忘了改 RAGGatekeeper  请求被路由到不存在的工具
  3. 两份配置不一致时,debug 很痛苦

─────────────────────────────────────

正确做法(现在的写法):
─────────────────────────────────────

只维护一份配置:第九章 tools.yaml 的 intent_tool_map
第八章直接读取:self.tool_registry.get_tools_for_intent(l1, l2)

  有 required 工具  走工具调用
  没有 required 工具 RAG

新增/下线工具只改 tools.yaml 一处,全链路自动生效

完整判断链路示意

         第九章 tools.yaml(唯一数据源)
         ┌────────────────────────────────────┐
          intent_tool_map:                    
            "售后服务/物流查询":               
              required: [query_logistics] ──────── 有工具
            "售后服务/退款":                   
              required: [query_order]         
              action: submit_refund ────────────── 有工具 + 有操作
            "售前咨询/功能咨询":               
              required: [] ─────────────────────── 无工具
            "售前咨询/库存查询":               
              required: [query_inventory] ──────── 有工具
         └────────────────────────────────────┘
                       
                        RAGGatekeeper 读取
                       
         ┌────────────────────────────────────┐
         required 工具?                   
         + 有 action  工具调用(操作类) 
         + 无 action  工具调用(查询类) 
         RAG 检索                   
         └────────────────────────────────────┘

准入判断结果汇总

query 示例意图工具注册表是否走 RAG路由去向原因
"你好"闲聊LLM 直接回答闲聊不需要知识
"帮我查订单 ORD-456 到哪了"售后/物流查询required: [query_logistics]工具调用有工具,查实时物流 API
"我要退款"售后/退款required: [query_order], action: submit_refund工具调用有工具+操作,走退款流程
"蓝牙耳机还有货吗"售前/库存查询required: [query_inventory]工具调用有工具,查实时库存 API
"蓝牙耳机支持降噪吗"售前/功能咨询required: []RAG 检索无工具,查产品手册
"退款政策是什么"售后/退款政策咨询required: []RAG 检索无工具,查政策文档
"耳机坏了怎么修"售后/维修required: []RAG 检索无工具,查维修手册
"(上轮已检索)再详细说说"售前/功能咨询required: []LLM 直接回答多轮追问,复用上轮知识
"(意图不明)这个怎么弄"低置信度RAG 兜底不确定时走 RAG 更安全

阶段三详解:LLM Query 改写

只在第一次缓存未命中时才触发改写,避免每次请求都调 LLM。

QUERY_REWRITE_PROMPT = """你是一个 Query 改写助手。请将用户的口语化问题改写为规范、完整的检索 query。

改写规则:
1. 补全省略的信息(根据上下文推断)
2. 去除口语化表达("咋"→"怎么"、"咋回事"→"什么原因")
3. 修正明显的错别字
4. 消除指代不明的代词(如果有对话历史,将"它"/"那个"替换为具体指代)
5. 保持原始语义不变,不要添加用户没提到的内容
6. 输出一个改写后的 query,不要输出解释

用户问题:{question}
对话历史(最近2轮):{history}

改写后的 query:"""


class QueryRewriter:
    """
    Query 改写器。

    触发时机:仅在原始 query 的缓存未命中时才调用。
    设计目的:
    - 补全信息、去口语化、修正错别字
    - 改写后的 query 更规范,用于第二次缓存查找和后续深度检索
    - "会员咋弄" 和 "怎么办会员" 改写后都变成 "如何办理会员"

    成本考量:
    - 只在缓存未命中时调用,缓存命中率 60% 时可省掉 60% 的调用
    - 轻量 LLM(Haiku / GPT-4o-mini),延迟约 100~300ms,成本约 $0.0001/次
    """

    def __init__(self, llm):
        self.llm = llm  # 轻量模型:Claude Haiku / GPT-4o-mini

    def rewrite(self, query: str, history: str = "") -> str:
        prompt = QUERY_REWRITE_PROMPT.format(
            question=query,
            history=history or "无",
        )
        response = self.llm.invoke([{"role": "user", "content": prompt}])
        rewritten = response.content.strip()
        # 如果改写结果为空或异常,回退到原始 query
        return rewritten if len(rewritten) > 2 else query

阶段六详解:深度检索策略选择与路由

两次缓存都未命中时,不应盲目地把所有检索策略都跑一遍。不同的 query 适合不同的策略,全部都跑会导致延迟过高(每种策略可能增加 200~500ms 的 LLM 调用)。

策略路由器

class RetrievalStrategyRouter:
    """
    根据 query 特征和意图,选择最合适的深度检索策略。

    设计原则:
    - 每次最多选择 2 种策略,避免延迟爆炸
    - 混合检索(向量 + BM25)是必选的基础策略
    - 在此基础上根据 query 特征叠加 1 种增强策略
    """

    def select_strategy(self, state: dict) -> dict:
        """
        返回检索策略配置。

        Returns:
            {
                "base": "hybrid",                      # 基础策略(始终开启)
                "enhancement": "hyde" | "decomposition" | "step_back" | None,
                "reason": "选择原因(调试用)",
            }
        """
        query = state.get("processed_input", "")
        confidence = state.get("confidence", 1.0)
        l2_intent = state.get("l2_intent", "")
        entities = state.get("filled_slots", {})

        # ── 规则一:低置信度 → HyDE ──
        # 意图识别不确定时,说明 query 可能模糊或罕见
        # HyDE 生成的假设性答案能弥补 query 本身信息不足的问题
        if confidence < 0.6:
            return {
                "base": "hybrid",
                "enhancement": "hyde",
                "reason": f"置信度 {confidence:.2f} < 0.6,query 可能模糊",
            }

        # ── 规则二:复杂/多意图问题 → Query Decomposition ──
        # 检测并列连词、多个问号等复杂问题特征
        complexity_markers = ["和", "还是", "对比", "区别", "以及", "另外", "同时"]
        question_count = query.count("?") + query.count("?")
        has_complexity = any(m in query for m in complexity_markers)

        if question_count >= 2 or has_complexity:
            return {
                "base": "hybrid",
                "enhancement": "decomposition",
                "reason": f"检测到复杂问题特征(问号数={question_count},并列词={has_complexity})",
            }

        # ── 规则三:政策/规则类咨询 + 有具体实体 → Step-back ──
        # 用户问的是具体场景,但答案在更上层的政策文档中
        policy_intents = {"退款", "换货", "保修", "维修", "投诉"}
        if any(pi in l2_intent for pi in policy_intents) and entities.get("order_id"):
            return {
                "base": "hybrid",
                "enhancement": "step_back",
                "reason": f"政策类意图 + 具体订单号,需要抽象到政策层面检索",
            }

        # ── 默认:仅混合检索 ──
        # query 清晰、意图明确时,混合检索本身已经足够
        return {
            "base": "hybrid",
            "enhancement": None,
            "reason": "query 清晰,混合检索即可",
        }

策略执行器

class DeepRetriever:
    """
    执行深度检索策略。

    调用顺序:
    1. 策略路由器选择策略
    2. 本类执行选定的策略,返回候选 chunks
    3. 候选 chunks 送入 Rerank 精排
    """

    def __init__(self, hybrid_retriever, embedding_service, llm):
        self.hybrid_retriever = hybrid_retriever  # 混合检索器(向量 + BM25)
        self.embedding_service = embedding_service
        self.llm = llm  # 轻量 LLM,用于 HyDE / Decomposition

    def retrieve(self, query: str, state: dict, strategy: dict) -> list[dict]:
        """
        根据策略执行检索,返回候选 chunks。
        """
        all_chunks = []

        # ── 基础策略:混合检索(始终执行)──
        base_chunks = self.hybrid_retriever.search(
            query=query,
            top_k=strategy.get("top_k", 10),
            filters=strategy.get("filters"),
        )
        all_chunks.extend(base_chunks)

        # ── 增强策略 ──
        enhancement = strategy.get("enhancement")

        if enhancement == "hyde":
            hyde_chunks = self._hyde_retrieve(query, strategy)
            all_chunks.extend(hyde_chunks)

        elif enhancement == "decomposition":
            decomp_chunks = self._decomposition_retrieve(query, strategy)
            all_chunks.extend(decomp_chunks)

        elif enhancement == "step_back":
            stepback_chunks = self._stepback_retrieve(query, state, strategy)
            all_chunks.extend(stepback_chunks)

        # 去重(同一个 chunk_id 只保留分数最高的)
        return self._deduplicate(all_chunks)

    def _hyde_retrieve(self, query: str, strategy: dict) -> list[dict]:
        """HyDE: 生成假设性答案 → embedding → 检索"""
        hypothetical_answer = generate_hyde_query(query, self.llm)
        return self.hybrid_retriever.search(
            query=hypothetical_answer,
            top_k=strategy.get("top_k", 10),
            filters=strategy.get("filters"),
        )

    def _decomposition_retrieve(self, query: str, strategy: dict) -> list[dict]:
        """Query Decomposition: 拆分子问题 → 分别检索 → 合并"""
        sub_queries = decompose_query(query, self.llm)
        all_chunks = []
        for sub_q in sub_queries:
            chunks = self.hybrid_retriever.search(
                query=sub_q,
                top_k=strategy.get("top_k", 5),  # 每个子问题少取几条
                filters=strategy.get("filters"),
            )
            all_chunks.extend(chunks)
        return all_chunks

    def _stepback_retrieve(self, query: str, state: dict, strategy: dict) -> list[dict]:
        """Step-back: 抽象化问题 → 检索更上层的文档"""
        entities = state.get("filled_slots", {})
        abstract_query = step_back_query(query, entities)
        return self.hybrid_retriever.search(
            query=abstract_query,
            top_k=strategy.get("top_k", 10),
            filters=strategy.get("filters"),
        )

    def _deduplicate(self, chunks: list[dict]) -> list[dict]:
        """按 chunk_id 去重,保留分数最高的"""
        seen = {}
        for chunk in chunks:
            cid = chunk.get("chunk_id", id(chunk))
            if cid not in seen or chunk.get("score", 0) > seen[cid].get("score", 0):
                seen[cid] = chunk
        return list(seen.values())

阶段八详解:缓存写入策略

缓存的不是 LLM 的最终回答,而是 Rerank 之后的 top-K chunks。这样设计的原因:

为什么缓存 Rerank 结果而不是 LLM 回答?
─────────────────────────────────────
                        缓存 Rerank 结果          缓存 LLM 回答
─────────────────────────────────────
灵活性                   ✅ 高                    ❌ 低
                        同样的 chunks 可以        回答固定,无法适配
                        配合不同 Prompt 模板       不同的对话场景

Prompt 模板更新          ✅ 不影响                ❌ 缓存全部失效
                        只是 chunks 不变,         Prompt 改了,旧回答
                        Prompt 可以随时改          就不适用了

个性化                   ✅ 支持                  ❌ 不支持
                        chunks 相同,但可以        A 用户的回答不适合
                        根据用户画像调整语气        直接给 B 用户

多轮对话                 ✅ 支持                  ❌ 不支持
                        结合对话历史重新           缓存的回答没有
                        组装 Prompt                对话上下文

节省成本                 中等(省检索+Rerank)     高(连 LLM 都省了)
─────────────────────────────────────
结论:生产环境推荐缓存 Rerank 结果,
     LLM 回答缓存可以作为第二层在第十章实现

缓存写入时需要一并存储的字段:

cache_entry = {
    # ── 匹配用 ──
    "query_embedding": [...],          # 改写后 query 的向量,用于语义匹配
    "intent": "售前咨询/功能咨询",       # 意图标签,防止跨意图误命中

    # ── 返回用 ──
    "reranked_chunks": [               # Rerank 后的 top-K chunks
        {
            "chunk_id": "doc_003_chunk_12",
            "content": "蓝牙耳机支持主动降噪(ANC)...",
            "score": 0.9234,
            "metadata": {
                "doc_id": "doc_003",
                "doc_title": "蓝牙耳机Pro 产品手册",
                "heading_path": "功能介绍 > 降噪",
                "doc_type": "product_manual",
            },
        },
        # ... 更多 chunks
    ],

    # ── 管理用 ──
    "created_at": 1717200000,          # 写入时间戳
    "ttl": 1800,                       # 30 分钟过期
    "source_doc_ids": ["doc_003", "doc_007"],  # 关联的文档 ID,用于精准失效
}

注意缓存 key 用的是改写后的 query embedding:改写后的 query 更规范,后续其他用户的改写结果更容易与之匹配,缓存命中率更高。

生产级缓存策略

前面的缓存实现演示了核心逻辑,但直接上生产会有三个严重问题:

问题一:缓存淘汰
  TTL=30 分钟,但内存是有限的。
  热点 query 永久常驻?内存满了怎么办?

问题二:缓存穿透 / 击穿 / 雪崩
  穿透:大量不存在的 query 反复打穿缓存,直压检索库
  击穿:热点缓存 TTL 同时到期,瞬间大量请求涌入检索库
  雪崩:Redis 宕机或大面积 key 同时过期,全部流量打到检索库

问题三:缓存一致性
  知识库更新了文档,但缓存还在返回旧内容
  30 分钟 TTL 窗口期内用户拿到的是过时知识

一、缓存淘汰与内存管理

class ProductionRetrievalCache:
    """
    生产级检索结果缓存。

    基于 Redis Stack(RediSearch 向量搜索模块)实现。

    淘汰策略(三层):
    1. TTL 过期:每条缓存有独立 TTL,到期自动删除
    2. 热点续期:被命中的缓存自动延长 TTL,高频 query 不会被误删
    3. 内存上限 + LRU:Redis maxmemory + allkeys-lru,内存满时淘汰最久未访问的
    """

    def __init__(
        self,
        redis_client,
        similarity_threshold: float = 0.95,
        base_ttl: int = 1800,           # 基础 TTL: 30 分钟
        max_ttl: int = 7200,            # 最大 TTL: 2 小时(热点续期上限)
        hit_ttl_extension: int = 900,   # 每次命中延长 15 分钟
    ):
        self.redis = redis_client
        self.threshold = similarity_threshold
        self.base_ttl = base_ttl
        self.max_ttl = max_ttl
        self.hit_ttl_extension = hit_ttl_extension

    def get(self, query_embedding: list, intent: str):
        """
        查缓存。

        命中后做两件事:
        1. 返回缓存结果
        2. 延长该条缓存的 TTL(热点续期)
        """
        # 用 RediSearch 的向量搜索,在 intent 过滤下找最近邻
        results = self.redis.ft("rag_cache_idx").search(
            query=f"(@intent:{{{intent}}})" \
                  f"=>[KNN 1 @embedding $vec AS score]",
            query_params={"vec": query_embedding},
        )

        if not results.docs:
            return None

        top = results.docs[0]
        score = float(top.score)

        if score < self.threshold:
            return None

        # ── 热点续期 ──
        # 被命中说明是高频 query,延长 TTL 避免热点失效
        current_ttl = self.redis.ttl(top.id)
        new_ttl = min(current_ttl + self.hit_ttl_extension, self.max_ttl)
        self.redis.expire(top.id, new_ttl)

        return json.loads(top.reranked_chunks)

    def put(self, query_embedding: list, intent: str, chunks: list, source_doc_ids: list):
        """
        写缓存。

        TTL 加随机偏移,防止大量 key 同时过期(防雪崩)。
        """
        import random

        # TTL 随机偏移:base_ttl ± 20%
        # 1800 ± 360 → 1440~2160 秒
        jitter = int(self.base_ttl * 0.2)
        ttl = self.base_ttl + random.randint(-jitter, jitter)

        cache_key = f"rag_cache:{uuid4().hex}"
        self.redis.hset(cache_key, mapping={
            "embedding": query_embedding,           # 向量(RediSearch 索引字段)
            "intent": intent,
            "reranked_chunks": json.dumps(chunks),
            "source_doc_ids": json.dumps(source_doc_ids),
            "created_at": int(time.time()),
        })
        self.redis.expire(cache_key, ttl)

Redis 内存配置(redis.conf):

# 内存上限:根据缓存条目大小估算
# 每条缓存约 5KB(向量 3072维×4字节 + chunks JSON)
# 5000 条 ≈ 25MB,留余量设 64MB
maxmemory 64mb

# 淘汰策略:所有 key 中淘汰最久未访问的(LRU)
# 不用 volatile-lru(只淘汰有 TTL 的),因为所有 key 都有 TTL
maxmemory-policy allkeys-lru

淘汰策略总结:

                    触发条件              效果
──────────────────────────────────────────────────
TTL 自动过期        每条 key 独立计时      冷门 query 30 分钟后自然消失
热点续期            每次缓存命中           高频 query 最多续期到 2 小时
内存 LRU 淘汰       Redis 内存达到上限     淘汰最久没被访问的 key
定时清理            每 5 分钟一次          主动清除已过期但未被 Redis 回收的 key

二、缓存穿透 / 击穿 / 雪崩防护

缓存穿透:不存在的 query 反复打穿缓存
问题场景:
  恶意爬虫 / 随机输入 → 大量从未见过的 query
  → 每次都缓存未命中 → 每次都走完整的检索 + Rerank
  → 检索库被打爆

  "asjdfklajsdf"     → 缓存没有 → 检索 → 空结果
  "xncvmnxcv"        → 缓存没有 → 检索 → 空结果
  "12345qwert"       → 缓存没有 → 检索 → 空结果
  ... 每秒几百个这样的请求
# ── 防穿透:空结果缓存 ──

def put_empty(self, query_embedding: list, intent: str):
    """
    缓存空结果(防穿透)。

    当检索返回空结果时,也写一条缓存,标记为 "empty"。
    下次相似的 query 进来,命中这条空缓存后直接返回空,不再打检索库。

    注意:空缓存的 TTL 要短(5 分钟),避免知识库补充内容后仍然返回空。
    """
    cache_key = f"rag_cache:empty:{uuid4().hex}"
    self.redis.hset(cache_key, mapping={
        "embedding": query_embedding,
        "intent": intent,
        "reranked_chunks": "[]",         # 空结果
        "is_empty": "true",              # 标记为空缓存
        "source_doc_ids": "[]",
    })
    self.redis.expire(cache_key, 300)    # 5 分钟过期(比正常缓存短得多)

在主流程中的应用:

# 阶段七 Rerank 之后
if reranked_chunks:
    retrieval_cache.put(rewritten_embedding, intent, reranked_chunks, source_doc_ids)
else:
    # 空结果也缓存,防穿透
    retrieval_cache.put_empty(rewritten_embedding, intent)
缓存击穿:热点 key 过期瞬间大量请求涌入
问题场景:
  "蓝牙耳机降噪" 是热门问题,每秒 50 次请求
  → 这条缓存 TTL 到期被删除
  → 50 个请求同时发现缓存未命中
  → 50 个请求同时执行检索 + Rerank
  → 检索库瞬间压力暴增

  时间线:
  ──────────────────────────────────────
  10:00:00  缓存存在,50 次/秒命中 ✅
  10:30:00  缓存过期
  10:30:01  50 个请求同时缓存未命中 ← 击穿
            → 50 次检索 + 50 次 Rerank ← 雷击效应
  10:30:02  其中一个请求写回缓存
  10:30:03  恢复正常
  ──────────────────────────────────────
  虽然只持续 1~2 秒,但足以让检索库超负荷
# ── 防击穿:互斥锁(Mutex Lock)──

import hashlib

def get_with_mutex(self, query_embedding: list, intent: str, rebuild_func):
    """
    带互斥锁的缓存查询。

    缓存未命中时,只允许一个请求去执行检索(拿到锁的那个),
    其他请求等待或降级返回。

    参数:
        rebuild_func: 执行完整检索链路的函数,返回 reranked_chunks
    """
    # 第一步:正常查缓存
    cached = self.get(query_embedding, intent)
    if cached is not None:
        return cached

    # 第二步:缓存未命中,尝试加锁
    # 用 query embedding 的哈希作为锁的 key(语义相近的 query 共享同一把锁)
    lock_key = f"rag_lock:{self._embedding_hash(query_embedding)}"

    # SET NX(不存在才设置)+ 过期时间(防死锁)
    acquired = self.redis.set(lock_key, "1", nx=True, ex=10)  # 锁 10 秒过期

    if acquired:
        # ── 拿到锁:我来执行检索 ──
        try:
            chunks = rebuild_func()
            # 写入缓存
            if chunks:
                self.put(query_embedding, intent, chunks, ...)
            else:
                self.put_empty(query_embedding, intent)
            return chunks
        finally:
            self.redis.delete(lock_key)  # 释放锁
    else:
        # ── 没拿到锁:别人在检索,我等一下 ──
        # 短暂等待后重试查缓存(别人应该很快写入)
        import time
        for _ in range(3):
            time.sleep(0.1)              # 等 100ms
            cached = self.get(query_embedding, intent)
            if cached is not None:
                return cached

        # 等了 300ms 还没有 → 降级:自己也去检索(避免无限等待)
        return rebuild_func()

    def _embedding_hash(self, embedding: list) -> str:
        """对 embedding 取哈希,用于锁的 key"""
        raw = ",".join(f"{v:.4f}" for v in embedding[:32])  # 取前 32 维足够区分
        return hashlib.md5(raw.encode()).hexdigest()[:12]
缓存雪崩:大量 key 同时过期 / Redis 宕机
问题场景 A(同时过期):
  系统刚启动,短时间内写入大量缓存,TTL 都是 30 分钟
  → 30 分钟后全部同时过期 → 所有请求打到检索库

问题场景 B(Redis 宕机):
  Redis 挂了 → 所有请求缓存未命中 → 全打到检索库 → 检索库也挂了 → 雪崩
# ── 防雪崩策略一:TTL 随机偏移(已在 put 方法中实现)──

# put 方法中:
jitter = int(self.base_ttl * 0.2)
ttl = self.base_ttl + random.randint(-jitter, jitter)
# 1800 ± 360 → 1440~2160 秒
# 即使同时写入,过期时间也分散在 6 分钟的窗口内


# ── 防雪崩策略二:Redis 不可用时降级 ──

class ResilientRetrievalCache:
    """
    带降级能力的缓存封装。
    Redis 不可用时自动降级为"直接检索",不让缓存故障拖垮全链路。
    """

    def __init__(self, cache: ProductionRetrievalCache):
        self.cache = cache
        self._redis_available = True
        self._last_check_time = 0

    def get(self, query_embedding: list, intent: str):
        if not self._is_redis_available():
            return None  # Redis 不可用,直接返回未命中,走检索

        try:
            return self.cache.get(query_embedding, intent)
        except Exception:
            self._redis_available = False
            return None  # 异常也降级

    def put(self, query_embedding: list, intent: str, chunks: list, source_doc_ids: list):
        if not self._is_redis_available():
            return  # Redis 不可用,跳过写缓存(数据不丢,只是下次重新检索)

        try:
            self.cache.put(query_embedding, intent, chunks, source_doc_ids)
        except Exception:
            self._redis_available = False
            # 写缓存失败不影响主流程

    def _is_redis_available(self) -> bool:
        """
        Redis 可用性检测。
        不可用后每 30 秒探测一次,恢复后自动重新启用。
        """
        if self._redis_available:
            return True

        now = time.time()
        if now - self._last_check_time < 30:
            return False  # 30 秒内不重复探测

        self._last_check_time = now
        try:
            self.cache.redis.ping()
            self._redis_available = True
            return True
        except Exception:
            return False

三种问题的防护总结:

                问题              原因                   解决方案
──────────────────────────────────────────────────────────────────────
缓存穿透      不存在的 query     缓存中没有对应数据       空结果缓存(短 TTL=5min)
              反复打检索库       每次都穿透到检索库       布隆过滤器(可选,超大规模时)

缓存击穿      热点 key 过期      高频 query 的缓存        互斥锁(只允许一个请求检索)
              瞬间大量请求       恰好到了 TTL             热点续期(每次命中延长 TTL)

缓存雪崩      大量 key 同时过期  TTL 相同 / Redis 宕机    TTL 随机偏移(±20%)
              检索库被打爆                               Redis 不可用时降级直查
                                                        Redis 集群 / 哨兵高可用

三、缓存一致性:知识库更新后如何保证不返回旧内容

问题场景:
  10:00  产品手册更新了降噪参数:-38dB → -42dB
  10:00  知识库离线重建完成,新 chunk 已入库
  10:01  用户问 "降噪深度多少" → 命中 10:00 之前的缓存 → 返回 -38dB ← 错误!
  10:30  缓存 TTL 过期 → 新请求走检索 → 返回 -42dB ← 30 分钟后才正确

  30 分钟的不一致窗口,对于产品参数这类信息是不可接受的
class CacheInvalidator:
    """
    缓存一致性管理(生产版)。

    策略:主动失效 + 版本号校验 + TTL 兜底,三层保障。
    """

    def __init__(self, cache: ProductionRetrievalCache):
        self.cache = cache
        self.redis = cache.redis

    # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
    # 策略一:主动失效(推送式)
    # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

    def on_document_updated(self, doc_id: str):
        """
        文档更新时,立即删除所有引用该文档的缓存。

        实现方式:
        - 每条缓存存了 source_doc_ids 字段
        - 用 RediSearch 的 TAG 过滤找到所有引用该文档的缓存 key
        - 批量删除

        触发时机:知识库构建流水线完成后,发送事件通知。
        """
        # 用 RediSearch 查找所有引用该文档的缓存
        results = self.redis.ft("rag_cache_idx").search(
            query=f"@source_doc_ids:{{{doc_id}}}",
        )

        if not results.docs:
            return 0

        # 批量删除
        pipeline = self.redis.pipeline()
        for doc in results.docs:
            pipeline.delete(doc.id)
        pipeline.execute()

        return len(results.docs)

    def on_documents_batch_updated(self, doc_ids: list):
        """批量文档更新(知识库重建场景)"""
        total = 0
        for doc_id in doc_ids:
            total += self.on_document_updated(doc_id)
        return total

    def on_knowledge_base_rebuilt(self):
        """
        知识库全量重建后,清空全部 RAG 缓存。

        用 key 前缀批量删除,不影响 Redis 中其他业务的数据。
        """
        cursor = 0
        deleted = 0
        while True:
            cursor, keys = self.redis.scan(cursor, match="rag_cache:*", count=100)
            if keys:
                self.redis.delete(*keys)
                deleted += len(keys)
            if cursor == 0:
                break
        return deleted

    # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
    # 策略二:版本号校验(拉取式)
    # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

    def set_knowledge_version(self, version: str):
        """
        更新知识库版本号。

        每次知识库构建完成后调用,写入一个全局版本号。
        缓存读取时会校验版本号,版本不一致的缓存视为失效。
        """
        self.redis.set("rag:knowledge_version", version)

    def get_knowledge_version(self) -> str:
        return self.redis.get("rag:knowledge_version") or "unknown"

版本号校验的工作机制:

# 写缓存时:记录当时的知识库版本号
def put(self, query_embedding, intent, chunks, source_doc_ids):
    current_version = self.redis.get("rag:knowledge_version")
    self.redis.hset(cache_key, mapping={
        "embedding": query_embedding,
        "intent": intent,
        "reranked_chunks": json.dumps(chunks),
        "source_doc_ids": json.dumps(source_doc_ids),
        "knowledge_version": current_version,     # ← 写入时的版本号
    })

# 读缓存时:校验版本号是否一致
def get(self, query_embedding, intent):
    # ... 向量搜索找到最相似的缓存条目 ...

    # 版本号校验
    cached_version = top.knowledge_version
    current_version = self.redis.get("rag:knowledge_version")

    if cached_version != current_version:
        # 知识库已更新,这条缓存是旧版本的 → 视为未命中
        self.redis.delete(top.id)    # 顺手删掉旧缓存
        return None

    return json.loads(top.reranked_chunks)
版本号校验的完整流程:

  10:00:00  知识库版本 = "v20260601_1000"
  10:00:05  用户问 "降噪深度" → 检索 → 缓存写入 (version="v20260601_1000")

  10:15:00  产品手册更新,知识库重建完成
            → set_knowledge_version("v20260601_1015")
            → on_document_updated("doc_003") → 主动删除相关缓存

  10:15:02  用户问 "降噪多少分贝"
            → 缓存查找 → 找到一条旧缓存(假设主动删除漏掉了)
            → 版本号校验:缓存 "v20260601_1000" ≠ 当前 "v20260601_1015"
            → 视为未命中 → 重新检索 → 返回新数据 ✅

  主动删除是第一道防线,版本号校验是第二道防线
  两层保障,不一致窗口从 30 分钟缩短到 ≈ 0

三种策略的配合关系

知识库更新
    │
    ├─→ 策略一:主动失效(立即生效,覆盖 99% 的情况)
    │     删除所有引用已更新文档的缓存
    │     延迟:~0s
    │
    ├─→ 策略二:版本号校验(兜底,防止主动失效遗漏)
    │     读缓存时校验版本号,不一致则视为未命中
    │     延迟:~0s(下一次读取时触发)
    │
    └─→ 策略三:TTL 过期(最后防线)
          即使前两层都失效,30 分钟后缓存自然消失
          延迟:最多 30 分钟

  正常情况下:策略一就够了,不一致窗口 = 0
  极端情况下:策略一遗漏 + 策略二兜住,不一致窗口 ≈ 0
  灾难情况下:策略一二都失效,策略三保底,不一致窗口 ≤ 30 分钟

检索后增强:四项生产级优化

前面的流程走到 Rerank 之后拿到了 top-K chunks,但直接塞进 Prompt 还有几个问题没解决。以下四项优化插在 Rerank(阶段七)之后、缓存写入(阶段八)之前

阶段七  Rerank 重排序
    │
    │  top-K chunks(已排序但未精加工)
    ▼
┌──────────────────────────────────────────────────┐
│  优化一:多粒度召回补充                             │
│  优化二:相关性校验与拒答增强                       │
│  优化三:Chunk 压缩 / 合并 / 去冗余                │
│  优化四:引用溯源元数据注入                         │
└──────────────────────────────────────────────────┘
    │
    │  精加工后的 chunks(可直接嵌入 Prompt)
    ▼
阶段八  写入 Redis 缓存

优化一:多粒度召回补充

前面的深度检索策略(HyDE / Decomposition / Step-back / 混合检索)覆盖了主流场景,但还有三种召回源可以补充:

1a. 摘要检索 / 文档层级检索
问题:
  用户问 "蓝牙耳机Pro 和 蓝牙耳机Lite 哪个好"
  → 向量检索可能只命中局部段落(降噪参数、续航参数)
  → 缺乏全局对比视角

解决:先在文档摘要层检索,定位到相关文档,再到段落层精检
class HierarchicalRetriever:
    """
    文档层级检索(两阶段)。

    第一阶段:在文档摘要索引中粗筛,定位相关文档
    第二阶段:在定位到的文档内精搜,找到具体段落

    前提:离线阶段需要为每篇文档生成摘要并单独索引
    (在知识库构建流水线中添加 doc_summary 字段即可)
    """

    def __init__(self, summary_store, chunk_store, embedding_service):
        self.summary_store = summary_store      # 文档摘要向量库
        self.chunk_store = chunk_store          # chunk 向量库
        self.embedding_service = embedding_service

    def search(self, query: str, top_k_docs: int = 3, top_k_chunks: int = 5) -> list[dict]:
        # 第一阶段:摘要粗筛 → 找到最相关的 3 篇文档
        query_emb = self.embedding_service.embed_query(query)
        relevant_docs = self.summary_store.search(query_emb, top_k=top_k_docs)
        doc_ids = [doc["doc_id"] for doc in relevant_docs]

        # 第二阶段:在这 3 篇文档内精搜 → 找到最相关的 5 个 chunk
        chunks = self.chunk_store.search(
            query_emb,
            top_k=top_k_chunks,
            filters={"doc_id": {"$in": doc_ids}},  # 限定范围
        )
        return chunks
适用场景                       不适用场景
────────────────────────────────────────────────
对比类问题(A vs B)            单一产品功能咨询
跨文档综合问题                  FAQ 类简单问答
知识库文档数量 > 100 篇时       文档少于 20 篇时(直接全库搜即可)
1b. 历史问答召回
问题:
  用户问 "耳机降噪怎么开"3 个月前已经有人工客服完美回答过这个问题
  → 那条历史回答比 RAG 检索+LLM 生成的质量更高

解决:维护一个"精标问答对"索引,优先匹配历史优质答案
class HistoricalQARetriever:
    """
    历史问答召回。

    数据来源:
    1. 人工客服的优质回答(质检标记为"优秀"的)
    2. LLM 回答中用户反馈"有用"的
    3. 运营手动录入的标准回答

    索引结构:
    - question_embedding: 问题的向量
    - answer: 人工/审核后的答案文本
    - category: 问题类别
    - quality_score: 质量评分
    - created_at: 录入时间
    """

    def __init__(self, qa_store, embedding_service, threshold: float = 0.93):
        self.qa_store = qa_store
        self.embedding_service = embedding_service
        self.threshold = threshold  # 历史问答匹配阈值要高(要求高相似度)

    def search(self, query: str, intent: str) -> dict | None:
        """
        查找是否有匹配的历史问答。

        返回 None 表示没有匹配的历史问答,继续走正常 RAG。
        返回 dict 表示找到了,可以直接用(跳过后续的 Prompt + LLM 生成)。
        """
        query_emb = self.embedding_service.embed_query(query)
        results = self.qa_store.search(
            query_emb,
            top_k=1,
            filters={"category": intent},
        )

        if not results:
            return None

        top = results[0]
        if top["score"] >= self.threshold:
            return {
                "answer": top["answer"],
                "source": "historical_qa",
                "match_score": top["score"],
                "qa_id": top["qa_id"],
            }
        return None
历史问答在全流程中的位置:

  阶段零 准入判断 → 需要 RAG
      │
      ▼
  ★ 历史问答召回 ← 插在这里,在 embedding 之前或之后都可以
      │
    命中 → 直接返回历史答案(跳过全部 RAG 流程)
    未命中 → 继续正常 RAG(阶段一 embedding → ...)

好处:
  - 历史问答的答案质量通常比 LLM 实时生成的更高(经过人工审核)
  - 完全跳过 RAG + LLM,延迟 ~20ms,成本 $0
  - 适合 FAQ 类高频问题
何时启用哪种多粒度召回
召回方式额外延迟适用场景启用条件
混合检索(基础)0ms所有请求始终启用
HyDE200~500ms模糊 query置信度 < 0.6
Query Decomposition200~500ms复杂多意图多个问号/并列词
层级检索30~60ms对比类、跨文档实体含多个产品名
历史问答10~20ms高频 FAQ始终启用(在 RAG 之前)

优化二:相关性校验与拒答增强

问题(语义漂移):
  用户问: "你们公司的股票代码是多少"
  → 向量检索命中了 "公司简介" 文档中的一些段落
  → Rerank 也给了不低的分数(因为确实和"公司"相关)
  → 但这些 chunk 里根本没有股票代码
  → LLM 拿着这些不相关的 chunk 胡编一个股票代码 ← 幻觉!

  Rerank 分数高 ≠ 能回答问题
  Rerank 衡量的是"语义相关度",不是"能否回答"
class RelevanceChecker:
    """
    相关性校验器(轻量 LLM 判断)。

    在 Rerank 之后、Prompt 拼接之前,用 LLM 快速判断:
    "这些检索结果能不能回答用户的问题?"

    如果不能 → 标记为"拒答",让下游 Prompt 诚实告知用户"未找到相关信息"
    而不是硬编答案导致幻觉。
    """

    RELEVANCE_PROMPT = """判断以下知识片段是否能回答用户的问题。

用户问题:{question}

知识片段:
{chunks_text}

请回答:
1. 这些知识片段是否包含回答该问题所需的信息?(yes/no)
2. 如果 yes,信息充分程度如何?(sufficient/partial)
3. 一句话理由

输出格式(严格 JSON):
{{"answerable": "yes/no", "sufficiency": "sufficient/partial/none", "reason": "..."}}"""

    def __init__(self, llm):
        self.llm = llm  # 轻量 LLM(Haiku / GPT-4o-mini)

    def check(self, query: str, chunks: list[dict]) -> dict:
        """
        校验检索结果是否能回答用户问题。

        Returns:
            {
                "answerable": True/False,
                "sufficiency": "sufficient" / "partial" / "none",
                "reason": "判断理由",
            }
        """
        if not chunks:
            return {"answerable": False, "sufficiency": "none", "reason": "无检索结果"}

        # 只取 top 3 的内容做判断(省 token)
        chunks_text = "\n---\n".join(
            chunk["content"][:200] for chunk in chunks[:3]
        )

        import json
        response = self.llm.invoke([{
            "role": "user",
            "content": self.RELEVANCE_PROMPT.format(
                question=query,
                chunks_text=chunks_text,
            ),
        }])

        try:
            result = json.loads(response.content)
            return {
                "answerable": result.get("answerable") == "yes",
                "sufficiency": result.get("sufficiency", "none"),
                "reason": result.get("reason", ""),
            }
        except (json.JSONDecodeError, KeyError):
            # 解析失败 → 保守策略:认为可以回答(不误拒答)
            return {"answerable": True, "sufficiency": "partial", "reason": "校验解析失败,默认放行"}

拒答结果在下游的处理:

# 在 Rerank 之后调用
relevance = relevance_checker.check(rewritten_query, reranked_chunks)

if not relevance["answerable"]:
    # ── 拒答路径 ──
    state["knowledge_context"] = (
        "<knowledge>\n"
        "未检索到能够回答该问题的相关知识。\n"
        "请诚实告知用户:该问题超出了当前知识库的覆盖范围,建议联系人工客服。\n"
        "</knowledge>"
    )
    state["rag_refused"] = True
    state["rag_refuse_reason"] = relevance["reason"]
    # 不写入缓存(拒答结果不应被缓存,因为知识库可能后续补充)
    return state

elif relevance["sufficiency"] == "partial":
    # ── 部分回答路径 ──
    # 在 knowledge_context 中提示 LLM:信息不完整,要诚实说明
    state["rag_partial"] = True
                    Rerank 分数高        Rerank 分数低
                  ────────────────    ────────────────
相关性校验 pass    正常回答 ✅          (不会出现这种情况)
相关性校验 fail    拒答 ← 防止幻觉     空结果,走兜底回答

成本控制:不是每次都调 LLM 校验

# 只在以下情况触发相关性校验:
should_check = (
    # top-1 Rerank 分数不够高(0.6~0.8 之间的"灰色地带")
    (0.5 < reranked_chunks[0].get("rerank_score", 0) < 0.8)
    # 或者 top-1 和 top-2 分数差距太大(说明只有一条勉强相关)
    or (len(reranked_chunks) >= 2
        and reranked_chunks[0]["rerank_score"] - reranked_chunks[1]["rerank_score"] > 0.3)
)

if should_check:
    relevance = relevance_checker.check(query, reranked_chunks)
else:
    # Rerank 分数很高(>0.8),大概率相关,跳过校验省成本
    relevance = {"answerable": True, "sufficiency": "sufficient", "reason": "high_rerank_score"}

优化三:Chunk 压缩 / 合并 / 去冗余

问题:
  Rerank 返回 5 条 chunks,每条约 300 tokens
  → 总共 1500 tokens 塞进 Prompt
  → 但其中 chunk 1 和 chunk 3 内容高度重复(同一章节的相邻段落)
  → chunk 4 有一大段和问题无关的参数表格
  → 实际有效信息可能只有 600 tokens,其余都是冗余

  冗余的后果:
  1. 浪费 LLM 的上下文窗口和费用
  2. 无关内容可能干扰 LLM 回答质量
  3. 重复内容导致 LLM 回答也重复
class ChunkPostProcessor:
    """
    Chunk 后处理器(Rerank 之后、Prompt 拼接之前)。

    三步处理:
    1. 合并相邻/重复片段 → 消除冗余
    2. 压缩长 chunk → 保留和 query 相关的部分
    3. 总量截断 → 适配 LLM 上下文预算
    """

    def __init__(self, max_total_tokens: int = 2000, llm=None):
        self.max_total_tokens = max_total_tokens
        self.llm = llm  # 可选:用 LLM 做智能压缩(更贵但效果更好)

    def process(self, query: str, chunks: list[dict]) -> list[dict]:
        if not chunks:
            return []

        # 第一步:合并来自同一文档相邻位置的重复/重叠片段
        merged = self._merge_overlapping(chunks)

        # 第二步:压缩过长的 chunk(去掉和 query 无关的部分)
        compressed = self._compress_chunks(query, merged)

        # 第三步:总量截断(适配上下文预算)
        truncated = self._truncate_to_budget(compressed)

        return truncated

    def _merge_overlapping(self, chunks: list[dict]) -> list[dict]:
        """
        合并来自同一文档、同一章节的重叠片段。

        检测标准:两条 chunk 的 doc_id 和 heading_path 相同,
        且文本有 30% 以上的重叠 → 合并为一条。
        """
        if len(chunks) <= 1:
            return chunks

        merged = [chunks[0]]
        for chunk in chunks[1:]:
            last = merged[-1]
            # 同文档、同章节
            if (chunk.get("metadata", {}).get("doc_id") == last.get("metadata", {}).get("doc_id")
                and chunk.get("metadata", {}).get("heading_path") == last.get("metadata", {}).get("heading_path")):

                overlap = self._text_overlap_ratio(last["content"], chunk["content"])
                if overlap > 0.3:
                    # 合并:取并集(去掉重复部分)
                    merged[-1] = {
                        **last,
                        "content": self._merge_texts(last["content"], chunk["content"]),
                        "merged_from": last.get("merged_from", 1) + 1,
                    }
                    continue

            merged.append(chunk)

        return merged

    def _compress_chunks(self, query: str, chunks: list[dict]) -> list[dict]:
        """
        压缩过长的 chunk,只保留与 query 相关的句子。

        规则版(不用 LLM):
        - 按句子分割
        - 用简单的关键词匹配过滤掉明显无关的句子
        - 保留包含 query 关键词的句子 + 上下各 1 句(保持连贯)
        """
        import re

        query_keywords = set(re.findall(r'[\u4e00-\u9fff]+', query))  # 提取中文词

        compressed = []
        for chunk in chunks:
            content = chunk["content"]
            # 短 chunk 不需要压缩
            if len(content) < 200:
                compressed.append(chunk)
                continue

            sentences = re.split(r'[。!?\n]', content)
            sentences = [s.strip() for s in sentences if s.strip()]

            # 标记哪些句子包含关键词
            relevant_indices = set()
            for i, sent in enumerate(sentences):
                if any(kw in sent for kw in query_keywords):
                    # 保留该句 + 上下各 1 句
                    relevant_indices.update(range(max(0, i-1), min(len(sentences), i+2)))

            if not relevant_indices:
                # 没有匹配的关键词 → 保留原文(保守策略)
                compressed.append(chunk)
            else:
                kept = [sentences[i] for i in sorted(relevant_indices)]
                compressed.append({
                    **chunk,
                    "content": "。".join(kept) + "。",
                    "compressed": True,
                })

        return compressed

    def _truncate_to_budget(self, chunks: list[dict]) -> list[dict]:
        """按 token 预算截断,优先保留高分 chunk"""
        result = []
        total_tokens = 0

        for chunk in chunks:
            chunk_tokens = len(chunk["content"]) // 2  # 粗估:中文约 2 字符/token
            if total_tokens + chunk_tokens > self.max_total_tokens and len(result) >= 2:
                break  # 至少保留 2 条
            result.append(chunk)
            total_tokens += chunk_tokens

        return result

    def _text_overlap_ratio(self, text_a: str, text_b: str) -> float:
        """计算两段文本的重叠比例(基于字符集合)"""
        set_a = set(text_a)
        set_b = set(text_b)
        if not set_a or not set_b:
            return 0
        intersection = set_a & set_b
        return len(intersection) / min(len(set_a), len(set_b))

    def _merge_texts(self, text_a: str, text_b: str) -> str:
        """合并两段文本,去掉重复部分"""
        # 简单策略:如果 b 的前半段在 a 中出现,只取 b 的后半段拼接
        overlap_len = min(len(text_a), len(text_b)) // 2
        for i in range(overlap_len, 10, -1):
            if text_b[:i] in text_a:
                return text_a + text_b[i:]
        return text_a + "\n" + text_b

处理前后对比:

处理前(5 条 chunks,~1500 tokens):
  [1] 蓝牙耳机支持主动降噪(ANC),降噪深度达-38dB...     (score: 0.95)
  [2] 降噪模式分为三档:深度降噪、适度降噪、通透模式...   (score: 0.91)
  [3] 蓝牙耳机具备主动降噪功能,降噪深度-38dB...         (score: 0.87) ← 和 [1] 重复
  [4] 产品规格:重量5.2g,蓝牙5.3,频响20Hz-40kHz...    (score: 0.72) ← 和降噪无关
  [5] 开启降噪:长按右耳机触控面板2秒...                  (score: 0.68)

处理后(3 条 chunks,~800 tokens):
  [1] 蓝牙耳机支持主动降噪(ANC),降噪深度达-38dB...     ← 保留
  [2] 降噪模式分为三档:深度降噪、适度降噪、通透模式...   ← 保留
  [3] 开启降噪:长按右耳机触控面板2秒...                  ← 保留

  [原3][1] 合并(重复内容)
  [原4] 压缩时去掉了无关的规格参数

  token 节省:47%,信息密度显著提升

优化四:引用溯源

问题:
  客服场景中,用户(和质检人员)需要知道回答的依据从哪来。
  "你凭什么说降噪深度是 -38dB?" → 需要能追溯到具体文档、章节、页码。
class CitationInjector:
    """
    引用溯源注入器。

    在 Prompt 中为每条知识片段标注结构化来源,
    让 LLM 在回答中引用出处,增强可信度。
    """

    def build_context_with_citations(self, chunks: list[dict]) -> tuple[str, list[dict]]:
        """
        构建带引用标记的知识上下文。

        返回:
            - context_text: 嵌入 Prompt 的文本
            - citations: 引用元数据列表(用于前端展示)
        """
        if not chunks:
            return "", []

        context_parts = ["<knowledge>"]
        citations = []

        for i, chunk in enumerate(chunks, 1):
            meta = chunk.get("metadata", {})

            # 构建引用标记
            citation = {
                "ref_id": f"[{i}]",
                "doc_title": meta.get("doc_title", "未知文档"),
                "heading_path": meta.get("heading_path", ""),
                "doc_type": meta.get("doc_type", ""),
                "page_number": meta.get("page_number"),        # PDF 页码(如有)
                "doc_url": meta.get("doc_url", ""),             # 文档链接(如有)
                "chunk_id": chunk.get("chunk_id", ""),
                "score": chunk.get("rerank_score", chunk.get("score", 0)),
            }
            citations.append(citation)

            # 在 Prompt 中的格式
            source_line = f"[{i}] 来源:《{citation['doc_title']}》"
            if citation["heading_path"]:
                source_line += f" > {citation['heading_path']}"
            if citation["page_number"]:
                source_line += f"(第{citation['page_number']}页)"

            context_parts.append(source_line)
            context_parts.append(chunk["content"])
            context_parts.append("")

        context_parts.append("</knowledge>")
        context_parts.append("")
        context_parts.append("回答要求:引用知识片段时请标注来源编号,如 [1]、[2]。")

        return "\n".join(context_parts), citations

Prompt 中的效果:

<knowledge>
[1] 来源:《蓝牙耳机Pro 产品手册》 > 功能介绍 > 降噪(第12页)
蓝牙耳机支持主动降噪(ANC),降噪深度达-38dB,支持降噪+通话同时使用。

[2] 来源:《蓝牙耳机Pro 产品手册》 > 使用指南 > 降噪操作(第25页)
开启降噪:长按右耳机触控面板2秒,听到提示音即表示降噪已开启。

[3] 来源:《常见问题FAQ》 > 降噪相关
降噪模式分为三档:深度降噪(嘈杂环境)、适度降噪(办公室)、通透模式。
</knowledge>

回答要求:引用知识片段时请标注来源编号,如 [1][2]

LLM 生成的回答:

蓝牙耳机支持主动降噪(ANC),降噪深度达 -38dB [1]。

开启方法:长按右耳机触控面板 2 秒即可 [2]。降噪共有三种模式可切换:
- 深度降噪:适合地铁等嘈杂环境
- 适度降噪:适合办公室
- 通透模式:可听到周围声音 [3]

前端展示引用(citations 列表传给前端):

[
  {
    "ref_id": "[1]",
    "doc_title": "蓝牙耳机Pro 产品手册",
    "heading_path": "功能介绍 > 降噪",
    "page_number": 12,
    "doc_url": "https://docs.internal/products/bt-earphone-pro.pdf"
  },
  ...
]

用户点击 [1] 可以跳转到原始文档的具体位置,质检人员也可以快速核实。

四项优化的集成

在主流程代码中,这四项优化插在 Rerank 之后:

# 阶段七:Rerank 重排序
reranked_chunks = reranker.rerank(query, candidates, top_k=5)

# ── 检索后增强(四项优化)──

# 优化二:相关性校验(仅在灰色地带触发,成本可控)
if should_check_relevance(reranked_chunks):
    relevance = relevance_checker.check(query, reranked_chunks)
    if not relevance["answerable"]:
        return refuse_answer(state, relevance["reason"])

# 优化三:Chunk 压缩/合并/去冗余
processed_chunks = chunk_processor.process(query, reranked_chunks)

# 优化四:引用溯源
knowledge_context, citations = citation_injector.build_context_with_citations(processed_chunks)
state["citations"] = citations

# 阶段八:写入缓存(缓存的是压缩后的 chunks)
retrieval_cache.put(embedding, intent, processed_chunks, source_doc_ids)

# 阶段九:Prompt 拼接
state["knowledge_context"] = knowledge_context

注意:优化一(多粒度召回)在阶段六深度检索中集成,不在这里。历史问答召回在阶段零之后、阶段一之前。

四项优化的取舍建议

优化复杂度额外延迟额外成本建议
多粒度召回 - 层级检索30~60ms需建摘要索引文档 > 100 篇时启用
多粒度召回 - 历史问答10~20ms需维护 QA 库强烈推荐,ROI 最高
相关性校验(拒答增强)100~200ms1 次 LLM面向 C 端必须有,仅灰色地带触发
Chunk 压缩/合并< 5ms强烈推荐,纯规则零成本
引用溯源< 1ms强烈推荐,客服场景刚需

各阶段耗时与成本分析

路径 ⓪:不需要 RAG,直接跳过(零成本)

阶段零  RAG 准入判断          ~0ms         免费(纯规则判断)
────────────────────────────────────────────────────
总计                          ~0ms         $0
                              无任何外部调用 ✅

适用:闲聊、纯工具调用、已被拦截、多轮追问(上轮已有知识上下文)

路径 A:原始 query 直接命中缓存(最快)

阶段一  原始 Query Embedding  10~30ms      $0.00002/次
阶段二  Redis 缓存查找        1~5ms        免费
────────────────────────────────────────────────────
总计                          ~15ms        ~$0.00002
                              无 LLM 调用 ✅

路径 B:改写后命中缓存(中等)

阶段一  原始 Query Embedding  10~30ms      $0.00002/次
阶段二  Redis 缓存查找        1~5ms        免费(未命中)
阶段三  LLM Query 改写       100~300ms     $0.0001/次(Haiku)
阶段四  改写后 Embedding      10~30ms      $0.00002/次
阶段五  Redis 缓存查找        1~5ms        免费(命中)
────────────────────────────────────────────────────
总计                          ~200ms       ~$0.00014

路径 C:完整深度检索(仅混合检索)

阶段一  原始 Query Embedding  10~30ms      $0.00002/次
阶段二  Redis 缓存查找        1~5ms        免费(未命中)
阶段三  LLM Query 改写       100~300ms     $0.0001/次
阶段四  改写后 Embedding      10~30ms      $0.00002/次
阶段五  Redis 缓存查找        1~5ms        免费(未命中)
阶段六  混合检索(向量+BM25) 30~100ms      免费(自建向量库)
阶段七  Rerank 重排序        30~100ms      $0.00005/次
阶段八  写入缓存             1~5ms         免费
────────────────────────────────────────────────────
总计                          ~350ms       ~$0.00019

路径 C:完整深度检索(混合检索 + HyDE)

阶段一~五  同上               ~200ms       ~$0.00014
阶段六  HyDE 生成假设性答案   200~500ms     $0.0001/次(Haiku)
        混合检索 ×2           60~200ms      免费
阶段七  Rerank 重排序        30~100ms      $0.00005/次
阶段八  写入缓存             1~5ms         免费
────────────────────────────────────────────────────
总计                          ~650ms       ~$0.00029

注意:以上耗时不含阶段九(Prompt 拼接 + LLM 生成回答),该部分约 500~2000ms,在第十章实现。

成本对比(假设日均 10 万次请求)

请求分布(典型电商客服场景):
  路径 ⓪ 跳过 RAG:    30%(闲聊 15% + 工具调用 10% + 追问 5%)
  路径 A 原始命中:     42%(剩余 70% 中的 60% 缓存命中)
  路径 B 改写后命中:   10%
  路径 C 完整检索:     18%

                    路径 ⓪        路径 A 命中    路径 B 命中    路径 C 完整
                    (30%)         (42%)         (10%)         (18%)
────────────────────────────────────────────────────────────────
请求次数             30,000        42,000        10,000        18,000
Embedding 费用       $0            $0.84         $0.4          $0.72
LLM 改写费用         $0            $0            $1.0          $1.8
检索+Rerank          $0            $0            $0            $0.9
────────────────────────────────────────────────────────────────
日成本小计           $0            $0.84         $1.4          $3.42
日总成本(RAG部分)   $5.66

对比:
  无准入判断 + 每次都先改写:  日总成本 ≈ $11.25
  有准入判断 + 缓存优先:      日总成本 ≈ $5.66
  节省:约 50%

全流程核心原则

  1. 准入先行、不该检索的别检索 — 闲聊、工具调用、多轮追问等场景在阶段零直接跳过,不浪费 embedding 和检索资源;检索到不相关的内容塞进 Prompt 反而干扰 LLM 回答质量
  2. 缓存优先、按需改写 — 先用原始 query 尝试命中缓存,省掉不必要的 LLM 改写调用;只在未命中时才触发改写
  3. 两次缓存、逐步升级 — 第一次用原始 query 快速匹配,第二次用改写后的规范 query 精确匹配,两次机会最大化命中率
  4. 向量复用、不浪费计算 — 阶段一算出的 embedding 用于第一次缓存查找,阶段四的 embedding 用于第二次缓存查找和后续向量检索
  5. 策略路由、按需加载 — 深度检索策略不是越多越好,路由器根据 query 特征选择 1~2 种最合适的策略
  6. 缓存结果而非回答 — 缓存 Rerank 后的 chunks 而非 LLM 最终回答,保留 Prompt 灵活性和个性化能力
  7. 缓存 key 用改写后的 embedding — 写入缓存时用改写后的规范化向量作为 key,后续匹配命中率更高
  8. 三层失效、数据不陈旧 — TTL 兜底 + 文档级精准失效 + 全量清除,确保缓存不会返回过时的知识

十三、本章小结

离线阶段

环节方案关键点
文档解析pdfplumber + BeautifulSoup + 正则表格必须结构化转换,不能直接 get_text
文本分块Parent-Child + Contextual 前缀(生产推荐)小 chunk 检索、大 chunk 回传;上下文前缀提升召回
向量化text-embedding-3-small / bge-large-zh批量处理 + 本地缓存,避免重复计算
向量存储Chroma(开发)/ pgvector(生产)HNSW 索引,m=32,ef_construction=128
BM25 索引jieba 分词 + 停用词过滤必须用 jieba,不能用 bigram;加载自定义业务词典
版本管理内容哈希变更检测 + 增量更新只处理变更文档,90 天过期预警

在线阶段

环节方案延迟关键点
Query 构造意图感知 + HyDE(低置信度时) + Decomposition(复杂问题)0~500ms不直接用原文;多路召回互补
混合检索向量 + BM25,RRF 融合30~100ms向量管语义,BM25 管精确匹配
Rerank交叉编码器精排30~100msbge-reranker-v2-m3,对候选集精排
MMR 去重λ=0.7 或规则去重< 5ms避免同一章节的重复内容占满结果

评估与监控

维度指标生产标准
离线评估Recall@5> 90%(否则知识库覆盖不全或分块有问题)
离线评估MRR> 0.75(正确答案应该排在前 2 名内)
在线监控空召回率< 5%
在线监控P99 延迟< 500ms
在线监控低分命中率(top-1 < 0.3)< 20%

核心原则

  1. 离线做多、在线做少 — 解析、分块、向量化、BM25 索引全部离线完成,在线只做检索和 rerank
  2. 意图驱动检索 — 前六章的意图识别结果是 RAG 的天然增强,知道用户想干什么才能精准找答案
  3. 不能度量就不能优化 — 评测集 + A/B 实验是调参的唯一依据,不要凭感觉
  4. Parent-Child 分块 — 生产级 RAG 的标配,根本性解决精度 vs 上下文的矛盾
  5. 每个子模块独立降级 — RAG 失败不阻塞流程,LLM 可以用通用知识兜底回答