提示

返回博客列表

站点搜索怎么做:PostgreSQL 全文检索 + 中文分词的完整方案

我们站上有接近 200 篇文章,搜索功能最初的实现是这样的:

python BlogPost.objects.filter(Q(title__icontains=q) | Q(content__icontains=q))

翻译成 SQL 就是 LIKE '%关键词%'。问题有三个:

  1. 搜不到:用户搜"ffmpeg 编译",文章里写的是"自己编译一个静态 ffmpeg",LIKE 是子串匹配,顺序不对就匹配不上;
  2. 没有相关度:搜"ffmpeg",一篇只提了一次的和一篇通篇都在讲的,排序完全一样;
  3. 慢:LIKE '%...%' 用不上索引,全表扫描。文章多了之后一次搜索 800ms。

我评估过 Elasticsearch(太重,为了 200 篇文章起一个 ES 集群不划算),也看过 Meilisearch(不错,但要多维护一个服务)。最后发现 PostgreSQL 自带的全文检索就够用了,只是中文分词要处理一下。这篇写完整的方案,包括"云上 RDS 装不了扩展"这个坑的解决办法。

TL;DR:用 tsvector 类型的列存搜索向量 + GIN 索引,查询用 plainto_tsquery / websearch_to_tsquery,排序用 ts_rank。中文分词是唯一的坎:PG 自带的 simple 配置对中文几乎无效,首选装 zhparser 扩展;云 RDS 装不了扩展时的兜底方案:在应用层用 jieba 分词,把分词结果拼成 tsvector 存进列里(不依赖任何扩展)。实测 200 篇文章的搜索从 800ms 降到 12ms。数据量上万、需要复杂聚合/多语言/高亮需求再考虑 ES。

目录

一、为什么 LIKE 不行

LIKE '%x%' 的三个硬伤:

1. 语义错误。它是子串匹配,"编译 ffmpeg" 和 "ffmpeg 编译" 是两回事,但用户认为是一回事。

2. 无法用索引。'%...' 开头的模式匹配,B-tree 索引完全用不上(除了 pg_trgm 的 GIN 索引能部分加速,但那是另一套方案)。

3. 没有相关度概念。返回的结果只能按 id 或时间排,跟"匹配得好不好"无关。

我实测过 200 篇文章、平均 8000 字的库:

查询方式 耗时 说明
LIKE '%ffmpeg%' 780 ms 全表扫 + 大字段读取
ILIKE(不区分大小写) 810 ms 更慢
pg_trgm + GIN 45 ms 能加速,但语义仍不对
tsvector + GIN 12 ms 快 + 语义正确 + 有相关度

二、PostgreSQL 全文检索基础

三个核心概念:

概念 是什么 例子
tsvector 文档的"词向量"(分词后的词 + 位置 + 权重) '编译':2 'ffmpeg':1
tsquery 查询条件 'ffmpeg' & '编译'
@@ 匹配操作符 tsvector @@ tsquery
-- 生成向量
SELECT to_tsvector('simple', '自己编译一个静态 ffmpeg');
-- 'ffmpeg':5 '一个':3 '编译':2 '自己':1 '静态':4

-- 生成查询
SELECT plainto_tsquery('simple', 'ffmpeg 编译');
-- 'ffmpeg' & '编译'

-- 匹配
SELECT to_tsvector('simple', '自己编译一个静态 ffmpeg') @@ plainto_tsquery('simple', 'ffmpeg 编译');
-- t

simple 配置是最常用的(它不做词干还原、不过滤停用词),对中英混合内容比较友好。PG 还有 english、french 等语言配置,它们会做词干还原("running" → "run"),对中文没用。

生成查询的三个函数,区别很大:

函数 输入 适合
to_tsquery 'ffmpeg & 编译'(要自己写操作符) 程序构造
plainto_tsquery 'ffmpeg 编译'(空格分隔,自动 AND) 用户输入
websearch_to_tsquery '"编译 ffmpeg" -windows'(支持引号、OR、排除) 用户输入(高级)

用户输入必须用 plainto_tsquery 或 websearch_to_tsquery,千万别用 to_tsquery——用户输入的 and、or、|、& 会被当成操作符,要么报错要么查出奇怪的结果:

-- 危险:用户搜 "C++ 和 python",& 和 + 会被当操作符
SELECT to_tsquery('simple', 'C++ 和 python');
-- ERROR:  syntax error in tsquery

三、中文分词:唯一的坎

这是全文检索在中文场景下的核心问题。

simple 配置怎么切中文?它用"非字母数字字符"做分隔符。所以:

SELECT to_tsvector('simple', '自己编译一个静态 ffmpeg');

