SQLite作为轻量级嵌入式数据库,被大量用在桌面软件、小型站点和移动端项目中。但一旦涉及中文内容检索,很多人第一反应是写WHERE content LIKE '%关键词%',数据量小的时候没问题,表里存了几十万条记录后,查询速度会明显下降,而且LIKE完全谈不上相关度排序,搜索体验很差。其实SQLite自带FTS5全文检索扩展,查询性能比LIKE高出一到两个数量级,可惜它原生不支持中文分词。这篇文章就来解决这个矛盾:借助jieba分词,让FTS5真正服务中文场景。

一、先搞清楚FTS5为什么搜不了中文
FTS5是SQLite内置的全文检索虚拟表模块,创建方式很简单:CREATE VIRTUAL TABLE docs USING fts5(content)。写入数据时,FTS5会把文本切分成一个个token(词元)存入倒排索引,查询时同样对查询词切分后去索引里匹配。这套机制对英文天然友好,因为英文单词之间有空格,切分就是按空白和标点断开。
问题出在中文上。中文句子是连续的汉字,没有天然分隔符。FTS5默认的unicode61分词器遇到中文时,会把整段连续汉字当作一个token处理。比如存入"数据库索引优化技巧",整句话就是一个词元,你搜"索引"根本匹配不到,只有完整输入整句话才命中。这样的检索基本没有使用价值。
官方提供的simple分词器按单字切分,能部分解决问题,搜索"索引"时可以按"索"和"引"两个字去匹配,但会带来两个副作用:一是索引膨胀严重,每个字都要建倒排项;二是无法区分词的边界,搜"上海"会命中包含"海边"的文本,误报率很高。所以业内常见做法是外接分词器,jieba就是Python生态里最常用的选择。
二、jieba预分词方案:写入前切好再入库
FTS5允许使用外置分词器,但通过C接口注册自定义分词器比较繁琐。在Python项目中,更实用的路线是预分词:写入数据前先用jieba把文本切开,用空格拼接后再存入FTS5表。FTS5的unicode61分词器按空格切分token,这样每个中文词就成了独立的词元。
import sqlite3
import jieba
conn = sqlite3.connect('articles.db')
cursor = conn.cursor()
cursor.execute('CREATE VIRTUAL TABLE IF NOT EXISTS articles USING fts5(title, body)')
def tokenize(text):
# 精确模式分词,用空格拼接
return ' '.join(jieba.cut_for_search(text))
def add_article(title, body):
cursor.execute(
'INSERT INTO articles(title, body) VALUES(?, ?)',
(tokenize(title), tokenize(body))
)
conn.commit()
add_article('SQLite数据库索引优化实践', '本文介绍SQLite中索引的创建与查询优化技巧')
add_article('jieba分词原理浅析', 'jieba基于前缀词典实现高效的中文分词')注意代码里用的是jieba.cut_for_search而不是cut。搜索模式会在精确切分的基础上,把长词再拆出短词,比如"中华人民共和国"会同时切出"中华"、"人民"、"共和国"等词元,面向检索场景命中率更高。写入侧处理好后,查询侧也要对关键词做同样的分词处理:
def search(keyword):
# 查询词同样要分词后拼接
words = tokenize(keyword)
# 注意不能用字符串格式化拼接SQL,参数绑定要配合IN语法
sql = '''
SELECT title, body, bm25(articles) AS rank
FROM articles
WHERE articles MATCH ?
ORDER BY rank
LIMIT 20
'''
return cursor.execute(sql, (words,)).fetchall()
results = search('索引优化')
for row in results:
print(row)这里有两个容易踩的坑。第一,FTS5的MATCH查询中,空格分隔的多个词默认是AND关系,即要求所有词都出现。如果希望命中任意一个词即可,需要在每个词后面加上OR:'索引 OR 优化'。第二,bm25函数返回值越小相关度越高,所以排序用ORDER BY rank即可,别习惯性地加DESC。
三、高亮显示与原文分离存储
预分词方案有个副作用:表里存的是分词后的文本,读出来满屏空格,不能直接展示给用户。解决思路是原文和索引分开存——普通表存原文,FTS5表只做索引,用content外部内容表的方式关联。
cursor.execute('''CREATE TABLE articles_raw(
id INTEGER PRIMARY KEY,
title TEXT,
body TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)''')
# content选项指定外部内容表,content_rowid指定关联列
cursor.execute('''
CREATE VIRTUAL TABLE articles_idx USING fts5(
title, body,
content='articles_raw',
content_rowid='id'
)
''')
def add_article2(title, body):
cursor.execute('INSERT INTO articles_raw(title, body) VALUES(?, ?)', (title, body))
rowid = cursor.lastrowid
cursor.execute(
'INSERT INTO articles_idx(rowid, title, body) VALUES(?, ?, ?)',
(rowid, tokenize(title), tokenize(body))
)
conn.commit()
def search_with_highlight(keyword, rowid):
words = ' OR '.join(jieba.cut_for_search(keyword))
sql = '''
SELECT highlight(articles_idx, 1, '<em>', '</em>')
FROM articles_idx WHERE articles_idx MATCH ? AND rowid = ?
'''
return cursor.execute(sql, (words, rowid)).fetchone()这个结构下,查询时先在FTS5索引表上MATCH拿到rowid和相关度,再用rowid回原表取完整字段。highlight函数可以在返回结果里给命中词加上标记,第一个参数是表名,第二个参数是列序号,后两个参数是前后包裹标记。不过要注意,highlight作用在分词后的文本上,如果想在原文上做高亮,更稳妥的做法是拿到命中的词元列表后,自己在前端或Python层对原文做替换。
另外,使用外部内容表时删除和更新要格外小心。直接删原表记录不会同步FTS5索引,推荐先删索引表再删原表,或者养成在同一个事务里操作两个表的习惯,避免索引残留脏数据导致查询命中已删除的行。
四、性能与适用场景评估
预分词方案的查询性能相当可观。在百万级记录、平均每条两百字的测试集上,LIKE模糊查询通常要数百毫秒甚至数秒,而FTS5加jieba分词的MATCH查询普遍在几十毫秒内完成,配合bm25排序还能直接给出按相关度排好的结果,这是LIKE完全做不到的。
写入方面有额外开销:jieba首次加载词典约需0.5到1秒,之后每条文本分词耗时约在毫秒级。批量导入时建议先开启事务,分批commit,可以把写入吞吐提升数倍。如果数据更新频繁,可以只对新增数据分词写入,历史数据保持不动,索引是增量维护的。
这套方案适合中小规模的独立应用:本地知识库、桌面软件的文档检索、爬虫结果的站内搜索等。如果数据量到了千万级以上,或者需要多机部署、复杂聚合分析,那就应该考虑Elasticsearch这类专业搜索引擎了。但对于绝大多数单机场景,SQLite加jieba的组合部署简单、零外部依赖,性价比非常高。
最后总结一下要点:写入前用jieba分词并用空格拼接,查询词做同样处理,原文和索引分表存储,批量写入记得开事务。掌握这几条,中文全文检索在SQLite里就能稳定跑起来了。