所属专题:Python 专题导航:基础语法、工具与实践

Django 博客站内搜索实战:SQLite FTS5、中文分词与安全高亮

Django 博客站内搜索实战:SQLite FTS5、中文分词与安全高亮 - 暂无配图,技术文章默认封面

站内搜索看起来只是一个输入框和一条查询语句,真正上线后却会同时影响数据库负载、搜索结果质量、页面可抓取性和安全边界。文章数量较少时,title__icontains 与 content__icontains 足够完成验证;文章增长后,用户会遇到结果排序不稳定、中文关键词命中不完整、分页越来越慢,以及搜索摘要把 HTML 当成文本输出的问题。

本文以 Django + SQLite 为例,说明如何从 LIKE 迁移到 SQLite FTS5(Full-Text Search 5)。示例重点放在可验证的工程步骤:先定义搜索范围,再建立索引、同步数据、清洗查询、分页和生成安全摘要。FTS5 能解决倒排索引和相关性排序问题,但它不是中文搜索引擎。中文分词需要单独设计,不能因为启用了 FTS5 就假设任意中文短语都能正确命中。

先明确搜索要解决什么问题

搜索的目标应该是帮助读者找到一篇适合当前问题的文章,而不是把所有包含某个字符的页面都列出来。上线前至少要回答四个问题:

  1. 搜索哪些字段?通常包括标题、摘要和正文;作者、标签和分类可以作为筛选字段,不一定复制到全文索引中。
  2. 哪些内容可以被搜到?只允许 published 状态进入索引,草稿、待审核文章和后台页面不能通过公开搜索暴露。
  3. 结果按什么顺序?标题命中通常比正文命中更有价值,还可以加入新鲜度、精选状态或人工质量分。
  4. 搜索失败后怎么办?无结果页应该给出可执行的修改建议,并记录脱敏后的零结果词,帮助规划后续文章。

现有 Django 查询可以这样写:

from django.db.models import Q

posts = Post.objects.filter(
    Q(title__icontains=query) | Q(content__icontains=query),
    status="published",
)

这段代码的优势是简单、容易调试,并且完全使用 Django ORM。问题在于前导通配符通常会让普通 B-Tree 索引失效,数据库需要检查大量文本;正文还包含 Markdown 标记,命中的字符不一定对应一个完整词。结果集没有相关性分数,数据库也不会因为标题命中而自动把结果排在前面。它可以作为小站的回退方案,但不应被误认为全文搜索方案。

FTS5 索引应该怎么设计

SQLite FTS5 是一个虚拟表扩展。它会为文本建立倒排索引,并提供 MATCH、bm25()、snippet() 等查询能力。对博客来说,一个单独的索引表比把搜索逻辑散落在模板和视图里更容易维护。下面的示例把 Django 文章主键作为 rowid,这样查询结果可以再回表读取完整的 Post 对象:

CREATE VIRTUAL TABLE IF NOT EXISTS post_search USING fts5(
    title,
    excerpt,
    body,
    body_tokens,
    tokenize = 'unicode61 remove_diacritics 2'
);

FTS5 表本身不会自动知道 Django 模型什么时候保存。可以在发布或更新文章时写入索引,也可以通过管理命令定期重建。两种方式最好同时保留:日常保存保持索引及时,重建命令用于修复异常、切换分词词典或部署后校验。

索引数据只保存可搜索的纯文本,不要直接把 Markdown 或未经处理的 HTML 放进去:

import re
from html import unescape

_MARKDOWN_LINK = re.compile(r"!?\[([^\]]*)\]\([^)]*\)")
_HTML_TAG = re.compile(r"<[^>]+>")


def searchable_text(markdown: str) -> str:
    text = markdown or ""
    text = _MARKDOWN_LINK.sub(r"\1", text)
    text = _HTML_TAG.sub(" ", text)
    text = unescape(text)
    text = re.sub(r"[`*_>#~]", " ", text)
    return re.sub(r"\s+", " ", text).strip()

这里的清洗只服务于搜索,不会覆盖文章原文。清洗规则应该和摘要、站内搜索高亮的规则保持一致,并用真实文章测试代码、链接、图片替代文本和代码块。对于代码文章,可以保留代码中的英文标识符和数字;对长日志或敏感字段,则应该在进入索引前脱敏。

在 Django 中写入和重建索引

推荐把索引操作集中在 search_index.py 或管理命令中,不要在每个视图里拼 SQL。下面是一个精简的写入函数。生产代码还应根据项目表名、数据库连接和分词器调整,并在事务提交后再执行,避免回滚后索引残留:

from django.db import connection


