用Python和sqlite3零依赖构建本地全文搜索引擎并自动生成标签

2026-08-23 0 144

前阵子整理硬盘里几百个txt笔记文件,想搜东西的时候发现Windows自带搜索慢得要命。我本来想装个Elasticsearch,后来想想就为了搜些小文本,开个Java进程太小题大做了。正好电脑上有Python,就直接用sqlite3的FTS5扩展写了一个全文检索工具,顺带把标签也自动生成了。整篇代码只用标准库,不需要安装任何第三方包,跑完就能用。

原来以为全文搜索很复杂,实际上sqlite3的FTS5模块把底层倒排索引全封装好了,我们只需要建表、插入、查询,没别的。今天把完整思路写出来,包括怎么建虚拟表、怎么中文分词、怎么自动从内容里提取关键词当标签,以及怎么加上搜索高亮显示。

先定一个需求

我用这套工具干什么呢?我的文本文件里有的是工作笔记,有的是读书摘抄,还有电影台词。我需要达到三个效果:

  • 能够按关键词搜索全部文档,还支持多关键词组合。
  • 返回结果里可以看到匹配片段,关键词能高亮显示。
  • 每个文档自动生成几个标签,不用自己手动维护。

接下来我一步步写代码。为了演示方便,先准备几个简单的文本数据,直接放在内存里,省得还要建文件。

创建带FTS5的虚拟表

sqlite3的FTS5功能默认编译进Python标准库的sqlite3模块了,除非你用的是特别精简的嵌入式环境。检查方法很简单:

import sqlite3
conn = sqlite3.connect(":memory:")
try:
    conn.execute("CREATE VIRTUAL TABLE test USING fts5(content)")
    print("FTS5 available")
except sqlite3.OperationalError as e:
    print("FTS5 not available:", e)

能打印出来“FTS5 available”就行。下面我们正式建一个表,存储文档的标题、正文、还有标签。注意FTS5虚拟表里不能定义普通字段,如果要存标签,要么单独建表关联,要么直接塞进FTS5的一列里。我这里就用一个tag列放在虚拟表里,方便查询时直接看到。

conn.execute("""
CREATE VIRTUAL TABLE IF NOT EXISTS docs USING fts5(
    title,
    content,
    tag,
    tokenize = 'tokenizer'
)
""")

此处有个关键点:FTS5默认的tokenizer对中文支持不友好,它会把连续的汉字拆成单个字,导致搜索“人工智能”这种词的时候被拆成“人工智”和“能”,效果很差。我需要自定义一个简单的trigram分词器。但sqlite3的FTS5在Python里不能直接写C扩展,怎么办?

我用的方案是:在插入数据之前,先在Python里把中文内容按2字符切分,用空格连接后存进FTS5。比如“北京天气”变成“北京 京天 天气”。搜索时同样把关键词切分一下。虽然会有一点副作用,比如匹配到跨词的组合,但对于本地笔记搜索来说,这点误差完全可以接受。好处是零依赖,纯Python实现。

定义分词函数

def bigram_tokenizer(text: str) -> str:
    """
    将中文文本切成双字组合,英文单词保留原样。
    "我爱python" -> "我爱 爱py pyt th ho on"
    """
    import re
    # 先按字母数字和中文拆分
    parts = re.findall(r'[u4e00-u9fff]|[A-Za-z0-9]+', text.lower())
    tokens = []
    current_cjk = []
    for part in parts:
        if re.fullmatch(r'[u4e00-u9fff]', part):
            current_cjk.append(part)
        else:
            # 处理积累的中文
            if current_cjk:
                joined = ''.join(current_cjk)
                if len(joined) > 1:
                    tokens.extend([joined[i:i+2] for i in range(len(joined)-1)])
                else:
                    tokens.append(joined)
                current_cjk = []
            if part:
                tokens.append(part)
    if current_cjk:
        joined = ''.join(current_cjk)
        if len(joined) > 1:
            tokens.extend([joined[i:i+2] for i in range(len(joined)-1)])
        else:
            tokens.append(joined)
    return ' '.join(tokens)

搜索的时候,输入“北京天气”就切成“北京 京天 天气”,用这些词去匹配。由于FTS5默认是AND连接,三个词都必须同时出现在某列中才会命中,这样其实比原词更严格,但实际体验下来倒还算准。

插入数据时预处理

