Python + SQLite FTS5 构建本地文档全文搜索器:增量索引、中文检索与高亮

Python + SQLite FTS5 构建本地文档全文搜索器:增量索引、中文检索与高亮

项目文档越来越多以后,靠文件名已经很难找到正文里的一段配置、一条报错或某个函数说明。把所有文件逐个打开搜索很慢;为了全文检索单独部署 Elasticsearch,对个人电脑和小型团队又太重。

本文只使用 Python 标准库和 SQLite FTS5,做一个可以直接运行的本地文档搜索器:

  • 递归索引 Markdown、TXT 和 Python 文件;
  • 使用 FTS5 trigram tokenizer 支持中文子串和代码片段检索;
  • 返回标题、路径、相关片段和关键词高亮;
  • 根据文件大小与纳秒级修改时间做增量更新;
  • 自动移除已经删除或不再符合规则的文件;
  • 支持分页、最大文件限制和 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() 相关性排序;
  • unicode61portertrigram 等 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

测试覆盖:

  1. Markdown 文档能够建立索引,并通过中文子串找到;
  2. 未修改文件会被跳过,修改后会更新索引;
  3. 磁盘文件删除后,对应搜索结果会被移除;
  4. 超过上限的大文件不会进入索引。

所有测试都在临时目录和临时数据库中运行,不读取真实文档目录。

十、当前版本的边界

这个项目适合个人文档、小型代码库和团队内部资料,不是 Elasticsearch 的替代品。

  • trigram 查询少于3个字符时不可用;
  • 没有中文语义分词、同义词和拼写纠错;
  • 没有 PDF、Word、图片 OCR 等解析器;
  • 正文以明文形式保存在本地数据库中,敏感目录仍需做好磁盘权限与加密;
  • 单次索引把每个符合上限的文件读入字符串,不适合超大文本;
  • 使用大小和修改时间判断变化,必要时应强制重建或加入哈希;
  • 没有文件变更监听,需要手动或通过系统定时任务更新。

这些限制不是遗漏说明,而是决定工具是否适合实际场景的必要条件。

十一、总结

一个真正可用的本地全文搜索器,不只是执行一次 MATCH。它还必须处理文件发现、中文检索、增量更新、删除同步、结果高亮、分页、大文件限制和索引根目录边界。

本文使用 Python 标准库完成了这条闭环:

text 复制代码
扫描文档 → 判断变化 → 写入 FTS5 → 中文子串检索 → 高亮片段 → 增量维护

如果你的资料规模还没有大到需要独立搜索集群,SQLite FTS5 是一个值得优先尝试的中间方案:部署成本低、数据留在本地、数据库文件便于备份,同时仍然具备真正的全文索引能力。

参考资料

相关推荐
玉鸯1 小时前
让 Agent 面向用户:AG-UI 协议构建 Agent 前端
前端·python·agent
云和恩墨1 小时前
从静态备份到极速恢复:zData S重构多元数据库时代的“第二存储”底座
数据库·重构
小白学大数据1 小时前
把 Modbus 轮询塞进 Trio 的异步循环:内存映射与定时采集实战
网络·python·搜索引擎
吴声子夜歌2 小时前
MongoDB 8.0——存储
数据库·mongodb
FriendshipT2 小时前
Ubuntu 20.04 下使用 Ollama 本地部署 AI 大模型
linux·人工智能·python·深度学习·ubuntu
天天爱吃肉82182 小时前
# 商用车多体动力学实战笔记|第7篇:制动系统与制动热衰退、ABS滞环控制
大数据·人工智能·笔记·python·嵌入式硬件·汽车
网教盟人才服务平台2 小时前
Redis Key集中过期引发的流量雪崩实战解析
数据库·redis·缓存
bamb002 小时前
一个项目带你入门AI应用开发08
python
AI大模型-小华2 小时前
ChatGPT充值后Codex误改数据库怎么办?用迁移审查避免数据丢失
数据库·chatgpt·codex·chatgpt plus·chatgpt pro·chatgpt充值