用 PostgresML 在数据库中构建机器学习驱动的搜索引擎

原创2026-10-08 13:50:301,441 阅读
文章标签:后端人工智能机器学习RAG向量数据库

用 PostgresML 在数据库中构建机器学习驱动的搜索引擎

本指南基于 PostgresML 官方文档《Improve Search Results with Machine Learning》展开,完整演示如何只使用标准 SQL 与 PostgresML 提供的机器学习函数,从零构建一个多层、可迭代优化的搜索引擎。你将掌握:基于 tsvector/GIN 索引的关键词召回、ts_rank 相关性排序、利用用户点击数据训练回归模型进行 Learning to Rank 重排,以及向量搜索、个性化、多模态等进阶扩展路径,全程无需编写 Python 微服务或搬移数据。

分层构建搜索:从关键词到机器学习重排

搜索引擎不是单个算法,而是由简单到复杂的多个层次叠加而成,并通过逐层迭代精化结果。本文的核心思路是:先用关键词搜索完成低成本召回,再用相关性函数排序,最后用机器学习模型基于更多特征对候选结果重排。整个过程都在 PostgreSQL 内部完成,得益于 PostgresML 把训练与推理直接做进了数据库(函数定义见 pgml-extension/src/api.rs)。

Keyword Search:打牢关键词召回的地基

关键词搜索是最传统也最基础的一层:用户在搜索框输入几个词,返回包含这些词的文档列表。虽然简单,但它是整个多层搜索系统的第一级漏斗,负责高效、低成本地召回候选文档。

建表与插入:准备文档语料

搜索应用从一张 documents 表开始。文档包含 title、body 以及供应用层更新/删除时引用的唯一 id,使用标准 CREATE TABLE 即可:

CREATE TABLE documents (
  id BIGSERIAL PRIMARY KEY,
  title TEXT,
  body TEXT
);

用标准 INSERT 向文本语料(text corpus)添加文档,BIGSERIAL 自增主键会自动生成唯一 id:

INSERT INTO documents (title, body) VALUES
  ('This is a title', 'This is the body of the first document.'),
  ('This is another title', 'This is the body of the second document.'),
  ('This is the third title', 'This is the body of the third document.')
;

对生产级规模的工作负载,后续还需要更深入的扩容与调优,但 Postgres 开箱即用的小表性能已经足够起步。

关键词查询:to_tsvector 与 to_tsquery

Postgres 内置的关键词查询不仅能匹配查询词本身,还能处理词形变化:复数、过去时等标准词根变体会被"词干化"(stemming)后匹配。实现这些语法清理规则的核心是下面两个函数:

  • to_tsvector(config, text):把纯文本转成 tsvector,可被索引以加速召回;
  • to_tsquery(config, text):把纯文本查询转成布尔规则(and、or、not、phrase)tsquery,可与 tsvector 用 @@ 运算符匹配。

语法规则支持高级定制,本例使用内置的 english 配置。下面的查询用 @@ 运算符找出 body 中包含单词 "second" 的文档:

SELECT *
FROM documents
WHERE to_tsvector('english', body) @@ to_tsquery('english', 'second');
id title body
2 This is another title This is the body of the second document.

这两个函数属于 Postgres 全文搜索(Full Text Search)能力,完整的函数与类型参考可查阅 Postgres 官方全文搜索文档。

索引:用生成列与 GIN 索引消除顺序扫描

Postgres 默认把 WHERE 子句当作过滤器:没有索引时,它会整表扫描(sequential scan),把每行 body 现场转成 tsvector 再与 tsquery 比对。小表没问题,但生产级规模必须有更高效的手段。

第一步是把 tsvector 存进表里,避免每次搜索都现场生成。做法是新增一个 GENERATED 列,它会自动保持最新;同时我们希望同时检索 title 和 body,所以用 || 把两个字段拼起来,中间用空格 ' ' 分隔:

ALTER TABLE documents
ADD COLUMN title_and_body_text tsvector
GENERATED ALWAYS AS (to_tsvector('english', title || ' ' || body )) STORED;