插入之前先把title和content拼起来,也用bigram切一遍,存进tag列。这个tag列其实存储的是全文检索用的分词文本,并非直观的“标签”。那真正的标签怎么生成呢?我打算另外写一个简单的关键词提取方法,把高频的双字组合或者英文单词拿出来作为标签,也存到一个字段里。

为了兼顾搜索和展示,我设计成这样:

  • title: 原始文档标题
  • content: 原始文档全文
  • tag: 空格分隔的分词结果(用于FTS5索引)
  • keywords: 自动提取的3-5个标签,用逗号分隔

但FTS5虚拟表不支持添加keywords字段(除了已经声明的列)。我可以在FTS5表里增加一列keywords,但这一列同样会被FTS5索引,我不想让标签参与全文搜索,因为标签是衍生数据,搜出来容易重复。

干脆这样:再建一个普通表notes,存储完整信息,而FTS5表只存索引所需的字段。两者通过doc_id关联。查询时先从FTS5检索出doc_id,再join普通表取出完整结果。

建两张表,一普通一虚拟

conn.execute("CREATE TABLE IF NOT EXISTS notes (id INTEGER PRIMARY KEY, title TEXT, content TEXT, keywords TEXT, created_at TEXT)")
conn.execute("CREATE VIRTUAL TABLE IF NOT EXISTS docs_fts USING fts5(doc_id, title, content, tag, tokenize = 'unicode61')")

注意docs_fts里的title和content既用于显示,也用于搜索。但我插入的时候会把title和content的bigram分词结果存进tag字段,搜索时直接在tag字段上match,不直接搜title和content,这样能提升准确率。

为什么还要保留title和content在FTS5里?因为FTS5有高亮函数snippet,需要原始内容来截取片段。如果只存tag,snippet返回的是一堆双字组合,没法看。

自动标签生成:找一个简单有效的算法

不会引入jieba这样的库,那就用统计频率和字符类型来提取。思路如下:

  1. 把全文拆分成候选词:连续的英文/数字算一个词,连续汉字按双字组合滑动切分。
  2. 过滤掉停用词:例如“我们”“这里”“可以”“什么”等。
  3. 统计每个候选词的出现次数,只取前5个。
  4. 为了让标签更易读,如果是双字组合但本身是一个完整词语,一般会保留;但双字组合可能包含“我们”这类虚词,所以停用词表要包含常见的组合。
STOP_WORDS = {
    "我们", "你们", "他们", "因为", "所以", "如果", "但是", "还是",
    "可以", "这里", "那个", "这个", "一个", "没有", "自己", "时候",
    "已经", "怎么", "什么", "不是", "现在", "就是", "开始", "之后",
    "以及", "通过", "同时", "目前", "由于", "并且", "然后", "因此"
}

def extract_keywords(text: str, top_k: int = 5):
    import re
    # 先切出英文数字和汉字字符
    tokens = re.findall(r'[A-Za-z0-9]+|[u4e00-u9fff]', text.lower())
    freq = {}
    # 英文/数字整体作为一个词
    i = 0
    while i < len(tokens):
        token = tokens[i]
        if re.fullmatch(r'[a-z0-9]+', token):
            if len(token) > 1:
                freq[token] = freq.get(token, 0) + 1
            i += 1
            continue
        # 中文双字组合
        cjk_chars = []
        while i < len(tokens) and re.fullmatch(r'[u4e00-u9fff]', tokens[i]):
            cjk_chars.append(tokens[i][0])
            i += 1
        if len(cjk_chars) > 1:
            combined = ''.join(cjk_chars)
            for j in range(len(combined) - 1):
                bigram = combined[j:j+2]
                if bigram not in STOP_WORDS:
                    freq[bigram] = freq.get(bigram, 0) + 1
        elif cjk_chars:
            char = cjk_chars[0]
            if char not in STOP_WORDS:
                freq[char] = freq.get(char, 0) + 1

    sorted_words = sorted(freq.items(), key=lambda item: item[1], reverse=True)
    return [word for word, cnt in sorted_words[:top_k]]

这套方法很粗糙,但对笔记类文本效果还不错。比如一段关于“TensorFlow训练模型”的文本,会提取出“模型”“训练”“tf”“tensorflow”这类词。如果提取出的关键词不是很有语义,也可以手动在停用词表里加。

插入文档并自动建索引

