Go + SQLite FTS5 + BM25 + 中文分词#
SQLite 自带 FTS5 全文检索模块,也内置了 bm25() 排序函数。对于英文或已经用空格分隔的文本,FTS5 可以直接建立索引;但中文没有天然空格,unicode61 tokenizer 不知道“孙悟空大闹天宫”应该切成哪些词。
所以比较稳的做法是:
在 Go 层先做中文分词,把分词后的 token 用空格拼起来,再交给 SQLite FTS5。
这篇文章整理一个最小可落地的方案:普通表保存原文,FTS5 表保存分词后的索引文本,查询时同样先分词,再用 MATCH 召回、bm25() 排序。
核心流程#
flowchart LR
A["原始文档"] --> B["Go 中文分词"]
B --> C["写入 FTS5 索引表"]
D["用户查询"] --> E["Go 查询分词"]
E --> F["构造 FTS5 Query"]
F --> G["MATCH 召回"]
G --> H["BM25 排序"]
H --> I["Join 原文表"]
这里有两个关键点:
- 写入索引和用户查询要使用同一套分词策略。
- FTS5 表里的
rowid 最好和业务表的 id 对齐,方便查回原文。
普通表保存原始数据:
1
2
3
4
5
6
|
CREATE TABLE docs (
id INTEGER PRIMARY KEY,
title TEXT NOT NULL,
body TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
|
FTS5 虚拟表保存用于检索的 token:
1
2
3
4
5
|
CREATE VIRTUAL TABLE docs_fts USING fts5(
title,
body,
tokenize = 'unicode61'
);
|
docs_fts 不是普通数据表,而是由 FTS5 模块管理的全文索引。这里的 title、body 不一定要保存原文,可以保存已经分好词的文本,例如:
unicode61 适合处理英文、数字、标点和已经空格分隔的 token。中文分词这一步交给 Go 完成,FTS5 只负责索引和检索。
中文分词#
Go 里可以用 gse:
1
2
3
4
5
|
import (
"strings"
"github.com/go-ego/gse"
)
|
写入索引前,先把原文切成搜索 token:
1
2
3
4
|
func indexText(seg *gse.Segmenter, text string) string {
terms := seg.CutSearch(text, true)
return strings.Join(terms, " ")
}
|
例如:
可能会被处理成:
实际项目里,分词效果比 FTS5 本身更影响中文检索质量。词典、停用词、同义词、专有名词,都应该在 Go 层统一处理。
写入索引#
建议把业务表写入和 FTS5 索引写入放在同一个事务里。否则中途失败时,可能出现“原文存在,但搜不到”或“索引存在,但原文不存在”的不一致状态。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
|
func insertDoc(ctx context.Context, db *sql.DB, seg *gse.Segmenter, title, body string) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
res, err := tx.ExecContext(ctx, `
INSERT INTO docs(title, body)
VALUES (?, ?)
`, title, body)
if err != nil {
return err
}
docID, err := res.LastInsertId()
if err != nil {
return err
}
_, err = tx.ExecContext(ctx, `
INSERT INTO docs_fts(rowid, title, body)
VALUES (?, ?, ?)
`,
docID,
indexText(seg, title),
indexText(seg, body),
)
if err != nil {
return err
}
return tx.Commit()
}
|
MATCH 查询#
MATCH 是 FTS5 的全文检索操作符:
1
2
3
|
SELECT rowid
FROM docs_fts
WHERE docs_fts MATCH ?;
|
注意,MATCH 后面的参数是 FTS5 query,不是普通 SQL 条件。即使用了 SQL 参数绑定,也仍然需要构造合法的 FTS5 query。
常见写法:
1
2
3
4
5
6
7
8
9
10
11
|
-- 匹配一个 token
docs_fts MATCH '孙悟空'
-- 必须同时命中两个 token
docs_fts MATCH '孙悟空 AND 天宫'
-- 命中任意一个 token 即可
docs_fts MATCH '孙悟空 OR 天宫'
-- 短语匹配,要求 token 顺序相邻
docs_fts MATCH '"大闹 天宫"'
|
前缀匹配#
FTS5 query 里的 * 表示前缀匹配:
它可以匹配以 孙悟空 开头的 token,但不是 SQL 里的 %孙悟空%,不能匹配任意位置。
例如:
含义是:
1
2
|
必须命中以“孙悟空”开头的 token
并且必须命中以“天宫”开头的 token
|
双引号用于把词作为一个 token 或 phrase 处理,减少特殊字符导致的查询语法问题。
构造查询语句#
用户输入不能直接拼进 FTS5 query。比较保守的做法是:
- 先用和建索引相同的分词器切词。
- 去掉空 token。
- 用双引号包住每个 token。
- 对双引号做转义。
- 根据召回策略用
AND 或 OR 连接。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
func buildFTS5Query(seg *gse.Segmenter, query string) string {
terms := seg.CutSearch(query, true)
parts := make([]string, 0, len(terms))
for _, term := range terms {
term = strings.TrimSpace(term)
if term == "" {
continue
}
term = strings.ReplaceAll(term, `"`, `""`)
parts = append(parts, `"`+term+`"*`)
}
return strings.Join(parts, " AND ")
}
|
例如用户搜索:
构造出来的 FTS5 query 是:
AND 更精准,但召回更少;OR 召回更多,但噪音也更多。很多系统会先用 AND 搜,如果结果太少,再降级到 OR 或混合策略。
BM25 排序#
SQLite FTS5 内置 bm25():
1
2
3
4
5
|
SELECT rowid, bm25(docs_fts, 2.0, 1.0) AS bm25_score
FROM docs_fts
WHERE docs_fts MATCH ?
ORDER BY bm25_score ASC
LIMIT ?;
|
bm25(docs_fts, 2.0, 1.0) 里的权重和 FTS5 表字段一一对应:
这表示标题命中比正文命中更重要。
需要注意的是,SQLite FTS5 的 BM25 分数是:
越小越相关。
所以排序时要用:
1
|
ORDER BY bm25_score ASC
|
如果系统内部统一使用“越大越好”的分数,可以额外转换一个展示用分数:
1
2
3
4
|
func normalizeBM25(score float64) float64 {
score = math.Abs(score)
return score / (1 + score)
}
|
例如:
1
2
3
|
bm25_score = -3.0 -> 0.75
bm25_score = -1.0 -> 0.50
bm25_score = -0.2 -> 0.166
|
这个分数适合做业务层统一排序或展示,但数据库查询时仍然按原始 bm25_score ASC 排。
完整查询示例#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
func searchDocs(ctx context.Context, db *sql.DB, seg *gse.Segmenter, query string, limit int) (*sql.Rows, error) {
ftsQuery := buildFTS5Query(seg, query)
if ftsQuery == "" {
return nil, sql.ErrNoRows
}
return db.QueryContext(ctx, `
WITH matches AS (
SELECT rowid, bm25(docs_fts, 2.0, 1.0) AS bm25_score
FROM docs_fts
WHERE docs_fts MATCH ?
ORDER BY bm25_score ASC
LIMIT ?
)
SELECT d.id, d.title, d.body, m.bm25_score
FROM matches m
JOIN docs d ON d.id = m.rowid
ORDER BY m.bm25_score ASC;
`, ftsQuery, limit)
}
|
实用建议#
- 普通表保存原文,FTS5 表保存分词后的索引文本。
- 写入、更新、删除原文时,同事务维护 FTS5 索引。
- 建索引和查询必须使用同一套分词策略。
- 查询词要转义后再构造 FTS5 query。
AND 适合精准搜索,OR 适合扩大召回。
bm25() 分数越小越相关,排序用 ASC。
- 标题、正文分列,方便给标题更高 BM25 权重。
如果只是做中小规模站内搜索、笔记搜索、个人知识库搜索,Go + SQLite FTS5 + BM25 已经足够实用。等到需要语义召回、跨语言检索或相似问题泛化时,再把向量检索作为第二路召回接进来,会更自然。