丹尼拉(Dayfing)
返回文章列表
4,225 字18 分钟

生产环境中的 RAG:混合搜索、pgvector、ACL 与回答质量

能 运行 的 演示 为什么 还 不是 生产 级 检索 器

检索 增强 生成,也 就是 RAG,是 一种 数据 产品,而 不是 prompt 技巧。一次 请求 会 被 转换成 一个 或 多个 搜索,结果 按照 身份 和 租户 过滤,模型 接收 有 界 上下文,最后 回答 带 着 证据 返回。每个 阶段 单独 看 都 可能 正确,但 最终 回答 仍然 可能 错误。检索 器 可能 找到 一段 相关 内容,却 属于 调用者 无权 查看 的 租户。一个 chunk 可能 包含 正确 句子,却 丢失 了 决定 含义 的 标题。语言 模型 也 可能 生成 一个 看似 合理、但 从未 出现 在 检索 来源 中 的 URL。

因此 生产 契约 必须 明确。每个 chunk 都 应 保存 稳定 的 文档 身份、修订 版本、来源 位置、chunk 顺序、访问 标签、embedding 模型 和 词法 表示。把 当前 修订 与 历史 修订 分开。让 PostgreSQL 强制执行 租户 边界,并 在 检索 查询 中 再次 加入 授权 过滤。引用 应该 是从 检索 行中 选出 的 标识符,而 不是 允许 模型 编造 的 文本。

这种 设计 可以 放在 一个 PostgreSQL 数据库 中,也 可以 在 以后 把 词法 搜索 或 reranker 移 到 独立 服务。AI 代理 评估 指南详细 介绍 测试 框架,AI 代理 可 观测 性 指南介绍 如何 跟踪 下面 的 各个 阶段。Prompt 注入 与 MCP 安全 指南说明 为什么 检索 文本 必须 被 视为 数据,而 不是 指令。

先 定义 检索 单元,再 选择 索引

分块 是 第一个 质量 决策。应该 从 来源 结构 开始,而 不是 从 字符 数量 开始。让 标题 和 下面 的 段落 保持 在 一起,不要 把 表格 行 与 它 的 标签 拆开,也 要 保留 列表 边界。一个 chunk 应该 能够 独立 回答 一个 小 问题,同时 保留 足够 上下文 供 reranker 判断。用所选 embedding 模型 的 token 数量 来 计算,因为 相同 的 字符 限制 在 不同 语言 和 代码 中 并不相同。

重叠 可以 保留 跨越 边界 的 句子,但 也 会 重复 术语、增加 embedding 工作量,并且 可能 让 生成器 重复 内容。只 使用 能够 修复 已 观察 到 的 边界 错误 的 最小 重叠。保存 source_startsource_end 或 来源 锚点,以及 规范化 文本 的 hash。这样 引用 可以 精确 到 位置,摄取 任务 也 能 跳 过 未 改变 的 chunk。语法 重要 时要 保持 代码 块 完整。对于 长 文档,可以 把 文档 标题 和 标题 路径 加入 发送给 embedder 的 文本,但 为 展示 保留 干净 的 正文。

Embedding 是 文本 的 带 版本 表示,并 不是 文本 永久 不变 的 属性。记录 模型 名称、维度、归一化 规则 和 创建 时间。改变 其中 任何 一项,都 可能 改变 最近 邻 的 排序。把 列 声明 为 vector(1536) 可以 拒绝 其他 维度 的 向量,从而 防止 模型 意外 混用。如果 需要 共存 多个 模型,使用 不同 的 列 或表 以及 不同 的 索引,不要 静默 地用 零 填充 向量。

带 租户 边界 的 PostgreSQL 数据 契约

下面 的 模式 保存 当前 文档 修订 的 指针,以及 搜索 和 引用 所 需要 的 所有 元 数据。english 只是 示例。多 语言 语料 应为 每种 语言 使用 对应 的 文本 搜索 配置,或者 使用 能够 处理 语料 语言 的 词法 服务。

CREATE EXTENSION IF NOT EXISTS vector;

CREATE TABLE rag_documents (
  tenant_id uuid NOT NULL,
  document_id uuid NOT NULL,
  current_revision bigint NOT NULL,
  source_uri text NOT NULL,
  deleted_at timestamptz,
  PRIMARY KEY (tenant_id, document_id)
);

