数据库基础体系 · 第 66/139 篇。文章以各产品官方稳定版本的公开语义为准;示例会明确引擎、事务与部署边界。

混合检索与 RAG 数据层:全文、向量、融合、重排和引用

检索增强生成(Retrieval-Augmented Generation,RAG)通常被描述为:

  1. 将用户问题转换为查询;
  2. 从知识库中检索相关内容;
  3. 将检索结果交给大语言模型生成答案。

这个描述没有错,但隐藏了一个关键事实:RAG 的质量首先由数据层决定,而不是由生成模型单独决定

数据层至少要同时处理以下问题:

  • 用户使用精确术语时,如何进行全文检索;
  • 用户使用同义表达、描述性语言时,如何进行向量检索;
  • 全文检索和向量检索返回的结果如何合并;
  • 为什么不能直接比较两种检索的原始分数;
  • 如何使用更昂贵但更准确的模型重排候选结果;
  • 如何让最终答案中的引用能够回溯到稳定、可验证的原文;
  • 文档更新、删除、权限变化、索引延迟和服务故障时,结果是否仍然可信。

本文以这些问题为主线,说明全文检索、向量检索、融合、重排和引用在 RAG 数据层中的完整关系,并使用 PostgreSQL + pgvector 给出一个可以运行的混合检索示例。Milvus 部分重点说明其组件和数据流,并明确版本与部署边界。


一、先建立问题模型:RAG 检索的对象到底是什么

设知识库中的原始文档集合为:

D={d1,d2,,dn}D = \{d_1, d_2, \ldots, d_n\}

实际检索时,通常不会直接把整篇文档作为最小单位,而是先切分为若干文本块(chunk):

C={c1,c2,,cm}C = \{c_1, c_2, \ldots, c_m\}

每个文本块至少应包含:

  • chunk_id:稳定且唯一的文本块标识;
  • document_id:所属文档;
  • content:用于展示和生成的原文;
  • embedding:文本向量;
  • 全文检索索引数据;
  • 原文位置,例如页码、段落号或字符区间;
  • 来源 URI、版本号或内容哈希;
  • 访问控制信息。

用户问题记为 qq。典型的 RAG 检索不是一次计算,而是多阶段过程:

q查询理解全文召回与向量召回结果融合重排上下文组装生成与引用q \rightarrow \text{查询理解} \rightarrow \text{全文召回与向量召回} \rightarrow \text{结果融合} \rightarrow \text{重排} \rightarrow \text{上下文组装} \rightarrow \text{生成与引用}

这里的“召回”与“排序”需要区分:

  • 召回(retrieval / recall):尽量找出所有可能相关的文本块;
  • 排序(ranking):在候选文本块中,把最相关的排在前面;
  • 重排(reranking):使用更强但更慢的相关性模型,对较小候选集合再次排序。

一个常见错误是直接让向量数据库返回前五条,然后把这五条交给模型。这样做同时放弃了全文检索的精确匹配能力,也没有真正利用重排阶段。


二、全文检索:处理术语、实体和精确约束

2.1 全文检索不等于字符串包含

全文检索会把文本和查询转换为可检索的词项结构。典型过程包括:

  1. 分词;
  2. 规范化大小写;
  3. 去除停用词;
  4. 词干提取或同义词扩展;
  5. 建立倒排索引;
  6. 根据词项出现位置和频率计算相关性。

倒排索引可以抽象为:

term{(document_id,positions,frequency)}\text{term} \rightarrow \{(document\_id, positions, frequency)\}

例如,查询包含 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索引进行向量近邻搜索

如果没有适合中文的分词器,查询“向量近邻”不一定能按照用户预期匹配。

生产系统通常有几种选择:

  1. 使用 Elasticsearch 等具备中文分析器的全文引擎;
  2. 在写入数据层之前完成中文分词,再建立倒排索引;
  3. 使用 PostgreSQL 扩展或外部搜索服务;
  4. 将 PostgreSQL 的全文检索用于英文、标识符和结构化术语,将中文语义召回交给向量检索。

