Python + SQLite FTS5 构建本地文档全文搜索器:增量索引、中文检索与高亮
项目文档越来越多以后,靠文件名已经很难找到正文里的一段配置、一条报错或某个函数说明。把所有文件逐个打开搜索很慢;为了全文检索单独部署 Elasticsearch,对个人电脑和小型团队又太重。
本文只使用 Python 标准库和 SQLite FTS5,做一个可以直接运行的本地文档搜索器:
- 递归索引 Markdown、TXT 和 Python 文件;
- 使用 FTS5
trigramtokenizer 支持中文子串和代码片段检索; - 返回标题、路径、相关片段和关键词高亮;
- 根据文件大小与纳秒级修改时间做增量更新;
- 自动移除已经删除或不再符合规则的文件;
- 支持分页、最大文件限制和 JSON 输出。
全文内容只保存在本机 SQLite 数据库中,程序不调用任何云端 API。配套代码已通过4项自动化测试。
一、完成后的效果
准备一个文档目录:
text
docs/
├── sqlite.md
├── deployment.txt
└── src/
└── database.py
首次建立索引:
powershell
python search_engine.py --db search.db index .\docs
返回:
json
{
"added": 3,
"updated": 0,
"unchanged": 0,
"removed": 0,
"skipped": 0
}
搜索中文关键词:
powershell
python search_engine.py --db search.db search "全文搜索"
结果同时给出路径和命中上下文:
json
[
{
"path": "sqlite.md",
"title": "SQLite 实战",
"snippet": "使用【全文搜索】建立本地文档索引。",
"score": -0.0000018
}
]
这里的 score 来自 FTS5 bm25()。数值越小,排序越靠前;它适合当前查询内部排序,不应当被解释成百分制相关度。
再次执行索引,如果文件没有变化:
json
{
"added": 0,
"updated": 0,
"unchanged": 3,
"removed": 0,
"skipped": 0
}
这就是增量索引:没有变化的文件不会被重新读取和写入全文索引。
二、为什么选 SQLite FTS5
普通 SQL 的模糊查询通常写成:
sql
SELECT * FROM documents WHERE body LIKE '%连接池%';
前置通配符往往需要扫描大量正文。FTS5 则为文本创建倒排索引,并提供:
MATCH全文查询;highlight()命中高亮;snippet()上下文片段;bm25()相关性排序;unicode61、porter、trigram等 tokenizer。
本文使用 trigram。它把文本拆成连续三字符片段,因此适合中文子串、英文单词的一部分以及代码标识符搜索。例如"全文搜索"可以匹配正文中的连续文本,而不依赖中文分词词典。
但它有一个必须说明的限制:普通 trigram 全文查询需要至少3个 Unicode 字符。本文程序对更短的关键词直接返回错误,避免让用户误以为索引坏了。
另外,FTS5 和 trigram 是否可用取决于 Python 所链接的 SQLite 构建。可以先自检:
powershell
python -c "import sqlite3; print(sqlite3.sqlite_version)"
再执行:
python
import sqlite3
connection = sqlite3.connect(":memory:")
connection.execute(
"CREATE VIRTUAL TABLE test USING fts5(content, tokenize='trigram')"
)
print("FTS5 trigram 可用")
本文核验环境为 Python 3.12.13、SQLite 3.50.4。这个版本记录只代表本次测试环境,不代表所有 Python 安装。
三、数据库为什么分成两张表
初始化代码:
python
def connect_database(path: Path) -> sqlite3.Connection:
connection = sqlite3.connect(path)
connection.row_factory = sqlite3.Row
connection.executescript(
"""
PRAGMA journal_mode=WAL;
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS file_metadata (
path TEXT PRIMARY KEY,
rowid INTEGER NOT NULL UNIQUE,
size INTEGER NOT NULL,
mtime_ns INTEGER NOT NULL
);
CREATE VIRTUAL TABLE IF NOT EXISTS documents_fts USING fts5(
path UNINDEXED,
title,
body,
tokenize='trigram'
);
"""
)
return connection
documents_fts 保存全文索引;file_metadata 保存增量更新需要的路径、大小、修改时间和 FTS rowid。
path UNINDEXED 表示路径随结果返回,但不参与全文索引。标题和正文参与检索。settings 记录数据库属于哪个根目录,防止把同一个数据库误用于另一个目录,导致旧索引被错误删除。
WAL 能改善读写并发,但本文仍是单进程小型工具,不把它包装成多用户搜索服务。
四、发现文件时先限定边界
python
DEFAULT_EXTENSIONS = {".md", ".txt", ".py"}
DEFAULT_MAX_BYTES = 2 * 1024 * 1024
def discover_files(root: Path, extensions: set[str]) -> list[Path]:
return sorted(
path
for path in root.rglob("*")
if path.is_file()
and not path.is_symlink()
and path.suffix.lower() in extensions
)
默认不跟随符号链接,也不是什么文件都读取。二进制文件、日志归档和超大生成文件不适合直接塞入全文索引。
最大文件限制可以修改:
powershell
python search_engine.py --db search.db index .\docs `
--ext .md .txt .py .json `
--max-bytes 5242880
这表示索引四种扩展名,单文件上限为5 MiB。程序用 UTF-8 读取文本,无法解码的字节用替换字符处理;如果需要严格编码识别,应单独增加编码检测策略。
五、增量索引的关键判断
每个文件先读取元数据:
python
if (
not force
and old is not None
and old["size"] == stat.st_size
and old["mtime_ns"] == stat.st_mtime_ns
):
result.unchanged += 1
continue
大小和 st_mtime_ns 都没变化时,跳过正文读取。发生变化时,使用原 rowid 替换全文索引内容:
python
rowid = old["rowid"]
connection.execute(
"DELETE FROM documents_fts WHERE rowid = ?",
(rowid,),
)
connection.execute(
"INSERT INTO documents_fts(rowid, path, title, body) VALUES(?, ?, ?, ?)",
(rowid, relative, title, content),
)
connection.execute(
"UPDATE file_metadata SET size = ?, mtime_ns = ? WHERE path = ?",
(stat.st_size, stat.st_mtime_ns, relative),
)
元数据判断速度快,但不是内容证明:某些工具可能在保持大小和修改时间的情况下改写文件。遇到这种情况可以使用 --force 强制重建:
powershell
python search_engine.py --db search.db index .\docs --force
如果对完整性要求更高,可以在元数据表中增加内容哈希,代价是每次检查都要读取全部文件。
六、删除文件也要同步清理索引
增量索引不能只处理新增和修改。文件从磁盘删除后,旧内容如果继续出现在搜索结果中,会形成"幽灵结果"。
python
for relative in set(existing) - active_paths:
rowid = existing[relative]["rowid"]
connection.execute(
"DELETE FROM documents_fts WHERE rowid = ?",
(rowid,),
)
connection.execute(
"DELETE FROM file_metadata WHERE path = ?",
(relative,),
)
result.removed += 1
这里比较"数据库中的旧路径"和"本次扫描仍存在的路径"。因此一个数据库只绑定一个扫描根目录;程序检测到根目录变化时会拒绝执行,而不是自行猜测用户意图。
七、搜索、高亮和分页
核心查询:
python
rows = connection.execute(
"""
SELECT
path,
highlight(documents_fts, 1, '【', '】') AS title,
snippet(documents_fts, 2, '【', '】', '...', 24) AS snippet,
bm25(documents_fts) AS score
FROM documents_fts
WHERE documents_fts MATCH ?
ORDER BY score
LIMIT ? OFFSET ?
""",
(match_query, limit, offset),
).fetchall()
默认模式会把用户输入作为一个字面短语处理,内部双引号会被转义,避免普通文件名或代码字符意外变成 FTS5 操作符。
高级用户可以使用 --raw 传入原始 FTS5 查询语法:
powershell
python search_engine.py --db search.db search 'SQLite OR PostgreSQL' --raw
原始模式可能因为查询语法错误而失败,所以不应成为普通用户的默认入口。
分页示例:
powershell
python search_engine.py --db search.db search "连接池" `
--limit 10 `
--offset 20
这表示每页10条,从第21条开始返回。当前实现没有单独计算总命中数;如果前端需要页码,应增加一条相同 MATCH 条件的计数查询。
八、Markdown 标题如何提取
对于 Markdown 文件,程序使用第一个一级标题作为标题:
python
def extract_title(path: Path, content: str) -> str:
if path.suffix.lower() == ".md":
for line in content.splitlines():
stripped = line.strip()
if stripped.startswith("# "):
return stripped[2:].strip() or path.name
return path.name
TXT 和 Python 文件直接使用文件名。这个规则简单、确定、容易测试。它不会解析 YAML Front Matter,也不会理解 Setext 风格标题;如果文档规范不同,应在这里扩展,而不是把解析逻辑散落到索引流程中。
九、自动化测试验证了什么
运行:
powershell
python -m unittest -v test_search_engine.py
本次结果:
text
Ran 4 tests in 0.091s
OK
测试覆盖:
- Markdown 文档能够建立索引,并通过中文子串找到;
- 未修改文件会被跳过,修改后会更新索引;
- 磁盘文件删除后,对应搜索结果会被移除;
- 超过上限的大文件不会进入索引。
所有测试都在临时目录和临时数据库中运行,不读取真实文档目录。
十、当前版本的边界
这个项目适合个人文档、小型代码库和团队内部资料,不是 Elasticsearch 的替代品。
- trigram 查询少于3个字符时不可用;
- 没有中文语义分词、同义词和拼写纠错;
- 没有 PDF、Word、图片 OCR 等解析器;
- 正文以明文形式保存在本地数据库中,敏感目录仍需做好磁盘权限与加密;
- 单次索引把每个符合上限的文件读入字符串,不适合超大文本;
- 使用大小和修改时间判断变化,必要时应强制重建或加入哈希;
- 没有文件变更监听,需要手动或通过系统定时任务更新。
这些限制不是遗漏说明,而是决定工具是否适合实际场景的必要条件。
十一、总结
一个真正可用的本地全文搜索器,不只是执行一次 MATCH。它还必须处理文件发现、中文检索、增量更新、删除同步、结果高亮、分页、大文件限制和索引根目录边界。
本文使用 Python 标准库完成了这条闭环:
text
扫描文档 → 判断变化 → 写入 FTS5 → 中文子串检索 → 高亮片段 → 增量维护
如果你的资料规模还没有大到需要独立搜索集群,SQLite FTS5 是一个值得优先尝试的中间方案:部署成本低、数据留在本地、数据库文件便于备份,同时仍然具备真正的全文索引能力。