生成列的一大优点是会自动为已有行回填数据,而且可以像普通列一样建索引。在上面新建的列上创建 Generalized Inverted Index(GIN),预先计算"每个关键词对应哪些文档"的倒排列表,从而跳过顺序扫描,直接通过索引拿到满足任意 tsquery 的精确文档列表:

CREATE INDEX documents_title_and_body_text_index
ON documents
USING GIN (title_and_body_text);

现在用一个更复杂的 tsquery 演示:要求 another 和 second 两个关键词都匹配上 title 或 body,查询会自动使用 title_and_body_text 上的索引:

SELECT *
FROM documents
WHERE title_and_body_text @@ to_tsquery('english', 'another & second');
id title body title_and_body_text
2 This is another title This is the body of the second document. 'anoth':3 'bodi':8 'document':12 'second':11 'titl':4

因为使用了 SELECT *,新的 tsvector 列也出现在结果中。可以看到它同时包含 title 和 body 的词干化单词及其位置信息——位置信息让 Postgres 除了单个关键词外还能支持短语(phrase)匹配。同时注意,像 "the"、"is"、"of" 这类停用词(stopwords)已被移除。这是关键词搜索的常见优化:这些词太常见,对结果相关性贡献很小。

排序:用 ts_rank 计算 TF 相关性分数

排序是搜索的关键环节,也是机器学习介入价值最大的地方:用户期望最相关的结果排在顶部。Postgres 提供简单的算术相关分数 ts_rank,它计算查询中每个关键词在文档中的词频(Term Frequency,TF)。例如文档总共 5 个词、命中 2 个关键词,则 ts_rank = 2 / 5 = 0.4。命中越多、总词数越少,分数越高、文档越相关。

当多个查询词用 OR | 连接时,ts_rank 会把分子和分母都加起来。例如第一个查询词在 5 个词中命中 2 个、第二个查询词在 5 个词中命中 1 个,则 ts_rank = (2 + 1) / (5 + 5) = 0.3。完整函数还有大量附加选项与配置,可参考 Postgres 官方文本搜索排序文档,这里先掌握基本思想:

SELECT ts_rank(title_and_body_text, to_tsquery('english', 'second | title')), *
FROM documents
ORDER BY ts_rank DESC;
ts_rank id title body title_and_body_text
0.06079271 2 This is another title This is the body of the second document. 'anoth':3 'bodi':8 'document':12 'second':11 'titl':4
0.030396355 1 This is a title This is the body of the first document. 'bodi':8 'document':12 'first':11 'titl':4
0.030396355 3 This is the third title This is the body of the third document. 'bodi':9 'document':13 'third':4,12 'titl':5

命中 2 个关键词的文档分数正好是只命中 1 个关键词文档的两倍。这里要特别提醒:这条查询没有 WHERE 子句,它会为表里每一行都计算并返回排名——即使 ts_rank 为 0(完全没匹配)。实际使用中通常要同时加上能走索引的 @@ 匹配过滤,并加 LIMIT 控制每页返回条数,避免把完全不相关的文档也返回给用户。

Boosting:手动加权 title 与 body 的相关性

一个立竿见影的改进是区分 title 与 body 的相关性权重——直觉上标题命中比正文命中更相关。可以把标题排名乘以 2,再加上正文排名,从而把标题命中推高到最终结果列表顶部。这只需在 ORDER BY 中写一个简单算术公式:

SELECT
  ts_rank(title, to_tsquery('english', 'second | title')) AS title_rank,
  ts_rank(body, to_tsquery('english', 'second | title')) AS body_rank,
  *
FROM documents
ORDER BY (2 * title_rank) + body_rank DESC;

但问题来了:标题命中到底该是 2 倍、10 倍,还是 log(π / ts_rank²) 倍?文档长度对较长的 body 内容惩罚更多,也许反而应该提升 body 匹配?你可以针对几个测试查询试几组公式,但很难知道哪个取值在所有查询上综合表现最好。优化这类函数,正是机器学习能发挥作用的领域。

Learning to Rank:用点击数据学习最优权重

前面只用 ts_rank 的 TF/IDF 这类简单统计量衡量相关性,但人对"相关性"的理解远比这精细。好在用户会用行动告诉你:点击哪个结果。我们可以利用这种反馈训练一个模型,学习 boosting 函数里 title_rank 与 body_rank 的最优权重,并把"相关性"重新定义为:给定 title_rank、body_rank 等输入,用户点击该搜索结果的概率。

