搜索功能实现:模糊查询与全文检索

📂 实战归属:道满博客·第四阶段(实战演练)
🔗 前置知识SQLAlchemy ORM · 文章发布与 Markdown 支持


对于个人博客类产品,可检索性是仅次于「内容发布流畅」的核心需求——试想辛辛苦苦写了干货,用户却找不到,体验会打对折。今天我们从最简单的 LIKE 模糊查询讲起,逐步实现分类/时间过滤、搜索结果高亮,最后用 PostgreSQL 的内置全文检索做升级,覆盖小/中型博客的搜索场景。


1. 基础方案:SQLAlchemy LIKE/ilike 模糊查询

1.1 多字段文章搜索

个人博客通常只需要搜 已发布文章 的三个核心字段:标题、摘要、正文。原代码用了 ilike(不区分大小写的 LIKE),这个在中文场景下虽然意义不大,但兼容英文关键词的习惯,保留很合理。

我们可以在原路由的基础上,补充一下路由装饰器的导入(虽然原场景大概率是有的,但代码片段要尽量可运行),还有模板参数的说明可以用注释补上,让逻辑更清晰。

# app/articles/routes.py
from flask import request, render_template
from sqlalchemy import or_  # 用于多字段「或」匹配
from app import db
from app.articles.models import Post
from app.articles import articles_bp  # 假设已注册好蓝图

@articles_bp.route("/search")
def search():
    # 获取参数:关键词、页码(默认1)、可选参数先留空
    query = request.args.get("q", "").strip()
    page = request.args.get("page", 1, type=int)

    # 空关键词直接返回空结果页
    if not query:
        return render_template("articles/search.html", results=[], query="")

    # 构建搜索规则:%xxx% 匹配任意位置的关键词
    search_pattern = f"%{query}%"
    # 构建查询链:先过滤已发布 → 多字段或匹配 → 按发布时间倒序 → 分页
    pagination = (
        Post.query
        .filter(Post.is_published.is_(True))  # SQLAlchemy 推荐用 is_(True) 代替 == True
        .filter(
            or_(
                Post.title.ilike(search_pattern),
                Post.summary.ilike(search_pattern),
                Post.content.ilike(search_pattern),
            )
        )
        .order_by(Post.created_at.desc())
        .paginate(page=page, per_page=20, error_out=False)  # error_out=False:页码超范围不报错
    )

    return render_template(
        "articles/search.html",
        results=pagination.items,
        query=query,
        pagination=pagination,
        total=pagination.total,
    )

💡 小细节优化:用 filter(Post.is_published.is_(True)) 替代 == True——虽然 == True 对 SQLite/MySQL/PostgreSQL 都能用,但更符合 SQLAlchemy 的规范,能避免某些特殊布尔类型(比如 PostgreSQL 的 BOOLEAN)的隐式转换问题。


1.2 搜索结果高亮+摘要截取

直接展示完整内容太臃肿,原代码做了摘要截取,但可以优化两个点:

  1. 优先用文章的 已写摘要,没有的话再搜正文关键词前后的内容(比直接截取前200字更有相关性)
  2. 原高亮函数在 match 分支才定义 match.group(),但 sub 可以接受函数动态处理匹配项,适配大小写混合的情况(比如搜「Python」,正文里的「python」「PYTHON」也能保留原大小写高亮)
# app/utils/search.py
import re

def highlight_excerpt(text: str, query: str, max_length: int = 300) -> str:
    """
    从文本中截取包含搜索词的片段并高亮
    :param text: 原始文本(支持Markdown但这里只处理纯文本片段)
    :param query: 搜索关键词
    :param max_length: 无匹配时的最大截断长度
    :return: 处理好的HTML片段
    """
    if not text or not query:
        return text[:max_length] if text else ""

    # 构建不区分大小写的正则
    pattern = re.compile(re.escape(query), re.IGNORECASE)
    match = pattern.search(text)

    # 优先截取关键词前后的内容
    if match:
        # 关键词前留50字符,后留max_length//2左右,避免溢出
        context_length = max_length // 2
        start = max(0, match.start() - context_length)
        end = min(len(text), match.end() + context_length)
        excerpt = text[start:end]

        # 加省略号表示截断
        if start > 0:
            excerpt = "..." + excerpt
        if end < len(text):
            excerpt += "..."
    else:
        # 无匹配时直接截断前max_length
        excerpt = text[:max_length]
        if len(text) > max_length:
            excerpt += "..."

    # 动态保留原大小写高亮:sub第二个参数传函数,每次匹配返回原内容套mark标签
    highlighted = pattern.sub(lambda m: f'<mark class="search-highlight">{m.group()}</mark>', excerpt)
    return highlighted

然后模板里也要调整,一定要加CSS控制highlight的样式,还有优先用已写摘要、再用高亮后的正文片段。