CREATE TABLE rag_chunks (
  chunk_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  tenant_id uuid NOT NULL,
  document_id uuid NOT NULL,
  revision bigint NOT NULL,
  chunk_no integer NOT NULL,
  title text NOT NULL,
  content text NOT NULL,
  source_start integer NOT NULL,
  source_end integer NOT NULL,
  acl text[] NOT NULL DEFAULT ARRAY['public']::text[],
  embedding vector(1536) NOT NULL,
  embedding_model text NOT NULL,
  search_tsv tsvector GENERATED ALWAYS AS (
    setweight(to_tsvector('english', coalesce(title, '')), 'A') ||
    setweight(to_tsvector('english', content), 'B')
  ) STORED,
  updated_at timestamptz NOT NULL DEFAULT now(),
  UNIQUE (tenant_id, document_id, revision, chunk_no)
);

CREATE INDEX rag_chunks_search_tsv_idx
  ON rag_chunks USING gin (search_tsv);
CREATE INDEX rag_chunks_acl_idx
  ON rag_chunks USING gin (acl);
CREATE INDEX rag_chunks_tenant_idx
  ON rag_chunks (tenant_id);
CREATE INDEX rag_chunks_embedding_hnsw_idx
  ON rag_chunks USING hnsw (embedding vector_cosine_ops);

如果选择 IVFFlat 作为近似索引,就使用它的列表和 probes 设置,而不是 HNSW:

CREATE INDEX rag_chunks_embedding_ivfflat_idx
  ON rag_chunks USING ivfflat (embedding vector_cosine_ops)
  WITH (lists = 100);

除非迁移或有意比较需要同时保留两者,否则每条路由的距离和访问路径只保留一个近似索引。

PostgreSQL 的 全文 搜索 把 规范化 词元 表示 为 tsvector,把 查询 表示 为 tsquery。生成 列 把 计算 移出 请求 路径,GIN 索引 通常 适合 重复 的 文本 搜索。如果 需要 严格 的 BM25 语义,就 使用 提供 BM25 的 词法 引擎 或 扩展,并 把 它 的 结果 作为 已经 排序 的 候选 集。PostgreSQL 的 ts_rank_cd 是 有用 的 词法 排序,但 它 不是 BM25。不能 因为 两者 都 会 返回 一个 分数,就 把 它们 当成 同一个 东西。

对 应用 角色,在 两张 表上 启用 行级 安全,并 使用 从 已 认证 claims 得到 的 事务 本地 租户 设置。处理 请求 的 角色 不应 是 表 的 所有者。如果 无法 做到,就 强制 所有者 也 遵守 策略。

ALTER TABLE rag_documents ENABLE ROW LEVEL SECURITY;
ALTER TABLE rag_documents FORCE ROW LEVEL SECURITY;
ALTER TABLE rag_chunks ENABLE ROW LEVEL SECURITY;
ALTER TABLE rag_chunks FORCE ROW LEVEL SECURITY;

CREATE POLICY rag_documents_tenant ON rag_documents
  USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid)
  WITH CHECK (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid);

CREATE POLICY rag_chunks_tenant ON rag_chunks
  USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid)
  WITH CHECK (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid);

在 每个 事务 开始 时 通过 参数 化 调用 设置 该值,然后 执行 搜索 并 提交 或 回滚。缺少 设置 时 不会 匹配 任何 租户。认证 值 格式 错误 时,应 在 打开 数据库 事务 前 拒绝。应用 仍然 要 加入 tenant_id、当前 修订、deleted_at 和 ACL 谓词。RLS 是 最后 一道 边界,并 不 意味着 可以 信任 客户端 发送 的 tenant ID 或 角色 列表。

HNSW 和 IVFFlat 是 不同 的 运行 选择

没有 近似 索引 时,pgvector 执行 精确 的 最近 邻 搜索。精确 搜索 可以 作为 recall 参考,也 可能 在 租户 或 状态 过滤 很 有 选择性 时 保持 实用。HNSW 建立 多层 图。它 通常 在 速度 和 recall 之间 提供 更好 的 折中,但 占用 更 多 内存,构建 也 更慢。它 不 需要 训练 数据,因此 可以 在 表 填充 之前 创建。mef_construction 选项 会 影响 图 大小、构建 工作 和 recall。

IVFFlat 把 向量 划分 到 多个 列表,并 探测 其中 距离 最近 的 一部分。它 占用 更 少 内存,构建 更 快,但 recall 取决于 列表 数量、数据分布 和 probes 数量。应该 在 加载 有 代表性 的 数据 后 创建 它。pgvector 项目 为 列表 选择 和 ivfflat.probes 调整 提供 了 起始 启发式。这些 不是 你 的 工作 负载 benchmark。要 用 自己 的 语言、过滤 选择性 和 更新 频率 进行 测量。

