同一个制度问题,系统引用了三段劳动合同条文和一段食堂管理规定。值班同学把 topK 从 3 加到 10,候选更多,答案却更不稳定。尝试回滚 Embedding 模型时,旧索引还在,但旧分块、旧词法器和旧模型路由已经无法组成一次可重放的请求。
这不是一篇“如何把文本写进向量库”的教程。面向 Java 后端、平台工程师和 SRE,本文把 企业 RAG 当作一套受权限、版本和容量约束的检索系统,回答三个生产问题:
- 正确证据究竟在哪一层丢失?
- 哪些文档有资格进入某个用户的候选集?
- 一次发布失败时,能否恢复到上一套完整、可审计、仍服从当前权限的配置?
示例栈为 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
- 重排输入问题、标题和命中子块,严格限制批次和超时。重排超时可回退 RRF,但回退结果仍要通过独立校准的证据闸门。
- 上下文按父块去重,优先保留命中窗口。使用生成模型对应 tokenizer,预算扣除系统指令、历史、格式、输出预留和安全余量。可从 3,000 token、最多 5 个证据窗口起测。
- 为每条已授权证据分配服务端 ID(
E1、E2)。模型只能输出这些 ID;应用分别验证“ID 存在”与“主张受证据支持”。候选为空、冲突或覆盖不足时,拒答、补问或转人工。
- 文档是外部不可信输入。使用明确分隔与系统约束;模型输出不能扩大权限、执行链接或触发工具。
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 重跑。只有旧模型、旧索引、旧分块、旧词法器、旧参数与当前权限能够一起工作时,“回滚”才不是切换一个配置名,而是真正可恢复的生产能力。