Appearance
Document 元数据设计:决定你后面能过滤什么
1. 本节产出
一套完整的元数据字段规范(含字段清单、命名、索引建议),以及一个在灌库时统一注入元数据的组件。定下这套字段后,后面所有隔离能力(多租户、版本、权限、时效)都能实现。
2. 前置依赖
- 02-04 切分策略:已产出
titlePath - 02-06 向量库选型:向量库已就绪
3. 为什么元数据是「不可逆决策」
有个项目上线三个月后要求加多租户隔离。此时库里 80 万片段没有任何租户字段,只能:
- 全量重新解析 + 切分(原始文档还在,但耗时);
- 重新 Embedding(80 万条 × 单价,几千元);
- 期间系统要么停服,要么双写。
总代价:两周工期 + 几千元 + 一次停服。
而如果在灌库时多加一个字段:tenantId,成本是零。
这就是元数据设计的本质:它是你在灌库那一刻,为未来所有需求买的保险。你不知道将来要过滤什么,所以要现在就把能想到的维度都记下来。
4. 核心原理
4.1 元数据字段清单(推荐基线)
| 字段 | 类型 | 作用 | 是否必填 |
|---|---|---|---|
sourceId | String | 文档唯一标识,增量更新的锚点 | ✅ |
tenantId | String | 多租户隔离 | ✅ |
docType | String | 文档类型(policy/faq/manual) | ✅ |
version | String | 文档版本,用于取最新 | ✅ |
titlePath | String | 标题路径,用于展示溯源 | ✅ |
acl | List<String> | 权限标签(角色/部门) | 建议 |
validFrom / validTo | Date | 有效期,用于过滤过期文档 | 建议 |
lang | String | 语言 | 建议 |
chunkIndex | Int | 片段序号,用于还原顺序 | 建议 |
embeddingModel | String | 向量化模型标识 | ✅ |
ingestedAt | Timestamp | 灌库时间 | ✅ |
最后两个字段常被忽略但很重要:
embeddingModel:模型升级时能校验一致性,避免「查询用新版、库里是旧版」的静默错误;ingestedAt:排查问题时能知道这批数据是什么时候灌的。
4.2 三类过滤场景
1. 硬隔离(必须在检索前过滤)
tenantId == 'acme'
→ 不过滤就是数据泄露,属于安全问题
2. 权限过滤(按用户角色)
acl contains 'dept:finance' OR acl contains 'role:manager'
→ 不过滤会越权
3. 时效过滤(按有效期)
validFrom <= now AND (validTo IS NULL OR validTo >= now)
→ 不过滤会给出过期制度的答案这三类必须在检索阶段过滤,不能在生成阶段靠 prompt 说「只回答 2026 版的」。原因:靠 prompt 约束是不可靠的,模型可能仍然引用了被塞进上下文的旧内容;而检索阶段过滤是确定性的。
4.3 元数据 vs 文本:什么时候写进文本
| 信息 | 放元数据 | 放文本 | 原因 |
|---|---|---|---|
| tenantId | ✅ | ❌ | 过滤用,且不该让模型看到 |
| 权限标签 | ✅ | ❌ | 同上,且泄露给模型有风险 |
| titlePath | ✅ | ✅ | 过滤要,检索也要(写进文本才能被向量化) |
| 文档标题 | ✅ | ✅ | 同上 |
| 版本号 | ✅ | ⚠️ | 版本多时写进文本反而干扰 |
| 正文内容 | ❌ | ✅ | — |
双重写入 titlePath 是关键技巧:元数据里存一份用于过滤和展示,文本开头拼一份用于向量化。只存元数据的话,标题里的关键词对检索毫无帮助。
4.4 命名规范
| 规则 | 说明 |
|---|---|
| 统一驼峰 | tenantId 不用 tenant_id |
| 枚举用大写 | docType = POLICY |
| 列表用 JSON 数组 | acl = ["dept:finance","role:manager"] |
| 不要嵌套对象 | 过滤表达式难以表达,平铺即可 |
| 值不要为 null | 用空字符串或特定哨兵值,避免过滤表达式写不出来 |
5. 代码走查
5.1 元数据常量类
java
// src/main/java/com/example/rag/domain/DocumentMeta.java
public final class DocumentMeta {
public static final String SOURCE_ID = "sourceId";
public static final String TENANT_ID = "tenantId";
public static final String DOC_TYPE = "docType";
public static final String VERSION = "version";
public static final String TITLE_PATH = "titlePath";
public static final String ACL = "acl";
public static final String VALID_FROM = "validFrom";
public static final String VALID_TO = "validTo";
public static final String EMBEDDING_MODEL = "embeddingModel";
public static final String INGESTED_AT = "ingestedAt";
private DocumentMeta() {}
}用常量类而不是字符串字面量。过滤表达式拼错字段名的错误非常隐蔽——不报错,只是过滤没生效,返回了不该返回的数据。
5.2 灌库时统一注入
java
// src/main/java/com/example/rag/ingest/MetadataEnricher.java
@Component
public class MetadataEnricher {
public void enrich(List<Chunk> chunks, IngestRequest req) {
String now = Instant.now().toString();
int idx = 0;
for (Chunk c : chunks) {
Map<String, Object> m = c.meta();
// 必填字段:缺失直接失败,不允许静默通过
require(m, SOURCE_ID, req.sourceId());
require(m, TENANT_ID, req.tenantId());
require(m, VERSION, req.version());
m.put(DOC_TYPE, req.docType().name());
m.put(ACL, req.acl());
m.put(VALID_FROM, req.validFrom());
m.put(VALID_TO, req.validTo());
m.put(EMBEDDING_MODEL, embeddingModelName());
m.put(INGESTED_AT, now);
m.put("chunkIndex", idx++);
// titlePath 双重写入:拼进文本让向量也能捕捉标题语义
c.text("[%s] %s".formatted(m.get(TITLE_PATH), c.text()));
}
}
private void require(Map<String, Object> m, String key, Object value) {
if (value == null || value.toString().isBlank()) {
throw new IllegalArgumentException("元数据必填字段缺失:" + key);
}
m.put(key, value);
}
}require() 强制校验必填项是本节最重要的工程实践。宁可灌库失败,也不要灌进去一批缺失关键字段的数据——后者的排查成本高几个数量级。
5.3 过滤表达式构造
java
// src/main/java/com/example/rag/retrieval/FilterBuilder.java
public final class FilterBuilder {
/** 构造硬隔离 + 权限 + 时效的复合过滤 */
public static String build(String tenantId, List<String> userAcl, Instant now) {
StringBuilder sb = new StringBuilder();
// 1. 租户硬隔离——永远在最前面
sb.append(TENANT_ID).append(" == '").append(tenantId).append("'");
// 2. 权限: acl 包含用户任一个标签
if (!userAcl.isEmpty()) {
String acl = userAcl.stream()
.map(a -> ACL + " contains '" + a + "'")
.collect(Collectors.joining(" OR "));
sb.append(" AND (").append(acl).append(")");
}
// 3. 时效
String nowStr = now.toString();
sb.append(" AND (").append(VALID_FROM).append(" <= '").append(nowStr)
.append("' OR ").append(VALID_FROM).append(" == '')");
sb.append(" AND (").append(VALID_TO).append(" >= '").append(nowStr)
.append("' OR ").append(VALID_TO).append(" == '')");
return sb.toString();
}
}5.4 建索引(PGVector)
sql
-- 元数据 GIN 索引(多租户与权限过滤全靠它)
CREATE INDEX idx_chunk_meta ON vector_store USING GIN (metadata);
-- 高频单字段的表达式索引
CREATE INDEX idx_tenant ON vector_store ((metadata->>'tenantId'));
CREATE INDEX idx_source ON vector_store ((metadata->>'sourceId'));6. 跑起来
bash
git checkout ch02-07-metadata
docker compose up -d
mvn -q test -Dtest=MetadataIsolationTest验收测试(本节核心):灌入三个租户、两个版本、带权限标签的文档,然后验证过滤。
bash
# 1. 租户隔离
curl -X POST http://localhost:8080/api/ask \
-d '{"q":"年假怎么算","tenantId":"acme","acl":["dept:hr"]}'
# 期望:只返回 acme 的内容
# 2. 越权测试(关键)
curl -X POST http://localhost:8080/api/ask \
-d '{"q":"薪酬结构","tenantId":"acme","acl":[]}'
# 期望:查不到 dep:finance 权限的文档
# 3. 版本过滤
curl -X POST http://localhost:8080/api/ask \
-d '{"q":"报销标准","tenantId":"acme","acl":["dept:hr"],"version":"2026"}'
# 期望:只返回 2026 版,不返回 2025 版
# 4. 必填校验
# 灌一个缺 tenantId 的文档 → 期望失败并报错,而不是静默成功| 检查项 | 通过标准 |
|---|---|
| 租户隔离 | A 租户查不到 B 租户任何内容 |
| 权限过滤 | 无对应 acl 时查不到受限文档 |
| 版本过滤 | 只返回指定版本 |
| 必填校验 | 缺字段时灌库失败,日志指明缺哪个 |
| 索引生效 | 带过滤的检索 P95 < 100ms |
第四项是本节最重要的验收点:宁可失败,不要静默。
7. 生产避坑
- 必填字段缺失必须让灌库失败,不能降级为「先灌进去」。一批缺
tenantId的数据进了库,就等于全租户可见——这是数据泄露事故,而且你很难知道有哪些数据受影响。fail fast 在这里是安全要求,不是洁癖。 - 不要把权限标签写进片段文本。写进文本后,模型可能在回答里复述出内部权限信息(比如「该文档仅财务部可见」),既泄露又奇怪。权限只走元数据过滤。
acl字段的语义要想清楚:是「拥有任一标签即可」还是「必须全部满足」。绝大多数场景是「任一」(OR),但实现时很容易写成 AND,导致权限过严、用户什么都查不到。用评测集覆盖这个分支。
8. 延伸与锚点
- 思考题:用户上传了一份个人文档,只有他自己能看。这套元数据怎么表达?(提示:acl 里加
user:xxx——完整方案在 02-12 多租户隔离) - 代码锚点:
git checkout ch02-07-metadata - 02A 基础链路完成,下一课时进入 02B 检索质量:02-08 相似度与距离度量
- 对应课件:L02-07 元数据设计