这里要区分两件事:

  • pgvector 解决的是向量存储与相似度搜索;
  • PostgreSQL 内置全文检索解决的是基于词项的倒排搜索;
  • 中文分词、同义词、拼写纠错和复杂分析器不是 pgvector 自动提供的能力。

三、向量检索:处理语义相似性

3.1 Embedding 和距离度量

Embedding 模型将文本转换为固定维度的数值向量:

f(text)xRdf(text) \rightarrow \mathbf{x} \in \mathbb{R}^d

用户问题得到查询向量:

q=f(q)\mathbf{q} = f(q)

文本块得到向量:

xi=f(ci)\mathbf{x}_i = f(c_i)

向量检索根据 q\mathbf{q}xi\mathbf{x}_i 的距离或相似度排序。

常见度量包括:

余弦相似度

cosine(q,x)=qxqx\operatorname{cosine}(\mathbf{q},\mathbf{x}) = \frac{\mathbf{q}\cdot\mathbf{x}} {\|\mathbf{q}\|\|\mathbf{x}\|}

它只关注方向,不关注向量长度。很多文本语义检索系统使用余弦距离:

dcos(q,x)=1cosine(q,x)d_{\cos}(\mathbf{q},\mathbf{x}) = 1-\operatorname{cosine}(\mathbf{q},\mathbf{x})

距离越小,越相似。

欧氏距离

d2(q,x)=j=1d(qjxj)2d_2(\mathbf{q},\mathbf{x}) = \sqrt{\sum_{j=1}^{d}(q_j-x_j)^2}

如果向量都已经归一化,欧氏距离和余弦距离在排序上存在单调关系;如果没有归一化,则二者含义不同。

内积

qx=j=1dqjxj\mathbf{q}\cdot\mathbf{x} = \sum_{j=1}^{d}q_jx_j

内积越大通常表示越相似,但向量长度也会影响结果。

因此,建索引和查询时必须使用与 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 两条检索通道各自解决什么问题

设全文检索返回:

L(q)={(ci,siL,riL)}L(q) = \{(c_i, s^L_i, r^L_i)\}

其中:

  • siLs^L_i 是全文相关性分数;
  • riLr^L_i 是该结果在全文通道中的名次。

向量检索返回:

V(q)={(ci,siV,riV)}V(q) = \{(c_i, s^V_i, r^V_i)\}

其中:

  • siVs^V_i 是向量相似度或距离变换后的分数;
  • riVr^V_i 是该结果在向量通道中的名次。

混合检索的候选集合通常是:

C(q)=L(q)V(q)C(q) = L(q) \cup V(q)

全文检索能够发现术语精确匹配,向量检索能够发现语义表达不同但含义接近的文本。两者的并集通常比任一单独通道具有更高的召回覆盖率。

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 归一化加权融合

一种方法是先将不同通道的分数归一化,再加权:

S(c)=αs^L(c)+(1α)s^V(c)S(c) = \alpha \cdot \hat{s}^L(c) + (1-\alpha)\cdot \hat{s}^V(c)

其中:

  • s^L(c)\hat{s}^L(c) 是归一化后的全文分数;
  • s^V(c)\hat{s}^V(c) 是归一化后的向量分数;
  • α[0,1]\alpha\in[0,1] 是权重。

最简单的 Min-Max 归一化为:

s^(x)=xmin(X)max(X)min(X)\hat{s}(x) = \frac{x-\min(X)} {\max(X)-\min(X)}

但这种方法有明显边界:

  • 如果候选集合很小,极值会非常不稳定;
  • 一个异常高分会压缩其他结果;
  • 查询之间的分数分布不同;
  • 缺失通道结果需要额外处理;
  • 全文分数不一定具有线性意义。

更可靠的分数融合通常需要使用固定验证集进行标定,例如通过历史点击、人工标注或问答正确性来学习权重。但这要求数据集和评估指标足够稳定。

5.2 Reciprocal Rank Fusion

RRF(Reciprocal Rank Fusion,倒数排名融合)不直接比较原始分数,而是比较名次:

RRF(c)=mMwmk+rm(c)\operatorname{RRF}(c) = \sum_{m\in M} \frac{w_m}{k+r_m(c)}

