AI混合搜索实践笔记
这让我意识到,关键词搜索和语义搜索各有优劣,真正的解决方案应该是将两者结合起来——也就是所谓的"混合搜索"。
写在前面
最近在做知识库搜索的时候,遇到了一个很经典的问题:用户搜索 “怎么解决数据库连接超时”,纯关键词搜索能匹配到包含这些词的文档,但会漏掉那些用 “connection timeout” 或 “DB connection issue” 表达相同意思的文档;而纯语义搜索虽然能理解语义,但对产品名称、版本号、配置项这些精确匹配的场景又不太靠谱。
这让我意识到,关键词搜索和语义搜索各有优劣,真正的解决方案应该是将两者结合起来——也就是所谓的"混合搜索"。
这篇文章记录了我从纯关键词搜索到语义搜索,再到混合搜索的完整实践过程,包括遇到的各种坑和最终的解决方案。
背景与需求
现状:传统搜索的局限性
最开始我们的知识库搜索是基于 Elasticsearch 的纯关键词搜索,实现起来很简单:
from elasticsearch import Elasticsearch
es = Elasticsearch(['http://localhost:9200'])
def search_docs(query):
result = es.search(
index='docs',
body={
'query': {
'multi_match': {
'query': query,
'fields': ['title', 'content', 'tags']
}
}
}
)
return result['hits']['hits']
这个方案有几个明显的问题:
- 同义词失效:搜索 “bug” 找不到 “issue” 或 “缺陷”
- 语义理解差:搜索 “性能优化” 找不到 “提升速度” 或 “优化响应时间”
- 上下文缺失:搜索 “Python” 会把所有提到 Python 的文档都返回,不管用户是要看教程、bug 还是架构设计
尝试:纯语义搜索的诱惑
听说最近很火的语义搜索能解决这些问题,我尝试了 OpenAI 的 text-embedding-3-small 模型:
import numpy as np
from openai import OpenAI
client = OpenAI()
def get_embedding(text):
response = client.embeddings.create(
model='text-embedding-3-small',
input=text
)
return np.array(response.data[0].embedding)
def search_semantic(query):
query_embedding = get_embedding(query)
# 这里简化了,实际需要用 Faiss 或其他向量数据库
similarities = cosine_similarity(query_embedding, doc_embeddings)
return docs[np.argsort(-similarities)[:10]]
结果发现语义搜索也有它自己的问题:
- 精确匹配差:搜索 “Redis 7.2” 可能会返回 Redis 6.0 的文档,因为语义上很接近
- 专有词汇敏感:产品名称、技术术语的语义向量可能不稳定
- 冷启动问题:新的技术或概念没有足够的语料,语义模型理解不准确
最终需求:既要又要
折腾了一圈后,我意识到真正需要的是一个能兼顾两者的搜索方案:
- 关键词搜索:擅长处理精确匹配、专有名词、版本号等
- 语义搜索:擅长理解意图、同义词、上下文关系
- 混合策略:根据查询类型动态调整两者的权重
实现方案
架构设计
最终的架构采用了"双路召回 + 重排序"的模式:
代码实现
1. 双路召回
from elasticsearch import Elasticsearch
from openai import OpenAI
import numpy as np
from typing import List, Dict
class HybridSearchEngine:
def __init__(self):
self.es = Elasticsearch(['http://localhost:9200'])
self.openai_client = OpenAI()
self.index = 'docs'
def keyword_search(self, query: str, top_k: int = 50) -> List[Dict]:
"""关键词搜索召回"""
result = self.es.search(
index=self.index,
body={
'query': {
'bool': {
'should': [
{'match': {'title': {'query': query, 'boost': 3.0}}},
{'match': {'content': query}},
{'match': {'tags': {'query': query, 'boost': 2.0}}}
]
}
},
'size': top_k
}
)
return [{'id': hit['_id'], 'score': hit['_score']} for hit in result['hits']['hits']]
def semantic_search(self, query: str, top_k: int = 50) -> List[Dict]:
"""语义搜索召回"""
query_embedding = self._get_embedding(query)
# 这里用简单的 ES kNN 搜索,生产环境建议用专门的向量数据库
result = self.es.search(
index=self.index,
body={
'knn': {
'field': 'embedding',
'query_vector': query_embedding.tolist(),
'k': top_k,
'num_candidates': 100
}
}
)
return [{'id': hit['_id'], 'score': hit['_score']} for hit in result['hits']['hits']]
def _get_embedding(self, text: str) -> np.ndarray:
"""获取文本的向量表示"""
response = self.openai_client.embeddings.create(
model='text-embedding-3-small',
input=text
)
return np.array(response.data[0].embedding)
2. 混合重排序
最关键的部分是如何混合两种搜索的结果。我尝试了几种策略:
策略 1:简单线性加权
def hybrid_rerank_linear(self, keyword_results: List[Dict],
semantic_results: List[Dict],
alpha: float = 0.5) -> List[Dict]:
"""线性加权混合"""
merged = {}
# 归一化关键词搜索得分
for item in keyword_results:
merged[item['id']] = {
'id': item['id'],
'keyword_score': item['score']
}
# 归一化语义搜索得分
for item in semantic_results:
if item['id'] in merged:
merged[item['id']]['semantic_score'] = item['score']
else:
merged[item['id']] = {
'id': item['id'],
'keyword_score': 0.0,
'semantic_score': item['score']
}
# 计算混合得分
for doc_id, scores in merged.items():
scores['final_score'] = (
alpha * scores.get('keyword_score', 0) +
(1 - alpha) * scores.get('semantic_score', 0)
)
return sorted(merged.values(), key=lambda x: -x['final_score'])
策略 2:RRF(Reciprocal Rank Fusion)
这个算法在实践中表现更好,因为它对分数的分布不那么敏感:
def hybrid_rerank_rrf(self, keyword_results: List[Dict],
semantic_results: List[Dict],
k: int = 60) -> List[Dict]:
"""RRF 混合排序"""
merged = {}
for rank, item in enumerate(keyword_results, 1):
if item['id'] not in merged:
merged[item['id']] = {'id': item['id'], 'rrf_score': 0}
merged[item['id']]['rrf_score'] += 1.0 / (k + rank)
for rank, item in enumerate(semantic_results, 1):
if item['id'] not in merged:
merged[item['id']] = {'id': item['id'], 'rrf_score': 0}
merged[item['id']]['rrf_score'] += 1.0 / (k + rank)
return sorted(merged.values(), key=lambda x: -x['rrf_score'])
3. 完整的搜索接口
def search(self, query: str, top_k: int = 10, method: str = 'rrf') -> List[Dict]:
"""混合搜索主入口"""
# 双路召回
keyword_results = self.keyword_search(query, top_k=50)
semantic_results = self.semantic_search(query, top_k=50)
# 混合重排序
if method == 'linear':
reranked = self.hybrid_rerank_linear(keyword_results, semantic_results)
elif method == 'rrf':
reranked = self.hybrid_rerank_rrf(keyword_results, semantic_results)
else:
raise ValueError(f"Unknown rerank method: {method}")
# 获取完整文档信息
top_ids = [item['id'] for item in reranked[:top_k]]
result = self.es.mget(index=self.index, ids=top_ids)
return [doc['_source'] for doc in result['docs'] if doc['found']]
踩坑记录
坑 1:向量维度不匹配
刚开始用的时候,我发现 ES 的 kNN 搜索总是报错,后来才发现是因为存储的向量维度和查询向量维度不一致:
# 错误示例
doc_embedding = get_embedding(doc_text) # 可能因为文本长度不同导致维度不同
解决方案:使用固定的嵌入模型,确保向量维度一致:
# 正确示例
def get_embedding(text: str) -> np.ndarray:
response = client.embeddings.create(
model='text-embedding-3-small', # 固定模型
input=text
)
return np.array(response.data[0].embedding) # 1536 维,固定不变
坑 2:召回效率问题
早期版本中,我先分别做关键词搜索和语义搜索,然后再合并结果,导致搜索响应时间很长(3-5 秒)。
优化方案:
- 并行召回:使用多线程同时执行两种搜索
- 减少召回数量:从 100 条降到 50 条,对最终结果影响不大但速度提升明显
- 缓存热门查询:对高频查询结果进行缓存
from concurrent.futures import ThreadPoolExecutor
def parallel_recall(self, query: str, top_k: int = 50) -> tuple:
"""并行召回"""
with ThreadPoolExecutor(max_workers=2) as executor:
keyword_future = executor.submit(self.keyword_search, query, top_k)
semantic_future = executor.submit(self.semantic_search, query, top_k)
keyword_results = keyword_future.result()
semantic_results = semantic_future.result()
return keyword_results, semantic_results
坑 3:语义搜索的幻觉
有一次搜索 “Redis 集群配置”,语义搜索返回了一篇关于 “MongoDB 分片集群” 的文档,因为语义上确实很接近,但用户显然不想要这个结果。
解决方案:在重排序时增加领域相关性检查:
def domain_filter(self, results: List[Dict], query: str) -> List[Dict]:
"""领域相关性过滤"""
# 提取查询中的技术关键词
tech_keywords = extract_tech_keywords(query)
filtered = []
for doc in results:
doc_text = f"{doc['title']} {doc['content']}"
# 检查是否包含主要技术关键词
if any(keyword in doc_text.lower() for keyword in tech_keywords):
filtered.append(doc)
return filtered
坑 4:混合参数难以调优
在尝试线性加权混合时,alpha 参数很难调优:alpha 太大就退化成了纯关键词搜索,alpha 太小又退化成了纯语义搜索。
解决方案:
- 基于查询类型动态调整:对精确查询(包含版本号、配置项)增加关键词权重
- 使用 RRF 替代线性加权:RRF 对参数不敏感,调参更容易
def adaptive_alpha(self, query: str) -> float:
"""根据查询类型自适应调整 alpha"""
# 精确查询特征:包含数字、特殊字符、技术术语
if re.search(r'\d+\.\d+|config|configuration|setup', query):
return 0.7 # 增加关键词权重
else:
return 0.5 # 默认均衡
结果评估
评估指标
为了评估混合搜索的效果,我设计了几个评估指标:
- Precision@10:前 10 个结果中相关文档的比例
- MRR(Mean Reciprocal Rank):第一个相关文档的倒数排名的平均值
- 用户满意度:通过用户点击和反馈间接评估
对比结果
| 搜索方式 | Precision@10 | MRR | 平均响应时间 |
|---|---|---|---|
| 纯关键词搜索 | 0.62 | 0.58 | 120ms |
| 纯语义搜索 | 0.68 | 0.65 | 800ms |
| 混合搜索(线性加权) | 0.75 | 0.72 | 950ms |
| 混合搜索(RRF) | 0.78 | 0.75 | 950ms |
可以看到,混合搜索在准确性和用户体验上都明显优于单一搜索方式,RRF 方案的表现略好于线性加权。
实际案例
案例 1:同义词匹配
查询:“修复 bug”
- 纯关键词搜索:只能找到包含 “bug” 的文档
- 纯语义搜索:能找到 “issue”、“缺陷”、“问题” 等相关文档,但可能包含不相关的
- 混合搜索:优先展示包含 “bug” 的文档,同时补充相关的语义匹配结果
案例 2:精确匹配
查询:“Redis 7.2 连接配置”
- 纯关键词搜索:能精确匹配到 Redis 7.2 的配置文档
- 纯语义搜索:可能返回 Redis 6.0 或其他版本的配置
- 混合搜索:优先匹配精确的版本号,同时补充相关的配置教程
案例 3:意图理解
查询:“提高数据库性能”
- 纯关键词搜索:只能匹配到包含这些词的文档
- 纯语义搜索:能找到 “性能优化”、“加速查询”、“索引优化” 等相关文档
- 混合搜索:结合关键词匹配和语义理解,提供最相关的结果
结语
从纯关键词搜索到纯语义搜索,再到最终的混合搜索,这个过程让我深刻理解到:技术选型没有银弹,关键是要理解每个方案的优缺点,然后根据实际场景选择合适的组合策略。
混合搜索不是简单地把两种搜索方式叠加起来,而是要针对具体的应用场景设计合适的召回策略、重排序算法和参数调优方案。在这个过程中,RRF 算法给了我很大的帮助,因为它对参数不敏感,且在各种场景下表现稳定。
现在这个混合搜索系统已经稳定运行了几个月,用户反馈也一直不错。当然,还有优化的空间,比如:
- 加入用户反馈的持续学习
- 针对不同领域的知识库采用不同的混合策略
- 探索更多的高级重排序算法(如 Learning to Rank)
搜索是一个深不见底的领域,但这次的实践让我对搜索系统有了更深入的理解,也让我更加相信:好的解决方案往往是在多个方案之间找到最佳的平衡点。
希望这篇文章对你在做类似搜索系统时有所帮助。如果你有更好的实践经验,欢迎交流!
版权声明: 本文首发于 指尖魔法屋-AI混合搜索实践笔记(https://blog.thinkmoon.cn/post/366-ai-hybrid-search-keyword-semantic-fusion/) 转载或引用必须申明原指尖魔法屋来源及源地址!
评论
使用 GitHub 账号登录后即可留言,支持 Markdown。