找回密码
立即注册
搜索
发回帖 发新帖

6670

积分

0

好友

827

主题
发表于 12 小时前 | 查看: 1| 回复: 0

同一个制度问题,系统引用了三段劳动合同条文和一段食堂管理规定。值班同学把 topK 从 3 加到 10,候选更多,答案却更不稳定。尝试回滚 Embedding 模型时,旧索引还在,但旧分块、旧词法器和旧模型路由已经无法组成一次可重放的请求。

这不是一篇“如何把文本写进向量库”的教程。面向 Java 后端、平台工程师和 SRE,本文把 企业 RAG 当作一套受权限、版本和容量约束的检索系统,回答三个生产问题:

  1. 正确证据究竟在哪一层丢失?
  2. 哪些文档有资格进入某个用户的候选集?
  3. 一次发布失败时,能否恢复到上一套完整、可审计、仍服从当前权限的配置?

示例栈为 JDK 17、Spring Boot 3.5.x、PostgreSQL 16、pgvector 0.8.2、1024 维余弦检索。依赖版本由 BOM、数据库镜像 digest 与部署清单锁定;升级任一核心组件前,重新执行本文的契约测试和评测集。Spring AI 1.0.9 Getting Started · pgvector

文中 38 万篇文档、1,240 万切片、峰值约 120 QPS 只用于说明量级,不构成性能承诺。所有阈值、容量与超时都是压测起点,必须在自己的语料、权限分布、模型和硬件上复核。


1. 先定位证据丢失,而不是先调大 TopK

一次回答质量回归通常不是一个问题,而是五层链路之一断裂:

现象 首先验证 需要保存的证据 常见误动作
新文档能答、旧文档失真 每代覆盖率、模型/维度/分块版本是否对应 按文档年龄分桶的 Hit@K、清单水位 只重建 HNSW
单租户只返回 0~2 条 同权限域内的精确基线、ANN 实际候选数 执行计划、过滤行数、Recall@K 盲目增大 ef_search
编号查不到 精确编号字段与路由是否命中 规范化值、B-tree 命中、第三路贡献 用语义检索补救编号
TopK 增大后引用更乱 标注证据是否经过重排与 token 预算 每层证据 ID、上下文 token、淘汰原因 继续加候选
撤权后仍可能答出内容 结果、缓存、引用打开与流式在途的 ACL 检查 授权版本、撤权时间、取消记录 只给 SQL 加租户条件

不同维度向量不能在同一固定维度列中混算。1536 → 1024 的故障先查类型、写入与查询范围;同维度模型切换则可能正常返回距离,却已发生语义退化。两类问题的探针、回滚和评测都不同。

诊断原则:记录每个请求在五层中的候选 ID 集合、淘汰原因、发布 ID 和时延。若第一个可用证据在重排阶段丢失,调高 ANN 参数只会增加无效工作。

2. 运行时不是“一个索引”,而是一份不可变发布清单

2.1 两条数据路径

摄入路径先写不可见暂存数据,完整性校验通过后才更新可见指针。在线路径在请求起点解析一份已启用发布;检索到生成始终使用该发布的模型、表族与参数。ACL、下架状态和撤权版本读取当前权威授权数据,而不是随发布回滚到旧权限。

2.2 发布清单必须持久化

YAML 可以是部署输入,但不能是唯一事实来源。将一份不可变清单落入数据库或受审计的配置仓库,并让请求携带其 release_id。下列模型把“能否回滚”变成可查询事实:

CREATE TABLE rag_release (
  release_id              text PRIMARY KEY,
  generation              bigint NOT NULL,
  vector_family           text NOT NULL,
  embedding_revision      text NOT NULL,
  dimension               integer NOT NULL CHECK (dimension > 0),
  metric                  text NOT NULL CHECK (metric IN ('cosine', 'l2', 'ip')),
  query_normalizer_rev    text NOT NULL,
  chunker_revision        text NOT NULL,
  analyzer_revision       text NOT NULL,
  retrieval_profile       jsonb NOT NULL,
  reranker_revision       text NOT NULL,
  prompt_revision         text NOT NULL,
  source_watermark        text NOT NULL,
  manifest_sha256         text NOT NULL UNIQUE,
  status                  text NOT NULL CHECK (status IN ('building', 'shadow', 'active', 'rollback', 'retired')),
  created_at              timestamptz NOT NULL DEFAULT now(),
  activated_at            timestamptz,
  retired_at              timestamptz
);

