我们站上有接近 200 篇文章,搜索功能最初的实现是这样的:
python BlogPost.objects.filter(Q(title__icontains=q) | Q(content__icontains=q))翻译成 SQL 就是
LIKE '%关键词%'。问题有三个:
- 搜不到:用户搜"ffmpeg 编译",文章里写的是"自己编译一个静态 ffmpeg",
LIKE是子串匹配,顺序不对就匹配不上;- 没有相关度:搜"ffmpeg",一篇只提了一次的和一篇通篇都在讲的,排序完全一样;
- 慢:
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 不行
- 二、PostgreSQL 全文检索基础
- 三、中文分词:唯一的坎
- 四、方案落地:搜索向量列 + GIN 索引
- 五、兜底方案:应用层分词(不依赖扩展)
- 六、Django 里怎么写
- 七、相关度排序:标题要比正文重要
- 八、结果高亮
- 九、索引什么时候更新
- 十、实测数据与什么时候该上 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。
十一、坑清单
- 用
LIKE '%x%'→ 全表扫描、语义错误、无相关度。 - 用
to_tsquery直接吃用户输入 → 特殊字符报错。用plainto_tsquery或websearch_to_tsquery。 - 中文没分词 → 整段变成一个 token,搜不到。装 zhparser 或应用层 jieba。
- 云数据库装不了扩展 → 兜底用应用层分词(这篇的方案)。
- 生成列里用了单参数
to_tsvector→generation expression is not immutable。要用to_tsvector('simple'::regconfig, ...)。 - 索引和查询的分词规则不一致 → "明明有却搜不到"。两边用同一个
tokenize()。 - GIN 索引建错了 → 全文检索必须 GIN(或 GiST),B-tree 无效。
- 没设权重 → 标题命中和正文命中权重一样,排序很怪。
setweight(..., 'A')。 ts_headline不限制长度 → 对长文档极慢(180ms)。加MaxWords。ts_headline的输出直接|safe渲染 → XSS。用 bleach 清洗,只保留mark。- 忘记加自定义词典 → "ffprobe" 被切成 "ff" + "probe"。技术类站点一定要加。
- 单个 tsvector 超过 1MB → 报错或截断。长文档要拆。
- 全量重建索引一次性 UPDATE → 长事务。分批。
- 停用词处理不一致 → 影响短语召回。两边规则保持一致。
- 搜索没做空查询保护 → 用户提交空字符串导致全表扫描(虽然快,但没意义)。
if not q.strip(): return []。 - 没监控搜索延迟 → 数据量涨了才发现变慢。搜索接口要有 P95 监控。
最后说点选型上的体会。
我一开始是有点想上 Elasticsearch 的——"专业的搜索方案"听起来就是更好。但冷静算了一下:为了 200 篇文章,要部署 ES、要维护索引同步、要处理 ES 的 JVM 调优和内存问题,还要多一个可能故障的服务。收益是"搜索快一点",代价是"运维复杂度翻倍"。
后来用 PG 的方案做出来,12ms 的延迟对站内搜索来说已经快到没感觉了,用户根本分辨不出它和 ES 的区别。
这件事让我形成了一个习惯:先算清楚"现有的东西能做到什么程度",再决定要不要引入新组件。很多时候我们发现"现有工具其实完全够用",只是因为没深入研究过它的能力,就默认了"要专业的才行"。PostgreSQL 是个典型的例子——它的全文检索、JSONB、窗口函数、地理信息(PostGIS)都很强,很多"看起来要上专业组件"的需求,它自己就能解决。
当然也有边界:数据量到了几十万、需要复杂聚合、需要多语言分词的时候,ES 确实更合适。关键是要知道边界在哪,而不是凭印象选。