这是一个监督学习(Supervised Learning)问题:我们有带标签(用户点击)的数据集可用来训练模型。输入给函数的量叫特征(features),要预测的输出叫标签(label)。

训练数据:记录点击事件

首先记录用户对搜索结果的点击。新建一张表保存训练数据,即新相关性函数的观测输入与输出。真实系统里通常会把 sessions、searches、results、clicks 等事件拆到多张表,但本示例为简洁起见,把训练模型所需的信息全部放在一张表:每次搜索记录 title 与 body 的 ts_rank,以及用户是否点击(clicked):

CREATE TABLE search_result_clicks (
  title_rank REAL,
  body_rank REAL,
  clicked BOOLEAN
);

机器学习最困难的部分之一,往往是从分散的数据源采集数据并加工成特征——数据工程师团队常要维护一条条从特征存储到数据仓库再返回的管道。PostgresML 不需要这种复杂度,可以直接把 ML 特征插进数据库。

下面虚构了 4 组搜索、覆盖 3 篇文档,记录每篇文档的 title/body ts_rank 和用户是否点击。数据是精心挑选的直觉结果:用户总是点击综合排名最高的文档:

INSERT INTO search_result_clicks
  (title_rank, body_rank, clicked)
VALUES
-- search 1
  (0.5, 0.5, true),
  (0.3, 0.2, false),
  (0.1, 0.0, false),
-- search 2
  (0.0, 0.5, true),
  (0.0, 0.2, false),
  (0.0, 0.0, false),
-- search 3
  (0.2, 0.5, true),
  (0.1, 0.2, false),
  (0.0, 0.0, false),
-- search 4
  (0.4, 0.5, true),
  (0.4, 0.2, false),
  (0.4, 0.0, false)
;

真实应用会记录数百万次带 ts_rank 与点击的搜索结果,但即便这么少的数据也足以让 PostgresML 训练出模型。数据还可以用多种技术引导(bootstrapping)或回填(back-filling):例如先搭好应用,让管理员或员工在正式发布前使用并产生训练数据。

训练排序模型:pgml.train

为 "Search Ranking" 项目调用 pgml.train 训练模型。其中 project_name 是之后排序时引用模型的句柄,task 是训练任务类型。这里要预测"给定 title_rank 和 body_rank,用户点击结果的概率",由于输出是 0 到 1 之间的连续值,属于回归(regression)问题;也可以训练分类模型做布尔点击预测,那是另一个示例的话题。

SELECT * FROM pgml.train(
  project_name => 'Search Ranking',
  task => 'regression',
  relation_name => 'search_result_clicks',
  y_column_name => 'clicked'
);
project task algorithm deployed
Search Ranking regression linear t

SQL 语句通常以 SELECT 开头表示"读取",但这里我们其实只关心训练函数的返回结果。pgml.train 的参数中最重要的两个是 relation_name(刚创建的训练数据表)和 y_column_name(要预测的列)。针对这类预测,机器学习有两种常见任务:分类(classification)做离散/类别预测,如 true/false;回归(regression)做浮点预测,相当于点击概率。由于我们要把结果从最可能点击排到最不可能点击,这里用 regression 任务。project 只是训练模型的名称,后面用它做预测。

从源码看,pgml.train 的完整签名(pgml-extension/src/api.rs 与其 SQL 定义 pgml-extension/sql/pgml--2.0.2--2.1.0.sql)还包含更多可调参数,均有合理默认值:

参数 默认值 说明
project_name 必填 项目名,后续引用模型的句柄
task NULL 训练任务,首次创建项目时必填(如 regression、classification)
relation_name NULL 训练数据表名;首次训练必须传入
y_column_name NULL 要预测的目标列;监督任务下必填
algorithm 'linear' 算法名,可切换为 xgboost、lightgbm、catboost 等
hyperparams '{}' 超参数 JSONB
test_size 0.25 测试集比例
test_sampling 'stratified' 采样策略
automatic_deploy true 新模型更优时是否自动部署
materialize_snapshot false 是否物化数据快照

