我桌面上常年乱糟糟地堆着各种txt、md、csv,真到想找某个细节的时候,靠系统搜索要么慢得像蜗牛,要么搜出来的结果排不到前面。后来我花了半个小时,用Python自带的sqlite3模块做成了一个本地全文检索工具。
虽然它没法跟Elasticsearch比,但胜在零依赖、部署简单,几百MB的文本建立索引以后,查询基本在毫秒以内。这篇文章就把具体做法一步步拆解给你。
思路比代码更重要
我一开始想用os.walk遍历文件,再用正则匹配每个文件内容。文件少的时候还好,文件一多,每次都把所有文件完整读一遍,不仅内存爆炸,而且重复扫描浪费时间。
所以正确姿势是提前把全部文本内容“灌”进数据库,做成倒排索引。查询时不用再看源文件,只需查索引表,结果自然就快了。
大多数现代系统里都有SQLite,而SQLite自带一个全文搜索扩展叫FTS5。Python从3.6开始,官方预编译的sqlite3库就默认开启了FTS5支持,所以不需要额外装任何东西。
FTS5虚拟表和普通表有什么不一样
普通建表是CREATE TABLE,而全文索引需要使用虚拟表,语法是:
CREATE VIRTUAL TABLE documents USING fts5(id, title, body);
表名后面直接跟列名,各列默认都是可全文搜索的文本类型。你可以插入和查询,像操作一张普通表。FTS5会自动帮你维护一个倒排索引,内部把文本切分成一个个词条。
FTS5还内置了排名算法,可以按相关度排序。这比我们手动用like要专业得多。
用Python建库的完整步骤
第一步:连接到SQLite数据库。
import sqlite3
conn = sqlite3.connect("search.db")
cursor = conn.cursor()
cursor.execute("CREATE VIRTUAL TABLE IF NOT EXISTS docs USING fts5(filename, content)")
conn.commit()
启动后用这个虚拟表,插入一行代表一个文件。比如我索引一个Markdown文件,就把文件名和全部内容放进去:
def add_document(filename, content):
cursor.execute("INSERT INTO docs(filename, content) VALUES (?, ?)", (filename, content))
conn.commit()
这里有一个隐藏坑:content不能是bytes类型,FTS5主要针对文本。如果你的文件是二进制(比如PDF、Word),需要先抽取文本再插入。
最基础的查询和BM25排序
FTS5的查询可以用MATCH关键字,查找包含“Python”和“异步”的文档:
SELECT filename, snippet(docs) FROM docs WHERE docs MATCH 'Python AND 异步'
在FTS5里,空格默认是AND的意思。如果查多个关键词可以写成Python 异步。加上ORDER BY rank就是按相关度排序。不过需要说明的是,在FTS5里,rank其实是隐藏列,直接可以用:
SELECT filename, rank FROM docs WHERE docs MATCH 'Python' ORDER BY rank LIMIT 10
一个可以直接运行的整目录扫描器
说了一堆理论,直接上代码。这里完整的脚本,能遍历指定目录下的txt和md文件,建立索引,并支持用命令行搜索。
import sqlite3
import os
import sys
DB_NAME = "file_index.db"
def init_db():
conn = sqlite3.connect(DB_NAME)
c = conn.cursor()
c.execute("""
CREATE VIRTUAL TABLE IF NOT EXISTS docs USING fts5(filename, path, content)
""")
return conn
def index_folder(conn, folder_path):
c = conn.cursor()
for root, _, files in os.walk(folder_path):
for f in files:
if not (f.endswith(".txt") or f.endswith(".md")):
continue
full = os.path.join(root, f)
try:
with open(full, "r", encoding="utf-8", errors="ignore") as fp:
content = fp.read()
except Exception:
continue
rel_path = os.path.relpath(full, folder_path)
c.execute("INSERT INTO docs(filename, path, content) VALUES (?, ?, ?)", (f, rel_path, content))
conn.commit()
def search(conn, query):
c = conn.cursor()
sql = """
SELECT filename, path, snippet(docs, 2, '[', ']', '...', 12)
FROM docs
WHERE docs MATCH ?
ORDER BY rank
LIMIT 20
"""
try:
c.execute(sql, (query,))
results = c.fetchall()
except sqlite3.OperationalError as e:
print("查询语法错误:", e)
return
for filename, path, s in results:
print(f"文件: {filename}n路径: {path}n摘要: {s}n{'-' * 40}")
if __name__ == "__main__":
conn = init_db()
# 第一次运行可以指定一个目录做索引,例如: python fts_index.py ./docs --index
if len(sys.argv) > 1 and sys.argv[1] == "--index":
folder = sys.argv[2] if len(sys.argv) > 2 else "."
index_folder(conn, folder)
print("索引创建完成")
else:
query = input("输入关键词:")
search(conn, query)
代码里那个snippet(docs, 2, ...)可能让人看迷糊。其实snippet是FTS5提供的内置辅助函数,用于从结果里截取包含关键词的片段,而不是返回整篇正文。第2个参数表示从第3列开始取摘要?这里有点容易错,参数分别为:表名、要提取的列索引(从0开始)、前缀、后缀、省略号、大概返回多少词。因为我在新建的表里列索引是:0-filename, 1-path, 2-content。所以用snippet(docs, 2, ...)是取content列做摘要。
一个真正的痛点:中文分词怎么办
FTS5默认的unigram分词器对中文支持得一言难尽。它会把连续的汉字整块当作一个词,所以搜索单个中文词时经常匹配不上。比如正文里有“我爱北京天安门”,你搜“北京”,它默认分词把“我爱北京天安门”当成一个token,所以你搜不到。
要解决中文搜索,最实用方案是自己在把文本插入数据库前先做分词,然后使用FTS5的tokenize = 'unicode61'配合空格分隔?不,FTS5无法真正分中文。
替代方案(不引入第三方库)是使用Python内置的`re`模块把中文文本切成一个个单独的字或者词?效果很一般。更专业的要使用`jieba`,但这就打破了“纯标准库”的初衷。
如果仅仅想实现一个本地快速搜索,你可以在插入前把每个字之间加一个空格:
import re
def tokenize_chinese(text):
# 给每个汉字中间加空格,让FTS5按单个汉字索引
return re.sub(r'([u4e00-u9fa5])', r' 1 ', text)
搜索时对用户输入也进行同样的处理。搜索“北京”时,会被拆成“北 京”,两个单字同时匹配,也就查到了。
这种分词方式虽然笨,但某种程度能实现“包含”匹配,而且索引体积会变大,不过普通个人资料够用了。下面是带单字切分的实现思路:
def add_document_zh(filename, content):
processed = tokenize_chinese(content)
cursor.execute("INSERT INTO docs(filename, content) VALUES (?, ?)", (filename, processed))
查询前把关键词也过一遍:
def zh_query(q):
return tokenize_chinese(q).strip()
给搜索加上权重:标题比正文更重要
真正在工作中搜索,我们往往希望标题命中比正文命中排得更靠前。FTS5里可以对不同列设置权重,只需要在MATCH里使用column:keyword语法。
SELECT filename, rank FROM docs WHERE docs MATCH 'filename:python OR content:python' ORDER BY rank
不过上面的SQL不会让filename的权重更高,只是关键词同时匹配两列。要设置权重,可以使用bm25()函数,但比较复杂。一个简单粗暴的加权重办法是建表时把标题多复制几遍,或者在插入时给标题内容后面附加多个空格和标题,相当于词频增加。
我自己的做法是额外添加一个`title_bonus`列,把文件名重复写3遍,查的时候一起查:
CREATE VIRTUAL TABLE docs USING fts5(title_bonus, path, content, tokenize='unicode61')
插入时`title_bonus`存的是文件名加文件名再加文件名,这样标题命中的文档在BM25排名里自然更靠前。虽然粗暴,但胜在有效且无额外依赖。
怎么增量更新:避免每次都重建索引
上面的例子每次`–index`都会把所有文件重新插入,效率很低。正常使用时,你应该先删除旧的关于某个文件的记录,再重新插入新内容。
def replace_document(conn, file_path, content):
c = conn.cursor()
c.execute("DELETE FROM docs WHERE path = ?", (file_path,))
insert_with_char_tokenize(c, file_path, content)
conn.commit()
我实际脚本里会保存每个文件的最后修改时间到一张普通表,每次扫描时如果文件没变化就跳过,这样增量扫描非常快。
更多你可能用到的技巧
1. 搜索词加双引号做精确短语匹配
SELECT filename FROM docs WHERE docs MATCH '"async def"'
这样会查找连续短语“async def”。如果是中文单字分词,“北京”会被处理成“北 京”,加双引号正好搜索相邻的两个单字,效果不错。
2. 前缀搜索
SELECT filename FROM docs WHERE docs MATCH 'Pyth*'
匹配Python、Python3、Pythonic等等。
3. 删除无效索引
DELETE FROM docs WHERE path = '被删除的文件路径'
性能实测和取舍
我拿自己电脑上一份300MB的日志文本做测试。传统扫描方式每次搜索耗时约20秒,而FTS5建立索引后同类搜索耗时不到0.05秒。第一次建索引可能会花两三分钟,但之后就一劳永逸。
占用磁盘空间大约是原文的1.5倍左右,还能接受。如果你的文件主要是中文,单字细分会把索引空间增大不少,但相对你得到的搜索收益,依然值得。
结尾:下一步还能做什么
这套基础索引建好之后,你可以做很多好玩的事:
- 做一个简单的Flask/FastAPI接口,变成局域网内搜索工具。
- 加一个GUI界面,双击就能搜。
- 把这个逻辑扩展为搜索Excel单元格、PDF文件名和邮件,思路完全一样。
越是简单的事情,往往越适合自己写工具打磨。至少我现在不用再挨个文件夹翻资料,sqlite3帮我记住了所有文本的位置。

