登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  科技周边 >  人工智能

RAG chunk metadata 怎样保留原文页码和标题层级

来源:17golang原创

时间:2026-11-14 04:39:03 114浏览 收藏

RAG 召回到一段相关文字,却只能告诉用户“来自某个 PDF”,通常不是向量检索本身出了问题,而是分块时没有把来源血缘一起保存。实用做法是:在 chunk 生成前统一页码口径和标题栈,再把 doc_id、page_start、page_end、heading_path 等字段复制到每个 chunk;召回后直接用这些字段拼出“文档名 · 第几页 · 标题路径”的来源行。

官方地址:https://platform.openai.com/docs/guides/tools-file-search

OpenAI 的检索文档展示了文件引用与文件名标注,向量库文件也支持用于过滤的 attributes。它们适合做文件级筛选和结果标识,但页码、标题层级属于更细的 chunk 级信息,应该由自己的解析与分块层明确维护。

先把“这段文字来自哪里”设计成数据字段,再谈向量库如何存储。只给文档名而没有页码和标题路径,召回结果很难稳定回指原文。

分块前先统一页码和标题路径

我在处理 PDF、Markdown 和网页导出文本时,最先固定的不是 chunk 大小,而是来源字段的含义。page_start 与 page_end 表示原文页码范围;heading_path 表示从文档标题到当前小节的完整路径,例如“部署手册 / 第 3 章 / 连接池配置”。不要把 PDF 内部从 0 开始的数组下标直接当作读者看到的页码,封面、目录和印刷页码可能让两者不同。

一条 chunk 的最小字段可以先定成下面这样。JSON 必须保持可解析,所以解释放在代码块外:

{
  "chunk_id": "guide-003-004",
  "doc_id": "guide-003",
  "source_uri": "s3://docs/guide.pdf",
  "page_start": 12,
  "page_end": 13,
  "heading_path": ["部署手册", "连接池配置"],
  "chunk_index": 4,
  "text": "连接池上限需要与数据库并发能力一起设置……"
}

chunk_id 用于定位记录,doc_id 用于聚合同一份文档,页码字段用于展示,标题路径用于让读者判断上下文。chunk_index 只表示在当前文档中的稳定顺序,不能替代页码;source_uri 则保存原始入口,是否公开要由你的产品权限策略决定。

原文文档、页面记录、标题路径和正文块汇聚到 RAG chunk metadata 的结构说明图
图1:结构说明图,展示原文页码和标题路径如何随正文块进入 chunk metadata;这是静态示意,不是运行截图。

切块时复制当前页码和标题栈

分块器的关键不是“切完再猜来源”,而是在扫描页面和标题时维护当前状态:遇到新页就更新页码,遇到标题就更新标题栈,产出 chunk 时把当前状态做一份不可变快照。这样一个 chunk 即使跨越两页,也能保留范围;跨越标题时,也能知道它覆盖了哪些层级。

from dataclasses import dataclass

@dataclass(frozen=True)
class Block:
    # 每个解析块都携带页码和当前标题路径,避免后续再次猜测来源。
    text: str
    page: int
    heading_path: tuple[str, ...]


def make_chunks(blocks, max_chars=900):
    # 连续拼接正文块,并把首尾页码与标题路径复制到 chunk。
    chunks = []
    buffer = []
    size = 0

    def flush():
        # 清空缓冲区前创建快照,保证列表后续变化不影响已产出的记录。
        nonlocal buffer, size
        if not buffer:
            return
        chunks.append({
            "text": "\n".join(item.text for item in buffer),
            "page_start": buffer[0].page,
            "page_end": buffer[-1].page,
            "heading_path": list(buffer[-1].heading_path),
        })
        buffer, size = [], 0

    for block in blocks:
        # 超过上限时先提交已有片段,再开始新的 chunk。
        if buffer and size + len(block.text) > max_chars:
            flush()
        buffer.append(block)
        size += len(block.text)
    flush()
    return chunks

