数据库基础体系 · 第 66/139 篇。文章以各产品官方稳定版本的公开语义为准;示例会明确引擎、事务与部署边界。
混合检索与 RAG 数据层:全文、向量、融合、重排和引用
检索增强生成(Retrieval-Augmented Generation,RAG)通常被描述为:
- 将用户问题转换为查询;
- 从知识库中检索相关内容;
- 将检索结果交给大语言模型生成答案。
这个描述没有错,但隐藏了一个关键事实:RAG 的质量首先由数据层决定,而不是由生成模型单独决定。
数据层至少要同时处理以下问题:
- 用户使用精确术语时,如何进行全文检索;
- 用户使用同义表达、描述性语言时,如何进行向量检索;
- 全文检索和向量检索返回的结果如何合并;
- 为什么不能直接比较两种检索的原始分数;
- 如何使用更昂贵但更准确的模型重排候选结果;
- 如何让最终答案中的引用能够回溯到稳定、可验证的原文;
- 文档更新、删除、权限变化、索引延迟和服务故障时,结果是否仍然可信。
本文以这些问题为主线,说明全文检索、向量检索、融合、重排和引用在 RAG 数据层中的完整关系,并使用 PostgreSQL + pgvector 给出一个可以运行的混合检索示例。Milvus 部分重点说明其组件和数据流,并明确版本与部署边界。
一、先建立问题模型:RAG 检索的对象到底是什么
设知识库中的原始文档集合为:
实际检索时,通常不会直接把整篇文档作为最小单位,而是先切分为若干文本块(chunk):
每个文本块至少应包含:
chunk_id:稳定且唯一的文本块标识;document_id:所属文档;content:用于展示和生成的原文;embedding:文本向量;- 全文检索索引数据;
- 原文位置,例如页码、段落号或字符区间;
- 来源 URI、版本号或内容哈希;
- 访问控制信息。
用户问题记为 。典型的 RAG 检索不是一次计算,而是多阶段过程:
这里的“召回”与“排序”需要区分:
- 召回(retrieval / recall):尽量找出所有可能相关的文本块;
- 排序(ranking):在候选文本块中,把最相关的排在前面;
- 重排(reranking):使用更强但更慢的相关性模型,对较小候选集合再次排序。
一个常见错误是直接让向量数据库返回前五条,然后把这五条交给模型。这样做同时放弃了全文检索的精确匹配能力,也没有真正利用重排阶段。
二、全文检索:处理术语、实体和精确约束
2.1 全文检索不等于字符串包含
全文检索会把文本和查询转换为可检索的词项结构。典型过程包括:
- 分词;
- 规范化大小写;
- 去除停用词;
- 词干提取或同义词扩展;
- 建立倒排索引;
- 根据词项出现位置和频率计算相关性。
倒排索引可以抽象为:
例如,查询包含 HNSW 时,全文检索可以直接找到包含该术语的文本;查询包含错误码、类名、表名、API 名称时,这种能力通常比向量检索更可靠。
全文检索尤其适合:
- 产品名、类名、函数名、错误码;
- 版本号和配置参数;
- 用户明确输入的专有名词;
- 需要包含某个词或词组的查询;
- 需要排除某个词的查询;
- 代码、日志和配置片段。
向量相似度则不天然保证这些词必须出现。一个描述“如何建立近似最近邻索引”的问题,可能会召回没有出现 HNSW 字样但语义相近的内容;这在开放问答中有价值,在精确排障中却可能不够。
2.2 PostgreSQL 全文检索的基本结构
PostgreSQL 内置全文检索使用 tsvector 表示文档,用 tsquery 表示查询。一个简化示例:
SELECT
to_tsvector('english', 'PostgreSQL uses an inverted index for text search')
@@ plainto_tsquery('english', 'inverted index') AS matched;
结果为:
matched
-------
t
其中:
to_tsvector将文本转换为词项结构;plainto_tsquery将普通文本转换为查询;@@判断文本是否匹配查询。
对于用户直接输入的搜索框,更适合考虑 websearch_to_tsquery,因为它支持更接近搜索引擎的语法,例如引号短语和排除词:
SELECT websearch_to_tsquery(
'english',
'"connection refused" -debug'
);
全文匹配和全文排序是两个不同操作:
SELECT
id,
ts_rank_cd(content_tsv, websearch_to_tsquery('english', $1)) AS text_score
FROM chunks
WHERE content_tsv @@ websearch_to_tsquery('english', $1)
ORDER BY text_score DESC
LIMIT 20;
@@ 负责筛选匹配项,ts_rank_cd 负责计算一种基于词项覆盖和位置的相关性分数。这个分数适合在同一全文检索体系内部排序,但不应直接与向量余弦相似度相加。
2.3 中文全文检索的边界
PostgreSQL 内置的文本搜索配置并不等价于完整的中文搜索分词系统。中文没有天然的空格分词边界,简单使用 simple 配置可能无法得到符合预期的词项。
例如,以下文本:
PostgreSQL支持HNSW索引进行向量近邻搜索
如果没有适合中文的分词器,查询“向量近邻”不一定能按照用户预期匹配。
生产系统通常有几种选择:
- 使用 Elasticsearch 等具备中文分析器的全文引擎;
- 在写入数据层之前完成中文分词,再建立倒排索引;
- 使用 PostgreSQL 扩展或外部搜索服务;
- 将 PostgreSQL 的全文检索用于英文、标识符和结构化术语,将中文语义召回交给向量检索。
这里要区分两件事:
pgvector解决的是向量存储与相似度搜索;- PostgreSQL 内置全文检索解决的是基于词项的倒排搜索;
- 中文分词、同义词、拼写纠错和复杂分析器不是
pgvector自动提供的能力。
三、向量检索:处理语义相似性
3.1 Embedding 和距离度量
Embedding 模型将文本转换为固定维度的数值向量:
用户问题得到查询向量:
文本块得到向量:
向量检索根据 和 的距离或相似度排序。
常见度量包括:
余弦相似度
它只关注方向,不关注向量长度。很多文本语义检索系统使用余弦距离:
距离越小,越相似。
欧氏距离
如果向量都已经归一化,欧氏距离和余弦距离在排序上存在单调关系;如果没有归一化,则二者含义不同。
内积
内积越大通常表示越相似,但向量长度也会影响结果。
因此,建索引和查询时必须使用与 Embedding 模型训练或部署约定一致的距离度量。不能先用余弦训练向量,再因为索引名称方便而改用欧氏距离。
3.2 pgvector 中的向量操作符
pgvector 的典型操作符包括:
<->:欧氏距离;<#>:负内积;<=>:余弦距离;<+>:L1 距离。
之所以内积操作符返回“负内积”,是因为 PostgreSQL 的 B-tree/近邻排序习惯是升序,而最大内积需要转换为最小负值。
余弦检索示例:
SELECT
id,
content,
embedding <=> $1::vector AS cosine_distance
FROM chunks
WHERE embedding IS NOT NULL
ORDER BY embedding <=> $1::vector
LIMIT 10;
这里 $1 是查询向量。返回结果按余弦距离升序排列,第一条距离最小。
如果需要将距离转换为相似度,可以使用:
1 - (embedding <=> $1::vector) AS cosine_similarity
但这只是数值变换,不能因此认为它可以直接和全文分数相加。全文分数和向量相似度的分布、范围、校准方式并不相同。
四、从全文和向量到混合检索
4.1 两条检索通道各自解决什么问题
设全文检索返回:
其中:
- 是全文相关性分数;
- 是该结果在全文通道中的名次。
向量检索返回:
其中:
- 是向量相似度或距离变换后的分数;
- 是该结果在向量通道中的名次。
混合检索的候选集合通常是:
全文检索能够发现术语精确匹配,向量检索能够发现语义表达不同但含义接近的文本。两者的并集通常比任一单独通道具有更高的召回覆盖率。
4.2 为什么不能直接把两个分数相加
假设全文搜索返回:
| 文本块 | 全文分数 |
|---|---|
| A | 12.4 |
| B | 8.1 |
向量搜索返回:
| 文本块 | 余弦相似度 |
|---|---|
| C | 0.91 |
| B | 0.87 |
如果直接相加:
- A:12.4;
- B:8.97;
- C:0.91。
结果几乎完全由全文分数支配。这不表示 A 一定比 C 更相关,只表示两个系统的分数尺度不同。
即使两个分数都在 0 到 1 之间,也不能直接相加,因为:
- 分数分布可能不同;
- 一个分数可能集中在 0.8 到 0.9;
- 另一个分数可能集中在 0.01 到 0.2;
- 分数的绝对值未必表示跨系统可比较的概率;
- 查询长度、词频和候选集大小都会影响全文得分。
因此,混合检索至少需要一个融合策略。
五、融合方法:从名次到分数
5.1 归一化加权融合
一种方法是先将不同通道的分数归一化,再加权:
其中:
- 是归一化后的全文分数;
- 是归一化后的向量分数;
- 是权重。
最简单的 Min-Max 归一化为:
但这种方法有明显边界:
- 如果候选集合很小,极值会非常不稳定;
- 一个异常高分会压缩其他结果;
- 查询之间的分数分布不同;
- 缺失通道结果需要额外处理;
- 全文分数不一定具有线性意义。
更可靠的分数融合通常需要使用固定验证集进行标定,例如通过历史点击、人工标注或问答正确性来学习权重。但这要求数据集和评估指标足够稳定。
5.2 Reciprocal Rank Fusion
RRF(Reciprocal Rank Fusion,倒数排名融合)不直接比较原始分数,而是比较名次:
其中:
- 是检索通道集合;
- 是文本块 在通道 中的排名,从 1 开始;
- 是通道权重;
- 是平滑常数,防止第一名的权重过大;
- 如果文本块没有出现在某个通道中,该通道贡献为 0。
RRF 的直觉是:一个结果只要在多个通道中都排得靠前,就应得到较高的融合分数;它不要求不同通道的原始分数具有相同含义。
完整数值算例
假设全文通道返回:
| 排名 | 文本块 |
|---|---|
| 1 | A |
| 2 | B |
| 3 | C |
向量通道返回:
| 排名 | 文本块 |
|---|---|
| 1 | C |
| 2 | B |
| 3 | D |
令 ,两个通道权重都为 1:
- A:
- B:
- C:
- D:
最终顺序为:
C、B、A、D
C 比 B 略高,是因为它在向量通道中排名第一,在全文通道中排名第三;B 在两个通道中都排名第二。实际系统中, 和通道权重需要通过验证集调整,而不是机械使用某个固定值。
5.3 RRF 的优点和局限
RRF 的优点:
- 不要求全文分数和向量分数同尺度;
- 对通道分数校准不敏感;
- 实现简单;
- 适合先做候选融合,再交给重排模型。
局限也很明确:
- 它只知道名次,不知道名次之间的实际差距;
- 第一名和第二名可能只差一点,也可能差很多;
- 如果某个通道召回质量很差,仍然可能污染候选集合;
- 如果两个通道切分方式或过滤条件不一致,融合结果可能失真。
混合检索的正确目标不是让两个通道“平均发言”,而是让它们在不同查询类型下互相补充。
六、用 PostgreSQL + pgvector 实现一个可运行的混合查询
下面示例使用:
- PostgreSQL;
pgvector扩展;- PostgreSQL 内置全文检索;
- 余弦距离;
- RRF 融合。
示例的向量维度故意设置为 3,便于演示。真实系统必须使用 Embedding 模型实际输出的维度。
6.1 创建扩展和表
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE rag_chunks (
chunk_id bigserial PRIMARY KEY,
document_id bigint NOT NULL,
document_version integer NOT NULL DEFAULT 1,
chunk_no integer NOT NULL,
content text NOT NULL,
content_tsv tsvector NOT NULL,
embedding vector(3),
source_uri text NOT NULL,
page_no integer,
char_start integer,
char_end integer,
content_hash text NOT NULL,
tenant_id bigint NOT NULL,
acl_group_ids bigint[] NOT NULL DEFAULT '{}',
UNIQUE (document_id, document_version, chunk_no)
);
这里没有把 content_tsv 定义为自动生成列,而是要求写入程序显式生成。这样可以明确控制使用的文本配置,也便于未来替换为外部中文分词结果。
建立索引:
CREATE INDEX rag_chunks_content_tsv_gin
ON rag_chunks
USING gin (content_tsv);
CREATE INDEX rag_chunks_embedding_hnsw_cosine
ON rag_chunks
USING hnsw (embedding vector_cosine_ops);
CREATE INDEX rag_chunks_tenant_idx
ON rag_chunks (tenant_id);
这些索引的含义不同:
- GIN 索引用于全文词项检索;
- HNSW 索引用于近似向量近邻搜索;
- 租户索引用于减少权限和租户过滤成本。
HNSW 是近似最近邻索引。它通常以更高的索引构建成本和存储成本,换取查询时较低的延迟。近似索引不等于结果绝对正确;如果业务要求精确最近邻,可以不建立向量近邻索引,直接按距离排序,但数据量增大后成本会明显上升。
6.2 写入示例数据
INSERT INTO rag_chunks (
document_id,
document_version,
chunk_no,
content,
content_tsv,
embedding,
source_uri,
page_no,
char_start,
char_end,
content_hash,
tenant_id,
acl_group_ids
)
VALUES
(
1001,
1,
0,
'HNSW 是一种用于近似最近邻搜索的图索引结构。',
to_tsvector('simple', 'HNSW 是一种用于近似最近邻搜索的图索引结构。'),
'[0.90,0.10,0.00]'::vector,
'https://example.test/vector-db.md',
3,
120,
148,
'sha256:chunk-1001-0',
7,
ARRAY[10, 20]
),
(
1001,
1,
1,
'余弦距离用于比较两个向量方向的差异,距离越小表示越相似。',
to_tsvector('simple', '余弦距离用于比较两个向量方向的差异,距离越小表示越相似。'),
'[0.80,0.20,0.00]'::vector,
'https://example.test/vector-db.md',
4,
149,
190,
'sha256:chunk-1001-1',
7,
ARRAY[10, 20]
),
(
1002,
1,
0,
'全文检索适合查找错误码、函数名、版本号和其他精确术语。',
to_tsvector('simple', '全文检索适合查找错误码、函数名、版本号和其他精确术语。'),
'[0.10,0.80,0.10]'::vector,
'https://example.test/search.md',
2,
10,
45,
'sha256:chunk-1002-0',
7,
ARRAY[10]
);
真实写入流程中,embedding 不应由数据库随意生成。应用需要明确:
- 使用哪个 Embedding 模型;
- 模型版本;
- 向量维度;
- 距离度量;
- 文本规范化方式;
- 模型变更时如何重建旧数据。
建议另外保存:
embedding_model = 'model-name@version'
embedding_dimension = 1536
embedding_normalized = true/false
否则模型升级后,很容易把不同空间中的向量混在同一列中。
6.3 分别取全文和向量候选
下面的查询使用参数:
$1:用户原始文本查询;$2:由同一查询生成的向量,例如'[0.85,0.15,0.00]';$3:租户 ID;$4:全文候选数量;$5:向量候选数量;$6:用户所属权限组数组。
先看全文通道:
SELECT
chunk_id,
ts_rank_cd(
content_tsv,
websearch_to_tsquery('simple', $1)
) AS text_score
FROM rag_chunks
WHERE tenant_id = $3
AND content_tsv @@ websearch_to_tsquery('simple', $1)
AND acl_group_ids && $6::bigint[]
ORDER BY text_score DESC, chunk_id
LIMIT $4;
这里的 acl_group_ids && $6 表示两个数组存在交集。它只是示例权限条件,生产环境必须根据实际授权模型设计。重要的是:权限过滤必须进入每个检索通道,而不是只在融合后过滤。
再看向量通道:
SELECT
chunk_id,
embedding <=> $2::vector AS cosine_distance
FROM rag_chunks
WHERE tenant_id = $3
AND embedding IS NOT NULL
AND acl_group_ids && $6::bigint[]
ORDER BY embedding <=> $2::vector, chunk_id
LIMIT $5;
如果需要使用相似度而不是距离,可以在外层计算:
1 - cosine_distance AS cosine_similarity
但排序时仍然应优先按距离升序写出,这更容易让 PostgreSQL 使用向量近邻索引。
6.4 在 SQL 中完成 RRF 融合
下面将两个通道分别编号,然后通过 UNION ALL 聚合 RRF 分数:
WITH
text_candidates AS MATERIALIZED (
SELECT
chunk_id,
row_number() OVER (
ORDER BY text_score DESC, chunk_id
) AS rank_no
FROM (
SELECT
chunk_id,
ts_rank_cd(
content_tsv,
websearch_to_tsquery('simple', $1)
) AS text_score
FROM rag_chunks
WHERE tenant_id = $3
AND content_tsv @@ websearch_to_tsquery('simple', $1)
AND acl_group_ids && $6::bigint[]
ORDER BY text_score DESC, chunk_id
LIMIT $4
) AS ranked_text
),
vector_candidates AS MATERIALIZED (
SELECT
chunk_id,
row_number() OVER (
ORDER BY cosine_distance ASC, chunk_id
) AS rank_no
FROM (
SELECT
chunk_id,
embedding <=> $2::vector AS cosine_distance
FROM rag_chunks
WHERE tenant_id = $3
AND embedding IS NOT NULL
AND acl_group_ids && $6::bigint[]
ORDER BY embedding <=> $2::vector, chunk_id
LIMIT $5
) AS ranked_vector
),
fused AS (
SELECT
chunk_id,
SUM(rrf_score) AS fusion_score
FROM (
SELECT
chunk_id,
1.0 / (60 + rank_no) AS rrf_score
FROM text_candidates
UNION ALL
SELECT
chunk_id,
1.0 / (60 + rank_no) AS rrf_score
FROM vector_candidates
) AS contributions
GROUP BY chunk_id
)
SELECT
c.chunk_id,
c.document_id,
c.document_version,
c.chunk_no,
c.content,
c.source_uri,
c.page_no,
c.char_start,
c.char_end,
c.content_hash,
f.fusion_score
FROM fused AS f
JOIN rag_chunks AS c
ON c.chunk_id = f.chunk_id
ORDER BY f.fusion_score DESC, c.chunk_id
LIMIT 20;
这个查询的中间状态可以理解为:
text_candidates得到全文前若干名;vector_candidates得到向量前若干名;- 每个候选根据自身通道排名贡献一个 RRF 分数;
- 同一个
chunk_id的多个贡献相加; - 重新关联完整文本和引用元数据。
MATERIALIZED 的作用是明确保留候选中间结果,避免后续优化器重新规划时改变预期的中间执行方式。是否需要它取决于 PostgreSQL 版本、数据量和执行计划,不能把它当成固定性能保证。
6.5 近似索引与过滤的实际边界
pgvector 的 HNSW 或 IVFFlat 查询通常需要呈现为:
ORDER BY embedding <=> $1
LIMIT 20
这样优化器才有机会选择向量近邻索引。
但混合查询还包含租户、权限、状态等过滤条件。近似向量搜索可能先找到一批近邻,再应用过滤;如果过滤条件很严格,前面找到的近邻可能大部分都被过滤掉,最终返回的有效结果不足,或者有效结果质量下降。
常见缓解方式包括:
- 增大向量候选数量;
- 调高 HNSW 查询搜索参数;
- 使用分区或部分索引;
- 将租户或数据域拆分到更小的搜索范围;
- 使用支持迭代扫描的版本能力,但必须以当前 pgvector 官方文档和执行计划为准;
- 对关键查询使用精确距离计算作为校验;
- 监控“过滤前候选数”和“过滤后有效候选数”。
不能简单地认为“建立了 HNSW 索引,所以过滤后的 Top-K 一定是全局精确 Top-K”。
七、重排:为什么融合后还需要第二个相关性模型
7.1 召回模型和重排模型的目标不同
Embedding 模型通常将单个文本编码成向量:
检索时通过两个向量的距离近似判断相关性。它的优势是:
- 查询和文档可以预先编码;
- 可以使用近似最近邻索引;
- 延迟较低;
- 适合从大规模文档中召回候选。
重排模型通常同时读取查询和候选文本:
例如 Cross-Encoder 会把查询和候选文本一起输入模型,直接建模词语之间的交互。它可以识别:
- 查询中的限定条件;
- 否定关系;
- 数字和版本号;
- 文档是否真正回答了问题;
- 两段文字只是共享主题,还是存在实际答案关系。
但因为每个候选都需要执行一次更昂贵的联合计算,它不适合直接扫描整个文档库。
因此典型流程是:
其中:
7.2 一个完整的重排算例
假设混合融合后得到四个候选:
| 文本块 | RRF 分数 | 重排模型分数 |
|---|---|---|
| A | 0.032 | 0.42 |
| B | 0.031 | 0.91 |
| C | 0.025 | 0.76 |
| D | 0.020 | 0.15 |
如果直接按 RRF 排序,顺序是:
A、B、C、D
如果使用重排模型,顺序变为:
B、C、A、D
这并不意味着融合阶段失败。融合的任务是把 B、C 召回并保留;重排的任务是识别 A 只是共享词汇,而 B 才真正回答了问题。
7.3 重排的输入边界
重排模型通常不能无限制地接收文本。工程中需要明确:
- 每个候选最大字符数或 token 数;
- 超长 chunk 是否需要二次截断;
- 是否将标题、路径、页码拼接到候选文本;
- 是否对同一文档的相邻 chunk 去重;
- 是否保留全文和向量通道的来源信息。
推荐给重排模型的输入结构类似:
查询:
如何调整 HNSW 的搜索范围?
候选:
标题:向量索引配置
章节:查询参数
正文:……
标题和章节路径通常有助于区分多个内容相似的 chunk,但它们不能替代正文,也不能被错误地当成用户可引用的原文。
7.4 重排分数仍然不能直接当作事实置信度
重排分数通常只是模型内部的相关性分数。它不自动表示:
- 答案正确;
- 原文没有冲突;
- 文档是最新版本;
- 用户有权限访问;
- 生成模型一定能够据此正确回答。
因此,重排后的高分只能用于排序。事实依据仍需由引用和原文验证提供。
八、RAG 数据层的数据流和状态
一个可审计的 RAG 数据层至少包含以下状态。
8.1 原始文档状态
DISCOVERED → FETCHED → PARSED → CHUNKED
原始文件可能来自网页、对象存储、代码仓库或数据库。数据层应保存:
- 原始来源;
- 抓取时间;
- 来源版本或 ETag;
- 内容哈希;
- 解析器版本;
- 解析失败原因。
如果只保存切分后的文本,而不保存原始文档版本,后续很难解释引用为何变化。
8.2 文本块状态
文本块通常经历:
CREATED → EMBEDDING_PENDING → EMBEDDED → INDEXED → ACTIVE
ACTIVE 不应只表示“数据库中有一行”,而应表示:
- 全文字段已经写入;
- Embedding 已成功生成;
- 向量维度正确;
- 文本块属于当前有效文档版本;
- 索引可见;
- 权限元数据完整。
如果全文字段已经提交,但向量生成失败,是否允许它进入全文检索,需要由业务明确决定。两种策略都合理:
- 原子可用:全文和向量都准备完成后才进入可检索状态;
- 分通道可用:允许全文先可用,但结果标记为向量缺失。
不能在没有状态字段的情况下,让不同写入阶段产生的半成品自然混入搜索结果。
8.3 文档更新和删除
文档更新通常不是简单覆盖一行,因为旧 chunk 可能已经被召回、缓存或引用。
一种安全的版本化流程是:
- 写入新文档版本;
- 生成新版本 chunk;
- 生成全部 Embedding;
- 构建或刷新索引;
- 在事务中将新版本标记为 active;
- 将旧版本标记为 inactive;
- 异步清理旧版本。
如果在第 3 步失败,旧版本仍可服务;如果先删除旧版本再生成新版本,就会产生知识库空窗期。
引用中应包含文档版本,否则用户可能打开了最新文档,却发现引用内容来自较早版本。
九、事务、一致性与部署边界
9.1 PostgreSQL 单库内的一致性
如果全文字段、向量和元数据都存储在同一个 PostgreSQL 数据库中,可以利用同一事务保证一组更新的原子提交:
BEGIN;
INSERT INTO rag_chunks (...);
-- 更新文档版本状态
UPDATE documents
SET active_version = 2
WHERE document_id = 1001;
COMMIT;
事务提交前,其他事务不会看到完整的新版本状态;提交后,数据库中的行和字段具有 PostgreSQL 事务语义。
但这里仍有一个重要边界:事务提交不等于所有查询客户端立刻看到完全相同的搜索结果。
原因包括:
- 事务隔离级别;
- 连接是否长期持有旧快照;
- 读取副本延迟;
- 向量索引构建或刷新过程;
- 应用层缓存;
- Embedding 生成是数据库外部操作。
9.2 Embedding 服务不属于 PostgreSQL 事务
典型流程是:
读取文档
→ 调用 Embedding 服务
→ 得到向量
→ 写回 PostgreSQL
调用外部模型服务无法被 PostgreSQL 的 BEGIN/COMMIT 自动纳入同一个原子事务。如果模型服务成功,但数据库写回失败,系统需要重试;如果数据库先写入任务记录但模型调用失败,系统需要恢复到 pending 或 failed 状态。
常见做法是使用任务表:
CREATE TABLE embedding_jobs (
job_id bigserial PRIMARY KEY,
chunk_id bigint NOT NULL,
status text NOT NULL CHECK (
status IN ('pending', 'running', 'succeeded', 'failed')
),
attempt_count integer NOT NULL DEFAULT 0,
last_error text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
任务处理器需要具备幂等性:
- 同一个
chunk_id + embedding_model重复执行不会产生不可区分的重复版本; - 写回时校验文本哈希;
- 文本已经变化时,旧任务不能覆盖新向量;
- 失败记录保留错误原因和重试次数。
9.3 Milvus 的一致性选择
Milvus 的集合、标量字段、向量字段和索引由 Milvus 的数据节点、查询节点等组件协同管理。其查询结果何时可见,与所使用的一致性级别和部署架构有关。
实际使用时需要区分:
- 写入是否已经被服务接受;
- 数据是否已经被查询节点看到;
- 索引是否已经建立并可用于查询;
- 读取请求使用了什么一致性级别;
- 多副本或分布式部署中是否存在可见性延迟。
这意味着,“写入成功后立刻查询”不能脱离 Milvus 版本、SDK 和一致性设置讨论。强一致、会话一致、边界一致和最终一致之间是延迟与可见性取舍,而不是向量相似度算法的差异。
如果全文检索在 Elasticsearch、向量检索在 Milvus,二者更不可能共享一个跨系统事务。此时需要:
- 文档版本号;
- outbox 或消息队列;
- 可重试的索引任务;
- 双边状态校验;
- 逻辑删除标记;
- 定期对账和修复。
十、Milvus 中的混合检索数据流
Milvus 主要负责向量字段、标量字段和向量检索。根据所使用的 Milvus 稳定版本和 SDK,混合检索能力可能包括:
- 对多个向量字段分别检索;
- 稠密向量检索;
- 稀疏向量检索;
- BM25 全文检索能力;
- 多路检索结果通过 WeightedRanker 或 RRFRanker 融合;
- 通过标量表达式执行过滤。
这里必须注意版本边界:Milvus 的全文检索、BM25、稀疏向量函数和不同语言 SDK 的调用形式在不同稳定版本中存在差异。部署时应以当前 Milvus 官方文档和对应 SDK 版本的 API 为准,不能把某个版本的示例直接复制到另一版本。
从组件角度看,一次混合检索可以抽象为:
用户查询
├─→ 文本分析 / BM25 通道
├─→ Embedding 模型 / 稠密向量通道
└─→ 标量过滤
↓
多路候选结果
↓
RRFRanker 或 WeightedRanker
↓
融合结果
↓
应用侧重排模型
↓
返回 chunk 和元数据
10.1 Milvus 的字段边界
一个用于 RAG 的 Milvus collection 通常需要保存:
- 主键;
- 文本块 ID;
- 文本内容或内容引用;
- 稠密向量;
- 可选稀疏向量;
- 租户、文档、版本、状态等标量字段;
- 来源和权限元数据。
如果正文存储在对象存储或 PostgreSQL,而 Milvus 只保存向量和主键,那么检索返回后还需要回源读取正文。这种设计可以减少向量库中的大文本存储,但会引入:
- 回源延迟;
- 主键一致性问题;
- 删除和版本切换问题;
- 检索结果与正文不一致的风险。
无论采用哪种方式,都应把 chunk_id 作为跨系统稳定身份,而不是使用数组位置或临时结果序号。
10.2 多路融合的过滤一致性
如果全文通道使用了过滤条件:
tenant_id = 7 AND status = 'active'
向量通道也必须使用语义等价的过滤条件。否则可能出现:
- 全文返回用户有权限的内容;
- 向量返回用户无权限的内容;
- 融合后的排序被无权限结果影响;
- 应用层最后过滤掉结果,导致前面的候选配额浪费。
最危险的做法是:
- 先对全部数据做向量 Top-K;
- 融合;
- 最后才检查权限。
权限不是普通排序条件,而是安全边界。它必须在每个可行的召回通道中尽早生效,并在最终返回前再次校验。
十一、引用不是“给搜索结果加一个 URL”
11.1 引用要回答三个问题
RAG 的引用至少要让用户能够回答:
- 这句话来自哪份文档?
- 来自文档的哪个版本和位置?
- 当前展示的文本是否就是生成时使用的文本?
因此,一个可验证的引用记录可以包含:
{
"chunk_id": 1001001,
"document_id": 1001,
"document_version": 3,
"source_uri": "https://example.test/vector-db.md",
"page_no": 4,
"char_start": 149,
"char_end": 190,
"content_hash": "sha256:...",
"retrieval_rank": 2,
"rerank_score": 0.91
}
其中:
chunk_id用于系统内部稳定回溯;document_version防止引用指向错误版本;page_no、char_start、char_end用于定位原文;content_hash用于检测正文是否被替换;- 检索和重排分数用于调试,不应被当成事实置信度。
11.2 引用和上下文组装必须绑定
生成模型看到的上下文不应只是纯文本拼接:
片段一……
片段二……
片段三……
更可靠的结构是:
[来源 S1]
文档:向量数据库基础
版本:3
位置:第 4 页
正文:……
[来源 S2]
文档:混合检索设计
版本:1
位置:第 2 节
正文:……
模型生成答案时,应用层需要维护:
答案句子 → 支撑 chunk_id 集合
如果模型输出 [S1],应用程序应验证:
S1是否确实被送入上下文;S1是否对应有效的chunk_id;- 该 chunk 是否仍然属于用户可访问范围;
- 引用内容是否与生成时的内容哈希一致。
不能只让模型自由生成一个 URL,然后认为它构成引用。
11.3 引用完整性与回答完整性不是一回事
一个答案可能带有真实存在的引用,但仍然没有被引用支持。例如:
原文只说:
HNSW 是一种近似最近邻索引。
模型却回答:
HNSW 一定比 IVFFlat 延迟低,并且不需要训练。
第一部分可能被引用支持,后两部分未必。引用系统需要进一步检查:
- 每个可验证陈述是否有对应证据;
- 证据是否表达了相同范围的结论;
- 是否把条件性描述扩展成了绝对结论;
- 多个文档之间是否存在版本或事实冲突。
因此,引用是证据链的一部分,不是生成结果末尾的装饰字段。
十二、切分策略直接影响检索和引用
12.1 Chunk 太小
如果每个 chunk 只有一句话:
- 向量语义上下文不足;
- 代词和条件容易丢失;
- 重排模型难以判断完整含义;
- 引用数量增多;
- 生成模型需要拼接更多碎片。
12.2 Chunk 太大
如果一个 chunk 包含整章:
- 向量表示被多个主题平均化;
- 召回后上下文浪费;
- 重排模型输入容易截断;
- 引用定位不精确;
- 同一文档中的无关内容可能干扰生成。
12.3 切分必须保存原文位置
切分时不要只保存字符串,还应保存:
document_id
document_version
section_path
chunk_no
char_start
char_end
page_no
content_hash
如果正文经过清洗、去 HTML 或 Markdown 转换,应明确 char_start 对应的是:
- 原始文件;
- 清洗后的文本;
- 页面抽取文本;
- 最终送入模型的文本。
否则页面定位看似存在,实际无法复现。
十三、查询处理:不要把用户一句话直接当成唯一检索请求
用户问题可能包含多种信息:
PostgreSQL 中 HNSW 过滤租户后结果变少,应该怎么诊断?
其中至少有:
- 精确术语:
PostgreSQL、HNSW; - 结构性条件:过滤租户;
- 现象:结果变少;
- 意图:诊断和处理。
可以将查询拆为:
全文查询:
PostgreSQL HNSW 租户 过滤
语义查询:
PostgreSQL HNSW 在租户过滤条件下召回不足的原因和诊断方法
过滤条件:
tenant_id = 当前租户
status = active
查询重写并不意味着修改用户原意。它应保留:
- 版本号;
- 错误码;
- 函数名;
- 否定词;
- 数字条件;
- 权限边界。
尤其不能让 LLM 在查询改写时删除看似不重要的技术标识符。对于排障问题,SQLSTATE 23505 和“数据库插入失败”并不是等价查询。
十四、常见失败表现和诊断路径
14.1 只有向量结果,没有精确术语结果
表现:
用户查询某个错误码,但结果只是解释同类问题的文章,原始错误码对应的页面没有出现。
原因:
- 术语在切分或清洗时被破坏;
- Embedding 模型没有保留标识符的优势;
- 向量候选数量过小;
- 没有启用全文通道;
- 查询向量模型与文档向量模型不一致。
诊断:
SELECT chunk_id, content
FROM rag_chunks
WHERE content ILIKE '%23505%';
如果这里找不到,问题在数据清洗或写入;如果能找到但全文查询找不到,检查分词配置和 content_tsv;如果全文能找到但融合结果没有,检查候选数量、过滤和 RRF 实现。
14.2 全文结果很多,但语义问题回答错误
表现:
结果包含大量相同关键词,却没有真正回答用户的问题。
原因:
- 关键词过于常见;
- 文档中术语出现频率高但语义关系弱;
- 全文候选过度支配融合;
- 没有重排;
- 重排输入被截断。
诊断:
分别记录:
text_rank
vector_rank
fusion_score
rerank_score
final_rank
如果全文排名靠前、向量排名很后,说明查询更偏关键词;如果融合后仍然全文结果占据大多数,调整通道权重、候选配额或使用重排。
14.3 新文档已经写入,但 RAG 仍回答旧内容
可能原因:
- 新版本没有生成向量;
- 新向量没有进入可查询索引;
- 查询读取副本存在延迟;
- 缓存仍使用旧候选;
- 旧 chunk 没有标记 inactive;
- 文档版本过滤缺失。
诊断:
查询状态和版本:
SELECT
document_id,
document_version,
chunk_no,
content_hash,
embedding IS NOT NULL AS has_embedding
FROM rag_chunks
WHERE document_id = 1001
ORDER BY document_version DESC, chunk_no;
然后单独验证:
- 全文是否命中新版本;
- 向量是否存在;
- 新版本是否满足过滤条件;
- 查询是否连接了正确的数据库或副本;
- 缓存键是否包含文档版本。
14.4 引用指向了错误位置
原因:
- chunk 重新切分后沿用了旧偏移量;
- 引用只保存文档 URL,没有保存版本;
- 同一文档的多个 chunk 使用了相同编号;
- 内容被重新清洗,但位置仍按原始文本计算;
- 文档内容发生变化而 URL 没有变化。
修复方式:
每次切分变化都重新计算位置和 content_hash。引用应绑定:
document_id + document_version + chunk_id + content_hash
而不是只绑定 URL。
14.5 过滤条件导致向量结果不足
表现:
不加过滤时可以返回 20 条,加上租户或权限条件后只剩 2 条。
原因:
近似向量检索的候选探索范围有限,过滤后有效候选不足。
诊断指标:
过滤前近邻候选数
过滤后有效候选数
全文通道有效候选数
融合后的去重候选数
重排输入数量
取舍:
- 扩大向量候选;
- 调整搜索参数;
- 让数据分区与过滤维度对齐;
- 对高安全或高准确场景使用精确检索;
- 不要通过“放宽权限过滤”解决召回问题。
十五、相关性、召回率和延迟的取舍
混合 RAG 通常需要同时关注三个指标:
15.1 召回率
在标注的相关文本集合 中,检索结果取前 后命中的比例:
向量通道和全文通道的价值,首先体现在它们能否互相补充召回集合。
15.2 排名质量
常见指标包括 MRR、NDCG、Precision@K 等。它们关注相关结果是否出现在更靠前的位置。
混合融合可以提升召回率,但不一定提升前几名排序;重排通常用于改善前几名的质量。
15.3 端到端延迟
粗略地说:
重排候选越多, 越高;全文和向量通道并行执行可以降低总延迟,但会增加连接、资源和错误处理复杂度。
不能只测向量数据库的查询耗时。RAG 用户感知的是从查询发出到答案和引用返回的端到端延迟。
十六、生产设计中需要明确的取舍
16.1 单库混合还是多系统混合
PostgreSQL + pgvector + PostgreSQL FTS:
优点:
- 事务边界清晰;
- 文本、向量和元数据可以同表管理;
- SQL 过滤和关联方便;
- 适合中小规模或强一致元数据场景。
边界:
- 中文分析能力有限;
- 大规模全文搜索能力不一定满足需求;
- 向量和全文查询共享数据库资源;
- 大量重排和生成仍需外部服务。
Elasticsearch + 向量能力:
优点:
- 全文分析器和 BM25 生态成熟;
- 聚合、过滤和全文查询能力强;
- 适合搜索系统已有 Elasticsearch 基础设施的场景。
边界:
- 文档、全文、向量和业务主库之间可能产生同步链路;
- 需要设计索引刷新、版本和删除一致性;
- 查询 DSL 与关系数据库事务模型不同。
Milvus + 外部全文系统:
优点:
- 向量检索能力独立扩展;
- 适合以向量为主的检索服务;
- 可以使用多路向量和混合排序能力。
边界:
- 全文和业务元数据可能分散在多个系统;
- 跨系统一致性需要消息和对账;
- 返回正文时需要额外回源;
- 权限过滤和引用完整性必须由整体架构保证。
没有脱离数据规模、过滤复杂度、事务要求和运维能力的“唯一正确架构”。
16.2 预计算向量还是查询时生成
文档向量通常在入库时预计算。查询向量在请求时生成。两者必须使用兼容的模型空间。
模型升级时有两种主要策略:
- 原地替换:简单,但容易混入不同模型的向量;
- 双版本迁移:同时保留
embedding_v1和embedding_v2,逐步切换查询流量。
第二种策略更容易验证,但需要更多存储和索引资源。
16.3 结果去重
同一文档的相邻 chunk 可能同时占据前几名,使上下文被单一文档占满。可以在融合或重排后进行:
- 按
document_id限制数量; - 合并相邻 chunk;
- 使用最大边际相关性(MMR)增加内容多样性;
- 保留同一文档多个 chunk,但让它们共享引用上下文。
去重不能过早进行。若在通道召回阶段直接按文档去重,可能丢失同一文档中真正互补的多个片段。
十七、一个可靠的端到端执行顺序
一次查询可以按下面的顺序执行:
第一步:验证请求身份和权限
先得到当前用户、租户、权限组和数据范围。权限条件必须参与全文和向量检索。
第二步:生成两类查询
- 原始或轻度清洗后的全文查询;
- Embedding 模型需要的语义查询。
错误码、类名、版本号等标识符应保留在全文查询中。
第三步:并行执行全文和向量召回
例如:
全文 Top-50
向量 Top-50
具体数量取决于文档规模、过滤选择性和重排成本。
第四步:融合并去重
使用 RRF、加权归一化分数或引擎提供的融合器。记录每个候选来自哪些通道以及各自排名。
第五步:重排前 M 条
例如从融合候选中取 50 条,使用 Cross-Encoder 或其他相关性模型打分,再取前 8 条。
第六步:上下文组装
每个片段都带上:
- 文档标题;
- 章节;
- 版本;
- 来源标识;
- 原文位置;
- 正文;
- 权限确认结果。
第七步:生成答案和引用映射
应用层保存:
引用标识 → chunk_id → document_version → content_hash
模型只能引用实际提供给它的来源标识。
第八步:记录可观测信息
至少记录:
query_id
tenant_id
text_query
embedding_model
text_candidate_count
vector_candidate_count
fused_candidate_count
rerank_candidate_count
final_chunk_ids
latency_by_stage
filter_expression
index/version information
日志中应避免记录不必要的敏感正文,但必须足以复现排序和权限判断。
十八、最终理解:各层不是替代关系,而是职责分工
全文、向量、融合、重排和引用解决的是不同问题:
- 全文检索回答“哪些文本明确出现了这些词或术语”;
- 向量检索回答“哪些文本在语义空间中接近这个问题”;
- 融合回答“如何合并多个召回通道,而不错误比较它们的分数”;
- 重排回答“在较小候选集合中,哪些内容真正回答了当前问题”;
- 引用回答“答案中的陈述能否回溯到哪个稳定版本的原文”。
它们的关系可以写成:
RAG 数据层真正要维护的,不只是一个向量字段,而是一条可解释的数据链:
原始文档
→ 文档版本
→ 文本块
→ 全文索引
→ Embedding
→ 多路召回
→ 融合结果
→ 重排结果
→ 上下文
→ 生成答案
→ 稳定引用
只要其中任一环节丢失身份、版本、权限或原文位置,最终答案即使语言流畅,也可能无法验证、无法复现,甚至在文档更新后引用错误。混合检索的核心因此不是“同时使用全文和向量”这么简单,而是让不同检索机制在统一的数据身份、过滤条件、生命周期和证据链下协同工作。
系列导航与关联阅读
- 系列入口:数据库完整学习路线:从关系模型、事务索引到分布式与向量检索
- 上一篇:Pinecone 托管向量库:Index、Namespace、Metadata 与容量成本
- 下一篇:向量数据库生产运维:摄取、版本、评测、备份、权限和成本
- 延伸:向量数据库基础:Embedding、距离度量、召回、过滤与一致性
- 延伸:Elasticsearch 查询与聚合:Query DSL、相关性、分页和统计
- 延伸:pgvector 实战:PostgreSQL 向量类型、HNSW、IVFFlat 与混合查询
官方资料
本文依据数据库官方文档重新梳理;正文、示例与生产检查清单由 WR BLOG 编写。

评论
0 条讨论