第1919篇:MySQL全文检索与向量搜索的融合——不用换数据库的混合查询方案
第1919篇:MySQL全文检索与向量搜索的融合——不用换数据库的混合查询方案
有人问过我这样一个问题:公司的核心数据全在 MySQL,业务团队不可能同意迁移数据库,但又想做语义搜索,怎么办?
这个问题非常现实。在国内大多数互联网公司,MySQL 是绝对的主流,PostgreSQL 用的都是少数,更别说 Elasticsearch 或向量数据库了。"换数据库"这件事,光是数据迁移、业务改造、风险评估就能让一个项目拖半年。
但其实不换数据库也能做混合搜索,甚至在中小数据量下效果相当不错。这篇文章就来拆解这个方案。
一、MySQL 的全文检索能力现状
先说 MySQL 全文检索能做什么、不能做什么,这是整个方案的基础。
MySQL 5.6+ 支持全文索引(FULLTEXT INDEX),MySQL 8.0 对中文支持有所改善,但默认情况下中文支持还是非常有限——它用的是 ngram 分词,把文本按 n 个字符切分,而不是按语义切分。
-- MySQL 的 FULLTEXT 全文搜索
SELECT *, MATCH(title, content) AGAINST ('人工智能应用' IN BOOLEAN MODE) AS score
FROM articles
WHERE MATCH(title, content) AGAINST ('人工智能应用' IN BOOLEAN MODE)
ORDER BY score DESC
LIMIT 20;优点:MySQL 内置,不需要额外组件。 缺点:语义理解能力极弱,只能做关键词匹配;对中文支持依赖 ngram,效果一般;FULLTEXT 索引在高并发写入时有锁竞争问题。
所以,MySQL 全文检索作为"精确关键词匹配"的组件是够用的,但想要语义理解能力,必须引入向量搜索。
二、整体架构:MySQL + 外挂向量索引
方案的核心思路是:MySQL 存业务数据和全文索引,向量索引单独维护(pgvector 或 Milvus Lite),两者通过业务 ID 关联。
向量索引选型建议:
- 如果能加 PostgreSQL:用 pgvector,部署简单
- 如果需要纯内存高性能:用 Redis RediSearch
- 如果数据量大:用 Milvus 或 Qdrant
本文以 pgvector 为外挂向量索引举例,MySQL 负责业务数据和全文检索。
三、MySQL 全文检索优化
3.1 建表与索引
-- 文章表(MySQL)
CREATE TABLE articles (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
article_id VARCHAR(64) NOT NULL UNIQUE,
title VARCHAR(500) NOT NULL,
content TEXT NOT NULL,
summary TEXT,
category VARCHAR(100),
author_id BIGINT,
status TINYINT DEFAULT 1, -- 1:正常 0:下架
publish_time DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
-- 全文索引(ngram 分词,n=2)
FULLTEXT KEY ft_title_content (title, content)
WITH PARSER ngram,
-- 普通索引
KEY idx_category (category),
KEY idx_author_id (author_id),
KEY idx_status_publish (status, publish_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
COLLATE=utf8mb4_general_ci;ngram 的参数配置在 MySQL 配置文件里:
# my.cnf
[mysqld]
# ngram token size,2 是中文的合理选择
innodb_ft_min_token_size = 2
ngram_token_size = 2
# 全文索引缓冲区
innodb_ft_cache_size = 64M
innodb_ft_total_cache_size = 2G3.2 全文检索查询优化
@Repository
public interface ArticleMapper {
// Boolean Mode 全文检索(更灵活,支持 +/- 操作符)
@Select("""
SELECT id, article_id, title, summary, category,
MATCH(title, content)
AGAINST(#{keyword} IN BOOLEAN MODE) AS ft_score
FROM articles
WHERE status = 1
AND MATCH(title, content)
AGAINST(#{keyword} IN BOOLEAN MODE) > 0
AND (#{category} IS NULL OR category = #{category})
AND publish_time >= #{startTime}
ORDER BY ft_score DESC
LIMIT #{limit}
""")
List<ArticleSearchResult> fullTextSearch(
@Param("keyword") String keyword,
@Param("category") String category,
@Param("startTime") LocalDateTime startTime,
@Param("limit") int limit
);
}Boolean Mode 的常用操作符:
+苹果:必须包含"苹果"-安卓:必须不包含"安卓"苹果手机:自然语言模式,可选词"苹果手机":短语精确匹配苹果*:前缀匹配
3.3 避免全文检索的常见陷阱
// 陷阱1:关键词太短(ngram 最小 2 字符)
// 搜索单个汉字会失败,需要在业务层处理
if (keyword.length() < 2) {
// 退化为 LIKE 查询
return fallbackLikeSearch(keyword);
}
// 陷阱2:特殊字符没有转义
// MySQL 全文布尔模式中,+,-,*,~,<,>,(,),"" 是操作符
String safekeyword = escapeFulltextKeyword(keyword);
private String escapeFulltextKeyword(String keyword) {
// 简单处理:移除布尔操作符,只保留中文和字母数字
return keyword.replaceAll("[+\\-<>()~*\"@]", " ")
.trim();
}
// 陷阱3:全文索引在 WHERE 条件外用,会触发全表扫描
// 错误写法:WHERE category='tech' ORDER BY MATCH(...) DESC
// 正确写法:把 MATCH 放进 WHERE 过滤,再加其他条件四、向量搜索层:pgvector 独立维护
4.1 pgvector 向量表
-- PostgreSQL 中的向量索引表
-- 只存 article_id 和向量,不存业务数据
CREATE TABLE article_vectors (
id BIGSERIAL PRIMARY KEY,
article_id VARCHAR(64) NOT NULL UNIQUE, -- 关联 MySQL 的 article_id
embedding VECTOR(1536),
category VARCHAR(100), -- 冗余存储,用于向量搜索预过滤
status INT DEFAULT 1,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_article_vectors_hnsw
ON article_vectors
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);
CREATE INDEX idx_article_vectors_category
ON article_vectors(category);4.2 数据同步:MySQL -> pgvector
数据写入时需要同时维护 MySQL 和 pgvector:
@Service
@RequiredArgsConstructor
@Transactional
@Slf4j
public class ArticleIndexService {
private final ArticleRepository articleRepository; // MySQL
private final ArticleVectorRepository vectorRepository; // PostgreSQL
private final EmbeddingClient embeddingClient;
/**
* 发布文章(同时写入 MySQL 全文索引和向量索引)
*/
public void publishArticle(ArticlePublishDTO dto) {
// 1. 写入 MySQL
Article article = Article.builder()
.articleId(dto.getArticleId())
.title(dto.getTitle())
.content(dto.getContent())
.summary(dto.getSummary())
.category(dto.getCategory())
.status(1)
.publishTime(LocalDateTime.now())
.build();
articleRepository.save(article);
// 2. 异步生成向量并写入 pgvector
// 向量写入失败不影响主业务(MySQL 写入成功即可发布)
CompletableFuture.runAsync(() -> {
try {
String textToEmbed = dto.getTitle() + "\n" + dto.getSummary();
float[] embedding = embeddingClient.embed(textToEmbed);
ArticleVector vector = ArticleVector.builder()
.articleId(dto.getArticleId())
.embedding(embedding)
.category(dto.getCategory())
.status(1)
.build();
vectorRepository.save(vector);
log.debug("向量写入成功,articleId={}", dto.getArticleId());
} catch (Exception e) {
log.error("向量写入失败,articleId={},将加入重试队列",
dto.getArticleId(), e);
// 写入失败加入重试队列(可以用 Redis List 实现)
retryQueue.add(dto.getArticleId());
}
});
}
/**
* 补偿任务:给还没有向量的文章生成向量
*/
@Scheduled(fixedDelay = 300000) // 每5分钟跑一次
public void compensationTask() {
// 从 MySQL 查出有记录但 pgvector 没有的文章
List<String> missingVectorArticles = findArticlesWithoutVector();
if (!missingVectorArticles.isEmpty()) {
log.info("发现 {} 篇文章缺少向量,开始补偿",
missingVectorArticles.size());
missingVectorArticles.forEach(this::generateAndSaveVector);
}
}
}五、混合搜索的核心实现
5.1 搜索 Service
@Service
@RequiredArgsConstructor
@Slf4j
public class HybridSearchService {
private final ArticleMapper articleMapper;
private final ArticleVectorRepository vectorRepository;
private final EmbeddingClient embeddingClient;
// RRF 的 k 参数
private static final int RRF_K = 60;
/**
* 混合搜索入口
*/
public List<ArticleSearchResult> search(
String query,
String category,
LocalDateTime startTime,
int topK) {
long start = System.currentTimeMillis();
// 1. 全文检索(MySQL)- 获取关键词相关文章
String booleanKeyword = buildBooleanKeyword(query);
List<ArticleSearchResult> ftResults = articleMapper.fullTextSearch(
booleanKeyword, category, startTime, topK * 2);
log.debug("全文检索耗时 {}ms,命中 {} 条",
System.currentTimeMillis() - start, ftResults.size());
// 2. 向量搜索(pgvector)- 获取语义相关文章
long vecStart = System.currentTimeMillis();
float[] queryVector = embeddingClient.embed(query);
List<ArticleVectorResult> vecResults = vectorRepository
.findTopKBySimilarity(queryVector, category, topK * 2);
log.debug("向量搜索耗时 {}ms,命中 {} 条",
System.currentTimeMillis() - vecStart, vecResults.size());
// 3. RRF 融合排名
Map<String, Double> rrfScores = computeRRF(ftResults, vecResults);
// 4. 取 topK,然后从 MySQL 批量查完整数据
List<String> topArticleIds = rrfScores.entrySet().stream()
.sorted(Map.Entry.<String, Double>comparingByValue().reversed())
.limit(topK)
.map(Map.Entry::getKey)
.collect(Collectors.toList());
if (topArticleIds.isEmpty()) {
return Collections.emptyList();
}
// 5. 批量查完整文章数据
List<Article> articles = articleMapper.findByArticleIds(topArticleIds);
// 6. 按 RRF 分数排序并组装结果
Map<String, Article> articleMap = articles.stream()
.collect(Collectors.toMap(Article::getArticleId, a -> a));
return topArticleIds.stream()
.filter(articleMap::containsKey)
.map(articleId -> {
Article article = articleMap.get(articleId);
return ArticleSearchResult.builder()
.articleId(article.getArticleId())
.title(article.getTitle())
.summary(article.getSummary())
.category(article.getCategory())
.score(rrfScores.get(articleId))
.build();
})
.collect(Collectors.toList());
}
/**
* RRF 算法:融合全文检索和向量搜索的排名
*/
private Map<String, Double> computeRRF(
List<ArticleSearchResult> ftResults,
List<ArticleVectorResult> vecResults) {
Map<String, Double> scores = new HashMap<>();
// 全文检索结果的 RRF 贡献
for (int i = 0; i < ftResults.size(); i++) {
String articleId = ftResults.get(i).getArticleId();
double rrfScore = 1.0 / (RRF_K + i + 1);
scores.merge(articleId, rrfScore, Double::sum);
}
// 向量搜索结果的 RRF 贡献
for (int i = 0; i < vecResults.size(); i++) {
String articleId = vecResults.get(i).getArticleId();
double rrfScore = 1.0 / (RRF_K + i + 1);
scores.merge(articleId, rrfScore, Double::sum);
}
return scores;
}
/**
* 把自然语言查询转换为 MySQL 布尔模式关键词
* 简单实现:分词后用 + 连接
*/
private String buildBooleanKeyword(String query) {
// 生产中应该用 IK 分词器或类似工具
// 这里简化为按空格切分
return Arrays.stream(query.split("\\s+"))
.filter(w -> w.length() >= 2)
.map(w -> "+" + w)
.collect(Collectors.joining(" "));
}
}5.2 查询策略自动选择
不是所有查询都需要混合搜索,根据查询特征自动选择最合适的策略:
@Service
@RequiredArgsConstructor
public class SearchStrategyRouter {
private final HybridSearchService hybridSearchService;
private final ArticleMapper articleMapper;
private final ArticleVectorRepository vectorRepository;
public enum SearchStrategy {
FULLTEXT_ONLY, // 纯全文:短查询、精确关键词
VECTOR_ONLY, // 纯向量:语义查询
HYBRID // 混合:默认
}
public List<ArticleSearchResult> search(SearchRequest request) {
SearchStrategy strategy = determineStrategy(request.getQuery());
log.info("查询策略:{},query={}", strategy, request.getQuery());
return switch (strategy) {
case FULLTEXT_ONLY ->
articleMapper.fullTextSearch(
request.getQuery(), request.getCategory(),
request.getStartTime(), request.getTopK());
case VECTOR_ONLY ->
vectorOnlySearch(request);
case HYBRID ->
hybridSearchService.search(
request.getQuery(), request.getCategory(),
request.getStartTime(), request.getTopK());
};
}
/**
* 根据查询特征决定搜索策略
*/
private SearchStrategy determineStrategy(String query) {
// 包含精确的引号匹配 -> 全文搜索
if (query.contains("\"")) {
return SearchStrategy.FULLTEXT_ONLY;
}
// 查询词少于 3 个字 -> 关键词搜索
if (query.length() < 4) {
return SearchStrategy.FULLTEXT_ONLY;
}
// 纯英文字母数字 -> 可能是产品名或技术词,全文更准
if (query.matches("[a-zA-Z0-9\\s.+#-]+")) {
return SearchStrategy.FULLTEXT_ONLY;
}
// 长查询(>20字)或有疑问词 -> 语义搜索更合适
if (query.length() > 20 ||
query.contains("如何") || query.contains("怎么") ||
query.contains("为什么") || query.contains("什么是")) {
return SearchStrategy.HYBRID;
}
// 默认混合
return SearchStrategy.HYBRID;
}
}六、数据一致性保障
MySQL 和 pgvector 两套存储之间的一致性是这个方案最大的工程挑战。
6.1 最终一致性方案
@Service
@RequiredArgsConstructor
public class VectorSyncService {
private final ArticleMapper articleMapper;
private final ArticleVectorRepository vectorRepository;
private final EmbeddingClient embeddingClient;
/**
* 定时对账:确保 MySQL 和向量索引的数据一致
* 每天凌晨跑一次
*/
@Scheduled(cron = "0 0 2 * * ?")
public void reconcile() {
log.info("开始 MySQL 与向量索引对账");
// 1. 找出 MySQL 中有但向量索引没有的
List<String> missingInVector = articleMapper.findArticleIdsNotInVector();
log.info("向量索引缺失 {} 条", missingInVector.size());
// 2. 找出向量索引有但 MySQL 没有的(僵尸数据)
List<String> zombieVectors = vectorRepository.findArticleIdsNotInMySQL();
log.info("向量索引僵尸数据 {} 条", zombieVectors.size());
// 3. 补偿写入
missingInVector.forEach(this::generateAndSaveVector);
// 4. 清理僵尸数据
vectorRepository.deleteByArticleIdIn(zombieVectors);
log.info("对账完成");
}
}七、性能测试数据
在实际项目中(500 万篇文章,32 核 64G 服务器,MySQL 8.0 + pgvector 16),各方案对比:
| 搜索策略 | P50 延迟 | P99 延迟 | 相关性评分 |
|---|---|---|---|
| MySQL 全文检索(单独) | 8ms | 45ms | 65分 |
| 向量搜索(单独) | 12ms | 30ms | 78分 |
| 混合搜索 | 20ms | 65ms | 85分 |
相关性评分是通过人工标注的 2000 条查询-结果对计算的 NDCG@10。混合搜索的效果明显优于单一方案,代价是延迟略高。
八、什么时候该换数据库
讲完不换数据库的方案,我也要说说什么情况下应该考虑迁移:
- 数据量超过 2000 万:MySQL 全文检索在超大数据量下性能很差,pgvector 单节点也会吃力
- 搜索是核心业务:如果搜索体验直接影响收入,值得投入专用的搜索引擎
- 需要实时更新的向量索引:两套存储的一致性维护成本很高,业务写入频繁时尤其如此
- 多语言、多模态搜索:MySQL 全文检索的多语言支持很差
在数据量可控、搜索不是核心业务的场景下,MySQL + pgvector 的方案是性价比最高的选择。工程是权衡的艺术,没有最好的技术,只有最合适的技术。