其中:

  • MM 是检索通道集合;
  • rm(c)r_m(c) 是文本块 cc 在通道 mm 中的排名,从 1 开始;
  • wmw_m 是通道权重;
  • kk 是平滑常数,防止第一名的权重过大;
  • 如果文本块没有出现在某个通道中,该通道贡献为 0。

RRF 的直觉是:一个结果只要在多个通道中都排得靠前,就应得到较高的融合分数;它不要求不同通道的原始分数具有相同含义。

完整数值算例

假设全文通道返回:

排名 文本块
1 A
2 B
3 C

向量通道返回:

排名 文本块
1 C
2 B
3 D

k=60k=60,两个通道权重都为 1:

  • A:

160+1=0.016393\frac{1}{60+1}=0.016393

  • B:

160+2+160+2=0.032258\frac{1}{60+2}+\frac{1}{60+2} = 0.032258

  • C:

160+3+160+1=0.032266\frac{1}{60+3}+\frac{1}{60+1} = 0.032266

  • D:

160+3=0.015873\frac{1}{60+3}=0.015873

最终顺序为:

C、B、A、D

C 比 B 略高,是因为它在向量通道中排名第一,在全文通道中排名第三;B 在两个通道中都排名第二。实际系统中,kk 和通道权重需要通过验证集调整,而不是机械使用某个固定值。

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 不应由数据库随意生成。应用需要明确:

  1. 使用哪个 Embedding 模型;
  2. 模型版本;
  3. 向量维度;
  4. 距离度量;
  5. 文本规范化方式;
  6. 模型变更时如何重建旧数据。

建议另外保存:

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;

这个查询的中间状态可以理解为:

  1. text_candidates 得到全文前若干名;
  2. vector_candidates 得到向量前若干名;
  3. 每个候选根据自身通道排名贡献一个 RRF 分数;
  4. 同一个 chunk_id 的多个贡献相加;
  5. 重新关联完整文本和引用元数据。

MATERIALIZED 的作用是明确保留候选中间结果,避免后续优化器重新规划时改变预期的中间执行方式。是否需要它取决于 PostgreSQL 版本、数据量和执行计划,不能把它当成固定性能保证。

6.5 近似索引与过滤的实际边界

pgvector 的 HNSW 或 IVFFlat 查询通常需要呈现为:

ORDER BY embedding <=> $1
LIMIT 20

这样优化器才有机会选择向量近邻索引。

但混合查询还包含租户、权限、状态等过滤条件。近似向量搜索可能先找到一批近邻,再应用过滤;如果过滤条件很严格,前面找到的近邻可能大部分都被过滤掉,最终返回的有效结果不足,或者有效结果质量下降。

常见缓解方式包括:

  • 增大向量候选数量;
  • 调高 HNSW 查询搜索参数;
  • 使用分区或部分索引;
  • 将租户或数据域拆分到更小的搜索范围;
  • 使用支持迭代扫描的版本能力,但必须以当前 pgvector 官方文档和执行计划为准;
  • 对关键查询使用精确距离计算作为校验;
  • 监控“过滤前候选数”和“过滤后有效候选数”。

不能简单地认为“建立了 HNSW 索引,所以过滤后的 Top-K 一定是全局精确 Top-K”。


七、重排:为什么融合后还需要第二个相关性模型

7.1 召回模型和重排模型的目标不同

Embedding 模型通常将单个文本编码成向量:

f(q),f(c)f(q),\quad f(c)

检索时通过两个向量的距离近似判断相关性。它的优势是:

  • 查询和文档可以预先编码;
  • 可以使用近似最近邻索引;
  • 延迟较低;
  • 适合从大规模文档中召回候选。

重排模型通常同时读取查询和候选文本:

g(q,c)s(q,c)g(q,c) \rightarrow s(q,c)

例如 Cross-Encoder 会把查询和候选文本一起输入模型,直接建模词语之间的交互。它可以识别:

  • 查询中的限定条件;
  • 否定关系;
  • 数字和版本号;
  • 文档是否真正回答了问题;
  • 两段文字只是共享主题,还是存在实际答案关系。

