1. 元数据在 RAG 流程中的位置

2. 元数据的本质:给每个 chunk 贴标签

{
  "content": "自签收之日起 7 天内,商品未经使用且不影响二次销售的,消费者可申请七天无理由退货。",
  "metadata": {
    "doc_id": "doc_20240315_001",
    "source_url": "https://docs.company.com/policy/return.pdf",
    "file_name": "退货政策.pdf",
    "title": "一、退货政策",
    "page_number": 3,
    "created_at": "2024-03-15T10:30:00Z",
    "updated_at": "2024-03-15T10:30:00Z",
    "department": "customer_service",
    "access_roles": ["employee", "customer_service", "manager"],
    "start_offset": 0,
    "end_offset": 58,
    "chunk_index": 0
  }
}

1.3 适用场景与注意事项

维度

说明

什么时候必须有

几乎所有场景都需要,这是最基础的元数据

什么时候可以省略

如果你的知识库只有一份文档,或者文档来源不重要(比如爬虫抓的公开网页)

注意事项

doc_id 要保证唯一性;source_url 如果是内网地址,要确保用户有访问权限

元数据的三大核心应用场景

1. 回答可引用:让 AI 的回答有据可查

import org.springframework.ai.document.Document;
import java.util.*;

public class CitationExample {

    /**
     * 从 chunk 元数据生成引用信息
     */
    public static String generateCitation(Document chunk) {
        Map<String, Object> metadata = chunk.getMetadata();

        String fileName = (String) metadata.get("file_name");
        String title = (String) metadata.get("title");
        Integer pageNumber = (Integer) metadata.get("page_number");
        String sourceUrl = (String) metadata.get("source_url");

        StringBuilder citation = new StringBuilder();
        citation.append("**依据**: ");

        if (fileName != null) {
            citation.append("《").append(fileName).append("》");
        }

        if (title != null) {
            citation.append(" ").append(title);
        }

        if (pageNumber != null) {
            citation.append(",第 ").append(pageNumber).append(" 页");
        }

        if (sourceUrl != null) {
            citation.append("\n\n[查看原文](").append(sourceUrl).append(")");
        }

        return citation.toString();
    }

    public static void main(String[] args) {
        // 模拟一个检索到的 chunk
        Map<String, Object> metadata = new HashMap<>();
        metadata.put("file_name", "员工手册.pdf");
        metadata.put("title", "第二章 员工入职 > 2.1 试用期规定");
        metadata.put("page_number", 5);
        metadata.put("source_url", "https://docs.company.com/handbook.pdf#page=5");

        Document chunk = new Document(
            "新员工试用期为 3 个月,试用期内工资为正式工资的 80%。",
            metadata
        );

        String citation = generateCitation(chunk);
        System.out.println("Answer: " + chunk.getContent());
        System.out.println("\n" + citation);
    }
}

2. 权限过滤:不同员工看不同知识

import org.springframework.ai.document.Document;
import java.util.*;
import java.util.stream.Collectors;

public class PermissionFilterExample {

    /**
     * 模拟向量数据库中的 chunks
     */
    private static List<Document> mockChunks() {
        List<Document> chunks = new ArrayList<>();

        // Chunk 1: 公开信息
        Map<String, Object> meta1 = new HashMap<>();
        meta1.put("sensitivity_level", "public");
        meta1.put("access_roles", Arrays.asList("employee"));
        chunks.add(new Document("员工手册规定,所有员工享有带薪年假。", meta1));

        // Chunk 2: 人事部内部信息
        Map<String, Object> meta2 = new HashMap<>();
        meta2.put("sensitivity_level", "confidential");
        meta2.put("access_departments", Arrays.asList("hr", "executive"));
        chunks.add(new Document("年终奖为月薪的 2-6 倍,根据绩效等级确定。", meta2));

        // Chunk 3: 技术部可见信息
        Map<String, Object> meta3 = new HashMap<>();
        meta3.put("sensitivity_level", "internal");
        meta3.put("access_departments", Arrays.asList("tech", "product"));
        chunks.add(new Document("技术部员工可申请远程办公,每周最多 2 天。", meta3));

        return chunks;
    }

    /**
     * 根据用户权限过滤 chunks
     */
    public static List<Document> filterByPermission(
            List<Document> chunks,
            String userRole,
            String userDepartment) {

        return chunks.stream()
            .filter(chunk -> hasPermission(chunk, userRole, userDepartment))
            .collect(Collectors.toList());
    }

