❝你的 RAG 系统检索效果不好?80% 的原因出在查询预处理上。本文系统梳理 7 种查询预处理方案,重点讲清原理、关键细节和提示词设计。 ❞
在 RAG 系统中,有一个被大多数人忽略的关键环节——「查询预处理(Query Preprocessing)」。
看一个典型场景:
用户:什么是 Transformer?
助手:Transformer 是一种基于自注意力机制的神经网络架构……
用户:它和 RNN 有什么区别?
最后一句"它和 RNN 有什么区别?"——如果直接拿这句话去向量数据库检索,「向量数据库完全不知道"它"指的是 Transformer」,检索结果大概率跑偏。
查询预处理要解决的核心问题就是:「将用户的原始查询转化为更适合检索的形式。」
一个好的查询预处理,可以将检索的 「Recall@10 提升 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 // 提取的关键词
}
有了统一接口,不同方案可以自由切换和组合。接下来逐一剖析。
最简单的方案——「什么都不做」,直接把用户的原始查询传给检索模块。
用户查询: "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
}
❝零延迟、零成本、零依赖,但完全依赖用户输入质量。适合作为起步方案和降级兜底。 ❞
通过预定义的规则和统计方法对查询进行预处理:
原始查询: "请问一下 trasformer 的 self atention 机制是怎么运作的呢?"
↓ 停用词移除 → "trasformer self atention 机制 运作"
↓ 拼写纠错 → "transformer self attention 机制 运作"
↓ 同义词扩展 → keywords: ["transformer", "self-attention", "自注意力", "机制"]
四个核心操作:
核心数据结构就是两张表——停用词表和同义词表:
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→依赖注入 |
口语映射 | 用户行为日志 | 挂了→服务不可用, 卡了→性能问题 |
❝延迟极低、可控性强、可解释,但需要人工维护词表,无法处理复杂语义。 ❞
使用轻量 NLP 模型从查询中提取结构化信息:
原始查询: "谷歌在2017年提出的Transformer架构是怎么处理长序列问题的?"
↓ NER 实体提取
实体: [谷歌(ORG), 2017(DATE), Transformer(TECH)]
↓ 关键词提取 + TF-IDF 排序
关键词: ["Transformer", "长序列", "架构"]
↓ 组合
增强查询: "Transformer架构 长序列处理"
两个核心任务:
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 | 动词、副词、介词、连词、助词 |
❝低延迟可离线,适合从查询中提取结构化信息辅助检索,但语义理解能力有限。 ❞
❝这是当前「最主流」的方案,重点讲。 ❞
将用户的「对话历史」和「当前查询」发给 LLM,LLM 理解上下文后输出一个「独立的、适合检索的查询」。
对话历史:
用户: 什么是 Transformer?
助手: Transformer 是一种基于自注意力机制的神经网络架构...
用户: 它和 RNN 有什么区别?
↓
[LLM 查询改写]
↓
改写结果: "Transformer架构与RNN循环神经网络的区别对比"
提示词是这个方案的灵魂。设计要点:
「System Prompt」——定义角色和规则:
你是一个查询改写助手。你的任务是将用户的对话式查询改写为适合向量数据库检索的独立查询。
规则:
1. 改写后的查询必须是独立的,不依赖对话上下文即可理解
2. 解析所有指代词("它"、"这个"、"那个"等),替换为具体名词
3. 保留关键技术术语,不要过度简化
4. 改写结果应该是一个陈述性的短语或短句,不要用疑问句
5. 如果当前查询已经足够明确,保持原样即可,不要过度改写
6. 只输出改写后的查询,不要解释
「User Prompt」——动态构建:
对话历史:
用户:什么是 gRPC?
助手:gRPC 是 Google 开发的高性能 RPC 框架...
当前查询:它支持哪些语言?
请改写上述查询。
「为什么提示词要这样写?几个关键细节:」
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 | 自部署成本 | ⭐⭐⭐⭐ |
❝效果最好的通用方案,多轮对话的刚需。注意降级策略和 Temperature 设为 0。 ❞
HyDE(Hypothetical Document Embedding)由 CMU 在 2022 年提出,思路非常巧妙:
「不直接用查询去检索,而是先让 LLM 生成一个"假想的回答文档",用这个假想文档的 Embedding 去检索真实文档。」
用户查询: "Go语言怎么做依赖注入?"
↓ LLM 生成假想回答
假想文档: "在Go语言中,依赖注入通常通过构造函数参数传递来实现。
常见模式包括:构造函数注入、Functional Options 模式、
Wire 框架的编译时注入、接口驱动设计……"
↓ 对假想文档做 Embedding
↓ 用该向量检索真实文档
「为什么有效?」
向量空间中,「"问题"和"回答"的向量距离通常较远」。用问题向量检索回答文档,天然存在语义鸿沟:
向量空间:
问题向量 ●
↗ ↖ (距离远)
真实文档A ● ● 真实文档B
↑(距离近)
假想文档 ● ← 用这个向量检索,和真实文档天然更近
假想文档本身就是"回答"的形式,它的向量和知识库中的真实文档向量天然更接近。
HyDE 的提示词有两个关键要求:
请针对以下问题,写一段专业的技术文档片段。
要求:
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」——和查询改写不同,这里希望生成的内容更丰富、覆盖更多术语,所以温度稍高。
❝巧妙地用"假想回答"弥补问题和文档之间的语义鸿沟,对简短查询效果显著。注意提示词要强调"像文档不像聊天"。 ❞
核心思路:「一个问题从不同角度生成多个查询变体」,分别检索后用排序融合算法合并结果。
原始查询: "如何优化 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 倍检索成本。
Multi-Query 的检索结果需要用 「Reciprocal Rank Fusion(RRF)」 合并:
公式:RRF_score(doc) = Σ 1/(k + rank_i) 其中 k 通常取 60
示例(3 个查询变体):
查询1排序: [文档A, 文档B, 文档C]
查询2排序: [文档C, 文档A, 文档E]
查询3排序: [文档A, 文档E, 文档C]
文档A: 1/(60+0) + 1/(60+1) + 1/(60+0) = 0.0498 ← 排第1
文档C: 1/(60+2) + 1/(60+0) + 1/(60+2) = 0.0489 ← 排第2
文档E: 0 + 1/(60+2) + 1/(60+1) = 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(k+rank)
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
}
❝召回率最高的方案,但延迟和成本也最高。提示词的关键是确保变体之间有真正的角度差异。 ❞
由 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
}
❝通过"后退一步"检索背景知识,让回答更有深度。提示词的关键是用 few-shot 示例控制后退粒度。 ❞
维度 | Passthrough | 规则方法 | 小模型NER | LLM改写 | HyDE | Multi-Query | Step-Back |
|---|---|---|---|---|---|---|---|
「延迟」 | 0ms | <1ms | 10-50ms | 200-500ms | 0.5-2s | 1-3s | 300-600ms |
「成本」 | 免费 | 免费 | 低 | 中 | 中高 | 高 | 中 |
「精度提升」 | 无 | 低 | 中 | 高 | 很高 | 最高 | 高 |
「多轮支持」 | ❌ | ❌ | ❌ | ✅ | ❌ | ⚠️ | ❌ |
「实现复杂度」 | 极低 | 低 | 中 | 低 | 中 | 高 | 低 |
你的场景是什么?
│
├─ 单轮 + 查询质量高? → Passthrough
├─ 有多轮对话? → LLM 查询改写(必须)
│ ├─ 还需要更高召回率?→ + Multi-Query
│ └─ 术语鸿沟大? → + HyDE
├─ 延迟要求 < 10ms? → 规则方法
├─ 延迟要求 < 50ms? → 小模型 NER
└─ 需要背景知识? → Step-Back + LLM 改写
任何使用外部模型的方案,都「必须」有降级处理。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 优化的正确姿势。