在 PostgresML 中,训练模型实际上是按最佳实践执行的多步骤流水线,默认顺序为:

  1. 把训练数据切分为训练集与测试集;
  2. 在训练集上训练模型;
  3. 在测试集上评估模型;
  4. 把模型连同评估指标保存进 pgml.models;
  5. 如果模型优于当前已部署模型,则部署它。

其中"切分训练集/测试集"的实现见 pgml-extension/src/orm/snapshot.rs:当 test_size 大于 1.0 时按绝对行数切分,否则按比例取整;若测试集行数导致训练集为空会直接报错。"评估后自动部署"则见 pgml-extension/src/api.rs:PostgresML 会对比新模型与当前部署模型在项目默认目标指标(如回归的 r2)上的表现,只有更优时才调用部署(见 pgml-extension/src/orm/project.rs),否则仅训练不入线。你可以用 automatic_deploy => false 关闭自动部署,稍后手动选择。

提示:pgml.train 会返回一个表,展示训练过程的信息,包括模型在训练数据上的表现等若干列。你可能看到有的调用写成 SELECT * FROM pgml.train(...) 而非 SELECT pgml.train(...),两者等价,但把函数放在 FROM 中像表一样调用,输出的表格结果更易读。

在线预测:pgml.predict 与模型缓存

模型训练完成后,用 pgml.predict 对新输入做预测:它接收项目名与一个特征数组。本例特征为 title_rank 与 body_rank。下面先对训练数据做一次快速 sanity check,看看模型对全部训练样本的预测:

SELECT
  clicked,
  pgml.predict('Search Ranking', array[title_rank, body_rank])
FROM search_result_clicks;
clicked predict
t 0.88005996
f 0.2533733
f -0.1604198
t 0.910045
f 0.27136433
f -0.15442279
t 0.898051
f 0.26536733
f -0.15442279
t 0.886057
f 0.24737626
f -0.17841086

注意:如果盯着数据库日志看,会发现模型第一次被使用时出现 "Model cache miss"。PostgresML 会把模型缓存在内存中加速预测;缓存在新模型部署时失效,数据库重启或连接关闭时也会失效。

这个机制在源码中可验证:pgml-extension/src/orm/model.rs 的 find_cached 先查 DEPLOYED_MODELS_BY_ID 全局锁缓存,未命中则打印 Model cache miss 并从 pgml.models 加载后写入缓存。点击样本预测值接近 1,未点击样本接近 0,说明模型学到了有用的信号。真实应用里当然是用模型预测未见过的新数据,而这正是在线搜索的用武之地。

用机器学习重排搜索结果:CTE 两段式查询

搜索结果通常分多步完成"召回(recall)与(重)排序(re-ranking)":每一步都可以在更多特征上应用更复杂(也更昂贵)的模型,再修剪掉不相关的候选项进入下一步。现在把最初的关键词查询扩展为包含 ML 模型重排的版本:先计算每条结果的 title/body rank,再用 pgml.predict 预测点击概率并据此重排。

用公共表表达式(Common Table Expressions,CTEs)把查询组织成清晰的逻辑步骤。CTE 就像只在查询期间存在的临时表,用 WITH 关键字定义,后续可像普通表一样引用。把这个 CTE 命名为 first_pass_ranked_documents:

  1. 用关键词索引高效召回匹配文档:WHERE title_and_body_text @@ to_tsquery('english', 'second | title');
  2. 用 ts_rank 像普通列一样为每行生成多个相关分数;
  3. 按 title_and_body_rank 排序并 LIMIT 100,避免下一步把 ML 模型浪费在低相关结果上;
  4. 在第二个查询里对每条文档的 title/body rank 应用 ML 模型,再用第二个 ORDER BY 重排。
WITH first_pass_ranked_documents AS (
  SELECT
    -- Compute the ts_rank for the title and body text of each document
    ts_rank(title_and_body_text, to_tsquery('english', 'second | title')) AS title_and_body_rank,
    ts_rank(to_tsvector('english', title), to_tsquery('english', 'second | title')) AS title_rank,
    ts_rank(to_tsvector('english', body), to_tsquery('english', 'second | title')) AS body_rank,
    *
  FROM documents
  WHERE title_and_body_text @@ to_tsquery('english', 'second | title')
  ORDER BY title_and_body_rank DESC
  LIMIT 100
)
SELECT
    -- Use the ML model to predict the probability that a user will click on the result
    pgml.predict('Search Ranking', array[title_rank, body_rank]) AS ml_rank,
    *
