PostgreSQL 全文检索擅长处理由空格分隔的文本,但面对中文、日文、韩文等连续书写语言时,默认解析器可能把整句话当成一个词元。AlloyDB AI 提供了一条数据库内解决路径:先用 Gemini 做上下文相关的分词,再用 tsvector 和 RUM 建立全文索引,并可进一步叠加 ScaNN 向量检索,形成一条 SQL 内的混合搜索链路。
问题不在索引,而在索引之前
PostgreSQL 需要先通过 to_tsvector 把文本转换成词元,索引才能工作。对于下面这句话:
SELECT to_tsvector('simple', '你们研究所有十个图书馆');
默认配置缺少中文语义分词能力,可能得到一个覆盖整句的长词元。此时搜索“研究所”或“图书馆”都无法命中,因为它们并未作为独立词元进入索引。
传统方案通常有两类:在数据库中安装 zhparser、pg_jieba 等扩展,或者把文本发给外部 Python 服务预处理。前者在全托管数据库中未必可用,而且静态词典容易漏掉品牌名、新术语和依赖上下文的歧义;后者则增加 ETL、网络调用、权限管理和数据外传的成本。
AlloyDB AI Functions 的关键变化,是允许 SQL 直接调用 Gemini。分词结果可以原地写回业务表,随后由生成列自动更新全文检索向量和语义向量,不再需要单独维护一条文本处理流水线。
把原文、分词和两类索引放在一张表中
可以这样实践。下面假设实例已经启用 AlloyDB AI、向量类型和相关模型访问能力;扩展名称、模型权限及函数签名应以当前 AlloyDB 环境为准。
CREATE TABLE documents (
id BIGSERIAL PRIMARY KEY,
title TEXT NOT NULL,
original_content TEXT NOT NULL,
content_segmented TEXT,
search_vector TSVECTOR GENERATED ALWAYS AS (
to_tsvector('english', COALESCE(content_segmented, ''))
) STORED,
embedding VECTOR(3072) GENERATED ALWAYS AS (
embedding('gemini-embedding-001', content_segmented)
) STORED
);
这里使用 english 并不是让 PostgreSQL 理解中文语法。它会保留非 ASCII 的中文词元,同时对夹杂其中的英语执行词干归一化和停用词过滤,例如把 running 归一化为 run。如果数据完全是中文,而且希望 Gemini 的输出不再被额外处理,可以改用 simple:
ALTER TABLE documents
DROP COLUMN search_vector;
ALTER TABLE documents
ADD COLUMN search_vector TSVECTOR GENERATED ALWAYS AS (
to_tsvector('simple', COALESCE(content_segmented, ''))
) STORED;
配置必须在写入和查询两侧保持一致。文档用 simple 建索引、查询却用 english 构造 tsquery,会制造难以排查的漏检。
批量调用 Gemini,而不是逐行发请求
对数万乃至数百万行执行逐行模型调用,速度慢,也容易因单次故障回滚整个任务。更稳妥的做法是用存储过程按主键取一批数据,把提示词聚合成数组,一次调用 ai.generate,然后按照数组下标把结果映射回文档 ID。
下面是可直接改造的版本。运行前需要根据数据长度、模型限额和实例资源调整 p_batch_size,建议从 20 到 100 开始压测。
CREATE OR REPLACE PROCEDURE segment_all_documents(p_batch_size INT DEFAULT 100)
LANGUAGE plpgsql
AS $$
DECLARE
v_processed_count INT;
BEGIN
LOOP
WITH batch_raw AS (
SELECT id, original_content
FROM documents
WHERE content_segmented IS NULL OR content_segmented = ''
ORDER BY id
LIMIT p_batch_size
),
batch_processed AS (
SELECT
ARRAY_AGG(id ORDER BY id) AS target_ids,
ai.generate(
prompts => ARRAY_AGG(
'对以下文本执行中文分词,用于全文检索。' || E'\n' ||
'规则:' || E'\n' ||
'- 在每个有意义的词之间插入一个空格。' || E'\n' ||
'- 标点符号与词分开。' || E'\n' ||
'- 保留换行、数字和英文内容。' || E'\n' ||
'- 只输出处理后的文本,不要解释或添加 Markdown。' || E'\n' ||
'待处理文本:' || original_content
ORDER BY id
),
model_id => 'gemini-2.5-flash-lite'
) AS ai_outputs
FROM batch_raw
),
unpivoted_results AS (
SELECT
p.target_ids[i] AS doc_id,
p.ai_outputs[i] AS segmented_text
FROM batch_processed AS p
CROSS JOIN GENERATE_SERIES(
1,
COALESCE(ARRAY_LENGTH(p.target_ids, 1), 0)
) AS i
)
UPDATE documents AS d
SET content_segmented = r.segmented_text
FROM unpivoted_results AS r
WHERE d.id = r.doc_id;
GET DIAGNOSTICS v_processed_count = ROW_COUNT;
EXIT WHEN v_processed_count = 0;
COMMIT;
RAISE NOTICE 'Processed and committed % documents', v_processed_count;
END LOOP;
END;
$$;
CALL segment_all_documents(100);
ARRAY_AGG(... ORDER BY id) 同时约束 ID 数组和提示词数组的顺序,GENERATE_SERIES 再按相同下标拆包,避免输出写到错误文档。每批 COMMIT 可以缩短事务、释放行锁,并让中断后的任务从尚未处理的行继续执行。
这个过程仍有明确边界:应通过顶层 CALL 执行,不要把它包在应用显式事务中,否则过程内 COMMIT 会受到事务上下文限制。示例也更适合单个迁移工作进程;如果要启动多个并发 worker,需要增加任务状态列,并使用 FOR UPDATE SKIP LOCKED 或独立队列表领取任务,防止多个进程处理同一批行。
模型调用还应具备失败状态、重试次数和输出校验。至少要拒绝空结果、解释性前缀和明显超长输出,否则错误文本会被生成列立即索引。
查询也必须经过同一种分词
只处理文档还不够。用户输入“你们研究所的图书馆在哪里?”时,查询侧也需要得到与索引侧兼容的词元。Gemini 可以同时去除“的”“你们”“哪里”等低价值语法成分,返回:
研究所 图书馆
可以这样从 SQL 生成查询关键词:
SELECT ai.generate(
prompt => '对以下中文搜索请求执行分词,并删除代词、语气词、疑问词和低价值语法词。' || E'\n' ||
'只输出由单个空格分隔的关键词,不要解释。' || E'\n' ||
'查询:你们研究所的图书馆在哪里?',
model_id => 'gemini-2.5-flash-lite'
) AS normalized_query;
线上请求不一定适合每次同步调用模型。模型延迟、配额和费用都会进入搜索接口的关键路径。常见优化包括缓存归一化后的热门查询、为短查询设置规则化快速路径,以及在模型不可用时回退到原始关键词。
用 RUM 排序,再与 ScaNN 做混合检索
完成分词后,可以在生成的 search_vector 上创建 RUM 索引。RUM 保存词元位置,并能通过距离操作符参与相关性排序:
CREATE INDEX idx_documents_rum
ON documents
USING rum (search_vector rum_tsvector_ops);
若要求两个词都出现,应使用能够表达操作符的 to_tsquery;传入 plainto_tsquery 时则提供普通空格分隔文本,不要依赖字符串中的 & 被解释为运算符。
SELECT
id,
title,
original_content,
search_vector <=> to_tsquery('english', '研究所 & 图书馆') AS distance
FROM documents
WHERE search_vector @@ to_tsquery('english', '研究所 & 图书馆')
ORDER BY distance ASC
LIMIT 20;
精确词元搜索可能漏掉表达不同但语义接近的文档,因此还可以为 embedding 建立 ScaNN 索引:
CREATE EXTENSION IF NOT EXISTS alloydb_scann;
CREATE INDEX idx_documents_scann
ON documents
USING scann (embedding cosine);
随后用 Reciprocal Rank Fusion(RRF)合并文本和向量候选。下面把模型生成的查询向量集中在一个 CTE 中,避免在同一条 SQL 中重复计算:
WITH query_input AS (
SELECT
to_tsquery('english', '研究所 & 图书馆') AS text_query,
ai.embedding(
'gemini-embedding-001',
'研究所 图书馆'
)::vector AS query_embedding
),
vector_search AS (
SELECT
d.id,
RANK() OVER (
ORDER BY d.embedding <=> q.query_embedding
) AS position
FROM documents AS d
CROSS JOIN query_input AS q
ORDER BY d.embedding <=> q.query_embedding
LIMIT 10
),
text_search AS (
SELECT
d.id,
RANK() OVER (
ORDER BY d.search_vector <=> q.text_query
) AS position
FROM documents AS d
CROSS JOIN query_input AS q
WHERE d.search_vector @@ q.text_query
ORDER BY d.search_vector <=> q.text_query
LIMIT 10
)
SELECT
COALESCE(v.id, t.id) AS id,
COALESCE(1.0 / (60 + v.position), 0.0) +
COALESCE(1.0 / (60 + t.position), 0.0) AS rrf_score
FROM vector_search AS v
FULL OUTER JOIN text_search AS t ON t.id = v.id
ORDER BY rrf_score DESC
LIMIT 5;
RRF 不要求两种检索分数处于相同量纲,它只根据各自排名融合候选。常数 60 是常见起点,但候选数量、最终条数和融合参数都应根据真实查询集调优,而不是直接视为通用最优值。
上线前应检查什么
这套方案把分词、全文检索和向量检索留在数据库层,减少了外部 ETL 与独立搜索服务的运维负担,但它并不等于零成本。模型调用仍有费用、配额、延迟和非确定性;生成 embedding 也会放大批量更新的资源消耗。
上线前至少完成以下检查:
- 用真实业务语料建立评测集,覆盖品牌名、技术缩写、数字、混合中英文和歧义短语。
- 固定并版本化分词提示词;提示词变化后,明确是否需要重建历史数据。
- 记录模型 ID、处理状态、重试次数和错误原因,保证任务可以恢复和审计。
- 压测批大小、RUM 查询、ScaNN 候选数以及混合查询的尾延迟。
- 对敏感数据确认 AlloyDB AI 的区域、权限、审计和模型数据治理设置。
- 为查询时模型故障准备缓存或降级路径。
适合采用这套架构的场景,是数据已经位于 AlloyDB、需要中英混合搜索,同时希望减少独立分词服务和搜索集群。若查询量极高、搜索模式复杂或需要严格可重复的分词结果,则仍应对比专用搜索引擎和确定性分词器的总体成本与可控性。