中文部分会被当成一个整体吗?让我们看实际行为——simple 的分词器会把连续的中文字符当成一个 token(因为它不认识词边界),除非有空格或标点。也就是说:

'自己编译一个静态 ffmpeg'  →  ['自己编译一个静态', 'ffmpeg']

整段中文变成一个词。这意味着只有用户输入完整的"自己编译一个静态"才能匹配上,搜"编译"完全搜不到。这显然不能用。

解决方案有三种:

方案 做法 优点 缺点
zhparser(基于 SCWS) 装 PG 扩展 分词准确、服务端完成 要装扩展,云 RDS 可能不允许
pg_jieba 装 PG 扩展 同上 同上,且维护一般
应用层分词(jieba) Python 分词后存 tsvector 不依赖扩展、可控 要自己维护向量列

首选 zhparser(如果环境允许):

CREATE EXTENSION zhparser;
CREATE TEXT SEARCH CONFIGURATION chinese (PARSER = zhparser);
ALTER TEXT SEARCH CONFIGURATION chinese ADD MAPPING FOR n,v,a,i,e,l WITH simple;

SELECT to_tsvector('chinese', '自己编译一个静态 ffmpeg');
-- 'ffmpeg':5 '一个':3 '编译':2 '静态':4 '自己':1     ← 正确切开了

但是,很多云数据库(阿里云 RDS、腾讯云、AWS RDS)不开放自定义扩展,或者只允许白名单内的扩展。CREATE EXTENSION zhparser 会报:

ERROR:  could not open extension control file ".../zhparser.control": No such file or directory

我们生产用的就是这类环境,所以走了方案三:应用层分词。下面的篇幅主要讲它,因为它通用性最好——不管你的数据库能不能装扩展,这套都能跑。

四、方案落地:搜索向量列 + GIN 索引

如果不装扩展,就不能用 to_tsvector('chinese', ...)。思路是:在 Python 里把文本分词,用空格连接,再用 simple 配置生成 tsvector。

表结构

-- 加一列存搜索向量
ALTER TABLE downloader_blogpost ADD COLUMN search_vector tsvector;

-- 标题赋最高权重 A,摘要 B,正文 C
UPDATE downloader_blogpost SET search_vector =
    setweight(to_tsvector('simple', coalesce(title, '')), 'A') ||
    setweight(to_tsvector('simple', coalesce(excerpt, '')), 'B') ||
    setweight(to_tsvector('simple', coalesce(search_text, '')), 'C');

-- GIN 索引(全文检索必须用 GIN,不是 B-tree)
CREATE INDEX blog_search_idx ON downloader_blogpost USING GIN (search_vector);

这里的 search_text 是分词后的正文(见下一节),不是原始正文。

用生成列(Generated Column)自动维护

如果数据库版本是 PG 12+,可以用生成列让数据库自动维护向量——省掉应用层的所有同步逻辑:

ALTER TABLE downloader_blogpost ADD COLUMN search_vector tsvector
GENERATED ALWAYS AS (
    setweight(to_tsvector('simple'::regconfig, coalesce(title, '')), 'A') ||
    setweight(to_tsvector('simple'::regconfig, coalesce(excerpt, '')), 'B') ||
    setweight(to_tsvector('simple'::regconfig, coalesce(search_text, '')), 'C')
) STORED;

注意 to_tsvector('simple'::regconfig, ...) 的写法:两个参数版本(带 regconfig)是 IMMUTABLE 的,才能用在生成列里;单参数版本不是 IMMUTABLE,会报错:

ERROR:  generation expression is not immutable

这个报错我卡了半小时才搞明白。

生成列的好处:插入/更新时自动重算,不会出现"内容和向量不一致"。代价:每次更新文章都要重算(大文章会慢一点),且 search_text 必须也是同一张表的列。

五、兜底方案:应用层分词(不依赖扩展)

用 jieba 把中文切开,空格连接后交给 simple 配置。

# search_utils.py
import re
import jieba

# 关闭 jieba 的新词发现(可选,能提速)
jieba.initialize()

# 保留英文单词、数字和中文,其它当分隔符
SPLIT_RE = re.compile(r"[^\w\u4e00-\u9fff]+", re.UNICODE)


def tokenize(text: str) -> str:
    """把中英混合文本切成「空格分隔的词序列」,供 to_tsvector('simple', ...) 使用。"""
    if not text:
        return ''
    text = text.lower()
    out = []
    for seg in SPLIT_RE.split(text):
        if not seg:
            continue
        if re.search(r'[\u4e00-\u9fff]', seg):
            # 含中文 → 用 jieba 切
            out.extend(w for w in jieba.cut(seg) if w.strip())
        else:
            # 纯英文/数字 → 直接作为一个词
            out.append(seg)
    return ' '.join(out)