CREATE TABLE rag_release_route (
  route_key text PRIMARY KEY,
  release_id text NOT NULL REFERENCES rag_release,
  stable_key text NOT NULL,
  enabled boolean NOT NULL DEFAULT false
);

release_id 解析后生成不可变 RequestSnapshot:

request_id + release_id + generation + vector_family
+ embedding_revision + analyzer_revision + retrieval_profile
+ authz_version + source_watermark

vector_family 只能经服务内白名单映射到已部署 Repository 和物理表族,不能由请求参数直接决定表名。模型别名必须解析到固定端点/部署版本。查询向量缓存至少按 embedding_revision + query_normalizer_rev 隔离;含私有证据的检索和答案默认不共享缓存。若业务开启共享缓存,命中后必须按当前 ACL 逐证据复核。

2.3 Spring AI 的职责边界

Spring AI 的 EmbeddingModel 与 ChatClient 适合统一模型接入和流式生成;PgVectorStore 可用于简单向量检索和元数据过滤。本设计的主查询使用 JDBC 与迁移脚本,原因是需要精确掌控 RLS、发布清单、三路融合 SQL 和执行计划。

spring:
  datasource:
    url: ${RAG_JDBC_URL}
    username: ${RAG_DB_USER}
    password: ${RAG_DB_PASSWORD}
    hikari:
      maximum-pool-size: 8
      connection-timeout: 250
  ai:
    retry:
      max-attempts: 1
    openai:
      embedding:
        base-url: ${EMBEDDING_BASE_URL}
        api-key: ${EMBEDDING_API_KEY}
        options: { model: ${EMBEDDING_MODEL} }
      chat:
        base-url: ${CHAT_BASE_URL}
        api-key: ${CHAT_API_KEY}
        options: { model: ${CHAT_MODEL} }

样例关闭框架自动重试,由在线编排器根据剩余 deadline 决定是否重试或词法降级。Chat、Embedding 分开部署时,应分别配置端点、超时与连接池。发布探针实测输出长度、有限值、非零范数和目标部署版本,不能只相信模型名。Embedding API · ChatClient

3. 数据模型:当前授权、不可变版本与可见指针分离

3.1 表职责

表 生命周期 作用
rag_doc 当前态 当前 ACL、删除墓碑、权威源版本
rag_doc_version 不可变 源对象 URI、内容哈希、解析器、页码/锚点映射、摄入清单
rag_parent / rag_chunk 随 generation 保留 父块、子块、全文字段与向量
rag_head 可变指针 每个 generation 下每篇文档的已发布版本
rag_release 不可变 可审计的完整运行时选择
CREATE EXTENSION IF NOT EXISTS vector;

CREATE TABLE rag_doc (
  tenant_id bigint NOT NULL,
  doc_id text NOT NULL,
  source_ver bigint NOT NULL,
  deleted boolean NOT NULL DEFAULT false,
  acl_subjects text[] NOT NULL DEFAULT '{}',
  authz_version bigint NOT NULL DEFAULT 0,
  PRIMARY KEY (tenant_id, doc_id)
);

CREATE TABLE rag_doc_version (
  tenant_id bigint NOT NULL,
  doc_id text NOT NULL,
  doc_ver bigint NOT NULL,
  source_uri text NOT NULL,
  content_sha256 text NOT NULL,
  parser_revision text NOT NULL,
  anchor_manifest jsonb NOT NULL,
  ingest_manifest_sha256 text NOT NULL,
  created_at timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (tenant_id, doc_id, doc_ver),
  FOREIGN KEY (tenant_id, doc_id) REFERENCES rag_doc
);

CREATE TABLE rag_head (
  generation bigint NOT NULL,
  tenant_id bigint NOT NULL,
  doc_id text NOT NULL,
  doc_ver bigint NOT NULL,
  PRIMARY KEY (generation, tenant_id, doc_id),
  FOREIGN KEY (tenant_id, doc_id, doc_ver) REFERENCES rag_doc_version
);

CREATE TABLE rag_parent (
  generation bigint NOT NULL,
  tenant_id bigint NOT NULL,
  doc_id text NOT NULL,
  doc_ver bigint NOT NULL,
  parent_no integer NOT NULL,
  heading text NOT NULL,
  source_ref text NOT NULL,
  body text NOT NULL,
  PRIMARY KEY (generation, tenant_id, doc_id, doc_ver, parent_no),
  FOREIGN KEY (tenant_id, doc_id, doc_ver) REFERENCES rag_doc_version
);

