手搓RAG系统 – Ragent AI(3)

eve2333 发布于 7 小时前 5 次阅读


元数据的作用与管理

元数据是在分块之后、向量化之前加入的。分块完成后,你得到的是一个个纯文本块,这时候给每个块打上标签,记录它的来源、权限、位置等信息。

这些元数据会和文本内容一起,被送到向量数据库里存储。后续检索的时候,不仅可以根据文本相似度找到相关的块,还可以根据元数据做过滤、排序、引用生成等操作。

在没有元数据之前,一个 chunk 就是一段文本:

"自签收之日起 7 天内,商品未经使用且不影响二次销售的,消费者可申请七天无理由退货。"

加上元数据之后,它变成了这样:

{"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. 文档标识类:doc_id、source_url、file_name

1.1 为什么需要文档标识

最基础的需求:知道这个 chunk 来自哪份文档。

用户问“报销流程是什么”,系统回答后,用户可能会追问“能把完整的报销文档发给我吗”。这时候如果 chunk 里记录了 source_url,系统就能直接返回文档链接。

另一个场景是去重和更新。假设你重新上传了一份更新后的《报销流程 v2.0》,系统需要把旧版本的所有 chunk 删掉。如果每个 chunk 都记录了 doc_id,就可以批量删除 doc_id = "报销流程 v1.0" 的所有块。

三个字段的分工:

  • doc_id:文档的唯一标识符,通常是系统生成的 ID,用于内部管理
  • source_url:文档的访问地址,可以是 HTTP 链接、文件路径、或者内部系统的 URL
  • file_name:文档的原始文件名,方便人类阅读

2. 结构信息类:标题层级(H1/H2/H3)、章节编号、页码

很多文档是有层级结构的:一级标题、二级标题、三级标题……每个 chunk 往往属于某个章节。

记录结构信息有两个好处:

一是生成引用时更精确。比如用户问“试用期多久”,系统回答“3 个月”,如果 chunk 里记录了 title = "第二章 员工入职 > 2.1 试用期规定",就可以返回“依据:《员工手册》第二章 2.1 节”。这比只说“来源:员工手册”要清晰得多。

二是检索时可以利用结构信息做排序。比如用户问的是一个概述性问题,那一级标题下的 chunk 可能比三级标题下的更相关;如果问的是细节问题,就反过来。

2.2 如何从原始文档中提取标题层级

这取决于你的文档格式:

  • Markdown 文档:标题层级是现成的(# 是 H1,## 是 H2)
  • HTML 文档:可以解析 <h1> <h2> <h3> 标签
  • Word 文档:可以通过 Apache POI 读取样式信息,判断哪些段落是标题
  • PDF 文档:比较麻烦,需要根据字体大小、加粗等特征推断标题,或者用专门的 PDF 解析库

一个简化的思路:在分块的时候,记录当前块属于哪个最近的标题。比如你按段落切块,切到第 5 段时,往前找最近的一个标题是“2.1 试用期规定”,就把这个标题记录到 chunk 的元数据里。

3. 时间版本类:创建时间、更新时间、生效时间、失效时间

3.1 为什么需要时间版本信息

企业的知识是会变化的。今年的报销政策和去年不一样,上个月的产品手册和这个月不一样。如果不记录时间信息,系统可能会把过时的内容返回给用户。

四个时间字段的含义:

  • created_at:这个 chunk 是什么时候创建的(通常是文档上传到系统的时间)
  • updated_at:这个 chunk 最后一次更新是什么时候
  • effective_date:这条规则从什么时候开始生效(比如“新版报销政策自 2024 年 4 月 1 日起执行”)
  • expiration_date:这条规则什么时候失效(比如“本优惠活动截止到 2024 年 12 月 31 日”)

前两个(created_atupdated_at)是系统自动记录的,后两个(effective_dateexpiration_date)通常需要从文档内容中提取,或者在上传文档时手动标注。

一个典型的应用场景:用户问“现在的报销额度是多少”,系统检索时可以过滤掉 expiration_date 早于当前日期的 chunk,只返回当前有效的规则。

4. 权限控制类:部门标签、角色标签、ACL(访问控制列表)

4.1 什么是 ACL,为什么需要它

ACL 是 Access Control List(访问控制列表)的缩写。简单说,就是一个列表,记录了“谁能访问这个资源”。

在 RAG 系统里,每个 chunk 可以带一个 ACL,记录哪些角色、哪些部门、哪些用户能看到这个块。检索的时候,系统会先检查当前用户的身份,然后只返回他有权限看的 chunk。

权限控制有几种常见的粒度:

  • 基于角色(Role-Based):比如 access_roles: ["manager", "hr"],只有经理和 HR 能看
  • 基于部门(Department-Based):比如 access_departments: ["finance", "legal"],只有财务部和法务部能看
  • 基于用户(User-Based):比如 access_users: ["user_001", "user_002"],只有特定用户能看
  • 基于敏感级别(Sensitivity-Based):比如 sensitivity_level: "public"/"internal"/"confidential"/"secret",不同级别的用户能看不同敏感度的内容

实际项目中,通常会组合使用。比如一个 chunk 标记为 sensitivity_level: "confidential",同时指定 access_departments: ["finance"],意思是“这是机密信息,只有财务部能看”。

检索时如何根据权限过滤

权限过滤通常发生在检索阶段。大多数向量数据库(如 Milvus、Qdrant、Weaviate)都支持元数据过滤,你可以在检索时加上过滤条件。

伪代码示例:

// 假设当前用户是财务部的普通员工String userRole ="employee";String userDepartment ="finance";
​
// 构造过滤条件:只检索用户有权限看的 chunkMap<String, Object> filter =newHashMap<>();
filter.put("access_roles", userRole);// 或者
filter.put("access_departments", userDepartment);
​
// 执行检索(具体 API 取决于你用的向量数据库)List<Document> results = vectorStore.search(
    query,
    topK,
    filter  // 传入过滤条件);

不同向量数据库的过滤语法不太一样,但核心思路是一致的:在向量相似度检索的基础上,叠加元数据过滤条件。

5. 位置追溯类:原文位置(start_offset、end_offset)、chunk_index

当你发现某个 chunk 的内容有问题,需要回到原文去修正时,位置信息就派上用场了。

start_offsetend_offset 记录了这个 chunk 在原文中的字符位置。比如 start_offset: 120, end_offset: 250,意思是这个 chunk 对应原文的第 120 到 250 个字符。

chunk_index 是这个 chunk 在所有 chunk 中的序号。比如一份文档被切成了 10 个 chunk,第一个 chunk 的 chunk_index 是 0,第二个是 1,以此类推。

有了这些信息,你可以:

  • 快速定位到原文的具体位置,方便人工审核和修正
  • 在展示答案时,高亮显示原文中的相关段落
  • 分析相邻 chunk 的关系(比如检索到了 chunk 5,可以顺便看看 chunk 4 和 chunk 6 的内容)

业务自定义类:产品类型、业务线、优先级等

业务场景可能还有自己特殊的需求。

比如电商场景,你可能需要给 chunk 打上商品类目标签:

这样用户问“生鲜能退货吗”,系统可以优先检索 product_category: "fresh_food" 的 chunk。

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

前面讲了元数据有哪些字段,现在看看这些字段在实际场景中怎么用。

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

用户问:“新员工试用期多久?”

系统回答:“新员工试用期为 3 个月。”

用户追问:“这个规定在哪份文档里?”

如果 chunk 里记录了文档来源和章节信息,系统可以这样回答:

新员工试用期为 3 个月。

依据:《员工手册》第二章“员工入职” > 2.1 节“试用期规定”,第 5 页

这就是回答可引用。用户不仅得到了答案,还知道答案的出处,可以点击链接查看完整的原文。

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

公司的知识库里有各种文档:

  • 产品部的需求文档(只有产品部和技术部能看)
  • 人事部的薪酬政策(只有人事部和管理层能看)
  • 财务部的预算数据(只有财务部和高管能看)
  • 公共的员工手册(所有人都能看)

技术部的小李问:“公司的年终奖怎么算?”

如果不做权限过滤,系统可能会把人事部的内部文档返回给他,泄露敏感信息。

正确的做法:检索时根据小李的身份(技术部、普通员工),只返回他有权限看的 chunk。如果没有匹配的公开信息,就回答“抱歉,这个问题涉及内部信息,请咨询人事部”。

检索前根据用户角色过滤 chunk

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

用户反馈:“系统告诉我报销需要贴发票,但实际上新流程已经改成线上提交了,不用贴纸质发票。”

技术团队需要:

  1. 1.找到返回错误信息的那个 chunk
  2. 2.定位到原始文档的具体位置
  3. 3.修正或删除这个 chunk
  4. 4.检查是否还有其他相关的过时 chunk 需要一起更新

如果 chunk 里记录了 doc_idchunk_indexstart_offsetcreated_at 等信息,这个过程就会简单很多。

实现思路:通过元数据定位问题 chunk,快速定位,精准修正,批量更新,版本管理

元数据设计的最佳实践

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

问题在于:存储成本,维护成本,检索性能

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

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":"..."}

3. 元数据的维护成本要考虑进去

有些元数据是系统自动生成的(如 created_atchunk_indexstart_offset),维护成本低。

有些元数据需要人工标注(如 access_rolesproduct_categoryeffective_date),维护成本高。

如果你的知识库有几千份文档,每份文档都要人工标注十几个字段,这个工作量是不现实的。

一个折中的方案:分层标注

  • 文档级标注:在上传文档时,标注文档级的元数据(如 access_departmentsdoc_type),这些元数据会自动继承给文档下的所有 chunk
  • Chunk 级标注:系统自动生成 chunk 级的元数据(如 chunk_indexstart_offset
  • 按需标注:只对重要的、高频访问的文档做精细化标注(如 effective_datepriority

这样可以在保证元数据质量的同时,把维护成本控制在可接受的范围内。

元数据字段用途适用场景维护成本优先级
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 等业务过滤和排序有明确业务分类的场景中到高(取决于分类复杂度)可选

使用建议:

  • 必须:这些字段几乎所有场景都需要,优先实现
  • 推荐:这些字段能显著提升用户体验或运维效率,建议实现
  • 可选:这些字段针对特定场景,根据实际需求决定是否实现

从文本到向量之理解Embedding

2.1 同义词问题

“一周”和“七天”是同一个意思,“退”和“退货”是同一个动作,但关键词检索不知道。它只做字面匹配,不理解语义。日常对话中的表达关键词检索不能,因为书面

2.2 一词多义问题

同一个词在不同语境下意思完全不同。用户问“苹果的售后政策是什么”——这里的“苹果”是指 Apple 品牌,还是指水果?关键词检索不知道,它会把所有包含“苹果”的文本块都返回,水果类目的退货政策和 Apple 产品的保修政策混在一起。

再比如“充值”这个词,在游戏场景是充游戏币,在话费场景是充话费,在会员场景是充会员余额。关键词检索没法区分。

2.3 上下文理解问题

有些问题需要理解整句话的意思,而不是拆成单个关键词。

用户问:“我不想要了,但已经拆了包装。”

关键词检索拆出来的是“不想要”“拆了”“包装”,可能匹配到“包装材料说明”或者“拆箱指南”这种完全不相关的内容。但实际上用户想问的是“拆封商品能不能退货”。

用一句话概括 Embedding 的核心思想:把文本映射到一个高维空间中,让语义相近的文本在空间中距离相近。

真实的语言含义非常丰富,两个维度根本不够用。

实际的 Embedding 模型会用几百甚至上千个维度来表示一段文本。每个维度捕捉文本含义的一个方面——虽然我们没法直观地说出每个维度具体代表什么,但模型通过大量训练数据学会了怎么分配这些维度。

回到开头的例子:

  • “七天无理由退货”的向量和“买了一周的东西还能退吗”的向量,在高维空间中距离很近
  • “七天无理由退货”的向量和“物流配送时效说明”的向量,距离很远

有了向量表示,计算机就不用再做字面匹配了。它只需要比较两个向量之间的距离,距离近就是语义相关,距离远就是语义无关。

这就是 RAG 系统能做语义检索的根基。


几个关键特性:

  • 输入长度有限制:每个模型都有最大输入 token 数(比如 512 或 8192 等等),超过会被截断。这也是为什么前面要做分块——把长文档切成短文本块,确保每个块都在模型的输入限制内
  • 输出维度固定:同一个模型输出的向量维度是固定的。比如某个模型输出 1024 维,那不管你输入一个词还是一段话,输出都是 1024 个浮点数
  • 同一模型内可比较:只有用同一个模型生成的向量才能互相比较。用模型 A 生成的向量和模型 B 生成的向量,没法直接算相似度

第三点非常重要。这意味着你在数据准备阶段用什么模型把 chunk 转成向量,检索阶段就必须用同一个模型把用户的 query 转成向量。换模型 = 所有向量要重新生成

在看具体模型之前,先搞清楚选型时要看哪些指标:

指标含义为什么重要
向量维度输出向量的浮点数个数维度越高,表达能力越强,但存储和计算成本也越高
最大输入 token 数单次能处理的最大文本长度决定了你的 chunk 最大能有多长
中文效果对中文文本的语义理解能力中文场景必须关注,有些模型主要针对英文训练
API 成本每次调用的费用大规模向量化时,成本差异会很明显
是否支持本地部署能不能在自己的服务器上跑涉及数据安全和隐私的场景,可能不允许数据出外网
模型提供方向量维度最大输入 token中文效果部署方式备注
text-embedding-3-smallOpenAI15368191中等仅云端 API性价比高,适合英文为主的场景
text-embedding-3-largeOpenAI30728191中等仅云端 API维度更高,效果更好,成本也更高
text-embedding-v3阿里通义1024/7688192优秀云端 API中文效果好,支持多种维度输出
BGE-large-zhBAAI(智源)1024512优秀本地部署/API开源模型,中文效果突出
BGE-M3BAAI(智源)10248192优秀本地部署/API支持多语言、多粒度,综合能力强
Qwen3-Embedding-8B阿里通义409632768优秀本地部署/API最新一代,维度高,上下文窗口大
GTE-large-zh阿里通义10248192优秀本地部署/API中文基准测试表现好

维度是选模型时绑定的——你选了某个模型,维度就确定了(部分模型支持多种维度输出,但大多数是固定的)。

打个比方:维度就像描述一个人用了多少个特征。用 2 个特征(身高、体重)描述一个人,信息很有限,很多人会“撞衫”。用 100 个特征(身高、体重、肤色、发型、口音、走路姿势……)描述,区分度就高多了。但特征越多,记录和比较的成本也越高。

维度范围适用场景存储成本(100 万条)
256~512简单场景,文本短、类目少约 1~2 GB
768~1024大多数生产场景的甜蜜点约 3~4 GB
1536~4096对精度要求极高的场景约 6~16 GB

对于大多数中文 RAG 项目,768 到 1024 维是一个比较稳妥的选择。既能保证足够的语义区分度,存储和检索成本也在可控范围内。除非你的场景对精度有极致要求(比如法律条文检索、医疗知识库等),否则不需要上 3072 或 4096 维。

相似度计算:怎么判断两个向量“像不像”

文本变成向量之后,下一步就是比较两个向量之间的相似程度。用户输入一个 query,系统把它转成向量,然后和知识库里所有 chunk 的向量逐一比较,找出最相似的几个——这就是语义检索的核心流程。

那怎么比较两个向量“像不像”?这就涉及到相似度计算。

1. 余弦相似度——最常用的度量方式

余弦相似度(Cosine Similarity)是 Embedding 检索中最常用的相似度度量方式。

  1. 算两个向量的点积(对应位置的数字相乘,然后全部加起来)
  2. 算每个向量的模(每个数字的平方加起来,再开根号)
  3. 点积除以两个模的乘积

在实际的 RAG 系统中,通常会设一个相似度阈值(threshold),只返回相似度高于阈值的结果。

常见的做法:

  • 阈值设 0.7:比较严格,只返回高度相关的结果,准确率高但可能漏掉一些相关内容
  • 阈值设 0.5:比较宽松,召回率高但可能混入一些不太相关的内容
  • 不设阈值,只取 Top-K:返回相似度最高的 K 个结果(比如 Top-5),不管分数多少

实际项目中,建议先用 Top-K(比如 K=5)+ 阈值 0.6 的组合策略:先取相似度最高的 5 个,再过滤掉低于 0.6 的。具体阈值需要根据你的数据和场景调试,没有放之四海而皆准的数字。

同样两段文本,用不同的模型算出来的相似度分数可能差很多。这是因为:

  • 不同模型的训练数据不同,对语义的理解方式不同
  • 不同模型的向量维度不同,表达能力不同
  • 有些模型输出的向量已经做了归一化(长度为 1),有些没有

所以,相似度阈值不能跨模型套用。换了模型之后,阈值要重新调。

demo 里我们一次性把 5 个 chunk 传给 API,实际项目中可能有几万甚至几十万个 chunk。一次全传过去不现实(API 有请求大小限制),一个一个传又太慢。

2.1 分批处理

最基本的优化:把 chunks 分成固定大小的批次,逐批调用 API。

/**
 * 分批向量化
 *
 * @param texts     所有待向量化的文本
 * @param batchSize 每批的大小(建议 20~50)
 * @return 所有文本的向量
 */publicList<double[]>embedInBatches(List<String> texts,int batchSize)throwsException{List<double[]> allEmbeddings =newArrayList<>();for(int i =0; i < texts.size(); i += batchSize){int end =Math.min(i + batchSize, texts.size());List<String> batch = texts.subList(i, end);System.out.printf("向量化进度:%d/%d%n", end, texts.size());List<double[]> batchEmbeddings =embed(batch);
        allEmbeddings.addAll(batchEmbeddings);// 简单的限流:每批之间等一下,避免触发 API 的速率限制if(end < texts.size()){Thread.sleep(200);}}return allEmbeddings;}

2.2 并发控制

分批处理是串行的,如果 API 支持并发,可以用多线程加速:

importjava.util.concurrent.*;/**
 * 并发批量向量化
 */publicList<double[]>embedConcurrently(List<String> texts,int batchSize,int maxConcurrency)throwsException{ExecutorService executor =Executors.newFixedThreadPool(maxConcurrency);List<Future<List<double[]>>> futures =newArrayList<>();for(int i =0; i < texts.size(); i += batchSize){int start = i;int end =Math.min(i + batchSize, texts.size());List<String> batch = texts.subList(start, end);

        futures.add(executor.submit(()->embed(batch)));}List<double[]> allEmbeddings =newArrayList<>();for(Future<List<double[]>> future : futures){
        allEmbeddings.addAll(future.get());}

    executor.shutdown();return allEmbeddings;}

并发数不要设太高,一般 3~5 就够了。设太高容易触发 API 的速率限制(Rate Limit),反而更慢。

2.3 错误重试

网络请求难免会失败,加一个简单的重试机制:

/**
 * 带重试的 embed 方法
 */publicList<double[]>embedWithRetry(List<String> texts,int maxRetries)throwsException{Exception lastException =null;for(int attempt =1; attempt <= maxRetries; attempt++){try{returnembed(texts);}catch(Exception e){
            lastException = e;System.err.printf("第 %d 次调用失败:%s,%s%n",
                    attempt, e.getMessage(),
                    attempt < maxRetries ?"准备重试...":"已达最大重试次数");if(attempt < maxRetries){// 指数退避:第 1 次等 1 秒,第 2 次等 2 秒,第 3 次等 4 秒Thread.sleep(1000L*(1<<(attempt -1)));}}}thrownewRuntimeException("向量化失败,已重试 "+ maxRetries +" 次", lastException);}

向量化和元数据的关系

一句话概括:元数据不参与向量化,但和向量一起存入向量数据库

打个比方:向量化就像给一本书拍了一张“语义照片”,元数据就是贴在照片背面的标签(作者、出版日期、分类等)。拍照的时候不需要看标签,但存档的时候标签要跟照片放在一起。

检索的时候,流程是这样的:

1.向量编码文本和 向量
2.ANN 检索Query向量
相似度计算 ,返回Top-K,按相似度降序,候选 chunks
3.元数据过滤根据条件得到最终结果

所以在存储阶段,每个 chunk 在向量数据库里的记录通常包含三部分:

字段内容说明
idchunk 的唯一标识用于更新和删除
vectorEmbedding 模型输出的向量用于相似度检索
metadata元数据(JSON 格式)用于过滤和展示

大部分向量数据库(如 Milvus)还可以把原始文本也存进去,这样检索的时候不用再去别的地方取文本内容。

什么时候需要重新向量化

4.1 换了 Embedding 模型

不同模型生成的向量是不兼容的。模型 A 生成的向量和模型 B 生成的向量,维度可能不同,即使维度相同,语义空间也不一样,不能混在一起做相似度计算。

换模型 = 所有 chunk 重新向量化 + 向量数据库里的旧向量全部替换。

4.2 文档内容更新

如果某份文档的内容改了(比如退货政策从 7 天改成了 15 天),对应的 chunk 文本变了,向量自然也要重新生成。

实际操作中,通常的做法是:

  1. 1.根据 doc_id 找到这份文档的所有旧 chunk
  2. 2.删除旧 chunk 的向量
  3. 3.对更新后的文档重新分块、向量化、存入向量数据库

这就是为什么前一篇强调元数据里要有 doc_id——没有它,你很难知道哪些 chunk 属于同一份文档。

4.3 分块策略调整

如果你调整了分块的大小或重叠策略(比如从 500 字一块改成 300 字一块),chunk 的内容变了,向量也要重新生成。

这种情况通常意味着全量重新向量化,工作量比较大。所以分块策略最好在项目初期就确定下来,别频繁改。

4.4 模型升级

同一个模型的不同版本(比如 text-embedding-v2 升级到 text-embedding-v3),生成的向量也可能不兼容。升级前要看模型提供方的说明,确认新旧版本的向量是否兼容。

一个实用的建议:在向量数据库的元数据里记录 embedding_modelembedding_model_version,这样你随时知道每个向量是用哪个模型生成的,升级时也方便做灰度切换。