<!-- templates/articles/search.html -->
<!-- 可以加在head或单独的CSS文件里 -->
<style>
.search-highlight {
  background-color: #fff9c4; /* 浅黄背景,不刺眼 */
  padding: 0 2px;
  border-radius: 2px;
  font-weight: 500;
}
.search-result {
  margin-bottom: 2rem;
  padding-bottom: 1rem;
  border-bottom: 1px solid #eee;
}
</style>

<h2>搜索结果:「{{ query }}」 ({{ total }} 篇)</h2>

{% if total > 0 %}
  {% for post in results %}
  <article class="search-result">
    <h3>
      <a href="{{ url_for('articles.detail', post_id=post.id) }}">
        {{ post.title }} <!-- 标题也可以用highlight,这里暂不加避免太乱 -->
      </a>
    </h3>
    <!-- 优先用已写摘要,无摘要用正文的高亮片段 -->
    <p>
      {% if post.summary %}
        {{ post.summary | truncate(200) }}
      {% else %}
        {{ post.content | striptags | highlight_excerpt(query) | safe }}
      {% endif %}
    </p>
    <small class="text-muted">
      发布于 {{ post.created_at.strftime('%Y-%m-%d') }}
      {% if post.category %} · {{ post.category.name }} {% endif %}
    </small>
  </article>
  {% endfor %}

  <!-- 分页组件(可以复用之前写好的) -->
  {% if pagination.pages > 1 %}
    <nav aria-label="搜索结果分页">
      <!-- 省略原博客的分页HTML -->
    </nav>
  {% endif %}
{% else %}
  <div class="text-center py-5">
    <h4>未找到相关文章 😔</h4>
    <p>试试调整关键词,或者去<a href="{{ url_for('articles.index') }}">首页</a>逛逛?</p>
  </div>
{% endif %}

⚠️ 安全提醒:模板里用了 | safe 过滤器,因为 highlight_excerpt 返回的是带 <mark> 标签的 HTML——但这个函数里只处理了我们自己生成的标签,原始文本已经通过 re.escape(query) 过滤了关键词里的特殊字符,正文还先做了 striptags 去HTML,所以没问题。


2. 进阶过滤:分类+时间筛选

博客搜索一般不需要太复杂的布尔逻辑(AND/OR/NOT),但加个 分类筛选近N天/指定时间开始的文章筛选 会很实用。原代码的基础上,我们把分类和时间的参数补全到模板里,让用户可以交互。

# app/articles/routes.py(替换原search函数,保留其他导入)
from datetime import datetime, timedelta
from app.articles.models import Category  # 假设已有Category模型

@articles_bp.route("/search")
def search():
    query = request.args.get("q", "").strip()
    category_id = request.args.get("category", type=int)
    date_from = request.args.get("from", type=str)
    recent_days = request.args.get("recent", type=int)  # 新增:近N天
    page = request.args.get("page", 1, type=int)

    # 先获取所有已发布文章的分类(用于下拉框)
    categories = Category.query.join(Category.posts).filter(Post.is_published.is_(True)).distinct().all()

    base_query = Post.query.filter(Post.is_published.is_(True))

    # 关键词过滤
    if query:
        search_pattern = f"%{query}%"
        base_query = base_query.filter(
            or_(
                Post.title.ilike(search_pattern),
                Post.summary.ilike(search_pattern),
                Post.content.ilike(search_pattern),
            )
        )

    # 分类过滤
    if category_id:
        base_query = base_query.filter_by(category_id=category_id)

    # 时间过滤:优先用指定日期,再用近N天
    if date_from:
        try:
            start_date = datetime.fromisoformat(date_from)
            base_query = base_query.filter(Post.created_at >= start_date)
        except ValueError:
            pass  # 无效日期直接忽略
    elif recent_days:
        start_date = datetime.utcnow() - timedelta(days=recent_days)
        base_query = base_query.filter(Post.created_at >= start_date)

    pagination = (
        base_query
        .order_by(Post.created_at.desc())
        .paginate(page=page, per_page=20, error_out=False)
    )

    return render_template(
        "articles/search.html",
        results=pagination.items,
        query=query,
        categories=categories,
        selected_category=category_id,
        selected_from=date_from,
        selected_recent=recent_days,
        pagination=pagination,
        total=pagination.total,
    )

然后在搜索结果页的顶部加个筛选栏,用 GET 请求提交参数(这样筛选条件可以保存到链接里分享):