使用 与 embedding 契约 一致 的 距离 操作符。余弦 距离 使用 <=>,内积 使用 <#>,L2 距离 使用 <->。余弦 HNSW 索引 必须 使用 模式 中 的 vector_cosine_ops。排序 中 加入 确定性 的 次级 键。迁移 时用 CREATE INDEX CONCURRENTLY 构建 新 索引,避免 阻塞 普通 写入,同时 记住 该 命令 不能 在 事务 中 运行。在 有 代表性 的 数据 上用 EXPLAIN (ANALYZE, BUFFERS) 检查 计划,并 把 近似 结果 与 精确 结果 比较。

带 过滤 条件 的 近似 搜索 需要 特别 谨慎。pgvector 在 扫描 近似 索引 后 才 应用 普通 过滤,因此 小 候选 列表 可能 只 剩下 很少 的 某 租户 或 ACL 行。增大 候选 预算,在 已 安装 的 pgvector 版本 支持 时 启用 iterative scans,或者 增加 选择性 关系 索引。过滤 值 很少 时 可以 考虑 部分 向量 索引。租户 很多 时,按 list 或 hash 分区 能够 隔离 搜索 群体,但 成千上万 个 分区 会 增加 规划 和 内存 成本。pgvector 文档 特别 提醒,共享 近似 索引 时,一个 租户 的 向量 会 影响 另 一个 租户 的 recall。

组合 词法 意图 和 向量 相似 度

向量 搜索 能够 处理 改写 和 相关 概念。词法 搜索 保护 精确 标识符、错误代码、产品名称、引号 短语,以及 embedding 可能 表示 不好 的 新 术语。可靠 的 流程 在 同 一组 已 授权 的 当前 修订 上 执行 两种 搜索,取得 比 最终 展示 更 多 的 候选,然后 合并 rank,而 不是 直接 相加 没有 统一 尺度 的 原始 分数。

下面 的 查询 用 PostgreSQL 全文 排序 作为 词法 分支。在 使用 BM25 服务 的 系统 中,用 它 的 候选 替换 text_hits,同时 保留 chunk_idtext_rank、tenant、revision 和 ACL 契约。Reciprocal Rank Fusion 不 需要 假设 余弦 距离 和 BM25 分数 处在 同一个 尺度。

WITH q AS (
  SELECT $1::vector AS embedding,
         websearch_to_tsquery('english', $2::text) AS tsq,
         $3::uuid AS tenant_id,
         $4::text[] AS roles
),
vector_hits AS (
  SELECT c.chunk_id,
         row_number() OVER (
           ORDER BY c.embedding <=> q.embedding, c.chunk_id
         ) AS vector_rank
  FROM rag_chunks c
  JOIN rag_documents d
    ON d.tenant_id = c.tenant_id
   AND d.document_id = c.document_id
   AND d.current_revision = c.revision
  CROSS JOIN q
  WHERE c.tenant_id = q.tenant_id
    AND d.deleted_at IS NULL
    AND c.acl && q.roles
  ORDER BY c.embedding <=> q.embedding, c.chunk_id
  LIMIT 50
),
text_hits AS (
  SELECT c.chunk_id,
         row_number() OVER (
           ORDER BY ts_rank_cd(c.search_tsv, q.tsq) DESC, c.chunk_id
         ) AS text_rank
  FROM rag_chunks c
  JOIN rag_documents d
    ON d.tenant_id = c.tenant_id
   AND d.document_id = c.document_id
   AND d.current_revision = c.revision
  CROSS JOIN q
  WHERE c.tenant_id = q.tenant_id
    AND d.deleted_at IS NULL
    AND c.acl && q.roles
    AND c.search_tsv @@ q.tsq
  ORDER BY ts_rank_cd(c.search_tsv, q.tsq) DESC, c.chunk_id
  LIMIT 50
),
ranked AS (
  SELECT chunk_id, vector_rank, NULL::bigint AS text_rank
  FROM vector_hits
  UNION ALL
  SELECT chunk_id, NULL::bigint AS vector_rank, text_rank
  FROM text_hits
),
candidate_scores AS (
  SELECT chunk_id,
         min(vector_rank) AS vector_rank,
         min(text_rank) AS text_rank
  FROM ranked
  GROUP BY chunk_id
)
SELECT c.chunk_id,
       c.document_id,
       c.chunk_no,
       c.title,
       c.content,
       d.source_uri,
       s.vector_rank,
       s.text_rank,
       coalesce(1.0 / (60.0 + s.vector_rank), 0.0) +
       coalesce(1.0 / (60.0 + s.text_rank), 0.0) AS rrf_score
