FTS5是SQLite内置的全文检索扩展,建表简单、查询高效,但它的检索质量很大程度上取决于分词器的选择。英文场景下默认配置基本开箱即用,可一旦数据里出现中文,事情就完全不一样了。本文围绕FTS5的三个内置分词器展开,分析它们各自的切词逻辑和适用边界,再手把手演示如何用C语言写一个自定义分词器对接中文分词库,最后总结几个容易踩的坑。

一、FTS5内置分词器的工作机制与选择依据
FTS5在创建虚拟表时通过tokenize参数指定分词器,格式为tokenize='分词器名 参数'。官方提供了三个内置实现:unicode61、ascii和porter,另外在较新版本中还有一个trigram。它们的差异主要体现在对字符的分类处理和词条归一化上。
unicode61是最常用的默认分词器,它基于Unicode 6.1的标准把字符分为分隔符和词条字符两类,按分隔符切分文本,并把所有字符折叠为小写。对英文来说,这相当于按空格和标点分词,效果符合直觉。ascii是它的简化版,只处理ASCII字符,速度略快但对非拉丁文字符不友好。porter则是在切词之后额外套一层Porter词干提取算法,把running归约为run、computers归约为computer,适合做英文模糊匹配的场景。通常porter会这样搭配使用:
-- porter 通常作为包装器套在 unicode61 外面
CREATE VIRTUAL TABLE docs USING fts5(
title,
body,
tokenize = 'porter unicode61'
);
-- trigram 分词器,适合中文兜底和子串匹配
CREATE VIRTUAL TABLE docs2 USING fts5(
title,
body,
tokenize = 'trigram'
);
trigram分词器的思路完全不同:它把文本按每三个连续字符切成一个词条,滑动窗口前进。这种切法不依赖任何语言知识,天然支持任意子串匹配,中文也能工作。它的代价也很明显:索引体积会膨胀数倍,且查询时必须用字符串语法或者至少三个字符的关键词,输入一两个字无法命中。
二、中文场景下的实际表现对比
先看unicode61处理中文的真实行为。假设正文是“数据库索引优化实践”,unicode61会因为这七个汉字之间没有分隔符,把整串当成一个词条存进倒排索引。用户搜索“索引”时,FTS5找不到任何词条等于“索引”,MATCH查询直接返回空结果。这就是很多初学者反馈“FTS5中文搜不到”的根本原因——不是FTS5不支持中文,而是默认分词器不认识中文的词边界。
trigram能缓解这个问题。还是那句“数据库索引优化实践”,会被切成“数据库”“据库索”“库索引”“索引优”“引优化”“优化实”“化实践”等词条。搜索“索引”依然搜不到(不足三字符),但搜索“索引优化”能通过“索引优”和“引优化”两个词条命中。它的优点是零配置、无需额外依赖;缺点是索引大、误召回可能高,比如搜“据库索”也能命中,虽然实际业务中这种查询很少见。
如果业务对召回精度和查询体验有要求,比如要求支持两字词搜索、支持同义词,那就需要真正的中文分词。FTS5留出了自定义分词器接口,允许开发者注册自己的分词函数,把jieba、cppjieba这类成熟分词库接入进来,这才是中文场景的正解。下面给出完整实现。
三、用C API编写自定义分词器并接入jieba
自定义分词器需要实现两个回调结构体:fts5_tokenizer描述分词器本身的创建逻辑,fts5_tokenizer_module里的xCreate和xTokenize是关键。xTokenize被调用时拿到输入文本,开发者负责切词,并对每个切出的词调用xToken回调把它交给FTS5。下面是接入cppjieba的骨架代码:
#include <sqlite3ext.h>
#include <fts5.h>
#include "cppjieba/Jieba.hpp"
static cppjieba::Jieba* g_jieba = nullptr;
// 分词器实例的上下文
typedef struct JiebaTokenizer {
fts5_tokenizer tokenizer; // 基类,必须放第一位
} JiebaTokenizer;
static int jiebaXTokenize(
Fts5Context *fts5ctx, // FTS5 传入的上下文
void *pCtx, // 分词器实例
int tflags, // FTS5_TOKEN_QUERY 表示查询阶段
const char *pText, int nText,// 待分词文本
int (*xToken)(void*, int, const char*, int, int, int) // 回调
) {
std::vector<std::string> words;
std::string input(pText, nText);
g_jieba->Cut(input, words, true); // mix 模式,兼顾精度与全切
int pos = 0;
for (auto &w : words) {
if (w.empty()) continue;
// 参数:回调上下文、词条标记、词条指针、长度、字节偏移、token序号
int rc = xToken(pCtx, tflags, w.data(), (int)w.size(),
pos, pos);
if (rc != SQLITE_OK) return rc;
pos++;
}
return SQLITE_OK;
}
static int jiebaXCreate(
void *ctx, const char **azArg, int nArg,
fts5_tokenizer **ppOut
) {
JiebaTokenizer *p = sqlite3_malloc(sizeof(JiebaTokenizer));
memset(p, 0, sizeof(*p));
p->tokenizer.xTokenize = jiebaXTokenize;
*ppOut = &p->tokenizer;
return SQLITE_OK;
}
// 注册入口
int sqlite3_jieba_init(sqlite3 *db, char **pzErr, const sqlite3_api_routines *pApi) {
SQLITE_EXTENSION_INIT2(pApi);
static fts5_tokenizer_module module = {0, jiebaXCreate, nullptr, nullptr};
fts5_api *fts5api = nullptr;
sqlite3_stmt *stmt = nullptr;
sqlite3_prepare(db, "SELECT fts5(?1)", -1, &stmt, nullptr);
sqlite3_bind_pointer(stmt, 1, (void*)&module, "fts5_api_ptr", nullptr);
sqlite3_step(stmt);
sqlite3_finalize(stmt);
return SQLITE_OK;
}
编译成动态库后,加载方式是SELECT load_extension('./libjieba_fts5'),然后就能建表了:
CREATE VIRTUAL TABLE articles USING fts5(
title, content,
tokenize = 'jieba'
);
-- 现在可以直接搜中文词了
SELECT * FROM articles WHERE articles MATCH '索引优化';
实现时有几个细节值得注意。第一,xTokenize在索引阶段和查询阶段都会被调用,可以通过tflags参数判断当前处于哪个阶段,查询阶段通常建议改用精确模式分词,避免查询词被过度切散。第二,xToken回调返回非SQLITE_OK时要立即终止循环并向上传递错误码。第三,偏移量参数如果填不准确,snippet和highlight函数会显示错位,如果不需要这些功能,可以像上面那样用序号占位。
四、性能与维护上的几条实践建议
索引构建成本方面,接入jieba后每次写入都要过一遍分词,混合模式下一篇几千字的文章分词耗时在毫秒级,普通写入场景无感,但批量导入几十万条数据时会明显变慢。建议大批量导入前用PRAGMA journal_mode=OFF和同步关闭来加速,导完再恢复。词典加载要做成全局单例,jieba的词典文件有几十MB,每个连接重复加载会白白吃掉大量内存。
分词一致性是另一个容易被忽视的问题。索引时用A版本词典,查询时换了B版本,词边界对不上就会漏召回。生产环境应当把词典文件版本化,随应用一起发布,并在升级词典后对FTS5表执行重建。FTS5提供了INSERT INTO tbl(tbl) VALUES('rebuild')命令来全量重建索引,数据量大时可以放在低峰期执行。
最后一点建议是保留查询侧的容错。即便有了自定义分词,用户输入仍是不可控的,可以在应用层对查询串做预处理:命中停用词就过滤掉、单字查询降级到LIKE、jieba切出的多个词用FTS5的AND语义拼接。综合下来,一套“自定义分词器负责索引质量、应用层负责查询体验”的组合,基本能覆盖绝大多数中文全文检索需求。
SQLite FTS5分词器中文全文检索修改时间:2026-09-16 06:16:42