RAG 检索效果差?多种查询预处理方案帮你搞定
❝你的 RAG 系统检索效果不好80% 的原因出在查询预处理上。本文系统梳理 7 种查询预处理方案重点讲清原理、关键细节和提示词设计。❞一、为什么查询预处理如此重要在 RAG 系统中有一个被大多数人忽略的关键环节——「查询预处理Query Preprocessing」。看一个典型场景用户什么是 Transformer 助手Transformer 是一种基于自注意力机制的神经网络架构…… 用户它和 RNN 有什么区别最后一句它和 RNN 有什么区别——如果直接拿这句话去向量数据库检索「向量数据库完全不知道它指的是 Transformer」检索结果大概率跑偏。查询预处理要解决的核心问题就是「将用户的原始查询转化为更适合检索的形式。」一个好的查询预处理可以将检索的「Recall10 提升 15%~40%」直接决定最终回答的质量。二、查询预处理的整体架构┌──────────────┐ │ 用户提问 │ └──────┬───────┘ ↓ ┌──────────────────────────┐ │ 第一层查询预处理 │ ← 本文重点 │ 查询改写/意图识别/查询扩展 │ └──────────┬───────────────┘ ↓ ┌──────────────────────────┐ │ 第二层向量检索粗排 │ └──────────┬───────────────┘ ↓ ┌──────────────────────────┐ │ 第三层精排 Reranker │ └──────────┬───────────────┘ ↓ ┌──────────────────────────┐ │ 第四层LLM 生成回答 │ └──────────────────────────┘在深入各方案之前先看一下接口抽象。不管用哪种方案对外暴露的接口应该是统一的// Enhancer 查询增强器接口 type Enhancer interface { EnhanceQuery(ctx context.Context, req *Request) (*Enhanced, error) } type Request struct { Query string // 用户当前查询 History []Message // 对话历史 } type Enhanced struct { Enhanced string // 增强后的查询 Keywords []string // 提取的关键词 }有了统一接口不同方案可以自由切换和组合。接下来逐一剖析。三、方案一Passthrough直接透传原理最简单的方案——「什么都不做」直接把用户的原始查询传给检索模块。用户查询: Go语言怎么做依赖注入 ↓ 检索查询: Go语言怎么做依赖注入 原封不动关键实现type PassthroughEnhancer struct{} func (p *PassthroughEnhancer) EnhanceQuery( ctx context.Context, req *Request, ) (*Enhanced, error) { return Enhanced{ Enhanced: req.Query, Keywords: []string{req.Query}, }, nil }适用场景✅「单轮对话」用户查询本身完整、自包含✅「MVP 快速上线」先跑通 RAG 全流程✅「成本敏感」不愿为每次查询额外调用模型❌ 多轮对话口语化查询一句话总结❝零延迟、零成本、零依赖但完全依赖用户输入质量。适合作为起步方案和降级兜底。❞四、方案二规则/统计方法Rule-Based原理通过预定义的规则和统计方法对查询进行预处理原始查询: 请问一下 trasformer 的 self atention 机制是怎么运作的呢 ↓ 停用词移除 → trasformer self atention 机制 运作 ↓ 拼写纠错 → transformer self attention 机制 运作 ↓ 同义词扩展 → keywords: [transformer, self-attention, 自注意力, 机制]四个核心操作「停用词移除」——去掉的、了、请问等无意义词「同义词扩展」——将ML扩展为Machine Learning「拼写纠错」——将Trasformer纠正为Transformer「词干提取」——将running还原为run英文场景关键实现核心数据结构就是两张表——停用词表和同义词表type RuleBasedEnhancer struct { stopWords map[string]bool // 停用词表 synonyms map[string][]string // 同义词映射 } func (e *RuleBasedEnhancer) EnhanceQuery( ctx context.Context, req *Request, ) (*Enhanced, error) { tokens : tokenize(req.Query) // 1. 移除停用词 filtered : make([]string, 0, len(tokens)) for _, t : range tokens { if !e.stopWords[strings.ToLower(t)] { filtered append(filtered, t) } } // 2. 同义词扩展扩充关键词列表不改变主查询 keywords : make([]string, 0) for _, t : range filtered { keywords append(keywords, t) if syns, ok : e.synonyms[strings.ToLower(t)]; ok { keywords append(keywords, syns...) } } return Enhanced{ Enhanced: strings.Join(filtered, ), Keywords: keywords, }, nil }「关键细节同义词表的维护策略」同义词表不是随便写的需要按优先级分层层级来源示例通用缩写行业通用k8s→kubernetes,db→database领域术语业务知识库DI→dependency injection→依赖注入口语映射用户行为日志挂了→服务不可用,卡了→性能问题适用场景✅ 有明确的行业术语/缩写词表✅ 延迟要求极高1ms✅ 搜索引擎式关键词匹配场景❌ 无法处理语义理解、多轮上下文一句话总结❝延迟极低、可控性强、可解释但需要人工维护词表无法处理复杂语义。❞五、方案三基于小模型的 NER 关键词提取原理使用轻量 NLP 模型从查询中提取结构化信息原始查询: 谷歌在2017年提出的Transformer架构是怎么处理长序列问题的 ↓ NER 实体提取 实体: [谷歌(ORG), 2017(DATE), Transformer(TECH)] ↓ 关键词提取 TF-IDF 排序 关键词: [Transformer, 长序列, 架构] ↓ 组合 增强查询: Transformer架构 长序列处理两个核心任务「命名实体识别NER」——提取人名、组织名、技术名词等实体「关键词提取」——用 TF-IDF 或词性标注识别最重要的术语关键实现Go 中常用 jieba 分词 词性标注来实现type NERKeywordEnhancer struct { segmenter *gojieba.Jieba idf map[string]float64 } func (e *NERKeywordEnhancer) EnhanceQuery( ctx context.Context, req *Request, ) (*Enhanced, error) { // 分词 词性标注 words : e.segmenter.Tag(req.Query) // 只保留名词(n)、专有名词(nr/ns/nt)、英文(eng) keywords : make([]string, 0) for _, w : range words { word, pos, _ : strings.Cut(w, /) if strings.HasPrefix(pos, n) || pos eng { keywords append(keywords, word) } } // TF-IDF 排序保留 Top-5 sort.Slice(keywords, func(i, j int) bool { return e.idf[keywords[i]] e.idf[keywords[j]] }) iflen(keywords) 5 { keywords keywords[:5] } return Enhanced{ Enhanced: strings.Join(keywords, ), Keywords: keywords, }, nil }「关键细节词性过滤规则」不是所有词性都有检索价值经验上保留的词性标签保留词性说明✅n/nr/ns/nt名词、人名、地名、机构名✅eng英文词通常是技术术语✅vn动名词如编程、部署❌v/d/p/c/u动词、副词、介词、连词、助词适用场景✅ 混合检索关键词检索 向量检索✅ 延迟可接受 10~50ms✅ 可离线部署不依赖外部 API❌ 无法处理多轮上下文和深度语义理解一句话总结❝低延迟可离线适合从查询中提取结构化信息辅助检索但语义理解能力有限。❞六、方案四LLM 查询改写Query Rewriting⭐❝这是当前「最主流」的方案重点讲。❞原理将用户的「对话历史」和「当前查询」发给 LLMLLM 理解上下文后输出一个「独立的、适合检索的查询」。对话历史: 用户: 什么是 Transformer 助手: Transformer 是一种基于自注意力机制的神经网络架构... 用户: 它和 RNN 有什么区别 ↓ [LLM 查询改写] ↓ 改写结果: Transformer架构与RNN循环神经网络的区别对比 提示词设计核心提示词是这个方案的灵魂。设计要点「System Prompt」——定义角色和规则你是一个查询改写助手。你的任务是将用户的对话式查询改写为适合向量数据库检索的独立查询。 规则 1. 改写后的查询必须是独立的不依赖对话上下文即可理解 2. 解析所有指代词它、这个、那个等替换为具体名词 3. 保留关键技术术语不要过度简化 4. 改写结果应该是一个陈述性的短语或短句不要用疑问句 5. 如果当前查询已经足够明确保持原样即可不要过度改写 6. 只输出改写后的查询不要解释「User Prompt」——动态构建对话历史 用户什么是 gRPC 助手gRPC 是 Google 开发的高性能 RPC 框架... 当前查询它支持哪些语言 请改写上述查询。「为什么提示词要这样写几个关键细节」「陈述性短语不要疑问句」——向量数据库中的文档是陈述性的用陈述句检索相似度更高「不要过度改写」——有些 LLM 会把简单查询润色得面目全非反而降低检索效果「只输出改写后的查询」——避免 LLM 输出好的改写结果如下...这类废话「Temperature 设为 0」——查询改写需要确定性输出不需要创造性关键实现const queryRewriteSystemPrompt 你是一个查询改写助手。你的任务是将用户的对话式查询改写为适合向量数据库检索的独立查询。 规则 1. 改写后的查询必须是独立的不依赖对话上下文即可理解 2. 解析所有指代词它、这个、那个等替换为具体名词 3. 保留关键技术术语不要过度简化 4. 改写结果应该是一个陈述性的短语或短句不要用疑问句 5. 如果当前查询已经足够明确保持原样即可不要过度改写 6. 只输出改写后的查询不要解释 示例 对话历史 用户什么是 gRPC 助手gRPC 是 Google 开发的高性能 RPC 框架... 当前查询它支持哪些语言 改写结果gRPC 支持的编程语言列表 type LLMQueryRewriter struct { client LLMClient model string } func (r *LLMQueryRewriter) EnhanceQuery( ctx context.Context, req *Request, ) (*Enhanced, error) { prompt : r.buildUserPrompt(req) resp, err : r.client.ChatCompletion(ctx, ChatRequest{ Model: r.model, Messages: []Message{ {Role: system, Content: queryRewriteSystemPrompt}, {Role: user, Content: prompt}, }, MaxTokens: 200, // 改写查询不需要太长 Temperature: 0.0, // 确定性输出 }) if err ! nil { // 降级返回原始查询不要让预处理失败阻断整个流程 return Enhanced{Enhanced: req.Query, Keywords: []string{req.Query}}, nil } enhanced : strings.TrimSpace(resp.Content) return Enhanced{Enhanced: enhanced, Keywords: extractKeywords(enhanced)}, nil } func (r *LLMQueryRewriter) buildUserPrompt(req *Request) string { var sb strings.Builder iflen(req.History) 0 { sb.WriteString(对话历史\n) for _, msg : range req.History { role : 用户 if msg.Role assistant { role 助手 } sb.WriteString(fmt.Sprintf(%s%s\n, role, msg.Content)) } sb.WriteString(\n) } sb.WriteString(fmt.Sprintf(当前查询%s\n\n请改写上述查询。, req.Query)) return sb.String() }模型选择查询改写任务「不需要大模型」7B~8B 的轻量模型完全够用模型延迟成本推荐度GPT-4o-mini~300ms~$0.0001/次⭐⭐⭐⭐⭐DeepSeek-V3~400ms~$0.0001/次⭐⭐⭐⭐⭐Qwen2.5-7B自部署~100ms自部署成本⭐⭐⭐⭐适用场景✅「多轮对话」必须方案✅ 口语化查询、跨语言检索❌ 超低延迟场景200~500ms 额外延迟❌ 完全离线环境一句话总结❝效果最好的通用方案多轮对话的刚需。注意降级策略和 Temperature 设为 0。❞七、方案五HyDE假想文档嵌入原理HyDEHypothetical Document Embedding由 CMU 在 2022 年提出思路非常巧妙「不直接用查询去检索而是先让 LLM 生成一个假想的回答文档用这个假想文档的 Embedding 去检索真实文档。」用户查询: Go语言怎么做依赖注入 ↓ LLM 生成假想回答 假想文档: 在Go语言中依赖注入通常通过构造函数参数传递来实现。 常见模式包括构造函数注入、Functional Options 模式、 Wire 框架的编译时注入、接口驱动设计…… ↓ 对假想文档做 Embedding ↓ 用该向量检索真实文档「为什么有效」向量空间中「问题和回答的向量距离通常较远」。用问题向量检索回答文档天然存在语义鸿沟向量空间 问题向量 ● ↗ ↖ 距离远 真实文档A ● ● 真实文档B ↑距离近 假想文档 ● ← 用这个向量检索和真实文档天然更近假想文档本身就是回答的形式它的向量和知识库中的真实文档向量天然更接近。 提示词设计核心HyDE 的提示词有两个关键要求「生成的内容要像一篇文档片段」而不是对话式回答「要尽量覆盖相关术语」因为最终目的是用 Embedding 做检索请针对以下问题写一段专业的技术文档片段。 要求 1. 直接给出内容不要包含根据、以下是等引导语 2. 尽量覆盖相关的技术术语和概念 3. 内容格式应该像知识库中的文档而不是聊天回答 4. 长度控制在 150~200 字 问题{query}「关键细节为什么要强调像文档而不是聊天回答」因为你的知识库里存的是文档如果假想文档的语体是首先我来给你解释一下……它的 Embedding 会偏向对话风格反而和文档的距离变远。关键实现type HyDEEnhancer struct { llmClient LLMClient model string } func (h *HyDEEnhancer) EnhanceQuery( ctx context.Context, req *Request, ) (*Enhanced, error) { hypoDoc, err : h.generateHypotheticalDoc(ctx, req.Query) if err ! nil { return Enhanced{Enhanced: req.Query, Keywords: []string{req.Query}}, nil } // 返回假想文档作为增强查询后续 Retriever 会对它做 Embedding 并检索 return Enhanced{Enhanced: hypoDoc, Keywords: extractKeywords(hypoDoc)}, nil } func (h *HyDEEnhancer) generateHypotheticalDoc( ctx context.Context, query string, ) (string, error) { prompt : fmt.Sprintf(请针对以下问题写一段专业的技术文档片段。 要求 1. 直接给出内容不要包含根据、以下是等引导语 2. 尽量覆盖相关的技术术语和概念 3. 内容格式应该像知识库中的文档而不是聊天回答 4. 长度控制在 150~200 字 问题%s, query) resp, err : h.llmClient.ChatCompletion(ctx, ChatRequest{ Model: h.model, Messages: []Message{ {Role: user, Content: prompt}, }, MaxTokens: 300, Temperature: 0.7, // 稍高温度覆盖更多术语 }) if err ! nil { return, fmt.Errorf(generate hypothetical document: %w, err) } return strings.TrimSpace(resp.Content), nil }「注意 Temperature 设为 0.7」——和查询改写不同这里希望生成的内容更丰富、覆盖更多术语所以温度稍高。适用场景✅「简短查询」——用户只输入几个词但需要匹配长文档✅「专业领域」——用户查询和文档之间存在术语鸿沟❌ 实时性要求高500ms~2s 延迟❌ LLM 幻觉敏感场景假想文档可能包含错误信息一句话总结❝巧妙地用假想回答弥补问题和文档之间的语义鸿沟对简短查询效果显著。注意提示词要强调像文档不像聊天。❞八、方案六Multi-Query多查询扩展原理核心思路「一个问题从不同角度生成多个查询变体」分别检索后用排序融合算法合并结果。原始查询: 如何优化 Go 程序的内存使用 ↓ LLM 生成多个变体 ┌─────────────────────────────────────┐ │ 变体1: Go语言内存优化最佳实践 │ │ 变体2: Golang 减少内存分配和GC压力 │ │ 变体3: Go内存泄漏排查和pprof使用 │ │ 变体4: Go sync.Pool对象池内存复用 │ └─────────────────┬───────────────────┘ ↓ 分别检索 → RRF 融合排序 → 更全面的结果 提示词设计核心Multi-Query 的提示词有一个核心要求——「变体之间要有差异化」不能换个措辞说同一件事请从不同角度改写以下查询生成 4 个搜索查询变体。 原始查询{query} 要求 1. 每行一个查询变体不要编号 2. 每个变体必须是独立的、完整的查询 3. 变体之间要有明确的角度差异例如 - 概念解释角度 - 实现方法角度 - 最佳实践角度 - 常见问题/排错角度 4. 不要只是换个措辞说同一件事 5. 只输出查询变体不要其他内容「关键细节不要只换措辞这条为什么重要」如果 4 个变体只是同义改写优化内存、减少内存、内存性能调优、内存管理优化它们检索到的文档高度重叠Multi-Query 就退化成了普通查询改写白白多花了 4 倍检索成本。RRF 排序融合算法Multi-Query 的检索结果需要用「Reciprocal Rank FusionRRF」合并公式RRF_score(doc) Σ 1/(k rank_i) 其中 k 通常取 60 示例3 个查询变体 查询1排序: [文档A, 文档B, 文档C] 查询2排序: [文档C, 文档A, 文档E] 查询3排序: [文档A, 文档E, 文档C] 文档A: 1/(600) 1/(601) 1/(600) 0.0498 ← 排第1 文档C: 1/(602) 1/(600) 1/(602) 0.0489 ← 排第2 文档E: 0 1/(602) 1/(601) 0.0325 ← 排第3关键实现type MultiQueryEnhancer struct { llmClient LLMClient model string numQueries int } func (m *MultiQueryEnhancer) EnhanceQuery( ctx context.Context, req *Request, ) (*Enhanced, error) { variants, err : m.generateVariants(ctx, req.Query) if err ! nil { return Enhanced{Enhanced: req.Query, Keywords: []string{req.Query}}, nil } // 原始查询 变体 allQueries : append([]string{req.Query}, variants...) return Enhanced{ Enhanced: strings.Join(allQueries, \n), Keywords: dedup(extractAllKeywords(allQueries)), }, nil } // RRF 融合排序配合 Retriever 使用 func rrfFusion(results [][]Document, k int) []Document { scores : make(map[string]float64) docs : make(map[string]Document) for _, ranking : range results { for rank, doc : range ranking { scores[doc.ID] 1.0 / float64(krank) docs[doc.ID] doc } } // 按 RRF 分数降序排列 type scored struct { doc Document score float64 } sorted : make([]scored, 0, len(docs)) for id, doc : range docs { sorted append(sorted, scored{doc: doc, score: scores[id]}) } sort.Slice(sorted, func(i, j int) bool { return sorted[i].score sorted[j].score }) result : make([]Document, len(sorted)) for i, s : range sorted { result[i] s.doc } return result }适用场景✅「高召回率要求」——不能漏掉任何相关文档✅「复杂问题」——涉及多个维度和子主题❌ 实时对话总延迟 1~3s❌ 成本敏感多次 LLM 调用 多次检索一句话总结❝召回率最高的方案但延迟和成本也最高。提示词的关键是确保变体之间有真正的角度差异。❞九、方案七Step-Back Prompting后退提示原理由 Google DeepMind 在 2023 年提出核心思路「不直接检索具体问题而是先后退一步将问题抽象为更高层次的概念性问题用抽象问题检索背景知识再结合原始问题检索具体细节。」原始查询具体: gRPC-Go 中如何实现双向流式 RPC ↓ Step-Back 抽象 抽象查询通用: gRPC 流式通信的类型和实现模式 ↓ 用抽象查询检索 → 背景知识流式 RPC 的四种类型、原理 用原始查询检索 → 具体细节Go 代码实现 ↓ 合并两组结果LLM 同时拿到背景和细节来回答 提示词设计核心Step-Back 的提示词需要精确控制后退的粒度——太粗会检索到不相关的内容太细等于没后退你是一个搜索专家。给定一个具体的技术问题请后退一步 生成一个更高层次的概念性问题用于检索回答原始问题所需的背景知识。 规则 1. 抽象问题应该覆盖原始问题所属的知识领域 2. 保留核心主题不要太笼统什么是编程就太笼统了 3. 只输出一个问题不要解释 示例 具体问题Python 3.12 中 type 语句的语法是什么 抽象问题Python 类型系统和类型注解的语法特性 具体问题React useEffect 的依赖数组为空时会怎样 抽象问题React Hooks 中 useEffect 的生命周期和依赖机制 具体问题{query} 抽象问题「关键细节为什么要给示例」Step-Back 的后退粒度很难用规则描述清楚但「few-shot 示例可以隐式传达粒度标准」。示例中Python 3.12 type 语句 → Python 类型系统就是一个恰到好处的后退——退到了所属知识领域但没退到什么是 Python。关键实现type StepBackEnhancer struct { llmClient LLMClient model string } func (s *StepBackEnhancer) EnhanceQuery( ctx context.Context, req *Request, ) (*Enhanced, error) { abstractQuery, err : s.generateStepBack(ctx, req.Query) if err ! nil { return Enhanced{Enhanced: req.Query, Keywords: []string{req.Query}}, nil } // 同时返回原始查询和抽象查询后续分别检索再合并 combined : req.Query \n abstractQuery keywords : dedup(append( extractKeywords(req.Query), extractKeywords(abstractQuery)..., )) return Enhanced{Enhanced: combined, Keywords: keywords}, nil }适用场景✅「需要背景知识才能回答」的问题✅「教程/文档检索」——知识库中有概念文档和实操文档❌ 简单直接的事实性问题Go 的 goroutine 上限是多少不需要后退一句话总结❝通过后退一步检索背景知识让回答更有深度。提示词的关键是用 few-shot 示例控制后退粒度。❞十、方案对比总结全维度对比维度Passthrough规则方法小模型NERLLM改写HyDEMulti-QueryStep-Back「延迟」0ms1ms10-50ms200-500ms0.5-2s1-3s300-600ms「成本」免费免费低中中高高中「精度提升」无低中高很高最高高「多轮支持」❌❌❌✅❌⚠️❌「实现复杂度」极低低中低中高低选型决策树你的场景是什么 │ ├─ 单轮 查询质量高 → Passthrough ├─ 有多轮对话 → LLM 查询改写必须 │ ├─ 还需要更高召回率→ Multi-Query │ └─ 术语鸿沟大 → HyDE ├─ 延迟要求 10ms → 规则方法 ├─ 延迟要求 50ms → 小模型 NER └─ 需要背景知识 → Step-Back LLM 改写十一、实战建议从简单开始逐步升级「MVP 阶段」Passthrough跑通全流程「优化阶段」加入 LLM 查询改写「精细化阶段」按需引入 HyDE / Multi-Query降级策略是必须的任何使用外部模型的方案都「必须」有降级处理。LLM 挂了不能让整个 RAG 挂func (r *LLMQueryRewriter) EnhanceQuery( ctx context.Context, req *Request, ) (*Enhanced, error) { result, err : r.callLLM(ctx, req) if err ! nil { log.WarnfContext(ctx, LLM rewrite failed, falling back: %v, err) return r.fallback.EnhanceQuery(ctx, req) // 降级到规则方法或 Passthrough } return result, nil }缓存可以大幅降低成本对于重复查询缓存改写结果可以同时省时间和省钱type CachedEnhancer struct { inner Enhancer cache *lru.Cache } func (c *CachedEnhancer) EnhanceQuery( ctx context.Context, req *Request, ) (*Enhanced, error) { key : buildCacheKey(req) if cached, ok : c.cache.Get(key); ok { return cached.(*Enhanced), nil } result, err : c.inner.EnhanceQuery(ctx, req) if err ! nil { returnnil, err } c.cache.Add(key, result) return result, nil }提示词设计的通用原则回顾全文各方案的提示词有一些共性原则原则说明反例「限制输出格式」只输出改写结果不要解释LLM 输出好的改写如下…「给 few-shot 示例」示例比规则描述更直观只写规则不给示例LLM 理解偏差「明确边界」如果已经足够明确保持原样LLM 过度改写简单查询「控制温度」改写用 0生成用 0.7改写用高温度导致不稳定「匹配目标形式」HyDE 要像文档改写要陈述句HyDE 生成对话式回答十二、写在最后查询预处理是 RAG 系统中被严重低估的环节。很多团队花大量时间调优 Embedding 模型和 Reranker却忽略了最前面的查询预处理。「记住一句话Garbage In, Garbage Out。」如果查询本身有问题后面的检索和排序做得再好也无济于事。选择适合你场景的方案从简单开始逐步优化——这才是 RAG 优化的正确姿势。