但因为每个候选都需要执行一次更昂贵的联合计算,它不适合直接扫描整个文档库。

因此典型流程是:

全文 Top-KL向量 Top-KV融合候选 Top-M重排 Top-N\text{全文 Top-}K_L \cup \text{向量 Top-}K_V \rightarrow \text{融合候选 Top-}M \rightarrow \text{重排 Top-}N

其中:

NMDN \le M \ll |D|

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 可能已经被召回、缓存或引用。

一种安全的版本化流程是:

  1. 写入新文档版本;
  2. 生成新版本 chunk;
  3. 生成全部 Embedding;
  4. 构建或刷新索引;
  5. 在事务中将新版本标记为 active;
  6. 将旧版本标记为 inactive;
  7. 异步清理旧版本。

如果在第 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'

向量通道也必须使用语义等价的过滤条件。否则可能出现:

  • 全文返回用户有权限的内容;
  • 向量返回用户无权限的内容;
  • 融合后的排序被无权限结果影响;
  • 应用层最后过滤掉结果,导致前面的候选配额浪费。

最危险的做法是:

  1. 先对全部数据做向量 Top-K;
  2. 融合;
  3. 最后才检查权限。

权限不是普通排序条件,而是安全边界。它必须在每个可行的召回通道中尽早生效,并在最终返回前再次校验。


十一、引用不是“给搜索结果加一个 URL”

11.1 引用要回答三个问题

RAG 的引用至少要让用户能够回答:

  1. 这句话来自哪份文档?
  2. 来自文档的哪个版本和位置?
  3. 当前展示的文本是否就是生成时使用的文本?

因此,一个可验证的引用记录可以包含:

