第一章:Query改写------把口语翻译成检索语言
1.1 为什么要改写
用户输入和文档语言之间存在"表达鸿沟"。文档里写的是"主轴驱动模块电源输入电压范围180-264VAC",用户问的是"这玩意儿电压要多少"。一个是标准化的技术表述,一个是口语化的提问。
向量检索有一定语义泛化能力,但碰到这种差距大的情况常常翻车。我们的实验显示,对query做改写后,Hit@5能提升4-8个百分点。
1.2 改写的核心规则
我们设计了一个三层改写策略:
**第一层:规范化**
去除口语词、语气词、重复词,补全省略的主语宾语。
```python
def normalize_query(query: str) -> str:
口语词映射
mapping = {
"咋": "怎么",
"啥": "什么",
"咋整": "如何处理",
"好不好": "是否正常",
"行不行": "是否可以",
"那": "", # 指代词单独处理
"这": "",
"个": "",
}
for k, v in mapping.items():
query = query.replace(k, v)
去除重复字符
query = re.sub(r'(.)\1{2,}', r'\1\1', query)
去除句首无意义词
query = re.sub(r'^(那|这|那个|这个|请问|我想问一下)\s*', '', query)
return query.strip()
```
**第二层:实体补全**
识别query中的实体(设备编号、故障代码、标准名称),确保格式规范化。
```python
import re
def normalize_entities(query: str) -> str:
设备编号:统一大写
MC-2023-001、mc2023-001 → MC-2023-001
pattern = r'(A-Za-z{2,})-_?(\d{4})-_?(\d{3})?'
query = re.sub(pattern, lambda m: m.group(1).upper() + '-' + m.group(2), query)
故障代码:确保有"报警"前缀
E201 → 报警E201
pattern = r'\b(A-Z)(\d{3})\b'
query = re.sub(pattern, r'\1\2报警', query) # E201报警
型号名称标准化
用维护的实体词典替换
for alias, standard in entity_dict.items():
query = query.replace(alias, standard)
return query
```
**第三层:语义补全**
结合会话上下文,补全省略的信息。这层最复杂,后面单独讲。
1.3 改写的实现方案
初期尝试了纯规则,覆盖了70%的常见模式,但边缘case太多,规则数量爆炸。后来改为**规则+小模型**的混合方案:
```python
class QueryRewriter:
def init(self):
self.rules = load_rules()
self.llm = LLMClient(model="qwen2-7b")
def rewrite(self, query: str, context: dict = None) -> str:
第一层:规则快速处理
normalized = self.apply_rules(query)
第二层:判断是否需要LLM改写
if self.need_llm_rewrite(normalized):
构造改写的prompt
prompt = self.build_rewrite_prompt(normalized, context)
rewritten = self.llm.generate(prompt)
return rewritten
return normalized
def need_llm_rewrite(self, query: str) -> bool:
满足以下条件之一需要LLM介入
conditions = [
len(query) < 5, # 太短
self.has_demonstrative(query), # 有指代词(这、那)
self.count_nouns(query) < 2, # 实词太少
self.is_vague_question(query) # 模糊问句
]
return any(conditions)
```
LLM改写的prompt:
```
你是一个检索查询改写专家。将用户的口语化问题改写为适合向量检索的技术问句。
规则:
-
补全省略的设备名称、参数名称
-
将指代词替换为具体的实体(从上下文中获取)
-
将模糊的动词替换为精确的技术动作("弄" → "处理")
-
如果问题信息严重不足(少于2个有效实体),返回"INSUFFICIENT"
-
不要添加用户没有提及的新信息
-
只输出改写后的问句,不要解释
对话上下文:
{context}
用户当前输入:{query}
改写结果:
```
1.4 一些坑和经验
**坑一:过度改写**
初期prompt写得太自由,LLM经常会"补充"一些用户没提到的信息。比如"MC-2023"被改写成"MC-2023数控机床主轴驱动参数",加了"数控机床"和"主轴驱动"------如果用户其实想问的是这个设备的维修记录呢?改写方向错了,检索直接跑偏。
后来在prompt里加了严格限制,并且对LLM的输出做了二次验证:
```python
def validate_rewrite(original: str, rewritten: str) -> bool:
检查改写结果是否保留了原query的核心实体
original_entities = extract_entities(original)
rewritten_entities = extract_entities(rewritten)
必须保留至少一个核心实体
if not original_entities:
return True # 原query没有实体,没办法
if not any(e in rewritten for e in original_entities):
return False
改写后长度不能超过原query的3倍
if len(rewritten) > len(original) * 3:
return False
return True
```
**坑二:性能问题**
LLM改写耗时80-150ms,对整体延迟影响很大。做了两级优化:
-
**缓存**:同样的query在短期内只改写一次。我们发现32%的查询是重复的。
-
**分级处理**:只有规则无法处理的query才走LLM,规则能解决的直接返回。规则覆盖约65%的query,只有35%走LLM。
**坑三:短query是死穴**
"温度""电压""报警"这种单个词的query,给什么模型都救不了。我们的做法是判断为"信息不足"后,不强行改写,而是返回一个引导式的追问:
```python
def handle_insufficient_query(query: str) -> str:
prompts = {
"温度": "请问您想查询什么设备的温度?是正常工作温度范围、还是温度异常报警的处理方法?",
"电压": "请问您想查询哪个设备的电压参数?输入电压还是输出电压?",
"报警": "请问您遇到了哪个报警代码?"
}
for keyword, prompt in prompts.items():
if keyword in query:
return prompt
return "您的问题信息较少,能否补充一下具体的设备名称或故障现象?"
```
第二章:意图分类------知道用户到底想干嘛
2.1 为什么要做意图分类
"MC-2023"这个query,在不同场景下可能指向不同意图:
-
查参数 → 需要检索规格说明书
-
查故障 → 需要检索维修日志
-
查库存 → 需要调API
同一个query,不同意图下最优的检索策略完全不同。如果不做意图分类,只能用通用策略覆盖所有情况,效果必然打折扣。
2.2 意图体系设计
我们设计了三级意图体系:
```
一级意图(4类)
├── 知识查询:问"是什么""怎么做"
├── 数据查询:问"是多少""当前状态"
├── 操作引导:问"怎么操作""步骤是什么"
└── 模糊/其他:无法明确归类
二级意图(细化)
├── 知识查询
│ ├── 参数查询
│ ├── 故障处理
│ ├── 操作流程
│ ├── 规范标准
│ └── 原理说明
├── 数据查询
│ ├── 实时数据(调用API)
│ ├── 历史记录
│ └── 统计分析
└── 操作引导
├── 设备操作
├── 软件操作
└── 安全规程
```
2.3 分类实现
**第一阶段:规则分类**
快速上线,覆盖高频场景:
```python
def classify_by_rules(query: str) -> str:
数字+单位 → 参数查询
if re.search(r'\d+(\.\d+)?\s*A-Za-z', query):
return 'knowledge/parameter'
包含"代码""编号""报警号" → 故障处理
if any(kw in query for kw in '代码', '编号', '报警', '故障'):
return 'knowledge/troubleshooting'
包含"多少""当前""现在" → 数据查询
if any(kw in query for kw in '多少', '当前', '现在'):
return 'data/current'
包含"怎么""步骤""如何" → 操作引导
if any(kw in query for kw in '怎么', '步骤', '如何', '操作'):
return 'guide/operation'
return 'unknown'
```
规则覆盖了约60%的query,准确率约85%。
**第二阶段:模型分类**
对规则覆盖不了的,用fasttext训练了一个轻量分类器:
```python
import fasttext
def train_intent_classifier(train_data_path: str):
训练数据格式:__label__intent1 文本内容
model = fasttext.train_supervised(
train_data_path,
lr=0.5,
epoch=25,
wordNgrams=2,
dim=100
)
return model
推理
def classify_intent(query: str) -> str:
labels, probs = model.predict(query, k=3)
if probs0 > 0.7:
return labels0.replace('label', '')
else:
return 'unknown' # 低置信度交给规则
```
训练数据来源:历史query的人工标注,累积了8000条。fasttext模型大小仅几MB,推理时间<10ms,非常适合线上部署。
模型分类准确率约78%,加上规则兜底,整体准确率能做到88%。
2.4 意图驱动的差异化处理
分类后不是简单的打标签,而是驱动后续处理:
```python
def route_by_intent(query: str, intent: str):
if intent.startswith('knowledge'):
纯RAG检索
return rag_search(query)
elif intent.startswith('data'):
尝试API调用,失败则降级到RAG
api_result = call_data_api(query)
if api_result:
return api_result
else:
return rag_search(query)
elif intent.startswith('guide'):
RAG + 偏向操作手册的检索权重
return rag_search(query, category_boost={'operation_manual': 2.0})
else:
未知意图:通用RAG + 返回追问
result = rag_search(query)
result'suggestion' = "如果没找到答案,请补充更多信息"
return result
```
第三章:指代消解------把"它"变成具体的东西
3.1 问题的严重性
我们统计了5000条历史query,发现约12%包含指代词(它、这、那、该设备、上述方法)。这些query如果不做指代消解,检索几乎必然失败。
3.2 方案一:简单回溯
用上一轮的实体作为替换:
```python
class CoreferenceResolver:
def init(self):
self.session_entities = {} # session_id -> 最近提到的实体列表
def resolve(self, query: str, session_id: str) -> str:
提取当前query中的指代词
references = self.extract_references(query)
if not references:
return query
从session中获取最近实体
entities = self.session_entities.get(session_id, \[\])
if not entities:
return query # 没有上下文,无法消解
替换
resolved = query
for ref in references:
用最近的实体替换
if ref in '它', '该', '其':
replaced = entities0 if entities else ref
elif ref == '那个':
replaced = entities1 if len(entities) > 1 else entities0
else:
replaced = entities0 if entities else ref
resolved = resolved.replace(ref, replaced)
return resolved
def extract_references(self, query: str) -> Liststr:
提取指代词
patterns = '它', '该', '其', '那个', '这个', '上述', '上述方法', '该设备'
return p for p in patterns if p in query
def update_session(self, session_id: str, entities: Liststr):
更新session中的实体列表(保留最近3个)
old = self.session_entities.get(session_id, \[\])
去重合并,新的放前面
merged = entities + e for e in old if e not in entities
self.session_entitiessession_id = merged:3
```
3.3 方案二:LLM联合消解
简单回溯不够用。比如:
> 用户:"MC-2023机床的加工精度是多少?"
> 系统:"MC-2023的加工精度为±0.01mm"
> 用户:"那它的定位精度呢?"
"它"指MC-2023,但"定位精度"和"加工精度"是同一设备的两个参数,需要从文档中补全。
LLM能做更好的消解:
```python
def llm_coreference_resolution(query: str, history: Listdict) -> str:
构造prompt,把对话历史传进去
history_text = "\n".join([
f"用户:{turn'user'}\n系统:{turn'assistant'}"
for turn in history-3: # 只保留最近3轮
])
prompt = f"""
以下是对话历史:
{history_text}
用户当前说:{query}
请将当前问题中的指代词替换为具体的实体,使其成为一个独立可检索的问句。
只输出替换后的结果,不要解释。
"""
return llm.generate(prompt)
```
LLM消解的准确率比规则高15个百分点,但延迟增加了80ms。权衡后采用混合策略:先试规则,规则的置信度低于0.7才走LLM。
第四章:Query扩展------把问号变成网
4.1 为什么要扩展
一个query只检索一次,命中的是某个"点"。但很多时候用户的问题有多个侧面,一个方向的答案不够。
比如"主轴温度报警",可能涉及:
-
报警代码含义
-
报警阈值设定
-
处理方法
-
预防措施
只检索一次只能覆盖一个方向。Query扩展可以生成多个视角的问句,覆盖更全面。
4.2 实现方案
```python
def expand_query(query: str) -> Liststr:
prompt = f"""
给定一个用户问题,生成4个语义相似但表达不同的检索问句。
这些问句应该从不同角度覆盖同一个问题的不同侧面。
原始问题:{query}
生成的4个问句(每行一个):
"""
variants = llm.generate(prompt).strip().split('\n')
return query + variants
```
然后多个变体分别检索,用RRF合并结果:
```python
def rrf_merge(results_lists: ListList\[Dict], k: int = 60) -> ListDict:
scores = {}
for results in results_lists:
for rank, doc in enumerate(results):
score = 1.0 / (k + rank + 1)
doc_id = doc'id'
if doc_id not in scores:
scoresdoc_id = {'score': 0, 'doc': doc}
scoresdoc_id'score' += score
sorted_docs = sorted(
scores.values(),
key=lambda x: x'score',
reverse=True
)
return item\['doc' for item in sorted_docs]
```
4.3 性能与取舍
Query扩展的代价是检索次数翻倍。我们只对"复杂的、开放性的"问题做扩展,对"明确的、事实性的"问题不做。
判断逻辑:
```python
def need_expansion(query: str) -> bool:
明确事实性问题不做扩展
if re.search(r'A-Z{2,}-\d{4,}', query): # 有设备编号
return False
if re.search(r'\d+\.?\d*\s*A-Za-z', query): # 有具体数值
return False
开放性问题做扩展
open_words = '如何', '怎么', '为什么', '有哪些', '什么', '怎么样'
if any(w in query for w in open_words):
return True
return False
```
第五章:用户意图的长期追踪
5.1 用户画像
不同岗位的用户,对同一问题的需求不同:
-
维修工问"主轴温度报警" → 要的是操作步骤
-
工艺工程师问同样的问题 → 要的是原因分析和参数调整建议
-
质量主管问同样的问题 → 要的是历史发生频率和影响范围
我们给每个用户维护一个"意图偏好":
```python
def update_user_profile(user_id: str, query: str, intent: str):
profile = get_profile(user_id)
profile'query_history'.append({
'query': query,
'intent': intent,
'timestamp': datetime.now()
})
分析用户最常用的意图
intents = h\['intent' for h in profile'query_history'-50:]
profile'preferred_intent' = Counter(intents).most_common(1)00
save_profile(user_id, profile)
```
在意图分类时,如果模型置信度不高,就用用户的历史偏好做bias:
```python
def classify_with_bias(query: str, user_id: str) -> str:
intent, confidence = model.predict(query)
if confidence < 0.7:
低置信度时用用户偏好
profile = get_profile(user_id)
if profile:
return profile'preferred_intent'
return intent
```
5.2 词汇演变
制造业的术语在变,老员工用旧称呼,新员工用新称呼。我们维护一个"术语演变表":
```python
从query日志中自动发现同义表达
def discover_synonyms():
同一个用户在一个session里先后问过:
"MC-2023" 和 "新加工中心"
如果后续问"新加工中心"检索到的内容和"MC-2023"一致
就把"新加工中心"加入MC-2023的同义词列表
pass
```
同义词自动发现是个复杂的任务,目前还在探索中。初步方案是用embedding相似度聚类 + 人工审核。
第六章:效果数据
这套Query理解系统上线前后的数据对比:
| 指标 | 上线前 | 上线后 | 提升 |
|------|--------|--------|------|
| Hit@5 | 0.78 | 0.87 | +9% |
| 空回答率 | 14% | 9% | -5% |
| 负反馈率 | 18% | 12% | -6% |
| 平均延迟 | 520ms | 610ms | +90ms |
| Query理解模块贡献 | - | 6个点 | - |
增加90ms延迟换来6个百分点的Hit@5提升,这笔交易是值得的。