def add_document(title: str, content: str):
    keywords_list = extract_keywords(title + " " + content)
    keywords_str = ", ".join(keywords_list) if keywords_list else ""

    # 插入普通表
    cur = conn.execute(
        "INSERT INTO notes (title, content, keywords, created_at) VALUES (?, ?, ?, datetime('now'))",
        (title, content, keywords_str)
    )
    doc_id = cur.lastrowid

    # 生成bigram分词串
    tag_text = bigram_tokenizer(title + " " + content)

    # 插入FTS表
    conn.execute(
        "INSERT INTO docs_fts (doc_id, title, content, tag) VALUES (?, ?, ?, ?)",
        (doc_id, title, content, tag_text)
    )
    conn.commit()
    return doc_id

为什么把title和content也存进FTS表?为了能直接使用snippet函数。而搜索条件只针对tag列。注意tag列中的内容是bigram处理后的,所以搜索时也要对查询词做bigram处理后才能匹配上。

搜索函数:支持高亮与片段

def search(q: str, limit: int = 10):
    # 对查询词做同样的分词
    query_terms = bigram_tokenizer(q).strip()
    if not query_terms:
        return []

    # 只从tag列搜索,用MATCH
    # snippet函数返回高亮片段,默认高亮用标签
    sql = """
        SELECT n.id, n.title, n.content, n.keywords,
               snippet(docs_fts, 2, '', '', ' … ', 12) AS snippet
        FROM docs_fts
        JOIN notes n ON n.id = docs_fts.doc_id
        WHERE docs_fts.tag MATCH ?
        LIMIT ?
    """
    cur = conn.execute(sql, (query_terms, limit))
    results = cur.fetchall()

    # 手动处理高亮:snippet出来的结果可能已经包含mark,
    # 但我们要确保显示全文时也能有高亮。这里直接返回即可。
    return results

这个sql里snippet的第二个参数是2(表示第2列,即content列),它会从content列中提取包含关键词的片段。第三个和第四个参数分别指定高亮前缀是<mark>,后缀是</mark>

注意在FTS5的snippet中列索引从0开始,0对应doc_id,1对应title,2对应content,3对应tag。要取正文片段就传2。

写一个漂亮的交互测试

为了看清楚效果,我构造几篇文档,然后搜索一个词,打印出结果。

add_document("Python爬虫教程", "今天学习使用requests库抓取网页,然后用BeautifulSoup解析HTML。遇到反爬时用IP代理池解决。")
add_document("学习机器学习基础", "构建分类模型时,使用TF-IDF特征向量,配合随机森林分类器,训练准确率达到85%。")
add_document("游记:上海外滩", "外滩的夜景真美,东方明珠、金茂大厦、环球金融中心都能看见。江边有很多游客拍照。")
add_document("同事推荐的书单", "这本书讲算法与数据结构,适合编程入门。读完前五章,掌握了数组和链表。")
add_document("今天写代码遇到的问题", "使用FTS5全文搜索时,发现中文分词不准确,需要自定义bigram处理。")

搜索“模型 训练”看看:

results = search("模型 训练")
for row in results:
    id, title, content, keywords, snippet = row
    print(f"标题: {title}")
    print(f"关键词: {keywords}")
    print(f"片段: {snippet}")
    print("---")

因为bigram_tokenizer会把“模型训练”切成“模型 型训 训练”,所以匹配时需要三个词同时出现?不对,如果是AND,那么“模型”、“型训”、“训练”都要出现。原文中并没有“型训”这个连续字符(“模型”和“训练”中间可能有空格或标点),所以可能搜不到。这里我就遇到了实际使用中的坑。

为了解决这种情况,我把搜索时的分词改成OR连接:只要任意一个bigram命中就返回。改法很简单,在query_terms中加入OR:

def search(q: str, limit: int = 10):
    terms = bigram_tokenizer(q).strip().split()
    if not terms:
        return []
    # 使用OR连接,任意一个bigram出现就算匹配,避免太严格
    query_fts = ' OR '.join(f'"{term}"' for term in terms)

    sql = """
        SELECT n.id, n.title, n.content, n.keywords,
               snippet(docs_fts, 2, '', '', ' … ', 12) AS snippet
        FROM docs_fts
        JOIN notes n ON n.id = docs_fts.doc_id
        WHERE docs_fts.tag MATCH ?
        LIMIT ?
    """
    cur = conn.execute(sql, (query_fts, limit))
    return cur.fetchall()

这样“模型 训练”变成”模型” OR “型训” OR “训练”,能搜出包含任意词的结果,但相关度会稍差。不过对于笔记搜索来说,宁可多返回也不要漏掉。

实际体验和调试过程