    /**
     * 判断用户是否有权限访问某个 chunk
     */
    private static boolean hasPermission(
            Document chunk,
            String userRole,
            String userDepartment) {

        Map<String, Object> metadata = chunk.getMetadata();

        // 公开信息,所有人都能看
        String sensitivity = (String) metadata.get("sensitivity_level");
        if ("public".equals(sensitivity)) {
            return true;
        }

        // 检查角色权限
        List<String> accessRoles = (List<String>) metadata.get("access_roles");
        if (accessRoles != null && accessRoles.contains(userRole)) {
            return true;
        }

        // 检查部门权限
        List<String> accessDepts = (List<String>) metadata.get("access_departments");
        if (accessDepts != null && accessDepts.contains(userDepartment)) {
            return true;
        }

        return false;
    }

    public static void main(String[] args) {
        List<Document> allChunks = mockChunks();

        // 场景 1: 技术部普通员工
        System.out.println("=== 技术部员工小李能看到的内容 ===");
        List<Document> techResults = filterByPermission(allChunks, "employee", "tech");
        techResults.forEach(chunk -> System.out.println("- " + chunk.getContent()));

        System.out.println("\n=== 人事部经理能看到的内容 ===");
        List<Document> hrResults = filterByPermission(allChunks, "manager", "hr");
        hrResults.forEach(chunk -> System.out.println("- " + chunk.getContent()));
    }
}

3. 回溯与纠错:发现错答能定位到源头

import org.springframework.ai.document.Document;
import java.util.*;

public class ErrorTrackingExample {

    /**
     * 根据关键词查找可能有问题的 chunks
     */
    public static List<Document> findChunksByKeyword(
            List<Document> allChunks,
            String keyword) {

        List<Document> results = new ArrayList<>();
        for (Document chunk : allChunks) {
            if (chunk.getContent().contains(keyword)) {
                results.add(chunk);
            }
        }
        return results;
    }

    /**
     * 展示 chunk 的详细信息,方便人工审核
     */
    public static void displayChunkDetails(Document chunk) {
        Map<String, Object> metadata = chunk.getMetadata();

        System.out.println("=== Chunk 详情 ===");
        System.out.println("内容: " + chunk.getContent());
        System.out.println("文档 ID: " + metadata.get("doc_id"));
        System.out.println("文件名: " + metadata.get("file_name"));
        System.out.println("Chunk 序号: " + metadata.get("chunk_index"));
        System.out.println("原文位置: " + metadata.get("start_offset")
                + " - " + metadata.get("end_offset"));
        System.out.println("创建时间: " + metadata.get("created_at"));
        System.out.println("来源: " + metadata.get("source_url"));
        System.out.println();
    }

    /**
     * 模拟更新 chunk(实际项目中需要调用向量数据库的 API)
     */
    public static void updateChunk(Document chunk, String newContent) {
        System.out.println(">>> 更新 Chunk");
        System.out.println("原内容: " + chunk.getContent());
        System.out.println("新内容: " + newContent);
        System.out.println("文档 ID: " + chunk.getMetadata().get("doc_id"));
        System.out.println("Chunk 序号: " + chunk.getMetadata().get("chunk_index"));
        System.out.println();
    }

