健康助手在Postgres中的混合搜索:模式设计、分块策略、RRF算法及性能评估。
面向健康助手的Postgres混合搜索技术:基于结构感知的分块处理、结合RRF的pgvector、二进制量化、得分阈值以及合理的评估方法。
来源: RAG in Postgres: What I Learned Building a Health Assistant Where Being Wrong Is Expensive
“健康人工智能”的演示往往看起来千篇一律。公共数据集被存入向量存储中,通过提示语说明该模型是高效的医疗助手,前三个问题都能得到完美的答案。接着就是第四个问题。
这个问题可能包含否定词。或者其正确答案取决于18个月前修改过的文档,而该文档取代了2011年写的版本。又或者两个权威的公共信息源存在分歧,此时正确的做法应是指出这一点并解释其重要性,而非强行选出一个正确答案。再或者,诚实的回答应该是“我不知道,应该咨询临床医生”,但那些被训练为要提供有用答案的模型仍会自信地写出一段内容。
第四个问题才是产品真正发挥作用的地方,而回答它所需的几乎所有工作都不是在语言模型中完成的。这些工作发生在检索环节,在本例中即 Postgres。下文将介绍这一层的工作流程:模式设计、文本分块、混合搜索查询、量化处理,以及用于防止错误结果被发布的评估机制。该机制独立于任何单一数据源,其理念同样适用于那些错误答案会带来严重后果的任何语料库。
健康领域文本为何能击败简单的 RAG
开放式的健康领域语料库具有四个特点,这些特点会悄悄破坏“分块、嵌入、余弦相似度计算、将结果放入提示词”这一标准流程。
- 含义并非以段落形式存在。药品专论中,禁忌症、相互作用和剂量等内容各有独立章节。若将其分割成512个标记的滑动窗口,某些片段会包含一半的禁忌症信息与一半的剂量表内容。这类片段的检索效果虽很好,余弦相似度很高,但实际准确性很低。这是整个系统中最危险的故障类型,因为检索指标看起来依然正常。
上述每一种情况都属于检索层问题,因此最终产生的决策虽然枯燥,却具有最大的决定性作用。
为何选择 Postgres 而非向量数据库
这种取舍值得明确说明,因为“不需要向量数据库”这种观点已经变得过于草率,实际上确实存在需要使用向量数据库的场景。
专用的向量数据库在极高规模下仍能保持每毫秒更高的召回率,具备更丰富的索引类型,支持从一开始就进行过滤的搜索功能,而且水平扩展极为简单。在面临数亿个向量且查询负载极大的情况下,这无疑是一个巨大的优势,值得充分利用。
Postgres仅提供一个事务边界。这看似微不足道,但实际上影响很大。在内容会不断变化的语料库中,双系统架构会带来诸多问题:
- 当原始文档被修改时,必须原子性地删除旧的内容块、插入新的内容块、重新计算嵌入向量并更新元数据。而使用独立的向量存储则无法实现原子性操作。在极端情况下,关键词索引与向量索引对文档内容的描述可能不一致,从而导致智能助手引用那些已不再符合引用条件的文档。
虽然存在扩展上限,但实际上比人们想象的要高,在当前经过良好调优的实例上,使用 pgvector 甚至可以处理数千万个数据块。合理的做法是从 Postgres 开始,将后续的迁移视为成功的标志而非设计目标。那些从未达到扩展上限的项目可以避免整个分布式系统带来的问题;而那些达到上限的项目则能拥有可运行的产品、真实的查询日志以及对其需求的清晰认识。
版本非常重要。pgvector 0.7.0版本引入了halfvec、sparsevec、位类型索引以及binary_quantize功能。0.8.0版本增加了迭代索引扫描功能,有助于解决下文所述的过滤问题,而0.8.x系列则新增了对Postgres 18的支持。在0.6.x版本上,下文中的大部分内容将无法按原样使用。
架构
该设计由四个表构成。其原则是数据来源必须以结构化形式存在,绝不能是JSON格式的字符串:答案层需要引用、过滤或处理的任何内容都会拥有独立的列。
CREATE EXTENSION IF NOT EXISTS vector;
CREATE EXTENSION IF NOT EXISTS pg_trgm;
-- Where knowledge comes from, and how much we trust it.
CREATE TABLE source (
id bigserial PRIMARY KEY,
slug text UNIQUE NOT NULL,
license text NOT NULL, -- redistribution rules are a query concern
authority smallint NOT NULL CHECK (authority BETWEEN 1 AND 5),
base_url text
);
-- One row per upstream document, at whatever granularity upstream uses.
CREATE TABLE document (
id bigserial PRIMARY KEY,
source_id bigint NOT NULL REFERENCES source(id),
external_id text NOT NULL, -- the stable ID assigned upstream
title text NOT NULL,
url text,
published_at date,
revised_at date, -- recency is a column, not a vibe
superseded_by bigint REFERENCES document(id),
lang text NOT NULL DEFAULT 'en',
raw_hash bytea NOT NULL, -- skip work on unchanged re-ingests
meta jsonb NOT NULL DEFAULT '{}',
UNIQUE (source_id, external_id)
);
-- Retrieval unit. Note heading_path: structure survives chunking.
CREATE TABLE chunk (
id bigserial PRIMARY KEY,
document_id bigint NOT NULL REFERENCES document(id) ON DELETE CASCADE,
ord int NOT NULL,
heading_path text[], -- {Contraindications,"Renal impairment"}
body text NOT NULL,
body_tsv tsvector GENERATED ALWAYS AS
(to_tsvector('english', body)) STORED,
token_count int NOT NULL,
content_hash bytea NOT NULL,
lang text NOT NULL, -- denormalized from document
is_current boolean NOT NULL, -- denormalized: superseded_by IS NULL
UNIQUE (document_id, ord)
);
CREATE INDEX chunk_tsv_idx ON chunk USING gin (body_tsv);
CREATE INDEX chunk_trgm_idx ON chunk USING gin (body gin_trgm_ops);
-- Embeddings are versioned by model. This is not optional.
-- lang and is_current are DENORMALIZED from document -- see below.
CREATE TABLE embedding (
chunk_id bigint NOT NULL REFERENCES chunk(id) ON DELETE CASCADE,
model text NOT NULL,
vec halfvec(1536) NOT NULL,
lang text NOT NULL,
is_current boolean NOT NULL,
PRIMARY KEY (chunk_id, model)
);
有四个细节是决定系统稳定性的关键。
嵌入向量以数据块和模型为键进行存储。 嵌入模型会发生变化。一旦发生变动,就需要同时启用两个版本,以便在真实流量中进行A/B测试,并且无需停机时间即可进行切换。从一开始就将模型作为主键,并为每个模型创建部分索引,从而保持数据结构的紧凑性:
CREATE INDEX embedding_hnsw_v3_en
ON embedding USING hnsw (vec halfvec_cosine_ops)
WITH (m = 16, ef_construction = 128)
WHERE model = 'emb-v3' AND lang = 'en' AND is_current;
语言信息和当前标志会被刻意复制到分块数据和嵌入向量中。 这是唯一一处故意打破规范化处理的地方,而其原因直到很久之后才被发现。Postgres中的部分索引谓词只能引用被索引表的列。嵌入向量的索引无法使用WHERE document.is_current这样的条件;即使包含子查询,Postgres也会直接拒绝。为了将高频过滤条件嵌入向量索引中——正如下文“过滤陷阱”部分所述的原因——这些列必须实际存在于嵌入向量的行中。
确保副本数据的准确性十分繁琐,首次尝试时就出现了两次错误。该标志并非在文档被导入时设置,而是在后续有文档替换当前文档时才会改变,因此更新操作需要在替换文档的事务中执行,并且必须覆盖属于被废弃文档的每一个数据块和嵌入行。应将这种传播逻辑封装在一个函数中,仅设置一个调用点,同时编写测试用例来验证非规范化值始终与父文档的值一致。当两个搜索模块对哪些行是当前有效数据存在分歧时,融合机制会将两个不同语料库中的数据位置混合在一起,从而导致查询结果质量下降,且不会产生任何错误提示。
这也不是免费的操作。翻转该标志会将那些行从两个部分HNSW索引中移除,而HNSW只有在清理空闲空间时才会回收这些资源,因此频繁的数据变动会导致索引体积不断膨胀。
该向量列实际上是halfvec类型,而非普通向量。使用16位浮点数存储后,1,536维数据下的原始数据量从6,152字节降至3,076字节,而HNSW索引的规模还会进一步缩小,因为较小的数据元组能更紧凑地存储在页面中。在此场景下,检索结果只需向重排器提供约50个候选项即可,测试表明其召回率差异仍在可接受范围内。此外,halfvec类型最多可支持4,000维的索引构建,而普通向量仅限于2,000维,这有助于处理大型嵌入模型。
被替代文档的信息是以指向同一表格的指针形式存储的,而非布尔值。单纯的过时标记无法为系统提供足够信息。只有知道是哪份文档取代了当前文档,系统才能说明相关指引已更新,并提供当前版本的链接。这种关联操作只有在数据导入时便捕获到相关边信息的情况下才有可能实现。
分块处理是价值最高的部分
如果这里有哪条经验值得总结的话,那就是分块器比嵌入模型更为重要。更优秀的模型只能提升少量的召回率,而能够尊重文本结构的分块器才能区分出正确的助手与错误但看似合理的助手。有三条规则需要遵循。
先考虑结构,再考虑长度。将每份文档解析为标题树结构,并确保所有分块都位于同一个顶层章节中。当某个章节的长度小于目标值时,就保持其当前大小,而非将其与相邻章节合并;60个标记的限制只是说明这种较短的分块比500个标记且内容被淹没的分块更有用。在结构分析失效时,标记限制才是备用方案,绝不能作为首要规则。
切勿拆分表格。健康相关表格通常是剂量网格、相互作用矩阵或适用条件表,这类表格中有一半的行即便存在也毫无价值。如果内容超出了预算限制,可将其拆分为多组行,在每组行上方重复标题,再将处理后的内容整合成句子后再进行嵌入。像“6至11岁:每日一次,5毫克”这样的表述,其嵌入效果远优于用竖线分隔的网格格式。
在嵌入内容之前先添加上下文信息。嵌入的文本不应是原始片段,而应是恢复了来源信息的完整片段:首先是一行标题,注明来源、文档标题及修订日期,接着是章节路径,如“禁忌症”,再是“肾功能损害”,最后才是正文内容。嵌入这样的结构后,可将原始正文保留下来用于显示和引用。单独出现“禁止用于清除率降低的患者”这样的表述几乎没有任何意义,因为它没有说明具体的药物、疾病状况或背景信息。只有当标题告知模型相关内容后,它才能被检索到。这一小小的改动带来的召回率提升效果超过了任何模型更换尝试,而且仅在索引时会产生少量token成本。
以下是两条实现注意事项。需对经过上下文处理的文本计算内容哈希值,这样仅修改标题的重新导入操作就能正确触发重新嵌入。同时要将整个“文档→分块→嵌入”替换过程放在同一事务中执行,以避免语料库出现部分更新的情况:
BEGIN;
DELETE FROM chunk WHERE document_id = $1; -- cascades to embedding
-- insert new chunks + embeddings
COMMIT;
正是由于那笔交易,它才被存储在 Postgres 中。
基于互反排名融合的混合检索
每个查询会分别进行语义搜索和关键词搜索,然后通过排名而非原始分数来合并结果。直接合并分数是不可行的:余弦距离与 ts_rank_cd 所处的尺度互不相关,不同查询之间无法比较,而且为某组查询设定的权重会导致过度拟合。而互反排名融合则完全避免了这些问题,仅通过位置信息来进行处理。
WITH params AS (
SELECT $1::halfvec(1536) AS qvec,
$2::text AS qtext,
60::int AS rrf_k, -- damping constant
50::int AS pool
),
-- Two rules in this CTE, both non-obvious, both explained below:
-- 1. qvec comes from a SCALAR subquery, never a joined relation.
-- 2. Filters live INSIDE each arm, not in the outer query.
semantic AS (
SELECT chunk_id, row_number() OVER (ORDER BY dist) AS rank
FROM (
SELECT e.chunk_id,
e.vec <=> (SELECT qvec FROM params) AS dist
FROM embedding e
WHERE e.model = 'emb-v3'
AND e.lang = 'en'
AND e.is_current
ORDER BY e.vec <=> (SELECT qvec FROM params)
LIMIT (SELECT pool FROM params)
) s
),
lexical AS (
SELECT chunk_id, row_number() OVER (ORDER BY score DESC) AS rank
FROM (
SELECT c.id AS chunk_id,
ts_rank_cd(c.body_tsv,
websearch_to_tsquery('english',
(SELECT qtext FROM params))) AS score
FROM chunk c
WHERE c.body_tsv @@ websearch_to_tsquery('english',
(SELECT qtext FROM params))
AND c.lang = 'en' -- same predicate as the semantic arm,
AND c.is_current -- from the same denormalized source
ORDER BY score DESC
LIMIT (SELECT pool FROM params)
) l
),
fused AS (
SELECT chunk_id,
SUM(1.0 / ((SELECT rrf_k FROM params) + rank)) AS score
FROM (
SELECT chunk_id, rank FROM semantic
UNION ALL
SELECT chunk_id, rank FROM lexical
) u
GROUP BY chunk_id
)
SELECT c.id, d.title, d.url, d.revised_at, s.slug, s.authority,
c.heading_path, c.body, f.score
FROM fused f
JOIN chunk c ON c.id = f.chunk_id
JOIN document d ON d.id = c.document_id
JOIN source s ON s.id = d.source_id
ORDER BY f.score DESC, s.authority DESC, d.revised_at DESC NULLS LAST
LIMIT 20;
在含义排序中位列第三、关键词排序中位列第七的结果,其表现优于仅在单一维度上排名第一的结果。这正是设计意图,因为两个独立信号的一致性比单个信号的强烈提示更具价值。websearch_to_tsquery是更合适的解析器,因为它能够处理真实用户的输入,包括带引号的短语和负排除项,且不会因格式错误的文本而出错。在此情况下,ts_rank_cd优于普通的ts_rank,因为词项出现的密集程度更受重视,而这正适合需要结合多个概念的查询。
该查询的两个特征看似是风格选择,但实际上并非如此。
第一个陷阱:查询向量必须是标量子查询
在语义分析阶段,查询向量是以标量子查询的形式出现,而非来自连接后的参数表的列。这就好比毫秒与分钟之间的差别一样巨大。
直观版本会同时从嵌入表和参数关系中选取数据,然后按与参数列的距离进行排序。它的表现良好、结果正确,能够返回恰当的行,这也正是它能通过代码审查的原因。不过问题在于,规划器只有在 ORDER BY 子句将索引列与整个扫描过程固定的值进行比较时,才能使用最近邻索引。通过连接操作获取的值不符合条件,因此根本不存在有序的查询路径:Postgres 必须读取每一行后再进行排序。
当数据块数量为五万时,这一操作需要几百毫秒的时间,慢到足以让人感觉到延迟,同时也足以将问题归咎于网络问题。而当数据块数量达到五百万时,则会出现超时情况。
应通过验证而非盲目信任。在CI环境中说明检索查询的逻辑,并确认查询计划中包含针对HNSW索引的索引扫描以及排序操作。如果先进行顺序扫描再排序,那就说明该向量索引只是摆设。针对此情况的测试非常有必要,因为即便出于好意进行重构,也可能会重新引入问题——不会直接导致错误答案,但最终会在生产环境中造成响应缓慢的问题。
第二个陷阱:在LIMIT之后再进行过滤
过滤条件,即当前性、语言类型以及未被替代状态,应放在每个子查询中、LIMIT语句之前。最初的版本将这些条件放到了外部查询中,虽然看似合理,但实际上是错误的做法。
HNSW返回的是近似最近的邻居。先按距离选取前50个,再进行过滤,最终可能只有12个保留下来。原本50个的候选集悄无声息地变成了12个,此时排名融合操作是在已被标记为待丢弃的行污染的集合上进行的,最后的LIMIT 20指令又悄悄只返回了9个结果。召回率下降,但日志中并未显示相关信息。由于每个单独的结果看起来仍然合理,因此没人会报告错误。在语料库过滤不均的问题上,该助手的表现会逐渐变差,而在高质量语料库中,这种情况通常出现在涉及近期修订内容的查询上。
按优先级排序的三个解决方案:
- 将过滤条件构建到部分索引中。这就是为什么语言信息和当前标志会被复制到嵌入行上的原因。一旦索引谓词与查询的WHERE子句匹配,索引中就只会包含经过过滤的行,问题也就解决了。这种方法适用于语言、货币或来源层级这类基数较低、变化缓慢的维度,但不适用于具有大量值的维度,因为那样就需要维护上百个索引。
- 迭代式索引扫描(pgvector 0.8.0及更高版本)可用于处理那些过于动态而无法直接嵌入过滤条件的情况:
SET hnsw.iterative_scan = relaxed_order;
SET hnsw.max_scan_tuples = 100000; -- default is 20000
SET hnsw.scan_mem_multiplier = 4; -- raise if the above is not enough
启用此功能后,索引会不断检索更多候选项,直到有足够多的项通过过滤条件。这里存在两个限制:过滤操作必须与索引扫描处于同一查询层级,否则不会有任何变化;同时扫描也有上限,因此返回的行数仍可能少于预期。这虽能缓解问题,但无法彻底解决。选择“relaxed_order”而非“strict_order”可以提高召回率,但代价是结果不会完全按距离排序,不过在后续重新排序步骤中这并无大碍。
通过二进制量化提升速度
在大规模应用中,最有效的优化方式是采用两阶段检索:首先根据汉明距离从二进制索引中筛选候选项,然后再对这些候选项进行精确的重评分。
CREATE INDEX embedding_bit_idx ON embedding
USING hnsw ((binary_quantize(vec)::bit(1536)) bit_hamming_ops)
WHERE model = 'emb-v3' AND lang = 'en' AND is_current;
SELECT chunk_id
FROM (
SELECT chunk_id, vec
FROM embedding
WHERE model = 'emb-v3' AND lang = 'en' AND is_current
ORDER BY binary_quantize(vec)::bit(1536)
<~> binary_quantize($1::halfvec(1536))
LIMIT 400
) candidates
ORDER BY vec <=> $1::halfvec(1536)
LIMIT 50;
第一阶段应大幅超额获取数据,数量约为最终数据量的八倍。第二阶段的重新评分会调整顺序,因此二进制阶段只需知道候选项是否存在,无需知道其准确排名。
节省空间的精度很重要,因为互联网上的相关数据往往管理不善。在1,536维的情况下,位向量大约为200字节,而半向量则约为3 KB,原始数据量减少了16倍。随处可见的32倍这一数值是基于全精度向量测得的,而该方案并未使用全精度向量。
该索引并没有缩小16倍,其原因很有启示意义。无论有效载荷如何,每个HNSW元组都会包含数百字节的固定开销,以及图链接和元组头部信息。对于3 KB的halfvec数据而言,这些开销几乎可以忽略;但对于200字节的位向量来说,开销反而超过了有效载荷本身。再加上页面打包带来的额外浪费,实际压缩率远低于理论值。在这段数据上,二进制索引的体积大约只有halfvec索引的1/4到1/5。这一比例依然决定了索引是否能够放入RAM中,而这正是我们关注的重点;不过,那些期望压缩比达到32倍的人将会大失所望。在规划存储容量之前,应先使用pg_relation_size函数进行测量,并使用真实的向量数据——有个测试因为使用的都是正数值的合成向量而浪费了整个下午的时间,binary_quantize函数生成的则是全1的向量,最终Postgres把所有数据都压缩到了几乎为零的大小。
其余部分均为常规操作且有详细文档说明。在构建HNSW索引之前,应为maintenance_work_mem分配更多内存,否则构建过程会溢出到磁盘上,耗时数小时而非几分钟。根据查询类型选择hnsw.ef_search的数值:在错误代价较高的场景下设置为100到200,自动补全场景则约为40。应同时创建生产环境索引。请在自己的数据上测试,因为官方公布的召回率数据往往不具备参考性。
重新排序与选择不返回结果的策略
Fusion只能提供不错的前20个结果,而非前5个,而提示框也仅能显示5个结果。交叉编码器重新排序器会将查询和候选项视为一对来处理,而非比较存储的向量,它在混合搜索后能带来最大的质量提升。不过该方法速度较慢,因此只能处理20个候选项,而非百万个。
人们常忽略的一点是:应将重排后的得分视为绝对阈值,而不仅仅是排序依据。如果最佳候选项的得分低于某个最低标准,那么正确的输出就不应是那组较差选项中的最佳者,而应承认该语料库中实际上没有关于该主题的可靠信息。
这正是大多数智能助手会出问题的地方,解决办法是通过代码实现合适的产品选择。一个旨在回答所有问题的系统在面对罕见问题时往往会编造答案。如果仅依靠重排进行排序,模型总会得到某种输出,而一个表现良好的模型也总会给出回应。设定得分阈值则能将检索失败转化为明确的拒绝,而非牵强的编造。可通过那些已知在语料库中不存在答案的标记问题来设定这一阈值,当用户抱怨拒绝响应过多时仍需保持该阈值不变。
首先理解查询内容
在检索之前还有另一层处理步骤,虽然会带来额外的延迟:这是一个紧凑高效的模型,会在索引看到问题之前就对其重新表述。
- 扩展并标准化。将通俗语言转化为语料库中的词汇,同时保留原文内容。两者都会被纳入混合查询中,原文进入词汇部分,扩展后的内容进入语义部分。
- 拆分复合问题。一个询问两种药物在怀孕期间是否可以一起使用的查询实际上需要三次检索:分别针对每种药物以及怀孕这一条件。分别执行检索后,合并排序结果,再对整合后的查询集进行重新排序。
- 处理上下文。诸如询问更高剂量这样的后续问题单独来看是没有意义的。需要结合之前的对话内容,将其重新表述为一个独立的问题后再进行嵌入,因为嵌入模型不具备记忆功能。
通过结构化引用实现规范
生成步骤很简短。大部分工作量集中在输出格式的规范上,而非提示语的措辞。需为提示语中检索到的每个内容片段标注稳定的标签,并要求输出结构化内容,使每项陈述都明确列出其所依赖的标签:
{
"claims": [
{ "text": "…", "sources": [2, 5] },
{ "text": "…", "sources": [1] }
],
"uncertain": true,
"route_to_clinician": false
}
请在代码中查看结果,而非提示信息中。来源列表为空的陈述会在显示前被删除,如果过多陈述被剔除,整个响应将被丢弃并进入拒绝处理流程。这将请求依据的提供与对其的确认区分开来:提示信息只是一种期望,而验证工具则是规则。删除率同时也可作为免费的性能指标,实时反映幻觉现象,即便无人查看记录也能触发警报。
还有两个较小的设计选择很重要。应在每个引用旁显示文档的修订日期,以便用户自行判断内容的新鲜度。当检索到的来源存在冲突时,应直接呈现冲突情况而非强行调和。“来源A称X,来源B称Y”这样的表述比综合后的平均值更具实用性和诚实性。
评估,还是猜测
在此情况下,凭直觉更改运输方式是不可行的。故障表现十分隐蔽,影响范围广泛,且每个设置都会波及其他所有设置。能够维持稳定的系统具有多层防护机制。
仅用于检索,不涉及大语言模型。通过手工标注相关片段ID的方式处理数百次查询,以此衡量recall@20和nDCG@10指标。该过程仅需数秒,即可检测出大多数性能退化问题,更重要的是,它能将检索质量与生成质量区分开来。当答案质量下降时,它能立即显示问题出在语料库还是提示词上。
准确性。对于生成的答案,需确认其每一项表述都能在所引用的片段中找到依据。只要给评估者分配简单的任务——一句句子、一段文字,以及一个关于是否支持的“是”或“否”答案——模型评分就能达到不错的效果。
拒绝处理的正确性。 另有一组问题要求以拒绝或转接的方式正确处理,这样阈值的变化就不会悄无声息地破坏这种处理方式。
相关阅读
- 用通俗语言讲解 LangGraph 概念:状态、内存与协调机制 —— 在编写代码之前:了解在 LangGraph 中协调机制、运行时、有状态智能体、短期与长期记忆、多智能体系统以及人类干预的含义。
- 用纯 Python 实现 RAG:分块、TF-IDF、余弦相似度与提示工程 —— 仅使用数学模块构建检索增强生成系统:滑动窗口分块、TF-IDF 向量、余弦相似度排序、实用的提示语及其局限性。