CREATE TABLE rag_chunk (
  generation bigint NOT NULL,
  tenant_id bigint NOT NULL,
  doc_id text NOT NULL,
  doc_ver bigint NOT NULL,
  chunk_no integer NOT NULL,
  parent_no integer NOT NULL,
  body text NOT NULL,
  lexemes text NOT NULL,
  exact_keys text[] NOT NULL DEFAULT '{}',
  search_tsv tsvector GENERATED ALWAYS AS
    (to_tsvector('pg_catalog.simple'::regconfig, lexemes)) STORED,
  embedding vector(1024) NOT NULL CHECK (vector_norm(embedding) > 0),
  PRIMARY KEY (generation, tenant_id, doc_id, doc_ver, chunk_no),
  FOREIGN KEY (generation, tenant_id, doc_id, doc_ver, parent_no)
    REFERENCES rag_parent (generation, tenant_id, doc_id, doc_ver, parent_no)
) PARTITION BY LIST (generation);

CREATE TABLE rag_chunk_g101 PARTITION OF rag_chunk FOR VALUES IN (101);
CREATE INDEX rag_chunk_g101_hnsw ON rag_chunk_g101
  USING hnsw (embedding vector_cosine_ops) WITH (m = 16, ef_construction = 128);
CREATE INDEX rag_chunk_g101_fts ON rag_chunk_g101 USING gin (search_tsv);
CREATE INDEX rag_chunk_g101_exact ON rag_chunk_g101 USING gin (exact_keys);
CREATE INDEX rag_doc_acl_gin ON rag_doc USING gin (acl_subjects);
CREATE INDEX rag_head_lookup ON rag_head (generation, tenant_id, doc_id, doc_ver);

exact_keys 存放经同一规范化器处理的工单号、SKU、合同号等,例如 INC-0042 与 inc0042 的规范化规则必须在摄入和查询端完全一致。高频撤权、主体数极多或 ACL 需要审计的系统,应将数组替换为规范化的 ACL 关联表;不要假设 GIN 能替代针对真实主体分布的压测。

父块按标题、条款、列表和表格边界切分;子块可从 200~350 token、父块从 700~1,200 token 起测。表格要保留列名与单位,双栏 PDF 与扫描件必须抽检文本顺序和源定位。命中子块后优先取相关窗口,再按 token 预算决定是否附带完整父块。

4. 安全:RLS 是租户护栏,ACL 是文档门禁

查询角色必须不是超级用户、表所有者或 BYPASSRLS 角色。迁移角色与摄入角色分离;查询角色只能从父表入口读取,不能直接访问分区。RLS 限制租户,SQL 再强制文档 ACL,二者不能互相替代。应用登录角色不继承 rag_query 的权限,而是在每个检索事务中显式切换到该角色。

CREATE ROLE rag_query NOLOGIN NOINHERIT;
-- 仅将其授予应用的专用登录角色;不要授予迁移或摄入角色。
GRANT rag_query TO rag_app_login WITH SET TRUE;
GRANT USAGE ON SCHEMA public TO rag_query;
GRANT SELECT ON rag_doc, rag_doc_version, rag_head, rag_parent, rag_chunk TO rag_query;

ALTER TABLE rag_doc ENABLE ROW LEVEL SECURITY;
ALTER TABLE rag_doc_version ENABLE ROW LEVEL SECURITY;
ALTER TABLE rag_head ENABLE ROW LEVEL SECURITY;
ALTER TABLE rag_parent ENABLE ROW LEVEL SECURITY;
ALTER TABLE rag_chunk ENABLE ROW LEVEL SECURITY;

CREATE POLICY tenant_read_doc ON rag_doc FOR SELECT TO rag_query
  USING (tenant_id = NULLIF(current_setting('rag.tenant_id', true), '')::bigint);
CREATE POLICY tenant_read_docver ON rag_doc_version FOR SELECT TO rag_query
  USING (tenant_id = NULLIF(current_setting('rag.tenant_id', true), '')::bigint);
CREATE POLICY tenant_read_head ON rag_head FOR SELECT TO rag_query
  USING (tenant_id = NULLIF(current_setting('rag.tenant_id', true), '')::bigint);