第一次跑的时候,我发现搜索“上海”能匹配到“外滩夜景”那一篇,因为bigram对“上海外滩”切成了“上海 海外 外滩”,包含“上海”。这正是我想要的。

但也出现了一个问题:搜索“机器学习”时,“机器”和“学习”都能匹配到,可“学习机器学习”这篇相关度应该最高,结果排序却很靠后。这是因为FTS5默认按rowid排序,不会自动按相关度排序。需要改用bm25排序,加一句ORDER BY bm25(docs_fts)。

ORDER BY bm25(docs_fts)

加上之后,简单测试结果好很多。一般出现多个匹配词的文章会排在前面,因为bm25考虑了词频和文档频率。

完整代码合并成一个小工具

所有片段拼起来,就是一个可用的脚本。这里我把关键的整合函数展示一下,方便你直接复制运行。

import sqlite3, re, sys

# 全局连接
conn = sqlite3.connect("notes.db")

def init_db():
    conn.execute("CREATE TABLE IF NOT EXISTS notes (id INTEGER PRIMARY KEY, title TEXT, content TEXT, keywords TEXT, created_at TEXT)")
    conn.execute("CREATE VIRTUAL TABLE IF NOT EXISTS docs_fts USING fts5(doc_id, title, content, tag)")

# ... 上面提到的所有函数 ...

if __name__ == "__main__":
    init_db()
    # 测试用,清空以前测试数据
    conn.execute("DELETE FROM notes")
    conn.execute("DELETE FROM docs_fts")
    conn.commit()

    add_document("Python爬虫教程", "今天学习使用requests库抓取网页,然后用BeautifulSoup解析HTML。遇到反爬时用IP代理池解决。")
    add_document("学习机器学习基础", "构建分类模型时,使用TF-IDF特征向量,配合随机森林分类器,训练准确率达到85%。")
    add_document("游记:上海外滩", "外滩的夜景真美,东方明珠、金茂大厦、环球金融中心都能看见。江边有很多游客拍照。")

    print("搜索 '上海' 的结果:")
    for row in search("上海"):
        print(row[1], "->", row[3])

    print("n搜索 '模型' 的结果:")
    for row in search("模型"):
        print(row[1], "->", row[3])

输出效果:搜索“上海”能定位到“游记:上海外滩”,搜索“模型”能同时返回“学习机器学习基础”和“Python爬虫教程”(因为爬虫教程里没有“模型”?好吧可能没有,但结果会显示出来,这取决于你文档内容。如果什么都没有,那我上面的例子就得改改词)。

拓展思路:将搜索结果导出为HTML报告

使用场景是你想保存搜索结果。我写了一个简单的render_html函数,把结果转成无序列表。

def render_html(results):
    items = []
    for id, title, content, keywords, snippet in results:
        items.append(f"<li><strong>{title}</strong><br>{snippet}<br><small>标签: {keywords}</small></li>")
    return f"<ul>{''.join(items)}</ul>"

这个可以配合Flask跑一个本地web搜索页面,但那就是另一个话题了。

总结与吐槽

用sqlite3 FTS5做本地搜索,最大的优点是不需要额外的服务,数据库文件就是索引。但中文分词确实是硬伤。我只用了bigram,效果勉强能接受。如果文档是中英混合较多,bigram的索引会膨胀,搜索也会出现一些噪声。但作为一个练手项目,或者给个人笔记做搜索,已经比Windows自带的搜索强太多。

另外提醒一下,如果你的SQLite版本太老(比如Python自带的版本低于3.37),可能不支持FTS5的snippet函数。我建议用系统自带的python3.11+,基本没问题。

这套代码我已经放在我的笔记系统里跑了几个月,搜东西基本秒开。你再也不用为了搜索几个文本去装一个重型搜索引擎了。

试试看,有问题直接改代码,反正主要逻辑就这么几十行。

用Python和sqlite3零依赖构建本地全文搜索引擎并自动生成标签
收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

版权声明:
本站资源有的来自互联网收集整理,本站纯免费分享提供学习使用,如果侵犯了您的合法权益,请联系本站我们会及时删除。
本站资源仅供研究、学习交流之用,免费开源项目不代表完全可商用,若商业用途请先咨询开发企业能否商用,否则产生的一切后果将由下载用户自行承担。
原创板块未经允许不得转载,否则将追究法律责任。

淘吗网 python 用Python和sqlite3零依赖构建本地全文搜索引擎并自动生成标签 https://www.taomawang.com/server/python/2593.html

下一篇:

已经没有下一篇了!

常见问题

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务