在向量数据库中增强文本检索 -- MyScaleDB
Benchmark 测试结果表明,集成 Tantivy 全文检索引擎后,MyScaleDB 的文本搜索在响应速度与检索精度上均实现了显著飞跃。➡️ TheNewStack 英文原版链接 🔗
全球数据规模正在快速膨胀,且其中绝大部分都是非结构化数据。传统数据库在处理这类文本数据时往往力不从心,而全文检索 (FTS) 恰恰能够提供一种更自然、更高效的访问方式,让用户按照主题、关键词甚至语义相关性来查找信息。
为了提升文本搜索能力,我们在 MyScaleDB 中集成了 Tantivy。MyScaleDB 是基于 ClickHouse 演进而来的开源 SQL 向量数据库,针对向量检索做了优化。这次升级,既能帮助那些把 ClickHouse 用于日志检索、并把它当作 Elasticsearch 或 Loki 替代方案的用户,也能帮助在大语言模型 RAG 场景中同时使用向量检索和文本检索的用户,从而进一步提升召回质量和结果准确性。
在这篇文章中,我将会重点介绍 MyScale 是如何把 Tantivy 集成进 MyScaleDB 的,以及我们是如何评估这项改造带来的性能提升的。
ClickHouse 原生文本检索的局限
ClickHouse 原生提供了一些基础的文本检索函数,例如 hasToken、startsWith 和 multiSearchAny。对于简单的词项匹配,这些函数已经够用;但在更复杂的需求面前,它们就有些捉襟见肘了。比如:
- 短语查询 (包含多个 term, 带有 AND OR 等复杂约束条件)
- 模糊匹配 (用户期望模糊检索一段文本)
- 基于 BM25 的相关性排序 (返回相似度评分)
这些能力在现代全文检索里非常关键,而 ClickHouse 原生文本能力并不能很好覆盖。因此,我们决定引入 Tantivy 作为全文索引的底层实现,为 MyScaleDB 增加完整的全文检索能力。Tantivy 不仅支持模糊文本查询和 BM25 相关性排序,还可以加速 hasToken、multiSearchAny 这类已有的词项匹配场景。
为什么选择 Tantivy
Tantivy 是一个用 Rust 编写的开源全文检索引擎库,以高性能和高效率著称,尤其擅长处理大规模文本数据。详见 GitHub Link
Tantivy 的核心工作方式
Tantivy 本质上是 lucene (Java) 的平替,从原理上看,Tantivy 做了两件事。
- 第一件是构建索引: 它会先对输入文本进行分词,将文本切分成独立 token,然后构建倒排索引,并把索引写入 Segment 文件中。与此同时,后台线程会根据合并策略持续对这些 Segment 做 merge 和更新。
- 第二件是执行检索: 用户发起查询后,Tantivy 会解析查询语句、提取查询 token,并在每个 Segment 上根据查询条件和 BM25 相关性算法对文档进行评分和排序,最后把各 Segment 的结果合并后返回。
下面由 Gemini 绘制的图像清晰的展示了这一流程:文档经过分词和索引写入形成多个 Segment,查询侧则由多个 Segment Reader 并行读取,再统按照 BM25 算法合并排序。
Tantivy 的几个关键特性
1. BM25 相关性评分 Elasticsearch、Lucene、Solr 默认都采用 BM25 作为相关性排序算法。BM25 能较好地衡量文本匹配的准确性和相关性,从而提升搜索体验。
2. 可配置分词器 不同语言、不同业务场景的分词需求差异很大。Tantivy 支持可配置分词器,可以更灵活地适配用户需求。
3. 更自然的查询表达方式
用户可以通过 AND、OR、IN 等关键字灵活组合查询条件,从而降低 SQL 编写复杂度。
为什么 Tantivy 适合嵌入 MyScaleDB
MyScaleDB 以 C++ 编写,构建在 ClickHouse 之上,是面向 AI 原生应用的搜索引擎式数据库。为了增强全文检索能力,我们需要一个能够直接嵌入 MyScaleDB 的全文检索库。
Tantivy 的灵感来自 Apache Lucene,但和 Elasticsearch、Apache Solr 这类“完整搜索引擎产品”不同,Tantivy 更像一个可嵌入式的检索库,能够被集成到不同数据库系统中,包括 MyScaleDB。虽然 Tantivy 是 Rust 写的,但是我们可以通过 ffi 实现跨语言的函数调用,具体可以参考:
- CMake 侧: 使用 corrosion 引入 rust crate 到 CMake
- Rust 侧: 使用 unsafe 指针暴露 ffi 接口不够优雅,我们用了 cxx.rs 进行跨语言接口桥接。
集成过程:我们是怎么做的
1)为 Tantivy 构建一个 C++ Wrapper
原生 Tantivy 不能直接被 MyScaleDB 使用。为了跨越 C++ 和 Rust 之间的语言边界,我们实现了一个名为 tantivy-search 的 C++ 封装层。它向 MyScaleDB 提供了一组 FFI(Foreign Function Interface)接口,使数据库能够直接完成索引创建、销毁、加载,以及不同场景下的文本搜索调用。下图展示了 FFI 封装层在 MyScaleDB 和 Tantivy 之间所处的位置:上层是 MyScaleDB,下层是 Tantivy,中间通过 FFI 暴露索引管理、查询执行和 BM25 统计等能力。
2)把 Tantivy 实现为 ClickHouse 的 Skipping Index
ClickHouse 原生的 skipping index 主要用于加速带 WHERE 条件的查询。我们新实现了一种 skipping index 类型,命名为 FTS(Full-Text Search),底层实现就是 Tantivy。
具体做法是:对于 ClickHouse 中每一个带 FTS 索引的数据 part,我们都为它构建一个对应的 Tantivy 索引。为了减少每个 data part 内需要存储的 segment 文件数量,MyScaleDB 会把这些 segment 序列化成两个文件保存:
skp_idx_[index_name].meta:记录每个 segment 文件的名称和偏移量skp_idx_[index_name].data:保存每个 segment 文件的原始数据
Tantivy 依赖 mmap 访问 segment 文件。这样的设计一方面可以提升并发检索速度,另一方面也有利于索引构建效率。但问题在于,Tantivy 不能直接对 skp_idx_[index_name].data 做内存映射。因此,当用户第一次发起需要使用 FTS 索引的查询时,MyScaleDB 需要先把 .meta 和 .data 反序列化回 Tantivy 可识别的 segment 文件,写入临时目录,再由 Tantivy 通过 mmap 加载。也正因为这个过程存在,用户的首次查询通常会比后续查询慢,可能需要几秒钟才能完成。下面这个示意图也展示了序列化、反序列化以及通过 mmap 读写 segment 文件的过程。
在云原生 AWS 环境中,我们把 Tantivy 的 segment 索引文件存放在 NVMe SSD 上。这样可以显著减少 I/O 等待时间,并在随机访问和 page fault 处理场景下提升 mmap 的实际表现。
3)增强 ClickHouse 原生文本检索函数
当带有过滤条件的查询作用于包含 FTS 索引的列时,MyScaleDB 会先访问 FTS 索引,取出所有满足 SQL 过滤条件的行 ID,并把这些行 ID 存入 roaring bitmap。随后,在遍历 granule 时,系统会判断 granule 的行 ID 范围是否和 bitmap 相交,从而决定该 granule 能否被跳过。最终,只读取那些未被跳过的 granule,以实现查询加速。下述流程图展示了这一机制:先从 FTS 索引中生成位图,再用它筛掉不必要的 granule。
但理想很丰满,现实并不总是这样。我们发现 skipping index 的加速效果存在明显边界:如果被搜索的词几乎出现在所有 granule 中,那么真正能跳过的 granule 很少,查询仍然要访问大量 granule,这时 skipping index 的收益就会很有限。
4)为此,我们引入了 TextSearch
为了绕开 skipping index 在高频词场景下的效率瓶颈,并且充分发挥 Tantivy 的全文检索能力,我们在 MyScaleDB 中加入了 TextSearch 函数。这个函数支持模糊文本检索,并返回按 BM25 相关性排序的文档结果;同时,它也允许用户用更自然的方式表达查询,从而显著降低 SQL 编写门槛。
TextSearch 的执行方式和 skipping index 不同。它在查询时会并发地对所有 data part 执行文本检索,每个 part 各自返回 top-k(例如前 1000 条)最相关结果,然后由 MyScaleDB 按 BM25 分数把这些结果聚合起来,结合用户 SQL 中的 ORDER BY 和 LIMIT 子句,保留最终的 top-k 结果。关键在于,TextSearch 不需要从 data part 内部逐块读取原始数据,而是直接通过 Tantivy 从索引中拿结果,因此速度非常快。从下图我们可以看到:每个 data part 先产出 top-k,再通过一个聚合函数统一合并。
不过,这里还有一个不太显眼但很关键的问题:多 part 场景下的 BM25 分数不能简单直接合并。 因为每个 data part 在计算 BM25 时,只知道自己这一部分数据的统计信息,比如:
- 文档总数(total docs number)
- token 总数(total tokens number)
- 词项文档频率(doc frequency)
如果直接把各 part 的 BM25 分数拿来排序,或者简单平均,就会导致最终结果不准确。
为了解决这个问题,我们在执行 TextSearch 前,先收集每个 part 的 BM25 统计信息,再把它们整合成整张表层面的全局 BM25 统计值。除此之外,我们还修改了 Tantivy,使其支持使用共享的 BM25 信息。这样一来,即便数据分散在多个 parts 中,最终 TextSearch 的排序结果依然能够保持 BM25 分数的正确性。
一个简单的 TextSearch 示例
原文中给出了一个基于 ms_macro 数据集的示例查询,返回结果中可以看到多条与 “Sasha Obama” 和 “Michelle Obama” 相关的文本记录,并附带对应的相关性分数。这个例子主要说明两点:
- 第一,
TextSearch可以直接完成面向文本内容的相关性检索,而不只是做简单词匹配。 - 第二,结果是按 BM25 分数排序的,因此相关性更高的文本会排在前面。
性能评估:我们是怎么测的
为了评估效果,我们使用 clickhouse-benchmark 对 MyScaleDB 在不同索引方案下的检索性能进行了压测,对比对象包括:
- MyScaleDB 实现的 FTS 索引
- ClickHouse 内置的 Inverted Index
- 不建立索引的情况
测试数据集
我们使用的是 Microsoft 提供的 ms_macro 数据集。这个数据集包含 8,841,823 条文本记录,我们把它转换成 parquet 格式后导入 MyScaleDB。与此同时,我们还根据不同词频构造了一组 SQL 文件,用来测试不同搜索条件下的性能。
其中,测试文件名会体现查询词在数据集中的出现频率以及包含的查询数量。比如,ms_macro_count_hastoken_100_100k.sql 表示该文件中有 100,000 条查询,且每条查询中的搜索词在数据集中出现约 100 次。
测试环境
| 项目 | 配置 |
|---|---|
| 操作系统 | Ubuntu 22.04.3 LTS |
| CPU | 16 核(AMD Ryzen 9 6900HX) |
| 内存 | 64 GB |
| 存储 | 512 GB NVMe SSD |
| 数据库版本 | MyScaleDB v1.5 |
虽然机器内存为 64GB,但测试过程中 MyScaleDB 的实际内存消耗大约只有 2.5GB。
索引构建方式
我们分别测试了三种情况:
FTS 索引
1
2
3
ALTER TABLE default.ms_macro DROP INDEX IF EXISTS fts_idx;
ALTER TABLE default.ms_macro ADD INDEX fts_idx text TYPE fts;
ALTER TABLE default.ms_macro MATERIALIZE INDEX fts_idx;
Inverted 索引
1
2
3
ALTER TABLE default.ms_macro DROP INDEX IF EXISTS inverted_idx;
ALTER TABLE default.ms_macro ADD INDEX inverted_idx text TYPE inverted;
ALTER TABLE default.ms_macro MATERIALIZE INDEX inverted_idx;
无索引
- 只需确保
ms_macro表中的text列不包含任何索引即可。
在正式压测时,我们使用 clickhouse-benchmark 进行持续压力测试,关于它的使用说明可以参考 ClickHouse 官方文档
1
clickhouse-benchmark -c 8 --timelimit=60 --randomize --log_queries=0 --delay=0 < ms_macro_count_hastoken_100_100k.sql -h 127.0.0.1 --port 9000
评估结果:TextSearch 始终更快
从结果来看,skipping index 的加速效果和搜索词在数据集中出现的频率高度相关。第 11 页的柱状图也展示了这一趋势:当查询词频很低时,FTS 的 hasToken 性能明显好于无索引;但随着词频升高,这种基于 granule 跳过的优化优势会显著减弱。
更具体地说:
- 当搜索词频率较高时(10 万到 100 万),skipping index 的加速效果很有限,和无索引相比大约只有 10 倍左右提升。
- 当搜索词频率较低时(100 到 1000),skipping index 的效果会明显变好,最高可以达到接近百倍的提升。
但无论哪种场景,TextSearch 的表现都始终优于 skipping index 和 inverted index。原因很直接:TextSearch 走的是 Tantivy 的全文检索路径,直接从索引中获取结果,不需要再去扫描 granule,因此查询链路更短,效率也更高。
总结
把 Tantivy 集成进 MyScaleDB 之后,我们显著增强了 MyScaleDB 的文本检索能力,也让它在文本分析和大语言模型 RAG 场景中变得更有竞争力。通过补足 ClickHouse 原生文本检索在 BM25 排序、模糊查询和自然语言查询上的短板,MyScaleDB 现在能够更好地应对复杂文本搜索需求。
这次改造主要包括三部分:
- 为 Tantivy 实现 C++ Wrapper
- 在 ClickHouse 中实现新的 FTS skipping index
- 引入
TextSearch函数并解决多 part 场景下的 BM25 统计一致性问题
这些工作不仅提升了 MyScaleDB 的性能,也扩展了它在更多应用场景中的适用性,使其能够提供更高效、更准确的文本检索能力。
如果你同时在做 SQL 查询、向量检索和全文检索,尤其是在 RAG 场景下,这样的架构会比单独依赖传统数据库或单独依赖搜索引擎更均衡,也更适合统一的数据处理链路。这个方向本质上不是“给数据库加一个搜索功能”,而是把数据库真正推进到“混合检索基础设施”的位置。