CREATE POLICY tenant_read_parent ON rag_parent FOR SELECT TO rag_query
  USING (tenant_id = NULLIF(current_setting('rag.tenant_id', true), '')::bigint);
CREATE POLICY tenant_read_chunk ON rag_chunk FOR SELECT TO rag_query
  USING (tenant_id = NULLIF(current_setting('rag.tenant_id', true), '')::bigint);

应用在显式事务中以认证后的租户写入 SET LOCAL 变量。能运行任意 SQL 的应用数据库角色也能伪造该变量,因此仍需参数化 SQL、数据库凭据隔离、受控 Repository 和禁止对外暴露 SQL 接口。RLS 默认对表所有者不生效;因此必须在部署验收中检查 rolbypassrls、所有权和直连分区权限。PostgreSQL RLS

最小验证应覆盖:未设置租户为零行、设置租户只见本租户、SET ROLE rag_query 有权限、查询角色不能读取分区入口、撤权后原命中文档立即不可见。

5. 摄入:所有块完成后,才移动可见指针

文档事件至少带 tenant_id、doc_id、单调递增 source_ver、不可变源对象版本和内容哈希。确定性主键由 generation、租户、文档、源版本、切片序号组成;同主键内容摘要不一致时阻断发布,排查解析器漂移。

阶段 事务边界 并发与重试约束
登记任务 短事务 保存预期块数、切片计划摘要、fencing token;旧事件跳过
解析与向量化 事务外 按条数和 token 双限批;校验返回顺序、维度、有限值、非零范数
暂存写入 短事务 数据与进度同事务;先校验租约和 fencing token;冲突比对摘要
发布 单事务 锁 rag_doc,复核源版本、未删除、清单完整,更新 rag_head 与 Outbox
下架 单事务 写墓碑、提升源版本、清除所有 generation 指针;历史物理数据延迟回收
INSERT INTO rag_head AS h (generation, tenant_id, doc_id, doc_ver)
VALUES (:generation, :tenant, :doc, :doc_ver)
ON CONFLICT (generation, tenant_id, doc_id) DO UPDATE
SET doc_ver = EXCLUDED.doc_ver
WHERE h.doc_ver < EXCLUDED.doc_ver;

发布事务要检查该语句影响行数;零行并不总是成功,必须区分“同版本已发布”与“旧任务试图覆盖新版本”。在提交前与提交后各杀一次 worker,验证前者仍读旧完整版本、后者可幂等确认。测试乱序 v11/v12、租约过期、删除后重建和同版本解析漂移。

6. 在线检索:精确、词法、向量三路在授权域内融合

6.1 路由规则

路径 适用问题 候选 不可接受的降级
精确路 工单号、SKU、合同号、明确 ID 规范化字段/B-tree 或 GIN 把编号完全交给 embedding
词法路 条款词、罕见术语、模型不可用 tsvector + GIN ACL 故障时继续搜索
向量路 意图表达、同义改写、自然语言 HNSW + 迭代扫描 向量模型与 generation 不匹配

编号识别不是授权绕过:三条路径都必须经过同一 eligible 范围。Embedding 不可用时,只能在词法/精确路径健康、剩余时间足够且证据闸门仍能通过时降级;ACL、RLS、发布解析或引用校验异常一律失败关闭。

6.2 参考 SQL

eligible 统一限定发布代、租户、当前可见版本与当前 ACL。生产中以真实 rag_query 角色和绑定参数做 EXPLAIN (ANALYZE, BUFFERS);下列 SQL 是逻辑基线,而非所有数据分布下的最优计划。