FROM candidate_scores s
JOIN rag_chunks c ON c.chunk_id = s.chunk_id
JOIN rag_documents d
  ON d.tenant_id = c.tenant_id
 AND d.document_id = c.document_id
 AND d.current_revision = c.revision
WHERE d.deleted_at IS NULL
ORDER BY rrf_score DESC, c.chunk_id
LIMIT 8;

角色 数组 由 授权 层 构造。acl && roles 表示 至少 有 一个 标签 重叠。如果 策略 要求 所有 标签、所有者 或 时间 窗口,就 使用 其他 操作符 或 is_public 列。把 查询 文本 和 embedding 作为 参数 绑定。websearch_to_tsquery 能 容忍 用户 语法,而 to_tsquery 要求 有效 操作符,不 应 接收 未经 检查 的 文本。

只 对 已 授权 候选 执行 重排

Cross-encoder 或 其他 reranker 会 同时 读取 查询 和 每个 候选,因此 可能 比 单个 embedding 更好 地区 分 相近 的 语义 匹配。但 它 会 增加 计算 和 串行 阶段。取得 有 界 候选 集,在 reranker 之前 应用 tenant、revision、删除 和 ACL 过滤,只 传递 排序 所 需 的 字 段。即使 最终 答案 会 省略 它们,也 绝不能 把 未经 授权 的 行 发送给 外部 模型。

保留 原始 向量 rank 和 词法 rank 以便 诊断。记录 候选 ID、索引 模式、probes 或 搜索 设置、reranker 模型 版本 和 最终 ID,并 对 敏感 文本 进行 保护 或 脱敏。Prompt 注入 与 MCP 安全 指南解释 了 为什么 检索 文本 是 数据 而 非 指令。文档 可能 包含 prompt injection,所以 生成 prompt 必须 明确指出 检索 段落 是 不可 信 证据,不能 改变 工具、权限 或 系统 规则。

引用 应该 是 数据,而 不是 装饰

每个 展示 的 引用 都 应 解析 到 检索 到 的 document_idrevisionchunk_nosource_uri,以及 来源 偏移 或 标题。让 生成器 在 论断 旁 返回 引用 ID,然后 把 这些 ID 与 实际 送入 上下文 的 行 进行 校验。标题 和 URL 从 数据库 渲染。未知 ID 应 被 删除,或 把 论断 标为 没有 支持。不要 让 模型 仅凭 文档 标题 制造 URL。

传递 足够 的 相邻 上下文 来 解释 结果,但 保留 每个 chunk 的 ID。如果 为了 模型 而 合并 相邻 chunk,也 要 保留 每 一段 的 来源。当 来源 变化 时,旧 修订 的 引用 不应 在 当前 回答 中 继续 解析。在 高风险 领域 展示 修订 日期、访问 范围,并 在 缺少 证据 时 明确 显示“没有 答案”。

分别 衡量 检索 质量 和 回答 质量

从 真实 问题、预期 来源 文档、可 接受 的 无 答案 情况、租户 身份、ACL 标签 和 语言 建立 带 版本 的 评估 集。加入 精确 查找、改写、多 跳 问题、过期 文档 问题 和 对抗 请求。保留 私有 测试 集,避免 对 开发阶段 使用 的 示例 过 拟合。

对 检索,测量 多个 cutoff 的 recall@k、用于 排序 的 reciprocal rank 或 nDCG,以及 满足 授权 契约 的 结果 比例。对 生成,测量 有 上下文 依据 的 论断 precision、引用 precision 和 coverage、回答 完整性、拒答 质量 以及 no-answer 校准。含糊 问题 仍然 需要 人工 复核。在 同一个 snapshot 上 比较 精确 搜索 与 HNSW 或 IVFFlat,量化 recall 损失,不要 从 latency 猜测 它。

运行 负面 的 安全 测试。用户 不 应 获得 另 一个 租户 的 已知 秘密,ACL 变化 应 在 下 一次 请求 中 生效,删除 的 文档 也 不应 在 删除 后 立即 出现。回答 质量 分数 高 并 不能 抵消 一次 chunk 泄漏。每次 评估 都 记录 embedding 模型、分块 版本、索引 设置、reranker 版本、prompt 版本 和 语料 修订,让 回归 有 可 复现 的 原因。

让 更新 和 删除 可 观察 且 安全

使用 内容 hash 让 摄取 幂 等。解析 并 分块 文档,批量 创建 embedding,在 移动 文档 指针 之前 写入 新 修订。指针 切换 可以 是 一个 短 事务,因此 读者 只会 看到 完整 的 旧 修订 或 完整 的 新 修订。每行 保存 embedding 模型,并 在 模型 变化 时 安排 backfill。不要 在 同一个 列中 混用 不同 维度 的 向量。