    public static void main(String[] args) {
        // 模拟知识库中的 chunks
        List<Document> allChunks = new ArrayList<>();

        Map<String, Object> meta1 = new HashMap<>();
        meta1.put("doc_id", "doc_reimbursement_v1");
        meta1.put("file_name", "报销流程.pdf");
        meta1.put("chunk_index", 2);
        meta1.put("start_offset", 150);
        meta1.put("end_offset", 220);
        meta1.put("created_at", "2023-06-01T10:00:00Z");
        meta1.put("source_url", "https://docs.company.com/reimbursement_v1.pdf");
        allChunks.add(new Document(
            "报销时需打印发票并贴在报销单上,提交给财务部审核。",
            meta1
        ));

        Map<String, Object> meta2 = new HashMap<>();
        meta2.put("doc_id", "doc_reimbursement_v2");
        meta2.put("file_name", "报销流程_v2.pdf");
        meta2.put("chunk_index", 1);
        meta2.put("start_offset", 80);
        meta2.put("end_offset", 140);
        meta2.put("created_at", "2024-01-15T14:00:00Z");
        meta2.put("source_url", "https://docs.company.com/reimbursement_v2.pdf");
        allChunks.add(new Document(
            "报销时在系统中上传电子发票,无需打印纸质版。",
            meta2
        ));

        // 场景:用户反馈"贴发票"的说法已经过时
        System.out.println(">>> 用户反馈:系统说要贴发票,但新流程不需要了\n");

        // 1. 查找包含"发票"的 chunks
        List<Document> suspectedChunks = findChunksByKeyword(allChunks, "发票");
        System.out.println("找到 " + suspectedChunks.size() + " 个相关 chunks:\n");

        // 2. 展示详情,供人工审核
        for (Document chunk : suspectedChunks) {
            displayChunkDetails(chunk);
        }

        // 3. 人工确认第一个 chunk 是过时的,需要删除或更新
        Document outdatedChunk = suspectedChunks.get(0);
        System.out.println(">>> 确认 chunk_index=2 的内容已过时,准备删除\n");

        // 4. 执行删除(实际项目中调用向量数据库的删除 API)
        System.out.println(">>> 删除过时 chunk:");
        System.out.println("文档 ID: " + outdatedChunk.getMetadata().get("doc_id"));
        System.out.println("Chunk 序号: " + outdatedChunk.getMetadata().get("chunk_index"));
        System.out.println("\n>>> 操作完成,问题已修正");
    }
}

元数据设计的最佳实践

1. 元数据字段不是越多越好

新手常犯的错误:给每个 chunk 加一堆元数据字段,恨不得把能想到的信息都塞进去。

问题在于:

  • 存储成本:元数据也要占存储空间,字段太多会显著增加存储成本

  • 维护成本:字段越多,维护越麻烦。每次上传文档都要填一堆字段,容易出错

  • 检索性能:有些向量数据库在元数据过滤时,字段越多性能越差

    一个实用的原则:只加对检索、过滤、展示有实际帮助的字段

    问自己三个问题:

    • 1.这个字段会用于检索过滤吗?(比如权限过滤、时间过滤)
    • 2.这个字段会展示给用户吗?(比如引用信息)
    • 3.这个字段会用于运维管理吗?(比如定位问题 chunk)

      如果三个问题的答案都是不会,那这个字段就不要加。

      2. 元数据的粒度要和业务场景匹配

      场景 1:面向公众的产品帮助文档

      {
        "doc_id": "...",
        "file_name": "...",
        "title": "...",
        "source_url": "..."
      }

      场景 2:企业内部知识库

      {
        "doc_id": "...",
        "file_name": "...",
        "title": "...",
        "source_url": "...",
        "access_departments": [...],
        "access_roles": [...],
        "created_at": "...",
        "updated_at": "...",
        "start_offset": ...,
        "end_offset": ...,
        "chunk_index": ...
      }

      场景 3:电商客服知识库

      {
        "doc_id": "...",
        "file_name": "...",
        "product_category": "...",
        "policy_type": "...",
        "priority": ...,
        "effective_date": "...",
        "expiration_date": "..."
      }

      4. 元数据参考表

      元数据字段

      用途

      适用场景

      维护成本

      优先级

      doc_id

      文档标识,用于批量管理

      几乎所有场景

      低(系统生成)

      必须

      file_name

      展示给用户,生成引用

      几乎所有场景

      低(系统生成)

      必须

      source_url

      提供原文链接

      需要回溯原文的场景

      低(系统生成)

      推荐

      title / h1_title / h2_title

      生成引用,展示章节信息

      有结构的文档

      中(需要解析)

      推荐

      page_number

      生成引用,定位原文

      PDF 等有页码的文档

      中(需要解析)

      推荐

      created_at / updated_at

      版本管理,追踪变更

      知识会更新的场景

      低(系统生成)

      推荐

      effective_date / expiration_date

      过滤过时内容

      有时效性的知识(政策、活动)

      高(需要人工标注)

      可选

      access_roles / access_departments

      权限过滤

      企业内部知识库

      高(需要人工标注)

      必须(如果有权限需求)

      sensitivity_level

      权限过滤

      企业内部知识库

      中(可以按文档标注)

      推荐(如果有权限需求)

      start_offset / end_offset

      定位原文,纠错

      需要人工审核和修正的场景

      低(系统生成)

      推荐

      chunk_index

      定位 chunk,分析相邻块

      需要管理 chunk 的场景

      低(系统生成)

      推荐

      product_category / policy_type 等

      业务过滤和排序

      有明确业务分类的场景

      中到高(取决于分类复杂度)

      可选

      更多推荐