WITH eligible AS NOT MATERIALIZED (
  SELECT c.*
  FROM rag_chunk c
  WHERE c.generation = :generation
    AND c.tenant_id = :tenant
    AND EXISTS (
      SELECT 1
      FROM rag_head h
      JOIN rag_doc d USING (tenant_id, doc_id)
      WHERE h.generation = c.generation
        AND h.tenant_id = c.tenant_id
        AND h.doc_id = c.doc_id
        AND h.doc_ver = c.doc_ver
        AND NOT d.deleted
        AND d.acl_subjects && ARRAY(
          SELECT jsonb_array_elements_text(CAST(:subjects_json AS jsonb))
        )
    )
),
q AS (
  SELECT plainto_tsquery('pg_catalog.simple'::regconfig, :lexemes) AS tsq
),
exact_hits AS MATERIALIZED (
  SELECT doc_id, doc_ver, chunk_no,
    row_number() OVER (ORDER BY doc_id, doc_ver, chunk_no) AS rank
  FROM eligible
  WHERE exact_keys && ARRAY(
    SELECT jsonb_array_elements_text(CAST(:exact_keys_json AS jsonb))
  )
  LIMIT :exact_k
),
vector_hits AS MATERIALIZED (
  SELECT doc_id, doc_ver, chunk_no,
    row_number() OVER (ORDER BY embedding <=> CAST(:qvec AS vector(1024)),
                                      doc_id, doc_ver, chunk_no) AS rank
  FROM eligible
  WHERE CAST(:qvec AS vector(1024)) IS NOT NULL
  ORDER BY embedding <=> CAST(:qvec AS vector(1024))
  LIMIT :vector_k
),
lexical_hits AS MATERIALIZED (
  SELECT e.doc_id, e.doc_ver, e.chunk_no,
    row_number() OVER (ORDER BY ts_rank_cd(e.search_tsv, q.tsq) DESC,
                                      e.doc_id, e.doc_ver, e.chunk_no) AS rank
  FROM eligible e CROSS JOIN q
  WHERE e.search_tsv @@ q.tsq
  ORDER BY ts_rank_cd(e.search_tsv, q.tsq) DESC, e.doc_id, e.doc_ver, e.chunk_no
  LIMIT :lexical_k
),
contributions AS (
  SELECT doc_id, doc_ver, chunk_no, 2.0 / (20 + rank) AS weight FROM exact_hits
  UNION ALL
  SELECT doc_id, doc_ver, chunk_no, 1.0 / (60 + rank) FROM vector_hits
  UNION ALL
  SELECT doc_id, doc_ver, chunk_no, 1.0 / (60 + rank) FROM lexical_hits
),
fused AS (
  SELECT doc_id, doc_ver, chunk_no, sum(weight) AS rrf
  FROM contributions GROUP BY doc_id, doc_ver, chunk_no
),
one_per_parent AS (
  SELECT c.doc_id, c.doc_ver, c.chunk_no, c.parent_no, c.body, f.rrf,
    row_number() OVER (
      PARTITION BY c.doc_id, c.doc_ver, c.parent_no
      ORDER BY f.rrf DESC, c.chunk_no) AS parent_rank
  FROM fused f JOIN rag_chunk c
    ON c.generation = :generation AND c.tenant_id = :tenant
    AND c.doc_id = f.doc_id AND c.doc_ver = f.doc_ver AND c.chunk_no = f.chunk_no
)
SELECT c.doc_id, c.doc_ver, c.chunk_no, c.parent_no, c.body AS matched_body,
       p.body AS parent_body, p.heading, p.source_ref, c.rrf
FROM one_per_parent c JOIN rag_parent p
  ON p.generation = :generation AND p.tenant_id = :tenant
  AND p.doc_id = c.doc_id AND p.doc_ver = c.doc_ver AND p.parent_no = c.parent_no
WHERE c.parent_rank = 1
ORDER BY c.rrf DESC, c.doc_id, c.doc_ver, c.chunk_no
LIMIT :fused_k;

精确路的权重、RRF 常数和三路 K 都是评测参数,而不是常量真理。plainto_tsquery 是 AND 语义的保守基线;长自然语言导致词法空召回时,应用经过评测的词提取或受控 OR 路由。HNSW 过滤发生在近似扫描之后,低选择性租户、ACL 和废弃版本会使候选不足;可以扫描 ef_search、迭代扫描预算、热租户分区与小租户精确排序方案。pgvector filtering

6.3 JDBC 事务与连接边界

List<Hit> search(RequestSnapshot snapshot, AuthenticatedPrincipal principal,
                 String qvec, String lexemes, String exactKeysJson) {
    var args = new MapSqlParameterSource()
        .addValue("tenant", principal.tenantId())
        .addValue("generation", snapshot.generation())
        .addValue("subjects_json", principal.subjectsJson(), Types.VARCHAR)
        .addValue("exact_keys_json", exactKeysJson, Types.VARCHAR)
        .addValue("qvec", qvec, Types.VARCHAR)
        .addValue("lexemes", lexemes, Types.VARCHAR)
        .addValue("exact_k", 20).addValue("vector_k", 50)
        .addValue("lexical_k", 50).addValue("fused_k", 30);

    return readOnlyTx.execute(status -> {
        jdbc.getJdbcTemplate().execute("SET LOCAL ROLE rag_query");
        jdbc.getJdbcTemplate().queryForObject(
            "SELECT set_config('rag.tenant_id', ?, true)", String.class,
            Long.toString(principal.tenantId()));
        jdbc.getJdbcTemplate().execute("SET LOCAL statement_timeout = '120ms'");
        jdbc.getJdbcTemplate().execute("SET LOCAL hnsw.ef_search = 100");
        jdbc.getJdbcTemplate().execute("SET LOCAL hnsw.iterative_scan = strict_order");
        return jdbc.query(SQL, args, hitMapper);
    });
}

