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

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

要解决什么问题

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

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

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

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

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

实现

python 复制代码
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 去检索,而不是用问题本身。

arduino 复制代码
为什么有效?
  → 用户问题是"问句",知识库里的文档是"陈述句"
  → 问句和陈述句的 embedding 天然存在语义鸿沟
  → HyDE 生成的假设性答案是陈述句,和知识库文档在同一个语义空间
  → 用陈述句检索陈述句,匹配度更高
python 复制代码
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(问题拆解)

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

python 复制代码
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(抽象化检索)

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

python 复制代码
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 所有请求 始终启用
HyDE 200~500ms 模糊问题、低置信度 confidence < 0.6 时启用
Query Decomposition 200~500ms 复杂/多子问题 检测到多个问号或并列连词时
Step-back 0ms 具体→抽象的场景 意图为政策咨询类时
python 复制代码
# 在 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)擅长精确匹配,但不懂语义。两者结合是目前业界的最佳实践。

实现

python 复制代码
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,不能对全库做。

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

实现

python 复制代码
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

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

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

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

python 复制代码
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 条:

python 复制代码
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

八、两层缓存架构

要解决什么问题

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

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

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

两层缓存的设计

erlang 复制代码
用户提问
    │
    ▼
┌──────────────────────────────────────────────────┐
│  第二层缓存: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 是不行的

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

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

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

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

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

检索结果缓存实现

python 复制代码
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

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

python 复制代码
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 改写,改写后再尝试一次缓存匹配,仍然未命中才进入完整的深度检索链路。

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

全流程架构图

erlang 复制代码
用户原始 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 生成最终回答                                     ║
╚══════════════════════════════════════════════════════════════╝
    │
    ▼
  输出给用户

三条路径对比

markdown 复制代码
路径 ⓪(直接跳过): 准入判断 → 不需要 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 调用,零成本。

为什么需要准入判断

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

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

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

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

ini 复制代码
用户: "帮我查一下订单 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),第八章直接读取它,不维护自己的映射表。

实现

python 复制代码
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 != ""

为什么不硬编码两份映射

swift 复制代码
反面示例(之前的写法,有维护隐患):
─────────────────────────────────────

第八章 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 一处,全链路自动生效

完整判断链路示意

swift 复制代码
         第九章 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。

python 复制代码
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 调用)。

策略路由器

python 复制代码
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 清晰,混合检索即可",
        }

策略执行器

python 复制代码
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。这样设计的原因:

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

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

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

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

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

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

python 复制代码
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 更规范,后续其他用户的改写结果更容易与之匹配,缓存命中率更高。

生产级缓存策略

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

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

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

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

一、缓存淘汰与内存管理

python 复制代码
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):

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

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

淘汰策略总结:

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

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

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

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

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 分钟过期(比正常缓存短得多)

在主流程中的应用:

python 复制代码
# 阶段七 Rerank 之后
if reranked_chunks:
    retrieval_cache.put(rewritten_embedding, intent, reranked_chunks, source_doc_ids)
else:
    # 空结果也缓存,防穿透
    retrieval_cache.put_empty(rewritten_embedding, intent)
缓存击穿:热点 key 过期瞬间大量请求涌入
arduino 复制代码
问题场景:
  "蓝牙耳机降噪" 是热门问题,每秒 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 秒,但足以让检索库超负荷
python 复制代码
# ── 防击穿:互斥锁(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 宕机
css 复制代码
问题场景 A(同时过期):
  系统刚启动,短时间内写入大量缓存,TTL 都是 30 分钟
  → 30 分钟后全部同时过期 → 所有请求打到检索库

问题场景 B(Redis 宕机):
  Redis 挂了 → 所有请求缓存未命中 → 全打到检索库 → 检索库也挂了 → 雪崩
python 复制代码
# ── 防雪崩策略一: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

三种问题的防护总结:

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

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

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

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

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

  30 分钟的不一致窗口,对于产品参数这类信息是不可接受的
python 复制代码
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"

版本号校验的工作机制:

python 复制代码
# 写缓存时:记录当时的知识库版本号
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)
css 复制代码
版本号校验的完整流程:

  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