效果:

>>> tokenize('自己编译一个静态 ffmpeg 的完整记录')
'自己 编译 一个 静态 ffmpeg 的 完整 记录'
>>> tokenize('HLS AES-128 加密流怎么下')
'hls aes 128 加密流 怎么 下'

几个优化点:

1. 去掉停用词(可选,能减小索引体积):

STOPWORDS = {'的', '了', '是', '在', '和', '与', '这', '那', '一个', '我们', '怎么', '什么', ...}

def tokenize(text, drop_stopwords=True):
    words = ...
    if drop_stopwords:
        words = [w for w in words if w not in STOPWORDS]
    return ' '.join(words)

注意:停用词会影响"精确短语搜索"。如果用户搜"怎么安装","怎么"被去掉了,可能搜不准。我的做法是建索引时保留停用词,查询时也分词(两边用同一套规则),只是给低频词更低权重——这样既不影响召回,也不影响排序。

2. 英文处理:aes-128 会被切成 aes 和 128,用户搜 "aes-128" 时我们用同样的规则切成 aes 128,用 & 连接即可匹配。

3. 自定义词典(行业术语):

jieba.load_userdict('dict.txt')

dict.txt 格式:词 词频 词性,比如:

ffprobe 100 n
断点续传 100 n
m3u8 100 n
HLS 100 n

这个很重要——不然 "ffprobe" 可能被切成 "ff" + "probe",用户搜 ffprobe 就搜不到。我们把项目里的技术名词都导进了自定义词典。

六、Django 里怎么写

Django 自带 django.contrib.postgres.search,但对中文分词帮不上忙,所以查询部分我用原生 SQL + ORM 混合。

模型

class BlogPost(models.Model):
    ...
    search_text = models.TextField(blank=True, verbose_name='分词后的正文', editable=False)
    search_vector = models.TextField(blank=True, verbose_name='搜索向量', editable=False)  # 见下方说明

注意:Django 没有 TSVectorField(除非用第三方包)。两种做法:

  • 做法 A:search_vector 用生成列(PG 12+),Django 侧不声明这个字段(或声明为 editable=False 但不参与迁移),数据库自己维护;
  • 做法 B:search_vector 用 models.TextField 存字符串形式的 tsvector,查询时再转换——不推荐(转换开销大,且用不上索引)。

我推荐做法 A:数据库生成列 + Django 只负责维护 search_text(分词后的正文),search_vector 由数据库自动生成。这样应用层代码最少。

写入侧用信号:

from django.db.models.signals import pre_save
from django.dispatch import receiver
from .search_utils import tokenize


@receiver(pre_save, sender=BlogPost)
def build_search_text(sender, instance, **kwargs):
    # 只取正文的纯文本(去掉 markdown 标记),并截断(正文很长,全部分词没意义)
    plain = strip_markdown(instance.content or '')
    instance.search_text = tokenize(plain[:20000])

截断正文的理由:分词 20000 字已经足够覆盖召回,再长只是徒增索引体积和计算时间。

查询

from django.db import connection
from .search_utils import tokenize


def search_posts(q: str, limit: int = 20):
    """全文检索。返回 [(id, title, excerpt, rank)]。"""
    if not q.strip():
        return []

    # 用户输入用同样的分词规则处理,再转成 tsquery
    words = [w for w in tokenize(q).split() if w]
    if not words:
        return []
    # 词之间用 & (AND):必须全部命中
    tsquery = ' & '.join(f"'{w}'" for w in words)

    sql = """
        SELECT id, title, excerpt, pub_date,
               ts_rank(search_vector, query) AS rank
        FROM downloader_blogpost, to_tsquery('simple', %s) query
        WHERE search_vector @@ query AND is_published = TRUE
        ORDER BY rank DESC, pub_date DESC
        LIMIT %s
    """
    with connection.cursor() as c:
        c.execute(sql, [tsquery, limit])
        return c.fetchall()

为什么用 to_tsquery 而不是 plainto_tsquery? 因为查询串是我们自己用分词结果拼的(已经清洗过,不会有非法操作符),这样能保证索引和查询用的是同一套分词规则——这是关键。

如果两边规则不一致(比如索引时用 jieba 切了,查询时用 plainto_tsquery 原样传入整句),就会出现"明明有这个词却搜不到"的经典 bug。

用 websearch 语法(可选的高级搜索)

如果想支持 "完整匹配" -排除词 这种语法:

sql = """
    SELECT ... WHERE search_vector @@ websearch_to_tsquery('simple', %s)
"""