TransactionTemplate、NamedParameterJdbcTemplate 必须绑定同一 DataSource。向量生成发生在进入数据库事务前,先验证维度、有限值和非零范数。语句超时会使当前事务不可继续使用;若剩余预算允许另一种查询,结束该事务后再开受控新事务。Hikari 的 connectionTimeout 最小值为 250 ms,若业务需要更短排队预算,应在借连接前以与池容量对齐的许可器快速拒绝。HikariCP configuration

7. 从候选到回答:重排、证据闸门、引用与 SSE

  1. 重排输入问题、标题和命中子块,严格限制批次和超时。重排超时可回退 RRF,但回退结果仍要通过独立校准的证据闸门。
  2. 上下文按父块去重,优先保留命中窗口。使用生成模型对应 tokenizer,预算扣除系统指令、历史、格式、输出预留和安全余量。可从 3,000 token、最多 5 个证据窗口起测。
  3. 为每条已授权证据分配服务端 ID(E1、E2)。模型只能输出这些 ID;应用分别验证“ID 存在”与“主张受证据支持”。候选为空、冲突或覆盖不足时,拒答、补问或转人工。
  4. 文档是外部不可信输入。使用明确分隔与系统约束;模型输出不能扩大权限、执行链接或触发工具。

Spring AI 可通过 chatClient.prompt().system(policy).user(input).stream().content() 流式生成。WebFlux 路径中 JDBC 与同步 Embedding 进入受限 blocking executor;模型流绑定取消、限流许可与超时。网关应关闭该 SSE 路由的响应缓冲,分别设置 idle 超时和总时限。TTFT 从入口到第一个可见正文 token 计时;主动断连测试必须确认上游模型调用和在途许可均回收。流已输出后,以结构化错误事件结束,不能假装回答完整。

故障 可否降级 用户面行为
Embedding 不可用 可以,限精确/词法且证据闸门通过 正常回答或明确暂不可答
重排超时 可以,回退已评测的 RRF 记录降级路径
ACL/RLS/发布解析失败 不可以 安全失败,不返回证据
引用校验失败 不可以直接输出 拒答或转人工
SSE 中撤权 取消尚未输出部分 记录授权版本与撤权窗口

8. 蓝绿发布与回滚:旧空间必须持续追平

阶段 操作 晋级条件
建绿代 建新分区/表族,锁定模型、分块、词法器和清单 类型、维度、索引、模型路由探针通过
全量回填 从一致快照读取,随后从相同事件水位消费增量 逐租户文档/块数与清单摘要一致
影子验证 相同请求、授权、时间点比较两代 质量、延迟、容量均达门禁
灰度读 以租户或会话稳定分流;请求固定 release_id 重点观察 ACL、最新文档、编号、短问题
回滚窗口 两代继续同步增量,保留旧模型/索引/清单 旧代水位未落后服务契约
退役 停写、排空请求和缓存、通过回滚演练后清理 记录责任人、时间与可恢复窗口

发布选择与文档可见性是两个维度:发布代决定模型空间和检索参数;rag_head 决定该代中某文档的完整已发布版本。撤权永远读取当前 rag_doc。若要求撤权阻止已完成检索但尚未发模型的回答,发模型前再次检查当前 ACL;若要求中断在途流,需由授权版本变化事件取消请求。已经发送给客户端的内容无法收回,应把这一窗口写入服务契约。

9. 评测、容量与验收

9.1 指标必须先定义分母