FROM first_pass_ranked_documents
ORDER BY ml_rank DESC
LIMIT 10;
ml_rank title_and_body_rank title_rank body_rank id title body title_and_body_text
-0.09153378 0.06079271 0.030396355 0.030396355 2 This is another title This is the body of the second document. 'anoth':3 'bodi':8 'document':12 'second':11 'titl':4
-0.15624566 0.030396355 0.030396355 0 1 This is a title This is the body of the first document. 'bodi':8 'document':12 'first':11 'titl':4
-0.15624566 0.030396355 0.030396355 0 3 This is the third title This is the body of the third document. 'bodi':9 'document':13 'third':4,12 'titl':5

计算 ml_rank 几乎不增加查询耗时。ml_rank 的数值不算"良好校准"——毕竟 search_result_clicks 只有 4 组虚构搜索数据——但它很好地示范了:无需写多少代码、无需部署任何新微服务,就能用机器学习极其高效地重排搜索结果。你还可以按需只返回应用需要的字段以节省网络开销,或在日志/调试模式下返回全部字段。说到底,这只是标准 SQL 里多了几个做预测的函数调用而已。

从源码层面看,pgml.predict 是一个多态函数(pgml-extension/src/api.rs):它根据特征数组的类型(f32、f64、i16、i32、i64、bool)重载,内部统一转成 f32 后调用已部署模型;还提供了 predict_row(直接传入行对象自动提取数值特征)、predict_proba、predict_joint、predict_batch 以及按 model_id 直接指定模型的 predict_model 系列。训练后的模型、项目与部署记录可以通过 pgml.trained_models、pgml.deployed_models、pgml.overview 等视图查询(见 pgml-extension/sql/schema.sql)。

Next steps:继续用机器学习扩展搜索引擎

借助可组合的 CTE 与成熟的 Postgres 生态,你可以从多个方向继续扩展搜索引擎能力。

添加更多特征

可以把更多数据作为特征(输入列)送入 ML 模型以提升预测质量。许多文档自带"流行度"或"质量"指标,如客户评论的 average_star_rating、视频的 number_of_views;另一类常用特征是全局点击率(CTR)与全局转化率(CVR)。建议把 sessions、searches、results、clicks、conversions 事件都记录在表中,从多个维度计算每个商品出现在搜索结果中时的整体吸引力。不仅应跟踪每个文档在所有搜索中的平均统计量,还应跟踪每个文档在每条具体查询中的统计量——"apples" 文档在 "apple" 查询与 "fruit" 查询下的 CTR 是不同的。因此全局 CTR 与查询级 CTR 都可以作为模型特征,还可以加入短期/长期统计、"新鲜度"(freshness)等维度。

Postgres 提供 MATERIALIZED VIEWS,可以周期性刷新,从应用写入的结构化事件表高效计算并缓存这些统计表,避免单条事件触发数十个相关统计更新时产生写放大(write amplification)。

使用更复杂的 ML 算法

PostgresML 内置数十种算法,枚举定义见 pgml-extension/src/orm/algorithm.rs,包括 linear、xgboost、xgboost_random_forest、lightgbm、catboost、random_forest、gradient_boosting_trees、hist_gradient_boosting、svm、ridge、lasso 等。现代梯度提升树模型(如 XGBoost、LightGBM、CatBoost)在排序问题上效果出众,且相对快速高效。PostgresML 只需给 pgml.train 多传一个 algorithm 参数即可切换:

SELECT * FROM pgml.train(
  project_name => 'Search Ranking',
  task => 'regression',
  relation_name => 'search_result_clicks',
  y_column_name => 'clicked',
  algorithm => 'xgboost'
);

所有训练出的模型都会记录在项目下,最好的那个会被自动部署。也可以给 pgml.predict 传具体的 model_id 而不是 project_name 来使用指定模型,便于对不同算法做统计对比;还可以在应用层做 A/B 测试,用业务指标(而不仅是 r2 这类统计量)比较不同算法的结果。