{
  "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_nochar_startchar_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 过滤租户后结果变少,应该怎么诊断?

其中至少有:

  • 精确术语:PostgreSQLHNSW
  • 结构性条件:过滤租户;
  • 现象:结果变少;
  • 意图:诊断和处理。

可以将查询拆为:

全文查询:
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;

然后单独验证:

  1. 全文是否命中新版本;
  2. 向量是否存在;
  3. 新版本是否满足过滤条件;
  4. 查询是否连接了正确的数据库或副本;
  5. 缓存键是否包含文档版本。

14.4 引用指向了错误位置

原因:

  • chunk 重新切分后沿用了旧偏移量;
  • 引用只保存文档 URL,没有保存版本;
  • 同一文档的多个 chunk 使用了相同编号;
  • 内容被重新清洗,但位置仍按原始文本计算;
  • 文档内容发生变化而 URL 没有变化。

修复方式:

每次切分变化都重新计算位置和 content_hash。引用应绑定:

document_id + document_version + chunk_id + content_hash

而不是只绑定 URL。

14.5 过滤条件导致向量结果不足

表现:

不加过滤时可以返回 20 条,加上租户或权限条件后只剩 2 条。

原因:

近似向量检索的候选探索范围有限,过滤后有效候选不足。

诊断指标:

过滤前近邻候选数
过滤后有效候选数
全文通道有效候选数
融合后的去重候选数
重排输入数量

取舍:

  • 扩大向量候选;
  • 调整搜索参数;
  • 让数据分区与过滤维度对齐;
  • 对高安全或高准确场景使用精确检索;
  • 不要通过“放宽权限过滤”解决召回问题。

十五、相关性、召回率和延迟的取舍

混合 RAG 通常需要同时关注三个指标:

15.1 召回率

在标注的相关文本集合 R(q)R(q) 中,检索结果取前 KK 后命中的比例:

Recall@K=R(q)TopK(q)R(q)Recall@K = \frac{|R(q)\cap TopK(q)|}{|R(q)|}

向量通道和全文通道的价值,首先体现在它们能否互相补充召回集合。

15.2 排名质量

常见指标包括 MRR、NDCG、Precision@K 等。它们关注相关结果是否出现在更靠前的位置。

混合融合可以提升召回率,但不一定提升前几名排序;重排通常用于改善前几名的质量。

15.3 端到端延迟

粗略地说:

Ttotal=Tquery rewrite+Ttext+Tvector+Tfusion+Trerank+TgenerationT_{\text{total}} = T_{\text{query rewrite}} + T_{\text{text}} + T_{\text{vector}} + T_{\text{fusion}} + T_{\text{rerank}} + T_{\text{generation}}

重排候选越多,TrerankT_{\text{rerank}} 越高;全文和向量通道并行执行可以降低总延迟,但会增加连接、资源和错误处理复杂度。

不能只测向量数据库的查询耗时。RAG 用户感知的是从查询发出到答案和引用返回的端到端延迟。


十六、生产设计中需要明确的取舍

16.1 单库混合还是多系统混合

PostgreSQL + pgvector + PostgreSQL FTS:

优点:

  • 事务边界清晰;
  • 文本、向量和元数据可以同表管理;
  • SQL 过滤和关联方便;
  • 适合中小规模或强一致元数据场景。

边界:

  • 中文分析能力有限;
  • 大规模全文搜索能力不一定满足需求;
  • 向量和全文查询共享数据库资源;
  • 大量重排和生成仍需外部服务。

Elasticsearch + 向量能力:

优点:

  • 全文分析器和 BM25 生态成熟;
  • 聚合、过滤和全文查询能力强;
  • 适合搜索系统已有 Elasticsearch 基础设施的场景。

边界:

  • 文档、全文、向量和业务主库之间可能产生同步链路;
  • 需要设计索引刷新、版本和删除一致性;
  • 查询 DSL 与关系数据库事务模型不同。

Milvus + 外部全文系统:

优点:

  • 向量检索能力独立扩展;
  • 适合以向量为主的检索服务;
  • 可以使用多路向量和混合排序能力。

边界:

  • 全文和业务元数据可能分散在多个系统;
  • 跨系统一致性需要消息和对账;
  • 返回正文时需要额外回源;
  • 权限过滤和引用完整性必须由整体架构保证。

没有脱离数据规模、过滤复杂度、事务要求和运维能力的“唯一正确架构”。

16.2 预计算向量还是查询时生成

文档向量通常在入库时预计算。查询向量在请求时生成。两者必须使用兼容的模型空间。

模型升级时有两种主要策略:

  1. 原地替换:简单,但容易混入不同模型的向量;
  2. 双版本迁移:同时保留 embedding_v1embedding_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

日志中应避免记录不必要的敏感正文,但必须足以复现排序和权限判断。


十八、最终理解:各层不是替代关系,而是职责分工

全文、向量、融合、重排和引用解决的是不同问题:

  • 全文检索回答“哪些文本明确出现了这些词或术语”;
  • 向量检索回答“哪些文本在语义空间中接近这个问题”;
  • 融合回答“如何合并多个召回通道,而不错误比较它们的分数”;
  • 重排回答“在较小候选集合中,哪些内容真正回答了当前问题”;
  • 引用回答“答案中的陈述能否回溯到哪个稳定版本的原文”。

它们的关系可以写成:

精确术语覆盖+语义召回覆盖候选融合精确相关性排序可验证证据\text{精确术语覆盖} + \text{语义召回覆盖} \rightarrow \text{候选融合} \rightarrow \text{精确相关性排序} \rightarrow \text{可验证证据}

RAG 数据层真正要维护的,不只是一个向量字段,而是一条可解释的数据链:

原始文档
→ 文档版本
→ 文本块
→ 全文索引
→ Embedding
→ 多路召回
→ 融合结果
→ 重排结果
→ 上下文
→ 生成答案
→ 稳定引用

只要其中任一环节丢失身份、版本、权限或原文位置,最终答案即使语言流畅,也可能无法验证、无法复现,甚至在文档更新后引用错误。混合检索的核心因此不是“同时使用全文和向量”这么简单,而是让不同检索机制在统一的数据身份、过滤条件、生命周期和证据链下协同工作。


系列导航与关联阅读

官方资料

本文依据数据库官方文档重新梳理;正文、示例与生产检查清单由 WR BLOG 编写。