def upsert_post_search(post):
    if post.status != "published":
        delete_post_search(post.pk)
        return

    title = post.title or ""
    excerpt = post.excerpt or ""
    body = searchable_text(post.content)
    body_tokens = tokenize_for_search(body)

    with connection.cursor() as cursor:
        cursor.execute("DELETE FROM post_search WHERE rowid = %s", [post.pk])
        cursor.execute(
            """
            INSERT INTO post_search(rowid, title, excerpt, body, body_tokens)
            VALUES (%s, %s, %s, %s, %s)
            """,
            [post.pk, title, excerpt, body, body_tokens],
        )


def delete_post_search(post_id):
    with connection.cursor() as cursor:
        cursor.execute("DELETE FROM post_search WHERE rowid = %s", [post_id])

Django 的 SQLite 参数占位符会由数据库后端转换;如果同一份代码要支持 PostgreSQL 或 MySQL,应把全文搜索实现封装在后端适配层,不能直接假设所有数据库都支持 FTS5。post_search 建表语句也应放进一次性的迁移或部署步骤中,执行后用 PRAGMA table_info 和 sqlite_master 验证,而不是每次请求时建表。

日常同步可以使用 post_save 与 post_delete 信号,但信号会让保存操作变慢,也可能在批量导入时反复重建。更可控的办法是提供 rebuild_search_index 管理命令:先清空索引,再按主键分批写入已发布文章,最后核对索引行数。

from django.core.management.base import BaseCommand
from blogapp.models import Post


class Command(BaseCommand):
    help = "重建已发布文章的 FTS5 索引"

    def handle(self, *args, **options):
        with connection.cursor() as cursor:
            cursor.execute("DELETE FROM post_search")

        count = 0
        queryset = Post.objects.filter(status="published").order_by("pk")
        for post in queryset.iterator(chunk_size=200):
            upsert_post_search(post)
            count += 1

        self.stdout.write(self.style.SUCCESS(f"已索引 {count} 篇文章"))

上面的命令需要在文件顶部导入 connection 和索引函数。清空和重建期间,搜索结果可能短暂不完整;文章量较大时,可以在维护窗口执行,或写入新的索引表后用表名切换。无论采用哪种策略,都要保留一份可回滚的数据库备份。

中文分词是 FTS5 的边界

SQLite 内置的 unicode61 tokenizer 对英文、数字和空格分隔的词处理得很好,但不会像中文搜索引擎那样理解词语边界。把“Django 查询优化”当成连续中文字符写入索引时,不能期待它自动拆成“Django”“查询”“优化”。中文查询直接使用 MATCH,可能出现命中过少、命中范围过宽,甚至因查询语法字符产生错误。

有三种实际选择:

  • 文章量小、查询量低:继续使用经过长度限制的 icontains 回退,换取实现简单和结果可预期。
  • 需要 FTS5 但不引入扩展:在应用层使用一个稳定的中文分词器,把分词结果用空格连接后写入 body_tokens;查询时使用同一词典分词。词典版本变化后必须重建索引。
  • 对搜索质量要求更高:使用支持中文分析器的专用搜索服务,并把 SQLite FTS5 作为开发环境或故障回退。不要在没有压测和运维方案时直接引入分布式组件。

分词器是可替换依赖,示例使用 jieba 表达思路:

try:
    import jieba
except ImportError:
    jieba = None


def tokenize_for_search(text: str) -> str:
    if not text:
        return ""
    if jieba is None:
        # 没有分词器时保留原文;查询层会回退到 LIKE。
        return text
    terms = [term.strip() for term in jieba.cut_for_search(text) if term.strip()]
    return " ".join(terms)

分词并不能解决所有语义问题。“Django 部署”和“Django 上线”可能表达相近意图,却不一定共享词项;同一个词也可能出现在不同搜索意图中。因此,搜索日志只能帮助发现问题,不能替代人工评估和文章结构调整。

清洗 MATCH 查询并控制资源

用户输入不是 SQL,也不是可信的 FTS5 表达式。MATCH 支持引号、OR、NEAR 等语法,如果把原始字符串直接拼入查询,用户可以触发语法错误、极宽查询或不必要的资源消耗。查询层应当限制长度、去掉控制字符、限制词数量,并为有分词和无分词两条路径准备回退。

import re
import unicodedata