websearch_to_tsquery 会处理引号和 -,且不会因非法语法报错(它会容错),适合直接把用户输入传进去。

七、相关度排序:标题要比正文重要

ts_rank 会根据四个权重(A/B/C/D)计算相关度。我们给标题赋 A(权重 1.0)、摘要 B(0.4)、正文 C(0.2)。

-- 权重的默认值
-- A=1.0, B=0.4, C=0.2, D=0.1

标题命中比正文命中重要得多,这符合直觉。实测效果:

搜索 "ffmpeg 编译":
1. 自己编译一个静态 ffmpeg:从依赖地狱到 16MB 单文件   (rank 0.91)  ← 标题全命中
2. 用 Python 包一层 ffmpeg 跑批量任务                 (rank 0.42)
3. FFmpeg 从入门到放弃                               (rank 0.38)

如果权重都一样,这三篇的排序会乱掉(因为正文里提到 ffmpeg 的次数差不多)。

加一点时间衰减(新文章稍微加分):

ORDER BY rank * (1.0 + 0.1 * EXTRACT(EPOCH FROM (now() - pub_date)) / -864000) DESC

这个写法有点丑,更常见的是在 Python 里做二次排序,或者在 SQL 里:

ORDER BY (rank + 0.05 * exp(-EXTRACT(EPOCH FROM (now() - pub_date)) / (180*86400))) DESC

我最后没加时间衰减——博客文章的时效性不强,按相关度排最符合用户预期。是否加取决于你的内容类型(新闻类要加,文档类不用)。

ts_rank_cd:另一个排序函数,它考虑词的距离(cover density),"词挨在一起"的文档得分更高。对长文档效果通常更好:

ts_rank_cd(search_vector, query, 32)    -- 32 = 归一化 + 考虑唯一词频

八、结果高亮

搜索结果里把命中的词标出来,用 ts_headline:

SELECT ts_headline(
    'simple',
    coalesce(title, '') || ' ' || coalesce(excerpt, ''),
    query,
    'StartSel=<mark>, StopSel=</mark>, MaxFragments=1, MaxWords=40, MinWords=15'
) AS headline
FROM downloader_blogpost, to_tsquery('simple', 'ffmpeg & 编译') query
WHERE search_vector @@ query;

输出:

自己编译一个静态 <mark>ffmpeg</mark>:从依赖地狱到 16MB 单文件

注意 MaxFragments / MaxWords:它们控制片段长度。不设的话,ts_headline 会把整个文档扫一遍,对长正文很慢。

我实测过:对 8000 字的正文做 headline,不加限制是 180ms,加了 MaxWords=40 是 8ms。

另外,前端要注意:ts_headline 返回的是 HTML 片段(带 <mark>),渲染时不能用 |escape 转义掉,但又不能直接 |safe(有 XSS 风险)。正确做法是先 bleach 清洗(保留 mark 标签):

import bleach

headline_html = bleach.clean(raw_headline, tags=['mark'], attributes={}, strip=True)

九、索引什么时候更新

如果用生成列,数据库自动维护,不用管。

如果是应用层维护 search_vector,有三种时机:

时机 做法 优点 缺点
保存时同步更新 post_save 信号 实时 大文档会拖慢保存
异步任务更新 post_save → celery 任务 不阻塞 有短暂延迟
定时批量重建 beat 任务,每 5 分钟扫 updated_at 批量高效 延迟最大

我推荐"保存时异步更新":主线程只更新 search_text 字段(分词,几毫秒),向量更新扔给 Celery。

@shared_task
def rebuild_search_vector(post_id):
    from django.db import connection
    with connection.cursor() as c:
        c.execute("""
            UPDATE downloader_blogpost SET search_vector =
                setweight(to_tsvector('simple'::regconfig, coalesce(title,'')), 'A') ||
                setweight(to_tsvector('simple'::regconfig, coalesce(excerpt,'')), 'B') ||
                setweight(to_tsvector('simple'::regconfig, coalesce(search_text,'')), 'C')
            WHERE id = %s
        """, [post_id])

首次上线要全量重建:

@shared_task
def rebuild_all():
    """分批重建,别一次性 UPDATE(参考不停机迁移那篇)。"""
    ids = list(BlogPost.objects.values_list('id', flat=True))
    for i in range(0, len(ids), 200):
        for pk in ids[i:i + 200]:
            rebuild_search_vector(pk)
        time.sleep(0.05)

十、实测数据与什么时候该上 ES

我们的实际数据:192 篇文章,平均 9000 字。

