五、意图感知的检索 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_id 或 heading_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%
全流程核心原则
- 准入先行、不该检索的别检索 --- 闲聊、工具调用、多轮追问等场景在阶段零直接跳过,不浪费 embedding 和检索资源;检索到不相关的内容塞进 Prompt 反而干扰 LLM 回答质量
- 缓存优先、按需改写 --- 先用原始 query 尝试命中缓存,省掉不必要的 LLM 改写调用;只在未命中时才触发改写
- 两次缓存、逐步升级 --- 第一次用原始 query 快速匹配,第二次用改写后的规范 query 精确匹配,两次机会最大化命中率
- 向量复用、不浪费计算 --- 阶段一算出的 embedding 用于第一次缓存查找,阶段四的 embedding 用于第二次缓存查找和后续向量检索
- 策略路由、按需加载 --- 深度检索策略不是越多越好,路由器根据 query 特征选择 1~2 种最合适的策略
- 缓存结果而非回答 --- 缓存 Rerank 后的 chunks 而非 LLM 最终回答,保留 Prompt 灵活性和个性化能力
- 缓存 key 用改写后的 embedding --- 写入缓存时用改写后的规范化向量作为 key,后续匹配命中率更高
- 三层失效、数据不陈旧 --- 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% |
核心原则
- 离线做多、在线做少 --- 解析、分块、向量化、BM25 索引全部离线完成,在线只做检索和 rerank
- 意图驱动检索 --- 前六章的意图识别结果是 RAG 的天然增强,知道用户想干什么才能精准找答案
- 不能度量就不能优化 --- 评测集 + A/B 实验是调参的唯一依据,不要凭感觉
- Parent-Child 分块 --- 生产级 RAG 的标配,根本性解决精度 vs 上下文的矛盾
- 每个子模块独立降级 --- RAG 失败不阻塞流程,LLM 可以用通用知识兜底回答