SQLite是一个嵌入式的轻量级数据库,广泛用于桌面软件、移动应用和小型后端服务。它内置的FTS5全文检索模块性能非常出色,但默认的tokenizer只按空格和标点切词,面对中文这种词与词之间没有明显边界的语言时,整句话会被当成一个巨大的token,搜索效果基本不可用。要让SQLite真正支持中文搜索,核心思路是在数据写入索引之前先做一次中文分词。本文以Go语言生态中的Wade分词库为例,完整演示从分词、建索引到查询的全流程实现。

为什么FTS5无法直接搜索中文
FTS5是SQLite从3.9版本开始引入的第五代全文检索模块,底层采用倒排索引结构。所谓倒排索引,就是把文档拆成一个个词项,记录每个词项出现在哪些文档中,查询时先在词典里定位词项,再取出对应的文档列表,效率远高于全表扫描的LIKE模糊匹配。
问题出在切词这一步。英文单词之间天然有空格分隔,默认的unicode61分词器按空白和标点切割就能得到合理的词项。而中文句子比如“今天天气不错适合出门散步”,中间没有任何分隔符,FTS5会把连续的汉字当成一个整体token存入索引。此后无论你搜索“天气”还是“散步”,都无法命中这条记录,因为索引里根本不存在这两个词项。
有人会退而求其次用LIKE '%关键词%'来做模糊查询,但这种方式有两个致命缺陷:一是无法利用索引,数据量到几十万条后查询延迟会明显上升;二是它只是简单的子串匹配,不具备相关性排序能力,也无法支持多关键词的组合检索。因此正规做法是在写入前引入外部分词器,这正是本文方案的核心。
Wade分词库的集成与切词实践
Wade是Go语言编写的一个轻量级中文分词库,采用词典加统计的混合策略,支持精确模式、搜索引擎模式和全模式三种切分粒度。对搜索场景来说,搜索引擎模式会把长词再拆出子词,比如“中华人民共和国”会同时产出“中华”、“人民”、“共和国”等词项,能显著提高召回率,是写入FTS5索引时的首选。
通过go get即可安装:
go get github.com/axgle/mahonia // 可选编码转换 go get github.com/go-ego/wade
接下来定义分词函数,把一段中文文本切成以空格连接的词串:
package main
import (
"strings"
"github.com/go-ego/wade"
)
var seg *wade.Segmenter
func InitSegmenter() {
seg = new(wade.Segmenter)
// 加载词典,可传入自定义词典路径提升专业领域效果
seg.LoadDictionary("zh.txt")
}
// SearchModeCut 使用搜索引擎模式切词,词项之间用空格连接
func SearchModeCut(text string) string {
pieces := seg.ModeSegment(text, true)
return strings.Join(pieces, " ")
}这里有个容易被忽略的细节:FTS5默认的unicode61分词器遇到空格会继续切分,所以只要我们把分词结果用空格拼接,FTS5就能正确地把每个词登记为独立的token。另外建议在拼接前把文本统一转为小写,并过滤掉纯标点符号,避免索引中出现无意义的词项,干扰词典查找。
建表、触发器同步与查询实现
设计上推荐业务表与索引表分离:业务表article存放原始标题和正文,索引用FTS5虚拟表article_fts存放切词后的内容。两者通过外键article_id关联,用触发器保证同步,这样更新原文后索引不会出现脏数据。
CREATE TABLE article (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
content TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE VIRTUAL TABLE article_fts USING fts5(
article_id UNINDEXED,
title_text,
content_text,
tokenize = 'unicode61'
);
-- 新增文章时同步写入分词索引
CREATE TRIGGER article_ai AFTER INSERT ON article BEGIN
-- 分词由应用层完成后写入,这里演示直接插入的写法
INSERT INTO article_fts(article_id, title_text, content_text)
VALUES (new.id, new.title, new.content);
END;实际项目中更常见的做法是不用触发器,而是在应用层的事务里先写业务表,再调用Wade切词后写索引表,逻辑更可控。下面是完整的写入与查询示例:
func AddArticle(db *sql.DB, title, content string) (int64, error) {
tx, err := db.Begin()
if err != nil {
return 0, err
}
res, err := tx.Exec(
"INSERT INTO article(title, content) VALUES(?, ?)",
title, content)
if err != nil {
tx.Rollback()
return 0, err
}
id, _ := res.LastInsertId()
// 关键步骤:写入索引前先做搜索引擎模式分词
_, err = tx.Exec(
"INSERT INTO article_fts(article_id, title_text, content_text) VALUES(?, ?, ?)",
id, SearchModeCut(title), SearchModeCut(content))
if err != nil {
tx.Rollback()
return 0, err
}
return id, tx.Commit()
}
func SearchArticles(db *sql.DB, keyword string, limit int) ([]Article, error) {
// 查询词同样需要分词,保证与索引词项一致
q := SearchModeCut(keyword)
rows, err := db.Query(`
SELECT a.id, a.title, a.content
FROM article a
JOIN article_fts f ON a.id = f.article_id
WHERE article_fts MATCH ?
ORDER BY bm25(article_fts)
LIMIT ?`, q, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var list []Article
for rows.Next() {
var art Article
if err := rows.Scan(&art.ID, &art.Title, &art.Content); err != nil {
return nil, err
}
list = append(list, art)
}
return list, nil
}注意查询词也必须经过同样的分词流程。如果用户输入“人工智能”,切成“人工 智能”两个词后,MATCH会默认查找同时包含两个词项的文档;若想实现“或”语义提高召回,可以在词项之间手动拼接OR,例如“人工 OR 智能”。排序方面FTS5内置的bm25函数会按相关性打分,标题命中的文档可以通过给title_text列设置更高权重(bm25函数支持按列传负权重)来获得优先展示。
性能优化与进阶改进方向
几十万条记录以内的场景,这套方案在普通笔记本上查询耗时通常在几毫秒级别,完全够用。当数据量继续增长时,可以从几个方向优化。第一是控制索引体积,FTS5支持外部内容表(content选项)模式,索引不重复存储原文,只保存词项位置,能把索引文件缩小一半以上,代价是查询原文需要回表JOIN。第二是批量写入时关闭同步PRAGMA,比如设置journal_mode为WAL、synchronous为NORMAL,大批量导入的速度可以提升数倍。
词典层面也有改进空间。Wade支持加载自定义词典,如果你的应用聚焦某个垂直领域,比如医疗、法律或电商,把领域专有名词加入词典后,切词准确率会有明显提升。此外还可以在切词结果之外额外维护一份拼音索引或同义词映射表,用户输入拼音首字母或口语化说法时也能命中结果,这类扩展只需要在写入索引时多插入一行同义词记录即可实现。
最后提醒一点部署层面的事项:SQLite是文件型数据库,多进程并发写入时会遇到锁竞争,如果搜索服务需要水平扩展,建议采用单写多读架构,由一个写入进程负责维护索引,查询进程以只读方式打开数据库。对于绝大多数单机应用、桌面工具和中小型网站来说,SQLite加Wade分词这套组合,用一个二进制依赖就换来了专业级的中文搜索能力,是非常划算的技术选型。