def make_match_query(raw: str) -> str:
    text = unicodedata.normalize("NFKC", raw or "")
    text = re.sub(r"[\x00-\x1f\x7f]", " ", text).strip()
    if not text or len(text) > 80 or jieba is None:
        # The caller uses a bounded ORM icontains fallback when jieba is unavailable.
        return ""

    # Use the indexer's tokenizer so Chinese query terms match indexed terms.
    tokens = tokenize_for_search(text).split()
    terms = []
    for token in tokens:
        if re.fullmatch(r"[A-Za-z0-9_]+|[\u4e00-\u9fff]+", token):
            terms.append(token[:32])
        if len(terms) == 8:
            break
    if not terms:
        return ""
    return " OR ".join('"' + term.replace('"', '""') + '"' for term in terms)

如果采用应用层中文分词,应该先用同一个 tokenize_for_search() 处理查询,再对每个词做 FTS5 引号转义。没有分词器时,短中文查询可以回退到 ORM 的 icontains,但要限制查询长度、结果页大小和可访问频率。搜索接口最好设置每个请求的数据库超时或反向代理超时;SQLite 没有一个能替代所有问题的“神奇超时参数”,索引、查询上限和限流需要一起设计。

相关性排序、分页和回表

FTS5 可以用 bm25() 返回相关性分数。分数越小通常表示越相关,因此排序方向要和自己写的分数规则保持一致。标题权重高于摘要和正文时,可以给前两个字段更大的权重:

SELECT rowid,
       bm25(post_search, 5.0, 2.0, 1.0, 1.0) AS score
FROM post_search
WHERE post_search MATCH ?
ORDER BY score ASC, rowid DESC
LIMIT ? OFFSET ?;

不要把 LIMIT 和 OFFSET 直接接在未经校验的字符串上。分页参数应转成整数,设置一个合理上限,例如每页 10 或 20 条,并拒绝超出最大页数的请求。先从 FTS5 取得一小批 rowid,再用 Django 的 in_bulk() 或带 Case/When 的查询回表,可以避免把文章正文全部复制到搜索结果页。

页码较深时,OFFSET 仍会逐条跳过结果。搜索结果通常只展示前几页;如果确实需要深分页,可以保存上一页的排序游标,并在应用层定义稳定的二级排序。结果回表后还必须再次过滤 status="published",防止索引同步延迟导致草稿被展示。

摘要和高亮必须经过 HTML 转义

snippet() 返回的字符串可能包含文章原文。若直接在模板中使用 |safe,原文中的标签或恶意属性可能进入页面,形成 XSS。最稳妥的做法是先把正文变成纯文本,在 Python 中进行 HTML 转义,再只给匹配片段添加固定的 <mark> 标签:

import html
import re
from django.utils.safestring import mark_safe


def safe_highlight(text: str, terms: list[str], limit: int = 180):
    plain = (text or "")[:limit]
    escaped = html.escape(plain, quote=False)
    clean_terms = sorted(
        {term for term in terms if term and len(term) <= 32},
        key=len,
        reverse=True,
    )
    if not clean_terms:
        return escaped
    pattern = re.compile("|".join(re.escape(term) for term in clean_terms), re.I)
    return mark_safe(pattern.sub(lambda match: f"<mark>{match.group(0)}</mark>", escaped))

mark_safe() 只应包住已经转义过的字符串。模板中的 post.title、post.excerpt 继续使用 Django 默认转义,不要为了高亮而全局关闭自动转义。高亮词来自已经清洗的查询词,且要限制数量和长度。对 Markdown 代码块、链接地址和 HTML 实体,要先确定展示策略,再写测试覆盖。

无结果页、SEO 和 Sitemap

搜索页属于用户输入页面,不应该让每一个查询参数生成可收录的重复页面。无论有无结果,都应输出 noindex, follow,canonical 可以统一到 /search/,分页和查询词不要进入文章 Sitemap。站内搜索结果仍可链接到已发布文章,因此 follow 有助于发现内容,但它不能替代文章之间的上下文内链。

无结果页不要只显示“没有找到”。可以给出三个动作:检查拼写、换一个更短的关键词、浏览分类或专题页。还可以显示少量相关分类,但不要把用户输入原样放进未经转义的 HTML。对零结果词做脱敏和聚合,例如只保存长度、哈希和计数;涉及邮箱、手机号或访问令牌的输入不应进入日志。

这类 SEO 处理可以和 Django SEO 发布前检查:用 Python 自动验证 canonical、robots、sitemap 与站内链接 的清单结合,发布前确认搜索页的 robots、canonical 和 Sitemap 规则。文章详情页的结构化数据可参考 结构化数据SEO实战指南:从基础标记到搜索富媒体展示,搜索页本身不应伪装成一组可索引的文章详情页。

测试、解释计划和重建校验

