ARTICLE · 1001784
AI之RAG系列-问题增强
本文约4680字,建议阅读时间15~30分钟
关键字:AI,LLM,RAG,Retrieval-Augmented Generation,知识库,向量数据库,知识检索,问题增强,Query Enhancement,查询改写,Query Rewriting,假设性文档嵌入,Hypothetical Document Embeddings,HyDE,Milvus,vllm,langchain,embedding
知识检索是指从结构化或非结构化的知识库中,根据用户的查询意图,自动定位、抽取并返回与之语义相关、可复用的知识单元(如事实、规则、概念、文档片段等)的过程,主要包括问题增强、元数据过滤、文档检索、重排优化等。
一、问题增强(Query Enhancement)
问题增强是指在执行向量检索前,通过变换、扩展或重构原始查询,以弥合用户查询表达与知识库文档表达之间的语义鸿沟,从而提升召回覆盖率与检索精度。用户查询通常是短、口语化、意图驱动的,而知识库文档是长、结构化、描述性的,两者在Embedding 空间中的分布天然存在偏差,直接检索往往召回不足。问题增强通过让"查询侧"的表达形式更接近"文档侧",来提升匹配精度,当前主流策略分为两大类:查询改写(Query Rewriting)与假设性文档嵌入(Hypothetical Document Embeddings,HyDE)。
1、查询改写
查询改写是最直接的问题增强方式,它通过LLM(如vLLM部署的模型)基于原始问题生成N个改写版本,提示词设计时要求模型保持语义一致性,改变句式结构、关键词同义替换等,通常生成2~5个变体,过多会引入噪声。LLM的temperature不宜过高(建议0.5~0.8),避免生成偏离原意的问题。原始问题和每个增强变体分别独立执行检索,然后将多路检索结果进行汇总、去重、排序。对于事实类问题查询可关闭增强以节省LLM调用成本。建议对生成的变体做语义去重,如与原始查询的向量相似度>0.95则丢弃。
查询改写通常适用于如下场景:
(1)用户问题表述模糊、口语化,与知识库正式表述差异大;
(2)需要覆盖同一概念的不同术语表达;
(3)多跳推理查询,需要分解为多个子问题分别检索;
2、假设性文档嵌入
假设性文档嵌入是一种反向生成式的查询增强策略,核心思想是利用LLM的生成能力,先根据用户查询"虚构"一篇理想答案文档,再用该假设文档的Embedding去检索真实文档。传统检索的困境在于查询-文档语义空间不对齐,HyDE则通过LLM将短查询扩展为一段包含关键信息的答案片段(100~300字),不需要完全准确,但应尽可能包含与用户问题相关的专业术语和核心概念,使查询侧的表达形式与文档侧对齐。假设文档与真实文档在长度、结构、术语密度上更接近,因此向量匹配更准确。
假设文档不宜过长,否则Embedding会稀释核心语义。建议temperature在0.3~0.5,比查询改写更低,确保假设文档聚焦不散漫。也可与查询改写叠加,先生成HyDE假设文档,同时用原始查询做改写变体,多路结果融合,覆盖更全面。HyDE通常适用于查询较短,而文档较长的场景。
3、查询改写与HyDE的比较
查询改写与HyDE是两种思路截然不同的问题增强策略,其联系与区别如下:
对比维度 | 查询改写 | 假设性文档嵌入(HyDE) |
核心思想 | 让查询覆盖更多表述变体 | 让查询的形态接近文档形态 |
增强对象 | 查询本身(保持短文本形式) | 生成假设文档(转换为长文本形式) |
输出形态 | N个短查询变体(每句5~20 字) | 1篇假设答案段落(100~300 字) |
检索方式 | 原始查询+N个变体分别独立检索,结果汇总去重 | 仅用假设文档的向量单路检索 |
对Embedding模型的要求 | 需支持查询指令前缀(如BGE的"为这个句子生成表示...") | 无需特殊指令,直接按普通文本编码 |
计算成本 | 较低(生成几个短句) | 较高(生成一篇段落) |
LLM Temperature | 0.5~0.8(需要一定多样性) | 0.3~0.5(需要聚焦不散漫) |
幻觉风险 | 低(只是改写措辞) | 中(假设文档可能包含编造信息) |
与原始查询的关系 | 原始查询与变体并行检索,结果合并 | 原始查询仅用于生成假设文档,不直接参与检索 |
召回特点 | 覆盖面广,适合术语变体多的场景 | 精度高,适合查询-文档长度鸿沟大的场景 |
最佳适用 | 术语变体多、表述差异大的开放域查询 | 查询极短、文档极长的专业领域查询 |
潜在缺点 | 变体过多引入噪声;temperature 过高偏离原意 | 假设文档幻觉严重,导致检索到错误主题 |
可叠加性 | 可与HyDE同时使用,多路结果融合 | 可与查询改写同时使用,多路结果融合 |
4、问题增强策略选择
当查询问题是事实等精确查询或者问题表述清楚且术语与知识库保持一致时,一般不需要做问题增强。但如果查询问题偏口语化,或者查询问题较短但知识库文档较长,或者对查询精度有要求,则需要采用问题增强。
场景 | 推荐策略 |
查询表述清晰、与文档术语一致 | 不增强 |
查询口语化、术语差异大 | 查询改写 |
查询极短(如少于5字)、文档极长 | HyDE |
高精度要求、预算充足 | 查询改写+HyDE |
事实等精确查询 | 不增强 |
二、代码示例
# 问题增强示例import loggingfrom typing import List, Optional, Dict, Any, Protocolfrom dataclasses import dataclass, fieldimport numpy as npfrom openai import OpenAI as _OpenAIfrom rag_embedding import RAGEmbedding# 调用向量化模块的RAGEmbedding类logging.basicConfig(level=logging.INFO,format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",)logger = logging.getLogger(__name__)# ==================== embedding模型与查询单元 ====================class EmbedderProtocol(Protocol):"""Embedding 模型协议,支持语义去重时注入"""def __init__(self,model_name: str = "BAAI/bge-small-zh-v1.5",device: str = "auto",cache_dir: Optional[str] = None):self.embedding=RAGEmbedding(model_name=model_name,device=device,normalize=True,cache_dir=cache_dir)def embed_query(self, text: str) -> List[float]:return self.embedding.encode(text).tolist()@dataclassclass EnhancedQuery:"""增强后的查询单元"""text: strquery_type: str = "original" # original / rewrite / hydedef get_search_text(self, query_instruction: Optional[str] = None) -> str:"""获取用于检索的文本。规则:- original / rewrite: 加 query_instruction 前缀(BGE 需要)- hyde: 直接返回,不加前缀(已是文档形态)"""if self.query_type == "hyde":return self.textif query_instruction:return query_instruction + self.textreturn self.text# ==================== 配置类 ====================@dataclassclass QueryEnhancementConfig:"""问题增强配置"""enabled: bool = Falsemethod: str = "rewrite" # "rewrite" / "hyde" / "both"num_variants: int = 3temperature_rewrite: float = 0.7temperature_hyde: float = 0.3max_tokens_rewrite: int = 256max_tokens_hyde: int = 300# 语义去重配置semantic_dedup_enabled: bool = True # 是否启用向量语义去重semantic_dedup_threshold: float = 0.95 # 余弦相似度阈值,>= 则丢弃prompt_rewrite: str = """你是一个问题改写专家。请基于用户的原始问题,生成 {num_variants} 个语义相近但表述不同的问题,用于帮助检索系统更全面地召回相关信息。原始问题: {query}请直接输出改写后的问题,每行一个,不要添加编号或额外说明:"""prompt_hyde: str = """你是一位知识库专家。请根据用户的问题,撰写一段详细的、包含关键信息的答案片段(100~200字)。这段文字不需要完全准确,但应尽可能包含与用户问题相关的专业术语和核心概念,以便用于从知识库中检索相关的真实文档。用户问题: {query}假设答案:"""@dataclassclass LLMConfig:"""LLM 客户端配置"""base_url: str = "http://localhost:8000/v1"api_key: str = "EMPTY"model_name: str = "Qwen2.5-7B-Instruct"top_p: float = 0.9# ==================== 余弦相似函数 ====================def _cosine_similarity(a: List[float], b: List[float]) -> float:"""计算两个向量的余弦相似度"""a_arr = np.array(a, dtype=np.float32)b_arr = np.array(b, dtype=np.float32)norm_a = np.linalg.norm(a_arr)norm_b = np.linalg.norm(b_arr)if norm_a == 0 or norm_b == 0:return 0.0return float(np.dot(a_arr, b_arr) / (norm_a * norm_b))# ==================== 核心增强类 ====================class QueryEnhancer:"""问题增强器(含向量语义去重)去重策略(按顺序执行):1. 字符串去重:前100字符精确匹配,快速过滤完全重复项2. 语义去重(可选):基于 Embedding 余弦相似度,过滤语义等价项"""def __init__(self,config: Optional[QueryEnhancementConfig] = None,llm_config: Optional[LLMConfig] = None,llm_client: Optional[Any] = None,embedding_model: Optional[EmbedderProtocol] = None, # 用于语义去重):self.config = config or QueryEnhancementConfig()self.llm_config = llm_config or LLMConfig()self.embedding_model = embedding_modelif llm_client is not None:self.llm_client = llm_clientelse:self.llm_client = _OpenAI(base_url=self.llm_config.base_url,api_key=self.llm_config.api_key,)def enhance(self, query: str) -> List[EnhancedQuery]:"""执行问题增强,返回增强后的查询列表(首项始终为原始查询)"""results: List[EnhancedQuery] = [EnhancedQuery(text=query, query_type="original")]if not self.config.enabled:logger.info("[Enhance] 问题增强已禁用,返回原始查询")return resultsmethod = self.config.method.lower()if method in ("rewrite", "both"):rewrite_results = self._rewrite(query)results.extend(rewrite_results)logger.info(f"[Enhance] 查询改写生成 {len(rewrite_results)} 个变体")if method in ("hyde", "both"):hyde_results = self._hyde(query)results.extend(hyde_results)logger.info(f"[Enhance] HyDE 生成 {len(hyde_results)} 个假设文档")# 双层去重results = self._deduplicate(results)logger.info(f"[Enhance] 最终返回 {len(results)} 个查询(含原始查询)")return resultsdef _rewrite(self, query: str) -> List[EnhancedQuery]:"""查询改写:调用 LLM 生成语义相近的查询变体"""cfg = self.configif cfg.num_variants <= 0:return []try:prompt = cfg.prompt_rewrite.format(query=query, num_variants=cfg.num_variants)response = self.llm_client.chat.completions.create(model=self.llm_config.model_name,messages=[{"role": "system", "content": "你是一个专业的问题改写助手。"},{"role": "user", "content": prompt},],temperature=cfg.temperature_rewrite,max_tokens=cfg.max_tokens_rewrite,top_p=self.llm_config.top_p,)generated = response.choices[0].message.content.strip()variants = [line.strip() for line in generated.split('\n') if line.strip()]variants = variants[: cfg.num_variants]results = []for v in variants:if v != query:results.append(EnhancedQuery(text=v, query_type="rewrite"))return resultsexcept Exception as e:logger.warning(f"[Enhance] 查询改写失败: {e}")return []def _hyde(self, query: str) -> List[EnhancedQuery]:"""HyDE:调用 LLM 生成假设答案文档"""cfg = self.configtry:prompt = cfg.prompt_hyde.format(query=query)response = self.llm_client.chat.completions.create(model=self.llm_config.model_name,messages=[{"role": "system", "content": "你是一位知识库专家,擅长撰写用于检索的假设答案。"},{"role": "user", "content": prompt},],temperature=cfg.temperature_hyde,max_tokens=cfg.max_tokens_hyde,top_p=self.llm_config.top_p,)hypothetical_doc = response.choices[0].message.content.strip()if not hypothetical_doc:logger.warning("[Enhance] HyDE 生成空文档")return []return [EnhancedQuery(text=hypothetical_doc, query_type="hyde")]except Exception as e:logger.warning(f"[Enhance] HyDE 生成失败: {e}")return []# ==================== 双层去重 ====================def _deduplicate(self, queries: List[EnhancedQuery]) -> List[EnhancedQuery]:"""双层去重:1. 字符串去重(快速过滤完全重复项)2. 语义去重(基于 Embedding 余弦相似度,过滤语义等价项)"""# 第一层:字符串去重unique = self._deduplicate_by_text(queries)# 第二层:语义去重(仅在启用且有 embedding_model 时执行)if self.config.semantic_dedup_enabled and self.embedding_model is not None:try:unique = self._deduplicate_by_semantic(unique)except Exception as e:logger.warning(f"[Enhance] 语义去重失败,回退到字符串去重: {e}")return uniquedef _deduplicate_by_text(self, queries: List[EnhancedQuery]) -> List[EnhancedQuery]:"""基于文本前100字符精确去重"""seen = set()unique = []for q in queries:key = q.text[:100].strip()if key not in seen:seen.add(key)unique.append(q)return uniquedef _deduplicate_by_semantic(self, queries: List[EnhancedQuery]) -> List[EnhancedQuery]:"""基于向量余弦相似度的语义去重。规则:- 以原始查询(queries[0])为锚点- 对每个改写变体(rewrite),计算与所有已保留项的最大相似度- 若相似度 >= threshold,则认为语义等价,丢弃该变体- HyDE 文档不参与语义去重(形态差异大,与原始查询的相似度天然较低)- 变体之间也互相计算相似度,避免生成多个雷同改写"""if len(queries) <= 1:return queriesthreshold = self.config.semantic_dedup_threshold# 编码所有查询文本(统一加指令前缀,确保与检索时一致)vectors = []for q in queries:if q.query_type == "hyde":text = q.textelse:# 语义去重时,对 original/rewrite 统一加前缀编码,保证可比性text = q.get_search_text("为这个句子生成表示以用于检索相关文章:")vec = self.embedding_model.embed_query(text)vectors.append(vec)kept = [queries[0]] # 保留原始查询kept_vectors = [vectors[0]]for i in range(1, len(queries)):q = queries[i]current_vec = vectors[i]# 计算与所有已保留项的最大相似度max_sim = 0.0for kept_vec in kept_vectors:sim = _cosine_similarity(current_vec, kept_vec)if sim > max_sim:max_sim = simif max_sim >= threshold:logger.info(f"[SemanticDedup] 丢弃语义等价变体: '{q.text[:40]}...' "f"(max_sim={max_sim:.4f} >= threshold={threshold})")else:kept.append(q)kept_vectors.append(current_vec)removed = len(queries) - len(kept)if removed > 0:logger.info(f"[SemanticDedup] 语义去重移除 {removed} 个变体,保留 {len(kept)} 个")return kept