指标 数值
索引大小(GIN) 18 MB
建索引耗时(全量) 6.2 秒
搜索延迟(P50) 12 ms
搜索延迟(P95) 34 ms
分词耗时(单篇 9000 字) 90 ms
召回质量(人工评估 top10 相关率) 86%

召回质量 86% 是我人工看了 20 组查询的结果——主要失分在"同义词"(搜"转码"找不到只写了"编码"的文章)。解决办法是加同义词词典(在分词阶段做映射):

SYNONYMS = {
    '转码': ['转码', '编码', '重编码'],
    '去重': ['去重', '重复', '查重'],
    '部署': ['部署', '上线', '发布'],
}

分词时把同义词也加进去(增加召回)。

什么时候该上 Elasticsearch

我的判断标准(满足任意两条就该考虑):

条件 阈值
文档数量 > 50 万
单文档大小 > 1 MB(PG 的 tsvector 有 1MB 限制!)
查询复杂度 需要聚合、分面统计、复杂过滤组合
多语言 需要多种语言的不同分词器
搜索 QPS > 500
团队 有人愿意维护 ES(它真的需要维护)

tsvector 有个 1MB 的硬限制(单个 tsvector 不能超过 1MB),超长文档会被截断或者报错——这是很多人踩过的坑。我们的文章 9000 字分词后大概 30KB,远没到限制;如果你要索引整本书,就得拆分或者上 ES。

结论:对几万篇以内的站内搜索,PostgreSQL 全文检索完全够用,而且少维护一个服务。 我见过太多"为了搜索上 ES,结果 ES 成为新的故障源"的案例。除非你的需求明确超出它的能力,否则先用 PG。

十一、坑清单

  1. 用 LIKE '%x%' → 全表扫描、语义错误、无相关度。
  2. 用 to_tsquery 直接吃用户输入 → 特殊字符报错。用 plainto_tsquery 或 websearch_to_tsquery。
  3. 中文没分词 → 整段变成一个 token,搜不到。装 zhparser 或应用层 jieba。
  4. 云数据库装不了扩展 → 兜底用应用层分词(这篇的方案)。
  5. 生成列里用了单参数 to_tsvector → generation expression is not immutable。要用 to_tsvector('simple'::regconfig, ...)。
  6. 索引和查询的分词规则不一致 → "明明有却搜不到"。两边用同一个 tokenize()。
  7. GIN 索引建错了 → 全文检索必须 GIN(或 GiST),B-tree 无效。
  8. 没设权重 → 标题命中和正文命中权重一样,排序很怪。setweight(..., 'A')。
  9. ts_headline 不限制长度 → 对长文档极慢(180ms)。加 MaxWords。
  10. ts_headline 的输出直接 |safe 渲染 → XSS。用 bleach 清洗,只保留 mark。
  11. 忘记加自定义词典 → "ffprobe" 被切成 "ff" + "probe"。技术类站点一定要加。
  12. 单个 tsvector 超过 1MB → 报错或截断。长文档要拆。
  13. 全量重建索引一次性 UPDATE → 长事务。分批。
  14. 停用词处理不一致 → 影响短语召回。两边规则保持一致。
  15. 搜索没做空查询保护 → 用户提交空字符串导致全表扫描(虽然快,但没意义)。if not q.strip(): return []。
  16. 没监控搜索延迟 → 数据量涨了才发现变慢。搜索接口要有 P95 监控。

最后说点选型上的体会。

我一开始是有点想上 Elasticsearch 的——"专业的搜索方案"听起来就是更好。但冷静算了一下:为了 200 篇文章,要部署 ES、要维护索引同步、要处理 ES 的 JVM 调优和内存问题,还要多一个可能故障的服务。收益是"搜索快一点",代价是"运维复杂度翻倍"。

后来用 PG 的方案做出来,12ms 的延迟对站内搜索来说已经快到没感觉了,用户根本分辨不出它和 ES 的区别。

这件事让我形成了一个习惯:先算清楚"现有的东西能做到什么程度",再决定要不要引入新组件。很多时候我们发现"现有工具其实完全够用",只是因为没深入研究过它的能力,就默认了"要专业的才行"。PostgreSQL 是个典型的例子——它的全文检索、JSONB、窗口函数、地理信息(PostGIS)都很强,很多"看起来要上专业组件"的需求,它自己就能解决。

当然也有边界:数据量到了几十万、需要复杂聚合、需要多语言分词的时候,ES 确实更合适。关键是要知道边界在哪,而不是凭印象选。

想亲手试试?用 VidDown 一键解析下载

粘贴视频链接即可解析,多平台支持、网页端即用;下载桌面客户端解锁海外平台本地解析,开通会员更享不限次下载。

顶部