文档进来这一关:PDF 与 HTML 解析、表格与扫描件、清洗规则和必须留下的元数据
检索质量的天花板是解析质量,这一天把 PDF、HTML、Markdown 三类来源的解析坑一次讲透,处理表格与扫描件,定下一套清洗规则,并把标题层级、页码、来源地址这些后面要用来做引用和过滤的元数据固定下来。
今日目标
- 能说出 PDF 解析的三种典型失败:多栏乱序、表格塌成一行、页眉页脚混进正文,并各给一个应对办法
- 能设计一份文档元数据结构,让后面的引用标注、权限过滤和增量更新都有据可依
- 能写出一条把多来源文档统一成同一种中间表示的解析管道,并对解析结果做可断言的质量检查
前两天我们一直在拿一份现成的、干干净净的 Markdown 语料做实验。今天把它撤掉,换成真实世界里文档进来的样子:一堆 PDF、几个帮助中心页面、还有一份扫描件。读完并做完实验之后,回到页面顶部把这三条目标逐一勾掉。
小白版讲解
先看料再下锅
一个厨师接到订单,第一件事不是开火,是看料。菜叶蔫没蔫、鱼新不新鲜、盐是不是受潮结块了——这些在下锅之前就该看完。因为下锅之后,火候再准、调料再讲究,也救不回一条不新鲜的鱼。更麻烦的是,客人尝出问题时,怪的是厨师的手艺,没人会想到是那条鱼的事。
检索系统就是这个厨房。D1 建的索引、D2 算的向量、后面十一天要加的重排和查询改写,全都是火候和调料。真正决定上限的是进厨房的那批料——也就是解析(parsing)这一步:把 PDF、网页、Word、扫描件变成一串可检索的文字。
这一步坏掉的时候,症状极其难认。它不会报错,不会崩溃,只会安安静静地在索引里放进一段串了行的表格、一句被拦腰截断的规则、一行每页都出现的「内部资料请勿外传」。然后有一天用户问「专业版存储配额多少」,系统信心十足地回答「不限」——因为那张表在解析时丢了一列分隔符,「不限」原本是隔壁那一栏的值。
回到 D1 那张「五个环节」的图:切块、建索引、检索、组装上下文、生成。解析在这五环之前,是第零环。它的错误会被后面每一环放大——切块会照着错误的文字边界去切,索引会把错误的词记进倒排表,检索会理直气壮地把它排到第一名,生成会一本正经地引用它。这就是「解析质量决定检索质量上限」这句话的具体传导链条,面试里被问到时,照着这条链说一遍就够了。
PDF 的三座山
PDF 是企业知识库里最常见、也最难啃的格式。难在哪?难在一句反直觉的事实:
PDF 文件里没有「段落」,也没有「阅读顺序」。
它存的是一堆绘制指令:在第几页、什么坐标、多大字号,画哪一段文字。段落、标题层级、阅读顺序,全都是你从坐标和字号里推出来的。真实的解析库(Python 的 pdfplumber、JavaScript 的 pdfjs)交给你的,也正是这样一条条记录。三座山由此而来。
第一座山是多栏阅读顺序。 排版软件写内容流时,常常按 y 值从上往下成带地写。单栏文档这样写没问题,双栏文档写出来就是「左栏第一行、右栏第一行、左栏第二行、右栏第二行」地交替。你如果直接照抄这个顺序,拿到的文字是两栏拌在一起的——而且每一句单独看都通顺,只有连起来读才发现前言不搭后语。应对办法是自己重建阅读顺序:把每页文字块的左边界排序,找一条足够宽的空隙当分栏线,然后按「栏号、y 从大到小、x 从小到大」重排。
第二座山是表格。 表格在 PDF 里就是一堆对齐的文本块,格线是画上去的图形,跟文字没有任何关联。抽出来之后,一整张表可能塌成一行,也可能每个单元格各成一段。应对办法分两层:能拿到位置信息时按 x 坐标聚类还原列;拿不到时,至少要认出来这里塌了,把它标记成低置信度、不参与检索,而不是让它冒充正文。
第三座山是扫描件。 有些 PDF 整页就是一张图,文本层里一个字都没有。这时唯一的路是光学字符识别(optical character recognition,OCR),把图片认成文字。代价有两个:一是要么装一套系统级依赖,要么按页付费调云服务;二是它一定会错。中文场景里错的多是形近字——「已」认成「己」、「未」认成「末」、「板」认成「版」。这些错字会顺着链条一路传导:分词切错、倒排索引里少一个词、检索时命中不了、模型看到的材料里带着错别字。缓解办法是接受它并做补偿:保留原图链接以便人工复核、在检索侧靠 D9 的向量一路兜住关键词一路的失手、对置信度低的页面单独标记。
HTML 与 Markdown:好解析,不等于好用
比起 PDF,HTML 和 Markdown 简直是恩赐——它们自带结构。标题就是标题标签,段落就是段落标签,列表就是列表标签,不需要你从坐标里猜。Markdown 更是连解析库都不用装,按行扫一遍就够。
但好解析不等于好用,它们的坑在另一头:一个网页里有一大半内容不该进正文。导航栏、侧边栏的「相关文档」、页脚的备案号、埋在页面里的统计脚本——这些东西在每一个页面上都长得一模一样。留着它们,等于给全库每篇文档都加了同一段文字,既拉低了区分度,又会让用户检索到一段毫无信息量的内容。所以 HTML 解析的第一步不是抽取,是整块删除:脚本、样式、导航、侧栏、页脚,一个选择器扫掉。
还有三样东西容易顺手丢掉,丢了就可惜:
- 代码块里的空格和换行是有意义的,用普通段落的「压缩空白」规则处理会把它揉成一行,读者再也看不出缩进层级。
- 脚注在正文里只留一个上标数字,真正的内容在页面底部。抽取时如果不把它接回引用处,用户看到的就是一个孤零零的方括号数字。
- 相对链接里那个
./doc-002.md是货真价实的元数据,它告诉你这篇文档指向哪一篇。丢掉它只要一行代码,找回来却要把原件重新解析一遍。今天实验里的做法是把它内联进文字——写成「任务板(链接:./doc-001.md)」,不新增字段也不丢信息。
Markdown 的坑要少得多,主要是两条:代码围栏里的空行不能当段落分隔符(否则一段 shell 命令会被切成互不相干的三块),以及标题不要单独成为一个节点——它太短,单独被检索到毫无价值;正确的做法是把它变成后续节点的标题路径,这就是下一节的主题。
统一中间表示:一切来源变成同一串节点
三个解析器,三种输出格式,后面十一天要为每一种各写一遍处理逻辑?当然不行。解析这一步的真正产出,是把所有来源收敛到同一个数据结构上:一串带结构、带出处的节点。从这里往后,D4 的切块、D6 的引用、D13 的权限过滤,都只认这一个结构,再也不关心文档原来是 PDF 还是网页。
这个结构本课定死八个字段,十四天里不许改名:
import { createHash } from 'node:crypto'
// 全课统一的解析产物节点。一个节点 = 一个解析单元(一段、一个列表、一张表)
export function makeNode(meta, index, text, headingPath, page) {
return {
docId: meta.docId,
chunkId: `${meta.docId}#c${String(index + 1).padStart(2, '0')}`,
text,
headingPath: [...headingPath],
page, // 只有 PDF 来源有页码,其余来源一律 null,不要写 0 也不要留空
department: meta.department, // 权限标签,D13 的访问控制直接用它
updatedAt: meta.updatedAt,
contentHash: meta.contentHash, // 整篇文档共享一个指纹
}
}
// 先把换行归一化再算:同一份文件从两台机器上传,字节不同但内容相同,
// 不归一化的话每次同步都会判定成「变了」,白重算一遍全库
export function contentHash(raw) {
const normalized = raw.replace(/\r\n/g, '\n').trim()
return createHash('sha256').update(normalized, 'utf8').digest('hex').slice(0, 16)
}import hashlib
from dataclasses import dataclass, field
@dataclass
class ParsedNode:
"""全课统一的解析产物节点。一个节点 = 一个解析单元(一段、一个列表、一张表)"""
doc_id: str
chunk_id: str
text: str
heading_path: list[str] = field(default_factory=list)
page: int | None = None # 只有 PDF 来源有页码,其余来源一律 None
department: str = "" # 权限标签,D13 的访问控制直接用它
updated_at: str = ""
content_hash: str = "" # 整篇文档共享一个指纹
def make_chunk_id(doc_id: str, index: int) -> str:
return f"{doc_id}#c{index + 1:02d}"
def content_hash(raw: str) -> str:
"""先把换行归一化再算:同一份文件从两台机器上传,字节不同但内容相同,
不归一化的话每次同步都会判定成「变了」,白重算一遍全库"""
normalized = raw.replace("\r\n", "\n").strip()
return hashlib.sha256(normalized.encode("utf-8")).hexdigest()[:16]八个字段里最容易被写错的是 headingPath。它不是「这个节点的上一个标题」,而是从一级标题一路走下来的完整路径,比如「套餐与配额对照表 / 文字说明」。维护它只需要一个栈:读到第 n 级标题时,先把栈截到 n 减 1 长,再把自己接上去。截这一下是关键——从三级标题退回二级时,三级那一层必须被丢掉,否则路径会错层,读者顺着引用点回原文会落到别的小节。
export function pushHeading(stack, level, title) {
const next = stack.slice(0, level - 1) // 退回上一级时,深层标题必须被丢掉
while (next.length < level - 1) next.push('') // 一级直接跳到三级,中间补空位防错层
next.push(title)
return next
}
// 读到「## 泳道」时:pushHeading(['任务板使用指南'], 2, '泳道')
// 得到 ['任务板使用指南', '泳道'],之后每个节点都带上这条路径def push_heading(stack: list[str], level: int, title: str) -> list[str]:
nxt = stack[: level - 1] # 退回上一级时,深层标题必须被丢掉
nxt += [""] * (level - 1 - len(nxt)) # 一级直接跳到三级,中间补空位防错层
nxt.append(title)
return nxt
# 读到「## 泳道」时:push_heading(["任务板使用指南"], 2, "泳道")
# 得到 ["任务板使用指南", "泳道"],之后每个节点都带上这条路径至于多栏 PDF 的阅读顺序,重建它的代码比想象中短:找空隙、分栏、栏内从上往下。
// 一页里所有文字块的左边界排序,最大的那道空隙就是分栏线
export function detectColumnBoundary(runs) {
const xs = [...new Set(runs.map((r) => Math.round(r.x)))].sort((a, b) => a - b)
let best = { gap: 0, left: 0, right: 0 }
for (let i = 1; i < xs.length; i += 1) {
const gap = xs[i] - xs[i - 1]
if (gap > best.gap) best = { gap, left: xs[i - 1], right: xs[i] }
}
if (best.gap < 60) return null // 空隙不够宽,判定为单栏
const boundary = (best.left + best.right) / 2
const ratio = runs.filter((r) => r.x >= boundary).length / runs.length
// 两边都要有像样的文字量,否则那道「空隙」多半只是一个缩进
return ratio > 0.15 && ratio < 0.85 ? boundary : null
}
export function sortRuns(runs) {
const boundary = detectColumnBoundary(runs)
const col = (r) => (boundary === null || r.x < boundary ? 0 : 1)
return [...runs].sort((a, b) => col(a) - col(b) || b.y - a.y || a.x - b.x)
}def detect_column_boundary(runs: list[dict]) -> float | None:
"""一页里所有文字块的左边界排序,最大的那道空隙就是分栏线"""
xs = sorted({round(r["x"]) for r in runs})
gaps = [(xs[i] - xs[i - 1], xs[i - 1], xs[i]) for i in range(1, len(xs))]
if not gaps:
return None
gap, left, right = max(gaps)
if gap < 60: # 空隙不够宽,判定为单栏
return None
boundary = (left + right) / 2
ratio = sum(1 for r in runs if r["x"] >= boundary) / len(runs)
# 两边都要有像样的文字量,否则那道「空隙」多半只是一个缩进
return boundary if 0.15 < ratio < 0.85 else None
def sort_runs(runs: list[dict]) -> list[dict]:
boundary = detect_column_boundary(runs)
def col(r: dict) -> int:
return 0 if boundary is None or r["x"] < boundary else 1
return sorted(runs, key=lambda r: (col(r), -r["y"], r["x"]))清洗的边界:什么该删,什么删了就再也追不回来
清洗这件事有个陷阱:删起来特别爽,而且删完看着更干净了。判据只有一条——
删掉之后还能不能从原件重新恢复? 能,就大胆删;不能,就先留着。
按这条判据把清洗动作分成两类。
放心删的:脚本与样式、导航栏与页脚、每页重复的页眉、纯装饰性的空白与分隔线、零宽字符和不可见控制符。它们的共同点是在原件里也没有信息量,删多少次都不会丢东西。页眉页脚有个额外好处:它们在每页的位置固定,按 y 值切掉上下两条带就行,但记得顺手打印剔除条数,确认没误伤正文——今天实验里两页的单栏 PDF 应该正好剔掉四条。
删了就追不回来的:页码(D6 的引用要靠它指回原文的第几页)、标题层级(丢了之后节点就成了一段没有上下文的孤立文字)、相对链接与来源地址、表格的行列关系、更新时间。这些东西一旦在解析阶段丢掉,唯一的补救是把原件重新解析一遍——而三年后你多半已经没有原件了,只剩一个索引。
还有一类介于两者之间:换行与空白。正文里压缩空白是对的,代码块里压缩空白就是灾难。所以清洗规则不能全局一刀切,得按节点类型分开走——这也是「统一中间表示要记录块类型」的理由之一。
元数据是后面所有功能的地基
现在回头看那八个字段,逐个说清它服务于谁——这一节是今天最该记住的部分,因为后面每一天都会回来取用其中某一个。
docId与chunkId:chunkId形如doc-005#c07,是全课引用体系的锚点。D6 让模型标出处时,标的就是这个编号;引用校验回查原文,查的也是它。没有稳定的编号,就没有可验证的引用,回答的可信度立刻退回到「模型说了算」。text:清洗后的正文,检索和生成都吃它。headingPath:一句话概括节点在全文里的位置。它有两个用途,一是让引用能告诉用户「这句话出自哪一节」,二是 D4 按文档结构切块时,标题层级天然就是语义边界——前提是今天没把它丢掉。page:只有 PDF 来源有。D6 的引用要精确到页,用户点开原文才能落到正确的位置。department:权限标签。D13 讲访问控制时会证明一件事——权限过滤必须下沉到检索查询里,在生成阶段才过滤等于内容已经泄露了。这个字段就是那时候的过滤条件,今天不打上,那天就得把全库重新解析一遍。updatedAt:D1 的实验里你已经见过它的用处:两篇文档结论打架时,提示词要求模型把两种说法都列出来并注明各自的更新日期,让用户自己判断。contentHash:整篇文档内容的指纹。D13 的增量同步靠它判断「这篇要不要重算」——文档变了才重新解析、重新切块、重新向量化。没有它,每次同步都是全量重建,一份三千篇的知识库每天重算一次,光 embedding 的账单就够看。
// 表格塌陷:一张表被解析成了对不上的形状
export function detectCollapsedTable(text) {
const lines = text.split('\n').filter((l) => l.trim())
const tableLines = lines.filter((l) => (l.match(/\|/g) ?? []).length >= 2)
if (tableLines.length === 0) return false
if (tableLines.length === 1) return true // 整张表塌成一行
const widths = new Set(tableLines.map((l) => l.split('|').length))
return widths.size > 1 // 各行列数对不上,就是串行了
}
// 空文本比例:抽出来几乎没字,多半是没有文本层的扫描件,该转 OCR
export function emptyRatio(nodes) {
if (nodes.length === 0) return 1
return nodes.filter((n) => n.text.trim().length < 4).length / nodes.length
}def detect_collapsed_table(text: str) -> bool:
"""表格塌陷:一张表被解析成了对不上的形状"""
lines = [l for l in text.split("\n") if l.strip()]
table_lines = [l for l in lines if l.count("|") >= 2]
if not table_lines:
return False
if len(table_lines) == 1:
return True # 整张表塌成一行
widths = {len(l.split("|")) for l in table_lines}
return len(widths) > 1 # 各行列数对不上,就是串行了
def empty_ratio(nodes: list) -> float:
"""空文本比例:抽出来几乎没字,多半是没有文本层的扫描件,该转 OCR"""
if not nodes:
return 1.0
return sum(1 for n in nodes if len(n.text.strip()) < 4) / len(nodes)最后交代一件今天不做的事:切块。这八个字段里的一个节点,是一个解析单元——一段话、一个列表、一张表,边界由文档自己的结构决定。它不一定是检索时用的那个块:一段话可能太短,一张表可能太长。怎么把节点重新组合成检索用的块,是明天一整天的题目,而且明天会证明那个决定必须靠评估而不是靠直觉。今天只负责一件事:把文档变成一串带结构、带出处、带元数据的节点,一个字段都不丢。
源码导读
动手实验
实验里的 PDF 和网页样例不从网上下载,而是用一段纯 JavaScript 从 D1 那份语料现造出来的:手写一个最小 PDF 字节流,把文字连同页码、坐标、字号一起写进内容流,再刻意排成双栏、把那张本来就串行的表压进去。这样做有两个好处——版权干净,以及离线可复现,你在飞机上也能跑。扫描件同样是假装的:不装任何 OCR 引擎,而是按「形近字替换加结构抹平」的规则造一份带噪声的纯文本,错字是确定性注入的,跑多少次都一样。
starter/ 里挖了四个练习点:标题栈、双栏阅读顺序、表格塌陷断言、内容指纹。原样跑起来会看到所有节点都是「(无标题路径)」,双栏 PDF 的乱序疑似度修不下去,那张坏表还会一路绿灯——四个练习各对应一类真实故障,做完才会全绿。
- 先跑一次 starter,把七段输出从头看到尾,记下哪几条断言是红的、分别红在哪一份文档上。
- 补完标题栈,重跑,看到示例行从「(无标题路径)」变成「任务板使用指南 / 泳道」这样的完整路径。
- 打开生成出来的双栏 PDF 样例对应的那段输出,把阅读顺序按分栏重排,看到乱序疑似度从 0.33 掉到 0.00,「顺着读前四段」那一行变通顺。
- 实现表格塌陷断言,重跑,看到那张缺了一列分隔符的表在 Markdown 原件和 PDF 两条路径上都被判红。
- 补完内容指纹,确认三份不同来源的指纹各不相同,再给其中一篇末尾补一句话重算一次,看到指纹变成另一个值——这是第 13 天增量同步的全部依据。
面试题
今天 4 道题在下方题库区,侧重解析质量对检索的传导、元数据设计,以及脏数据的检测与拦截。展开后先看"分析过程"再看要点——照着推导练,比背要点管用。标注"国内高频 / 海外高频"方便按目标市场取舍。
检查清单与明日预告
- 能说出 PDF 解析的三种典型失败:多栏乱序、表格塌成一行、页眉页脚混进正文,并各给一个应对办法
- 能设计一份文档元数据结构,让后面的引用标注、权限过滤和增量更新都有据可依
- 能写出一条把多来源文档统一成同一种中间表示的解析管道,并对解析结果做可断言的质量检查
- 能把八个字段逐个说清它服务于后面哪一天的哪个功能,尤其是权限标签和内容指纹
- 能说清「删了还能不能从原件恢复」这条清洗判据,并各举两个该删与不该删的例子
- 实验的 5 条验收标准全部通过,
out/nodes.json已经生成 - 4 道面试题不看要点也能答出至少 3 道
明天(D4)我们把今天这串节点重新组合成检索用的块,也就是切块:固定长度、递归、按文档结构、父子、语义五种切法各实现一遍。顺序是刻意的——切块的很多选项只有在解析保住了结构之后才存在,比如「按标题层级切」这一种,前提正是今天那条 headingPath。更重要的是,明天会立下一条从此不再动摇的规矩:块该切多大不能靠直觉,只能靠跑一遍评估看数字。
面试题库
一份 PDF 解析出来的文字顺序是乱的,你会怎么排查和修复?The text extracted from a PDF comes out in the wrong order. How do you diagnose and fix it?
国内高频海外高频进阶#pdf-parsing#ingestion#data-quality分析过程 · 先想清楚再作答
- 这题在考你有没有真的动手解析过 PDF。区分度在第一句:能不能说出「PDF 里根本没有阅读顺序」这个前提。答不出这句的人,后面只会说「换个库试试」。
- 先给排查顺序:把抽出来的文本片段连同页码、坐标、字号一起打印出来,别只看拼好的字符串。乱序的原因几乎都藏在坐标里,看纯文本永远看不出来。
- 然后按现象分三类。左右两栏一行一行地交替,是多栏没识别;同一段话被拆成很多短片段且 y 值有回跳,是内容流按绘制顺序写的;文字整体没问题但夹着重复出现的短句,那不是乱序,是页眉页脚没剔。
- 修法对应着来:多栏就重建阅读顺序——把每页文字块的左边界排序找最大空隙当分栏线,再按「栏号、y 从大到小、x 从小到大」重排;页眉页脚按固定的 y 值带切掉,并打印剔除条数确认没误伤。
- 补一条能证明你在生产里干过的话:修完要有可回归的判据,不能靠肉眼。用乱序疑似度——顺着排好的顺序走一遍,统计「同栏内往回跳」和「从右栏跳回左栏」的比例,它不需要标准答案,可以挂进流水线天天跑。
- 可预期的追问:多栏识别错了怎么办?回答分两头——把分栏判定做保守(空隙不够宽、或者一侧内容占比太低就按单栏处理),并且让断言在双栏被误判成单栏时同样会报警,宁可漏修也不要悄悄改错。
How to reason about it · think before answering
- This checks whether you have actually parsed a PDF yourself. The first sentence is the differentiator: a PDF has no reading order at all, only drawing instructions with coordinates.
- Start with the diagnostic step: dump the extracted fragments together with page, x, y and font size instead of looking at the concatenated string. The cause is always in the coordinates.
- Then classify the symptom. Lines alternating between left and right means multi-column layout was not detected. Fragments with y jumping backwards means the content stream was written in drawing order. Clean text sprinkled with a repeated short line is not disorder at all, it is a header or footer that was never stripped.
- Match the fix to the symptom. For columns, rebuild the order: sort the left edges of the fragments on each page, take the widest gap as the column boundary, then sort by column, then y descending, then x ascending. For headers and footers, cut fixed bands at the top and bottom and print how many fragments you dropped so you can confirm you did not cut into the body.
- Add the production-grade part: the fix needs a regression signal, not an eyeball check. Compute an out-of-order score by walking the sorted fragments and counting backward jumps within a column plus right-to-left column jumps. It needs no ground truth, so it can run on every ingest.
- Expected follow-up: what if column detection is wrong? Keep the detector conservative, treating a narrow gap or a lopsided split as single column, and make sure the assertion still fires when a two-column page is misread as one. Missing a fix is better than silently corrupting the order.
答题要点
- 前提先说清:PDF 只存「在某页某坐标画某段文字」,段落和阅读顺序都是解析时推出来的。
- 排查时把片段连同页码、坐标、字号一起打印,纯文本看不出乱序的原因。
- 三种典型成因:多栏没识别、内容流按绘制顺序写、页眉页脚没剔除。
- 多栏的修法是找最大 x 空隙定分栏线,再按「栏号、y 降序、x 升序」重排。
- 修完要有不依赖标准答案的回归指标,比如乱序疑似度,能挂进摄取流水线。
Key points
- State the premise: a PDF stores only drawing instructions, so paragraphs and reading order are inferred, not read.
- Debug by dumping fragments with page, coordinates and font size; plain text hides the cause.
- Three common causes: undetected multi-column layout, content stream written in drawing order, and headers or footers left in.
- Fix columns by finding the widest gap between left edges and sorting by column, then y descending, then x ascending.
- Add a ground-truth-free regression metric such as an out-of-order score so the fix stays fixed.
文档解析阶段应该保留哪些元数据?少了其中某一项会在哪个环节出问题?Which metadata should a document parsing stage preserve, and which downstream feature breaks if you drop each one?
国内高频海外高频进阶#metadata#ingestion#access-control分析过程 · 先想清楚再作答
- 这题最容易答成列清单。区分度不在你能列出几个字段,而在能不能给每个字段配一个具体的下游功能——列了八个字段却说不出谁在用,等于没设计过。
- 用一条判据把字段选出来:删掉之后还能不能从原件重新恢复。不能恢复的,解析时就必须留;能恢复的(比如格式、空白)可以放心丢。
- 然后一一对应地说:块编号支撑可验证的引用,没有它引用就只能靠模型自觉;标题路径支撑「这句话出自哪一节」和按结构切块;页码支撑引用精确到页;权限标签支撑检索层过滤;更新时间支撑材料冲突时的取舍;内容指纹支撑增量同步。
- 挑两个讲透代价。权限标签少了,等到要做访问控制时只能全量重新解析一遍;更糟的是有人会图省事在生成阶段过滤,那等于内容已经进了上下文,泄露已经发生。
- 内容指纹少了,每次同步都是全量重建:重新解析、重新切块、重新向量化。一份几千篇的知识库每天重算一次,光 embedding 的账单就够说服任何人。
- 可预期的追问:字段拿不准要不要留怎么办?答保守——存储是整条链路上最便宜的一环,加一个字段的代价远小于重跑一次全量解析。
How to reason about it · think before answering
- The trap here is answering with a bare list. The differentiator is pairing every field with a concrete downstream feature. Listing eight fields without naming who consumes them shows you never designed one.
- Give the selection rule first: can this be recovered from the original file later? If not, it must be captured at parse time. Formatting and whitespace can be dropped because the original still has them.
- Then map fields to consumers: a stable chunk id makes citations verifiable, a heading path tells the user which section a sentence came from and enables structure-aware chunking, page numbers make citations land on the right page, an access-control label enables filtering inside retrieval, an updated-at date resolves conflicting sources, and a content hash enables incremental sync.
- Take two of them all the way to cost. Without the access label you must re-parse the whole corpus when access control lands, and worse, people work around it by filtering at generation time, which means the content already reached the context and the leak already happened.
- Without a content hash, every sync is a full rebuild: re-parse, re-chunk, re-embed. For a few thousand documents synced daily, the embedding bill alone settles the argument.
- Expected follow-up: what about a field you are unsure of? Be conservative. Storage is the cheapest part of the pipeline, and adding a field costs far less than re-running a full parse.
答题要点
- 判据是「删了还能不能从原件恢复」,不能恢复的必须在解析时留下。
- 块编号服务于可验证的引用,标题路径服务于定位与按结构切块,页码服务于引用精确到页。
- 权限标签必须在解析时打上,否则做访问控制时要全量重解析,且容易被错误地放到生成阶段过滤。
- 更新时间用于材料冲突时并列两种说法,内容指纹用于增量同步,少了它每次都要全量重建。
- 拿不准就保守保留:加一个字段的成本远低于重跑一次全量解析。
Key points
- The rule is recoverability: if it cannot be recovered from the original later, capture it at parse time.
- Chunk ids back verifiable citations, heading paths back localisation and structure-aware chunking, page numbers make citations land precisely.
- Access-control labels must be attached during parsing, otherwise enabling ACL means re-parsing everything, and teams end up filtering at generation time where the leak has already occurred.
- Updated-at lets you present conflicting sources side by side; a content hash enables incremental sync instead of full rebuilds.
- When unsure, keep the field: storage is far cheaper than a full re-parse.
扫描件走光学字符识别之后错字率不低,这些噪声会怎样影响检索和生成?怎么缓解?OCR output from scanned documents carries a non-trivial error rate. How does that noise propagate into retrieval and generation, and how do you mitigate it?
国内高频海外高频深入#ocr#data-quality#hybrid-search分析过程 · 先想清楚再作答
- 这题考的是你会不会顺着链条推传导,而不是背「OCR 会有错字」这句废话。判据是有没有分别说清「检索侧怎么错」和「生成侧怎么错」——它们的失效方式完全不同。
- 先说检索侧。中文 OCR 的错主要是形近字,「已」认成「己」、「板」认成「版」。关键词检索是字面匹配,一个字错了这个词就查不到;更隐蔽的是二元组分词会连带毁掉相邻两个词元,一个错字影响的其实是两处。这一路的表现是召回悄悄掉下去,而且不报错。
- 再说生成侧。错字进了上下文,模型往往能读懂大意,但一旦是关键实体(人名、型号、金额、日期)出错,它会照着错的答,而且答得很自信。更麻烦的是引用校验也会跟着失效——原文本身就是错的,校验通过了也没意义。
- 缓解按三层说。入口层:先用空文本比例这类断言判断这份 PDF 有没有文本层,有就别走 OCR;真要走,保留原图链接以便人工复核。
- 检索层:靠混合检索兜底,向量一路对个别错字不敏感,能补上关键词一路的失手,这是 D9 那套东西在这里的具体价值。生成层:把低置信度的页面标出来,让模型在引用它们时明确提示「该材料来自扫描件,可能有识别误差」。
- 可预期的追问:能不能自动纠错?可以但要克制——用词典或模型做后处理会修好一批,也会「修」坏一批原本正确的专有名词。稳妥的做法是只对置信度低的片段做纠错,并且保留原文以便回退。
How to reason about it · think before answering
- This tests whether you can trace propagation rather than recite that OCR makes mistakes. The differentiator is separating how retrieval fails from how generation fails, because the two failure modes are entirely different.
- Retrieval first. Chinese OCR errors are mostly visually similar characters. Keyword search is literal, so one wrong character makes the term unmatchable, and bigram tokenisation makes it worse because a single wrong character corrupts two adjacent tokens. Recall drops quietly and nothing raises an error.
- Generation second. The model usually reads through minor noise, but when the corrupted token is a key entity such as a name, a model number, an amount or a date, it answers confidently with the wrong value. Citation checking degrades too: verifying against a source that is itself wrong proves nothing.
- Mitigate in three layers. At ingest, use an empty-text assertion to decide whether the PDF even needs OCR, and keep a link to the original image so a human can verify.
- At retrieval, hybrid search absorbs some of the damage because dense retrieval is less sensitive to a single wrong character than literal matching. At generation, mark low-confidence pages so the answer can state that the source came from a scan and may contain recognition errors.
- Expected follow-up: can you auto-correct? Yes, but carefully. Dictionary or model based post-processing fixes some errors and breaks correct proper nouns. Restrict correction to low-confidence spans and keep the raw text so you can fall back.
答题要点
- 检索侧:形近字让字面匹配直接查不到,二元组分词还会让一个错字毁掉相邻两个词元,表现是召回悄悄下降且不报错。
- 生成侧:模型能读懂大意,但关键实体出错时会自信地答错,引用校验也失去意义。
- 入口层缓解:先判断有没有文本层再决定要不要 OCR,并保留原图链接供人工复核。
- 检索层缓解:混合检索里的向量一路对个别错字不敏感,能兜住关键词一路的失手。
- 生成层缓解:标出低置信度来源,让回答显式提示可能存在识别误差;自动纠错只对低置信片段做并保留原文。
Key points
- Retrieval: visually similar characters break literal matching, and bigram tokenisation lets one bad character corrupt two tokens, so recall drops silently.
- Generation: the model reads through general noise but confidently repeats corrupted entities, and citation verification against a corrupted source proves nothing.
- At ingest: check for a text layer before running OCR at all, and keep the source image for human verification.
- At retrieval: hybrid search helps because dense retrieval tolerates a single wrong character better than literal matching.
- At generation: flag low-confidence sources in the answer, and restrict auto-correction to low-confidence spans while keeping the raw text.
为什么说解析质量决定了检索质量的上限?举一个具体的传导链条。Why is parsing quality the ceiling on retrieval quality? Walk through one concrete chain of propagation.
国内高频海外高频基础#ingestion#data-quality#failure-analysis分析过程 · 先想清楚再作答
- 这是一道送分题,但很多人答成口号。判据只有一个:有没有给出一条能落到具体现象上的链条,而不是重复一遍「垃圾进垃圾出」。
- 先说清位置:解析在切块、建索引、检索、组装、生成这五环之前,是第零环。它的错误会被后面每一环放大,而且后面每一环都无法察觉——它们只是在忠实地处理一段已经错了的文字。
- 给一条具体链条:一张套餐配额表在 PDF 里丢了一列分隔符,抽出来串了行;切块照着错误的边界切,「专业版」和隔壁那一栏的值被切进同一块;索引把错误的词对记进倒排表;用户问「专业版存储配额多少」,这一块分数很高被排到第一;模型只依据给定材料回答,于是给出一个错误但带着正确引用编号的答案。
- 点破最要命的一句:这条链上没有任何一环会报错,回答甚至是带出处的,看起来比平时更可信。所以解析的错误不能靠事后发现,只能靠入口处的断言拦。
- 反过来说明「上限」二字:后面所有优化——向量、混合检索、重排、查询改写——优化的都是「从候选里挑得更准」。材料本身错了,挑得再准也是错的,所以它们的天花板由解析封死。
- 可预期的追问:那怎么证明是解析的锅?答案接回 D1 那条习惯——排查从右往左看,把检索出来的原文打印出来自己读一遍,如果原文本身就是串行的,那就不用再往生成侧查了。
How to reason about it · think before answering
- This is a giveaway question that many people answer with a slogan. The only test is whether you produce a chain that lands on a concrete symptom instead of repeating garbage in, garbage out.
- Place it first: parsing sits before chunking, indexing, retrieval, context assembly and generation. Its errors are amplified by every later stage, and none of those stages can detect the problem because each is faithfully processing text that is already wrong.
- Give the chain: a pricing table in a PDF loses one column separator and comes out with cells shifted. Chunking splits on those wrong boundaries, so a plan name ends up next to the neighbouring column value. The index records the wrong term pairing. A user asks about that plan's storage quota, the corrupted chunk scores highest, and the model, faithfully answering only from the provided material, returns a wrong answer carrying a correct-looking citation.
- Name the nastiest part: nothing on that chain raises an error, and the answer even comes with a source, so it looks more trustworthy than usual. Parsing errors cannot be caught after the fact, only by assertions at ingest.
- Explain the word ceiling: every later optimisation, dense retrieval, hybrid search, reranking, query rewriting, improves how well you pick from the candidates. If the material itself is wrong, picking better still returns something wrong, so parsing caps all of them.
- Expected follow-up: how do you prove parsing is at fault? Reuse the habit from day one. Diagnose right to left and print the retrieved passages verbatim. If the source text is already scrambled, there is no point looking at the generation side.
答题要点
- 解析是五个环节之前的第零环,它的错误会被后面每一环放大,而后面每一环都察觉不到。
- 具体链条:表格串行 → 切块按错误边界切 → 倒排表记进错误词对 → 检索把它排第一 → 模型据此给出带引用的错误答案。
- 最危险的是全程零报错,且答案带着出处,看起来比平时更可信。
- 后面所有优化解决的是「挑得更准」,材料本身错了就都无效,所以上限由解析封死。
- 定位方法是排查从右往左:先把检索到的原文打印出来读一遍,原文错了就不必再查生成侧。
Key points
- Parsing is stage zero, before the five-stage pipeline; its errors are amplified downstream and invisible to every later stage.
- Concrete chain: a shifted table, chunking on wrong boundaries, wrong term pairs in the index, that chunk ranked first, and a wrong answer delivered with a citation.
- The dangerous part is that nothing errors out and the answer carries a source, so it looks more credible than usual.
- Later techniques only improve selection from candidates; if the material is wrong, better selection still returns something wrong.
- Diagnose right to left: print the retrieved passages first, and if the source text is already broken, stop looking at the generation side.
评论
登录后即可参与讨论
还没有评论,来说第一句。