<!-- templates/articles/search.html(在搜索结果标题上方加筛选栏) -->
<div class="search-filter card mb-4 p-3">
  <form method="GET" action="{{ url_for('articles.search') }}" class="row g-3">
    <!-- 隐藏关键词输入框?不,要保留让用户可以修改 -->
    <div class="col-md-6">
      <input type="text" name="q" value="{{ query }}" placeholder="搜索文章..." class="form-control" required>
    </div>
    <!-- 分类下拉框 -->
    <div class="col-md-2">
      <select name="category" class="form-select">
        <option value="">所有分类</option>
        {% for cat in categories %}
        <option value="{{ cat.id }}" {% if selected_category == cat.id %}selected{% endif %}>{{ cat.name }}</option>
        {% endfor %}
      </select>
    </div>
    <!-- 时间筛选:近7/30天+指定日期 -->
    <div class="col-md-2">
      <select name="recent" class="form-select">
        <option value="">不限时间</option>
        <option value="7" {% if selected_recent == 7 %}selected{% endif %}>近7天</option>
        <option value="30" {% if selected_recent == 30 %}selected{% endif %}>近30天</option>
      </select>
    </div>
    <div class="col-md-2">
      <button type="submit" class="btn btn-primary w-100">搜索</button>
    </div>
    <!-- 可选:指定日期单独放一行小按钮 -->
    <div class="col-12 mt-1">
      <small class="text-muted">或指定起始日期:</small>
      <input type="date" name="from" value="{{ selected_from }}" class="form-control form-control-sm d-inline-block w-auto ms-1">
    </div>
  </form>
</div>

3. 优化方案:PostgreSQL 内置全文检索

LIKE/ilike 虽然简单,但有两个致命缺点:

  1. 无法利用索引(除非用 %xxx 后缀匹配,但一般博客要的是任意位置匹配),数据量超过1万篇就会明显变慢
  2. 没有分词功能(比如搜「道满博客」,正文里的「道满的博客」搜不到)

如果你的博客用的是 PostgreSQL(强烈推荐!个人博客免费、功能全),可以用它的内置全文检索解决这两个问题。原代码用了 chinese 配置,但 PostgreSQL 12+ 开始虽然内置了中文分词,但分词质量一般,生产环境可以配合 zhparser 插件或者 Python 的 jieba 分词生成自定义的 tsvector。

3.1 基础版:内置 chinese 配置

先看最简单的写法,然后讲怎么优化:

# app/articles/routes.py(仅替换关键词过滤部分的代码)
from sqlalchemy import func

@articles_bp.route("/search")
def search():
    # ... 前面的参数获取、分类时间过滤、分页逻辑保留 ...

    if query:
        # 1. 构建搜索向量:把标题和正文合并成一个中文向量(| 是向量合并操作符)
        search_vector = (
            func.to_tsvector("chinese", Post.title)
            .op("||")(func.to_tsvector("chinese", Post.content))
        )
        # 2. 构建搜索查询:plainto_tsquery 会自动把空格转成「且」匹配
        ts_query = func.plainto_tsquery("chinese", query)
        # 3. 过滤+按相关性排序(ts_rank 给标题更高的权重会更好,后面讲)
        base_query = (
            base_query
            .filter(search_vector.op("@@")(ts_query))  # @@ 是向量匹配操作符
            .order_by(func.ts_rank(search_vector, ts_query).desc())
        )

    # ... 后面的分页、渲染逻辑保留 ...

3.2 生产优化建议

  1. 加索引:在 Post 模型里加一个 GIN 索引,这是 PostgreSQL 全文检索的专用索引,查询速度会提升几十倍:
    # app/articles/models.py
    class Post(db.Model):
        # ... 原有字段 ...
        # 添加一个生成列存储搜索向量(PostgreSQL 12+支持)
        search_vector = db.Column(
            db.TSVECTOR,
            db.Computed(
                "to_tsvector('chinese', coalesce(title, '') || ' ' || coalesce(content, ''))",
                persisted=True,
            ),
        )
        # 添加GIN索引
        __table_args__ = (db.Index("idx_post_search_vector", search_vector, postgresql_using="gin"),)
    ⚠️ computed 列在 SQLite 里不支持,所以开发环境如果用 SQLite,这部分可以暂时注释掉。
  2. 给字段加权重:标题的相关性应该比正文高,用 setweight 函数:
    # models.py 里的 computed 列改成:
    db.Computed(
        "setweight(to_tsvector('chinese', coalesce(title, '')), 'A') || setweight(to_tsvector('chinese', coalesce(summary, '')), 'B') || setweight(to_tsvector('chinese', coalesce(content, '')), 'D')",
        persisted=True,
    )
    权重从高到低是 A > B > C > D,ts_rank 会自动计算带权重的分数。
  3. 用 jieba 分词:PostgreSQL 内置的中文分词太粗糙,比如搜「人工智能」,可能会拆成「人」「工」「智」「能」,可以用 Python 的 jieba 分词在插入/更新文章时生成 tsvector,或者配合 zhparser 插件。

4. 方案总结与选型建议

我们今天讲了三种搜索方案,适合不同规模的博客:

方案优点缺点适用场景
SQLAlchemy ilike 模糊查询开发简单、兼容所有数据库无索引、无分词、数据量>1万变慢新博客、文章<5000篇
PostgreSQL 内置全文检索(带GIN索引)无需额外服务、支持中文(优化后)、速度快、相关性排序需要用 PostgreSQL、内置中文分词质量一般中型博客、文章<10万篇
Elasticsearch/Whoosh分词功能强大、支持复杂查询、分布式需要额外部署服务(Whoosh是纯Python但速度不如ES)、开发成本高大型网站、文章>10万篇、需要复杂查询

🔗 扩展阅读