---
title: 搜索功能实现
description: 基于 SQLAlchemy 模糊查询实现数据库搜索,以及集成全文检索提升搜索质量。
---

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

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

---

对于个人博客来说,**可检索性**是仅次于“发布流畅”的核心体验。写了干货文章却让用户找不到,投入的写作精力就打了折扣。今天我们从最基础的 `LIKE` 模糊查询出发,一步步加上分类/时间过滤、搜索结果高亮,最后再用 PostgreSQL 内置的全文检索实现升级,覆盖小到中型博客的搜索场景。

---

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

### 1.1 多字段文章搜索

博客搜索通常只需要匹配**已发布文章**的三个核心字段:标题、摘要和正文。原实现中使用 `ilike` 做不区分大小写的模糊匹配,这在中文场景下虽然意义不大,但能兼容英文关键词的习惯,保留它很合理。

下面这段代码完整展示了搜索路由,注意我们补上了蓝图和模板参数的说明,让逻辑更清晰:

```python
# 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))  # 推荐用 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:页码超出范围时不抛出 404
    )

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

💡 小细节:使用 filter(Post.is_published.is_(True))== True 更符合 SQLAlchemy 的规范,也能避免某些数据库布尔类型(比如 Postgres 的 BOOLEAN)在比较时发生隐式转换问题。


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

直接展示整篇文章内容太臃肿,最好的做法是:优先使用文章已有的摘要,如果没有则截取正文中包含关键词的部分,并对关键词高亮。下面的高亮函数在截取时会围绕关键词前后取上下文,比直接截取前 200 字更能体现相关性。

# app/utils/search.py
import re

def highlight_excerpt(text: str, query: str, max_length: int = 300) -> str:
    """
    从文本中截取包含搜索词的片段并高亮
    :param text: 原始文本(这里传入纯文本,已移除 Markdown/HTML 标签)
    :param query: 搜索关键词
    :param max_length: 无匹配时的最大截断长度
    :return: 带 <mark> 高亮的 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:
        # 关键词前后各保留 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:
        excerpt = text[:max_length]
        if len(text) > max_length:
            excerpt += "..."

    # 高亮:保留原文大小写,用 <mark> 标签包裹
    highlighted = pattern.sub(lambda m: f'<mark class="search-highlight">{m.group()}</mark>', excerpt)
    return highlighted

在模板里要记得为高亮添加样式,并且优先展示文章摘要:

<!-- templates/articles/search.html -->
<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 }}
      </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="搜索结果分页">
      <!-- 分页组件,复用已有的分页模板 -->
    </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() 转义,原始文本也先用 striptags 去掉了 HTML 标签,因此不存在 XSS 风险。


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

大多数博客不需要太复杂的布尔查询,但加上分类筛选时间范围(近 7/30 天或指定起始日期)会非常实用。我们可以在原有搜索基础上补充这两个维度。

路由代码升级如下:

# 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)
    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 表单,这样筛选条件可以随 URL 分享:

<!-- 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>
    <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 上(个人博客强烈推荐,免费功能又全),就可以使用它的内置全文检索解决这两个问题。

3.1 基础版:使用 chinese 配置

PostgreSQL 从 12 版本开始内置了中文分词配置(虽然分词质量一般,但能用)。用法如下:

# 在搜索路由中替换关键词过滤的部分
from sqlalchemy import func

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

    if query:
        # 构建搜索向量:将标题和正文合并成一个中文搜索向量(|| 是向量合并符)
        search_vector = (
            func.to_tsvector("chinese", Post.title)
            .op("||")(func.to_tsvector("chinese", Post.content))
        )
        # 构建查询:plainto_tsquery 会自动把用户输入的空格转换成“且”逻辑
        ts_query = func.plainto_tsquery("chinese", query)
        # 过滤并按相关性降序
        base_query = (
            base_query
            .filter(search_vector.op("@@")(ts_query))  # @@ 是全文匹配操作符
            .order_by(func.ts_rank(search_vector, ts_query).desc())
        )

    # ... 分页与渲染 ...

3.2 生产环境优化建议

① 添加 GIN 索引,查询速度提升几十倍

Post 模型中添加一个计算列存储搜索向量,并为它创建 GIN 索引:

# app/articles/models.py
from app import db

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"),)

⚠️ 计算列在 SQLite 中不支持。如果开发环境使用 SQLite,可以将这段代码脱敏注释掉,生产环境再启用。

② 给不同字段设置权重

标题的相关性应该比正文更高。PostgreSQL 的 setweight 函数可以做到:

# 修改 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 会自动计算加权分数,让标题更匹配的文章排在前列。

③ 使用 jieba 分词改进中文处理

PostgreSQL 内置的中文分词比较粗糙,比如“人工智能”会被拆成“人”“工”“智”“能”。如果需要更好的分词效果,可以考虑:

  • 在插入/更新文章时,使用 Python 的 jieba 分词生成 tsvector,再存入数据库;
  • 或者安装 PostgreSQL 插件 zhparser,它能借助 SCWS 分词引擎提供更准确的中文分词。

这两条路的投入产出根据你的博客规模选择即可。


4. 方案总结与选型建议

通过以上三种方案,你可以根据博客的规模灵活选择:

方案优点缺点适用场景
SQLAlchemy ilike 模糊查询实现简单、兼容所有数据库无索引、无分词、数据量大于 1 万会明显变慢新博客,文章数 < 5000
PostgreSQL 内置全文检索 + GIN 索引无需额外服务、支持中文、速度快、自带相关性排序必须使用 PostgreSQL,内置中文分词质量一般中型博客,文章数 < 10 万
Elasticsearch / Whoosh分词强大、支持复杂查询、可横向扩展需要额外部署服务(Whoosh 为纯 Python 方案,性能不及 Elasticsearch),开发成本较高大型网站,需要复杂查询或文章 > 10 万

所有代码示例都基于你熟悉的 Flask + SQLAlchemy 技术栈,可以直接迁移到实际项目中。


🔗 扩展阅读