BEGIN;
SELECT set_config('app.tenant_id', $1::text, true);

INSERT INTO rag_chunks (
  tenant_id, document_id, revision, chunk_no, title, content,
  source_start, source_end, acl, embedding, embedding_model
)
VALUES ($1::uuid, $2::uuid, $3::bigint, $4::integer, $5::text, $6::text,
        $7::integer, $8::integer, $9::text[], $10::vector, $11::text)
ON CONFLICT (tenant_id, document_id, revision, chunk_no) DO UPDATE
SET title = EXCLUDED.title,
    content = EXCLUDED.content,
    source_start = EXCLUDED.source_start,
    source_end = EXCLUDED.source_end,
    acl = EXCLUDED.acl,
    embedding = EXCLUDED.embedding,
    embedding_model = EXCLUDED.embedding_model,
    updated_at = now();

INSERT INTO rag_documents (
  tenant_id, document_id, current_revision, source_uri, deleted_at
)
VALUES ($1::uuid, $2::uuid, $3::bigint, $12::text, NULL)
ON CONFLICT (tenant_id, document_id) DO UPDATE
SET current_revision = EXCLUDED.current_revision,
    source_uri = EXCLUDED.source_uri,
    deleted_at = NULL;

COMMIT;

这 段 代码 只 插入 一个 chunk。真正 的 loader 会 在 更新 指针 前 插入 该 修订 的 全部 chunk,并 检查 预期 行 数。删除 时,先 在 同一个 授权 边界 中 设置 deleted_at,让 文档 对 检索 不 可见。按照 产品 和 合规 策略 要求 的 保留 期,再 物理 删除 chunk。分批 清理 旧 修订,监视 dead tuples,并 在 需要 时 运行 VACUUM (ANALYZE)。大量 轮换 后 HNSW 可能 让 vacuum 变得 昂贵。在 vacuum 前 并发 重建 受 影响 索引 有时 能 降低成本,但 必须 在 实际 表上 测量。

为 每个 阶段 规划 延迟 和 成本

分别 跟踪 查询 规范化、embedding、词法 搜索、向量 搜索、融合、重排、生成 和 引用 校验 的 p50、p95 与 p99 延迟。还要 记录 候选 数、被 过滤 行 数、token 数、重试 和 缓存 命中。embedding 提供商 的 timeout 可能 掩盖 一个 很快 的 向量 查询。如果 retriever 把 太 多 段落 发送给 reranker 或 生成器,便宜 的 检索 也 会 变贵。

批量 生成 文档 embedding,并 使用 有 界 backoff 重试。只 缓存 稳定 且 不 敏感 的 产物,把 模型 版本、归一化 和 租户 范围 放进 缓存 键。查询 embedding 的 缓存 需要 隐私 评审,因为 重复 查询 可能 暴露 不同 用户 的 兴趣。根据 实测 recall、内存、构建 时间、写入 行为 和 尾 延迟 选择 HNSW 或 IVFFlat,不要 根据 通用 benchmark。让 每条 路由 或 每个 租户 都 可以 配置 候选 上限 和 reranker 预算。

生产 仪表盘 应 显示 空 结果 率、无 引用 回答 率、引用 校验 失败、ACL 拒绝、旧 修订 命中、索引 构建 状态、vacuum 健康 度,以及 近似 对 精确 recall 的 抽样。这样,AI 代理 可 观测 性就 能 和 AI 代理 评估连接起来。应该 对 这些 比例 的 变化 报警,而 不 只是 对 数据库 CPU 报警。

实际上 线 顺序

先 在 小 而 有 代表性 的 语料 上 使用 精确 向量 搜索 和 PostgreSQL 全文 搜索。在 接入 真实 租户 前 加入 修订 指针 和 RLS。冻结 带 标注 的 评估 集,再 把 HNSW 和 IVFFlat 与 精确 结果 比较。逐 阶段 加入 RRF、reranking 和 引用,使 每个 改动 都 有 可 测量 效果。扩大 上线 前 测试 更新、删除、ACL 变化、连接池 复用 和 索引 构建 失败。

官方 pgvector 文档介绍 向量 类型、距离 操作符、HNSW、IVFFlat、过滤 搜索、iterative scans、分区 和 维护。PostgreSQL 文档 介绍全文 搜索文本 GIN 与 GiST 索引行级 安全策略索引 创建分区MVCC。上面 的 SQL 和 取舍 遵循 这些 接口,并 不 声称 存在 通用 的 延迟、recall 或 成本 结果。

更多文章