三种策略的配合关系

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

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

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

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

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

优化一:多粒度召回补充

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

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

解决:先在文档摘要层检索,定位到相关文档,再到段落层精检
python 复制代码
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
css 复制代码
适用场景                       不适用场景
────────────────────────────────────────────────
对比类问题(A vs B)            单一产品功能咨询
跨文档综合问题                  FAQ 类简单问答
知识库文档数量 > 100 篇时       文档少于 20 篇时(直接全库搜即可)
1b. 历史问答召回
arduino 复制代码
问题:
  用户问 "耳机降噪怎么开"
  → 3 个月前已经有人工客服完美回答过这个问题
  → 那条历史回答比 RAG 检索+LLM 生成的质量更高

解决:维护一个"精标问答对"索引,优先匹配历史优质答案
python 复制代码
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
markdown 复制代码
历史问答在全流程中的位置:

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

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

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

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

  Rerank 分数高 ≠ 能回答问题
  Rerank 衡量的是"语义相关度",不是"能否回答"
python 复制代码
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": "校验解析失败,默认放行"}

拒答结果在下游的处理:

python 复制代码
# 在 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
markdown 复制代码
                    Rerank 分数高        Rerank 分数低
                  ────────────────    ────────────────
相关性校验 pass    正常回答 ✅          (不会出现这种情况)
相关性校验 fail    拒答 ← 防止幻觉     空结果,走兜底回答

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

python 复制代码
# 只在以下情况触发相关性校验:
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 压缩 / 合并 / 去冗余

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

  冗余的后果:
  1. 浪费 LLM 的上下文窗口和费用
  2. 无关内容可能干扰 LLM 回答质量
  3. 重复内容导致 LLM 回答也重复
python 复制代码
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

处理前后对比:

scss 复制代码
处理前(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%,信息密度显著提升

优化四:引用溯源

arduino 复制代码
问题:
  客服场景中,用户(和质检人员)需要知道回答的依据从哪来。
  "你凭什么说降噪深度是 -38dB?" → 需要能追溯到具体文档、章节、页码。
python 复制代码
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 中的效果:

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

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

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

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

LLM 生成的回答:

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

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

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

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

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

四项优化的集成

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

python 复制代码
# 阶段七: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~200ms 1 次 LLM 面向 C 端必须有,仅灰色地带触发
Chunk 压缩/合并 < 5ms 强烈推荐,纯规则零成本
引用溯源 < 1ms 强烈推荐,客服场景刚需

各阶段耗时与成本分析

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

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

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

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

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

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

bash 复制代码
阶段一  原始 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:完整深度检索(仅混合检索)

bash 复制代码
阶段一  原始 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)

bash 复制代码
阶段一~五  同上               ~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 万次请求)

bash 复制代码
请求分布(典型电商客服场景):
  路径 ⓪ 跳过 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~100ms bge-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 可以用通用知识兜底回答
相关推荐
逻极4 小时前
Hermes Agent深度解析:从ReAct到多智能体系统架构实战
llm·agent·react·rag·多智能体系统
冬奇Lab14 小时前
Agent 系列(13):Agent 安全与防护——提示词注入、工具滥用、数据泄露怎么防
人工智能·llm·agent
装不满的克莱因瓶17 小时前
学习并掌握 LangChain 检索器的作用,实现让 LLM 动态调用知识库功能
人工智能·python·ai·langchain·llm·agent·智能体
惟愿光怪陆离19 小时前
OpenCode 注意事项
llm
初旭save1 天前
Agent Skill 不是写 Prompt,是给 LLM 做存储分层
llm·agent·claude
AINative软件工程1 天前
LLM 应用的 Rate Limiting 工程实战:Per-User Token 配额、滑动窗口限流与优先级队列的生产落地
llm
晨欣2 天前
Claude Opus 4.8:模型小幅升级,平台大步向前
llm·claude·anthropic·claude code·harness
lhxcc_fly2 天前
6.LangChain--RAG
langchain·llm·rag
lhxcc_fly2 天前
6.1RAG--文档加载器
langchain·llm·rag