定期重新训练

每当有新数据可用时就可以重训模型,随着数据集变大、包含更多边缘案例与离群值,模型会自然变好。但要注意:只有在数据集出现"统计上显著"的变化时才需要重训,而不是每条新搜索/新结果都触发一次。每天或每周训练一次通常足以避免"概念漂移"(concept drift)。

定期训练还有一个额外收益:更快发现数据管线的故障。如果数据管线因故损坏——比如应用团队删掉了一个没意识到模型在用的重要列——宁可 24 小时内看到报错、损失 1 天训练数据,也不要等数据科学家下次碰模型时才发现过去一年数据已丢失,导致模型永远无法重训、无法被新版本超越,最终只能推倒重来。这种事在其他更复杂的分布式系统里屡见不鲜,代价极其高昂。

向量搜索与 LLM Embeddings

PostgresML 不仅集成了最新向量搜索(包括 pgvector 提供的高召回 HNSW),还能在数据库内部用从 HuggingFace 下载的最新预训练 LLM 生成 embedding,零网络开销。这本身足以单独成篇,完整的系列见 生成 LLM Embeddings,配合向量索引可参考 向量数据库指南。

个性化与推荐

实现搜索个性化的方式有很多。PostgresML 同时支持协同过滤(collaborative)与基于内容(content-based)的过滤,可用于个性化与推荐系统。一种具体做法见 用应用数据个性化 embedding 结果,但借助 PostgresML 提供的积木,你可以实现多种不同方案。

多模态搜索

你可能需要跨多种文档类型返回搜索结果,例如职业社交网站可能同时返回 People、Companies、JobPostings 等类型的结果。每种文档类型可以有专属特征,PostgresML 会处理文档缺失对应特征时的 NULL 输入,从而可以用一个模型把所有类型的"文档"一起排序,优化单一全局目标。

在单条查询中串联所有能力

可以把多个模型与排序算法分层编排在同一条查询里。例如:用向量搜索与关键词搜索同时召回候选,关联文档级全局 CTR/CVR 与其他统计,再关联每个文档在此精确查询下的转化统计,再关联来自用户历史或当前会话的个性化统计或向量,把所有这些特征输入树模型进行重排。在微服务架构里从多个特征存储拉取这些特征、再到应用层做 join,规模一大就会慢到不可接受;而 PostgresML 可以在数据库中、用带索引的 join 在几毫秒内完成这一切,必要时用 CTE 分层组织以保持查询可维护。

让它更快

当一条查询横跨多张表、包含十几个 join 时,保证速度至关重要。PostgresML 官方目标是在大规模生产数据集上做到端到端搜索延迟低于 100ms,包括 LLM embedding 生成、向量搜索与个性化重排。可以用标准 SQL 的 EXPLAIN ANALYZE 查看查询的哪些部分最耗时/耗内存。Postgres 提供多种索引类型(BTREE、GIST、GIN、IVFFLAT、HNSW),可以有效处理十亿行级别的数值、文本、关键词、JSON、向量甚至地理空间数据。

让它可扩展

大多数云平台都能提供数百核的现代机器,单机即可扩展到每秒数万次查询。更进阶的技术如分区(partitioning)与分片(sharding)可用于超越十亿行数据集或达到每秒数百万次查询。Postgres 有久经考验的复制模式,PostgresML 云托管平台用简单滑块即可横向扩展到所需机器数;同时 PostgresML 是开源的,你也可以按自己习惯的方式在本地/自托管环境扩展 Postgres 工作负载。

结论

你可以用 PostgresML 在应用与领域数据之上构建具备前沿能力的搜索引擎。整个方案以"关键词召回 → 统计排序 → 机器学习重排"的多层架构为主线,全部在数据库内完成:训练用 pgml.train,在线推理用 pgml.predict,数据与特征直接存进 Postgres 表,无需数据搬运、无需部署额外微服务。源码实现(pgml-extension/src/api.rs)进一步印证了模型缓存、自动部署、训练/测试切分等关键机制如何保证在线排序的低延迟与易运维。在此基础上,向量搜索、个性化、多模态与单查询编排都是对同一套积木的自然延伸。

登录后查看全文
postgresml