测试不能只验证“输入一个词有结果”。至少准备以下样例:英文词、中文词、中文和英文混合词、引号和控制字符、超长输入、无结果词、包含 <script> 的恶意输入、草稿文章以及刚刚取消发布的文章。每个样例都要检查 HTTP 状态、响应时间、结果状态和 HTML 是否仍然有效。

SQLite 可以用 EXPLAIN QUERY PLAN 检查是否走 FTS5:

EXPLAIN QUERY PLAN
SELECT rowid
FROM post_search
WHERE post_search MATCH 'django';

输出应体现对虚拟表的全文检索;如果查询实际走回表全扫描,就要检查 MATCH 条件是否写错、是否误用了普通表别名,或是否在应用层先加载了全部文章。索引重建后比较三组数字:已发布文章数、索引行数和随机抽样文章的命中数。若三者不一致,先修复同步问题,再研究排序。

部署时还要保留可恢复的数据库备份。SQLite 的 FTS5 索引一般与业务数据库在同一个文件中,备份必须是数据库级别的一致快照,不能只复制正在写入的文件。上线后观察锁等待、请求耗时、索引大小和错误日志;访问日志和爬虫识别方法可以参考 Django 部署后的日志排查与性能监控:从请求链路到故障定位。

把搜索数据用于内容规划

搜索功能的最后一环是内容反馈。把零结果词按主题、时间和出现次数聚合,人工过滤品牌词、个人信息、拼写错误和攻击性输入,再判断它们属于哪一种需求:现有文章标题不够准确、已有内容缺少一个小节、需要一篇全新的文章,或搜索词本身不适合本站。

如果一个词有大量搜索但没有结果,可以先在已有文章中补充准确的小节和内链;如果它代表独立的任务,再建立新文章并链接回主题页。文章发布后可用 站内链接审计实战:用 Python 找出孤立文章、断链与过深页面 检查入口,用 SEO 发布后监控实战:用 Python 检查抓取、收录与主动提交结果 观察搜索页面和文章页面的抓取情况。内容更新和相似文章的合并决策,则应回到 SEO 内容更新与文章合并实战:用 Python 识别内容衰退、关键词冲突与重复页面 的内容台账中。

当搜索涉及接口设计、分页或数据库查询时,可以继续阅读 Django API 分页、缓存与数据库查询优化:构建稳定的接口响应。安全边界应与 Django安全实战:7个关键防护策略对抗CSRF与XSS攻击 一起检查;搜索输入、摘要输出和后台零结果统计都属于同一条输入输出链路。

一条可回滚的上线路径

搜索改造适合分阶段上线。第一步先记录当前 icontains 的响应时间、结果数量和常见查询,建立可以比较的基线。第二步在备份后的数据库上创建 FTS5 表,使用管理命令导入已发布文章,并随机抽取标题、中文短语和英文标识符对比新旧结果。第三步只让一小部分请求进入 FTS5,保留 icontains 作为明确的故障回退;出现异常时切换开关即可恢复服务,不需要删除文章或回滚业务数据。

上线后观察至少一个完整的访问高峰,重点看搜索接口的 P95 响应时间、SQLite 锁等待、500 错误、索引行数和零结果比例。确认结果一致后,再逐步扩大流量。分词词典、清洗规则或权重发生变化时,应先在副本上重建并对比样本,再替换线上索引。每次重建记录词典版本、代码版本和文章数量,出现问题时才能知道应该恢复哪一个快照。文章下线或取消发布时同步删除索引,并在发布后再次用未登录请求确认不会通过搜索发现它。

还应为搜索建立最小的运维约定:索引写入失败要有日志和告警,不能静默吞掉异常;数据库备份要定期演练恢复,而不是只确认文件存在;搜索词统计要设置保存期限,避免把它变成永久的个人行为档案。这样搜索优化才不会以牺牲稳定性和隐私为代价。

如果站点由多人维护,还要把索引重建、分词词典和回退开关写进部署记录,并明确谁负责确认结果。清晰的责任边界能减少临时修改造成的索引漂移。

结语

从 icontains 迁移到 FTS5 不是把一条 SQL 换成另一条 SQL,而是建立一条有边界的搜索数据流:发布内容进入索引,查询经过清洗和限流,结果按相关性分页,摘要经过转义,搜索页明确不参与收录,零结果词再反馈到内容规划。SQLite FTS5 适合轻量 Django 博客,但它的中文 tokenizer 有明确限制。把中文分词、回退策略、重建命令和测试一起交付,搜索才会在文章数量增加后仍然可用、可解释、可维护。

分享这篇文章:

评论 (0)

请 登录 后发表评论, 还没有账户?立即注册

暂无评论,快来抢沙发吧!