示例把标题路径取为 chunk 最后一个解析块的路径,适合“一个 chunk 尽量不跨标题”的策略。如果你的分块器允许跨越多个小节,就不要假装只有一个标题;可以把路径改成 heading_paths 数组,或在切块前按标题边界拆开。关键是字段语义要和实际切分策略一致。

我还会把页码定义写进数据字典:是 PDF 物理页、文档印刷页,还是网页导出的虚拟页。不同来源统一成一种展示口径后,再把原始页索引作为内部字段保留,排查偏移时会轻松很多。

召回后用 metadata 生成可回指来源

向量记录中至少要能同时取回 chunk 文本和来源字段。若使用支持文件 attributes 的向量库,可以把 tenant、doc_type、published_at 这类文件级过滤字段放进去;但 page_start、page_end 和 heading_path 仍应跟着每个 chunk 返回,不能因为文件级 attributes 存在就丢掉它们。

def format_citation(hit):
    # 召回结果缺字段时给出可识别的降级文本,不拼接 None 或错误页码。
    metadata = hit.get("metadata") or {}
    title_path = metadata.get("heading_path") or ["未标注标题"]
    page_start = metadata.get("page_start")
    page_end = metadata.get("page_end", page_start)

    if page_start is None:
        page_text = "页码未标注"
    elif page_end and page_end != page_start:
        page_text = f"第 {page_start}-{page_end} 页"
    else:
        page_text = f"第 {page_start} 页"

    # 标题路径只负责提供上下文,正文仍应来自召回的 chunk 文本。
    return f"{metadata.get('doc_name', '未命名文档')} · {page_text} · {' / '.join(title_path)}"

引用格式化器只做展示,不应该重新推断页码,也不应该把检索分数写成“证据强度”。分数是排序信号,页码和标题是来源定位;两者解决的是不同问题。

向量记录召回 chunk 文本、页码范围和标题层级后交给引用格式化器的关系说明图
图2:关系说明图,展示召回结果如何保留 chunk 文本与页码、标题字段,再交给引用格式化器;这是静态示意,不是运行结果。

用四个检查项定位引用不准

当回答里的来源位置不可信时,我会按下面的顺序排查,而不是先重建向量索引:

  • 页码口径:解析器记录的是物理页还是印刷页?封面和目录是否导致固定偏移?
  • 标题快照:chunk 是否在每个页面和标题变化处更新了状态?是否只给第一块写过标题?
  • 跨页范围:跨页 chunk 是否保存了 page_start 与 page_end,而不是只保留首尾之一?
  • 召回回传:向量检索结果是否把 metadata 一并带回?是否在序列化或重排时丢了字段?

如果只需要 OpenAI File Search 的文件级引用,官方结果可以提供文件名等标识;如果产品要显示“第 12 页 / 连接池配置”,就必须在自己的摄取链路里保存更细的 chunk metadata。页码和标题层级不是装饰字段,而是让用户能回到原文、判断上下文并复核答案的最短路径。

常见问题

只保存 source_uri,不保存页码可以吗?

可以完成文件级回指,但无法稳定告诉读者具体位置。对于长文档,至少保留文档标识、页码范围和标题路径;网页没有页码时,可用章节路径、段落序号或字符偏移替代。

标题层级应该保存字符串还是数组?

数组更适合结构化处理和后续展示,字符串更适合直接输出。实践中可以用数组作为主字段,展示时再用分隔符拼成路径,避免标题中本来就含有分隔符时难以还原。

向量库的 attributes 能替代 chunk metadata 吗?

通常不能。attributes 更适合文件级过滤和分类;页码、跨页范围和标题路径会随每个 chunk 变化,应跟着 chunk 文本一起进入召回结果,或由可追踪的旁路表按 chunk_id 取回。

一句话总结:先在解析阶段定义页码口径和标题栈,再把来源字段随每个 chunk 一起存储;召回后只格式化已有 metadata,不要临时猜页码。

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>