指标 定义
ANN Recall@K 同授权、同模型、同版本下 ANN 与精确向量 TopK 的交集 ÷ 精确结果数
证据 Recall@K 命中的不同标注证据数 ÷ 该题全部标注证据数,逐题宏平均
Hit@K 至少命中一条标注证据的题数 ÷ 有标注证据的题数
上下文证据保留率 进入 Prompt 的标注证据数 ÷ 已召回的标注证据数;零分母单列
引用精确率 正确支持主张的引用数 ÷ 已给出引用数
忠实度/拒答正确率 分开标注可答、不可答、授权不足和检索不可用
SQL/检索/TTFT/完成耗时 分别量取数据库、检索编排、首正文和总完成时间

标注以“源文档版本 + 页码或段落锚点”为准,经覆盖映射再判断切片命中;切块改变不能悄悄改变评测分母。评测集应覆盖真实工单、条款问答、跨段组合、编号、近期更新、过期版本、不可答、跨租户与撤权。历史样本与合成样本分开报告,调参集与留出集按文档族隔离。

9.2 消融与结果报告模板

实验 固定项 变化项 必报结果
A:精确 vs HNSW 数据、ACL、模型、K HNSW 参数 ANN Recall、SQL P99、计划
B:三路融合 候选预算、权限、重排关闭 精确/词法/向量/RRF 分问题类型的证据 Recall、Hit
C:重排与上下文 固定候选集 重排、窗口、token 保留率、忠实度、TTFT、token
D:容量 语料、到达率、并发 冷暖缓存、回填并行 成功率、超时率、候选缺口、恢复时间

每次报告至少记录:样本量、语料与授权快照、硬件、PostgreSQL/pgvector 镜像 digest、模型部署、索引参数、缓存状态、并发、重复次数、成功样本分位数以及超时/拒绝比例。以下只是一张应由团队用真实数据填充的结果表,不应伪装为通用结论:

方案 证据 Recall@10 SQL P99 TTFT P95 超时率 结论
精确基线 <测量值> <测量值> <测量值> <测量值> 质量上界
HNSW ef=100 <测量值> <测量值> <测量值> <测量值> 候选方案
三路 + 重排 <测量值> <测量值> <测量值> <测量值> 上线候选

9.3 预算与值班路径

环节 起始预算 耗尽策略
查询 Embedding 100 ms 在剩余时间内走精确/词法
获取连接 + SQL 160 ms 许可器快速失败;SQL 局部超时
重排 120 ms 回退经验证的 RRF
TTFT 1.5 s 首字失败单列,检查剩余预算
SSE 总时限 15 s 停止上游生成并释放资源
告警 第一证据 首个动作
SQL P99 上升、CPU 平稳 区分连接获取与服务端 SQL;看 wait event、长事务 限制在途;必要时降低回填并发
候选骤降、SQL 正常 按租户/ACL 看 ANN 与精确结果数 精确有结果则查计划和扫描预算;否则查 ACL、head、水位
仅最新文档可答 每代覆盖率、清单、模型版本 停止晋级,切回已追平旧代
TTFT 上升、检索稳定 应用首正文与网关外首正文 查模型排队、代理缓冲、下游背压

10. 上线门禁

  • release_id 可解析为不可变清单;每次请求都记录发布、授权版本与事件水位。
  • RLS、跨租户、撤权、删除和引用打开均通过失败关闭测试。
  • 精确编号、词法和向量三路均在同一 ACL 范围内运行;每路贡献可追踪。
  • 重复、乱序、中断、删除后重建与解析器漂移只暴露完整已提交版本。
  • 真实查询角色和绑定参数下的计划已与同权限精确基线对照。
  • 新旧代覆盖率、事件水位、回滚窗口和旧代持续追平都有量化门禁。
  • 证据召回、忠实度、TTFT、完成耗时、超时率和拒答正确率独立验收。
  • 阻断 Embedding、拖慢 SQL、重排超时、终止 Pod、撤权和断开 SSE 的演练通过。

当质量回归发生时,使用同一个问题、同一份授权、同一个文档水位和同一个 release_id 重跑。只有旧模型、旧索引、旧分块、旧词法器、旧参数与当前权限能够一起工作时,“回滚”才不是切换一个配置名,而是真正可恢复的生产能力。




上一篇:Claude Fable 5.2 深度拆解:训练期主动设限与千级智能体协作实测
下一篇:滚动发布后订单 API 仍 502?Gin + K8s 摘流与幂等实战
您需要登录后才可以回帖 登录 | 立即注册

关闭

站长推荐上一条 /1 下一条

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-11-13 16:38 , Processed in 0.083855 second(s), 40 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表