Getting Documents In: Parsing PDF and HTML, Tables and Scans, Cleaning Rules, and Metadata You Must Keep
Retrieval quality's ceiling is parsing quality; today go all the way through the parsing pitfalls of PDF, HTML, and Markdown sources, handle tables and scanned pages, settle on a set of cleaning rules, and lock down the metadata — heading level, page number, source address — you'll need later for citations and filtering.
Today's Goals
- Name three typical PDF parsing failures — multi-column reordering, tables collapsing into one line, headers and footers leaking into the body — and give a fix for each
- Design a document metadata structure that gives later citation labeling, permission filtering, and incremental updates something to rely on
- Write a parsing pipeline that normalizes documents from multiple sources into one intermediate representation, and run an assertable quality check on the parsed result
For two days we experimented on a ready-made, spotless Markdown corpus. Today it goes away, replaced by how documents arrive in the real world: a pile of PDFs, a few help center pages, and one scanned document. Once you have read this and finished the lab, scroll back to the top and tick off the three goals.
Plain-Language Walkthrough
Look at the ingredients before the pan
A cook taking an order does not light the stove first but looks at the ingredients. Are the greens wilted, is the fish fresh, has the salt caked with damp — all of that gets checked before anything hits the pan. Because after it hits the pan, no precision of heat and no refinement of seasoning rescues an unfresh fish. Worse, when the diner tastes the problem, the blame falls on the cook's skill and nobody thinks of the fish.
A retrieval system is that kitchen. D1's index, D2's vectors, and the reranking and query rewriting of the coming eleven days are all heat and seasoning. What sets the ceiling is the ingredients coming into the kitchen — that is, parsing: turning PDFs, web pages, Word files, and scans into a searchable stream of text.
When that step breaks, the symptoms are extremely hard to recognize. It raises no error and does not crash; it quietly puts a row-crossed table into the index, a rule cut off mid-sentence, a line of "internal, do not distribute" that appears on every page. Then one day a user asks what the professional plan's storage quota is and the system confidently answers "unlimited" — because that table lost a column separator during parsing and "unlimited" was the value in the neighboring column.
Return to D1's five-stage diagram: chunking, indexing, retrieval, context assembly, generation. Parsing precedes all five as stage zero. Its errors get amplified by every later stage — chunking cuts along wrong text boundaries, indexing records wrong terms in the inverted list, retrieval confidently ranks it first, and generation cites it earnestly. That is the concrete transmission chain behind "parsing quality sets retrieval quality's ceiling", and reciting that chain is enough when an interview asks.
The three mountains of PDF
PDF is the most common and hardest format in an enterprise knowledge base. Hard because of one counter-intuitive fact:
A PDF file contains no paragraphs and no reading order.
What it stores is a pile of drawing instructions: on which page, at which coordinates, at what size, draw this run of text. Paragraphs, heading levels, and reading order are all inferred by you from coordinates and font sizes. What a real parsing library (pdfplumber in Python, pdfjs in JavaScript) hands you is exactly such a list of records. Three mountains follow.
The first mountain is multi-column reading order. Layout software often writes the content stream in bands from top to bottom by y value. That is fine for a single-column document; for two columns it comes out as left column line one, right column line one, left column line two, right column line two, alternating. Copy that order directly and the text is two columns interleaved — and each sentence alone reads fine, with the incoherence appearing only when read continuously. The fix is rebuilding the reading order yourself: sort each page's text blocks by left edge, find a gap wide enough to be a column boundary, then reorder by column, then y descending, then x ascending.
The second mountain is tables. A table in a PDF is a pile of aligned text blocks, with the rules drawn as graphics unrelated to the text. Extracted, a whole table may collapse into one line or each cell may become its own paragraph. The fix has two layers: where positions are available, cluster by x coordinate to restore columns; where they are not, at least recognize that it collapsed and mark it low-confidence and excluded from retrieval rather than letting it masquerade as body text.
The third mountain is scans. Some PDFs are one image per page with not a character in the text layer. The only road then is optical character recognition (OCR), reading the image as text. Two costs: either installing a system-level dependency or paying per page for a cloud service; and it will certainly make mistakes. In Chinese those mistakes are mostly visually similar characters, and in English they are letter confusions — rn read as m, l read as 1, O read as 0. Those errors propagate down the chain: tokenization splits wrongly, a term is missing from the inverted index, retrieval fails to hit, and the material the model sees carries typos. The mitigation is accepting it and compensating: keep a link to the original image for human review, let D9's vector path cover the keyword path's misses, and mark low-confidence pages separately.
HTML and Markdown: easy to parse is not easy to use
Compared with PDF, HTML and Markdown are a gift — they carry their own structure. A heading is a heading tag, a paragraph is a paragraph tag, a list is a list tag, with nothing to guess from coordinates. Markdown needs no parsing library at all; one line-by-line scan does it.
But easy to parse is not easy to use, and their pitfall is at the other end: over half of a web page's content does not belong in the body. Navigation bars, a sidebar's related documents, a footer's registration number, analytics scripts embedded in the page — all identical on every page. Keeping them adds the same passage to every document in the store, lowering discrimination and letting users retrieve content with no information in it. So the first step of HTML parsing is not extraction but wholesale deletion: scripts, styles, navigation, sidebars, footers, all swept out by selector.
Three things are easily dropped along the way and are a shame to lose:
- Code blocks have meaningful spaces and newlines, and running them through the ordinary paragraph's whitespace-collapsing rule kneads them into one line where the reader can no longer see the indentation levels.
- Footnotes leave only a superscript number in the body with the real content at the page's foot. Extract without reattaching it to the reference point and the user sees a lonely bracketed number.
- Relative links such as
./doc-002.mdare genuine metadata telling you which document this one points at. Dropping it takes one line of code and recovering it means reparsing the original. Today's lab inlines it into the text — writing "the task board (link: ./doc-001.md)" — adding no field and losing nothing.
Markdown has far fewer pitfalls, mainly two: a blank line inside a code fence must not act as a paragraph separator (or a shell snippet gets cut into three unrelated chunks), and a heading must not become a node of its own — it is too short and worthless retrieved alone; the right approach makes it the heading path of the following nodes, which is the next section's subject.
One intermediate representation: every source becomes the same stream of nodes
Three parsers, three output formats, and eleven days of writing handling logic for each? Of course not. Parsing's real product is converging every source onto one data structure: a stream of nodes with structure and provenance. From there on, D4's chunking, D6's citations, and D13's permission filtering know only this structure and no longer care whether a document was a PDF or a web page.
This course fixes that structure at eight fields, not to be renamed across fourteen days:
import { createHash } from 'node:crypto'
// The course-wide parse node. One node = one parse unit (a paragraph, a list, a table)
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, // only PDF sources have a page; every other source is null, never 0 and never blank
department: meta.department, // the permission label, used directly by D13's access control
updatedAt: meta.updatedAt,
contentHash: meta.contentHash, // one fingerprint shared by the whole document
}
}
// Normalize newlines before hashing: the same file uploaded from two machines differs in bytes
// and matches in content, and without normalization every sync judges it changed and recomputes the store
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:
"""The course-wide parse node. One node = one parse unit (a paragraph, a list, a table)"""
doc_id: str
chunk_id: str
text: str
heading_path: list[str] = field(default_factory=list)
page: int | None = None # only PDF sources have a page; every other source is None
department: str = "" # the permission label, used directly by D13's access control
updated_at: str = ""
content_hash: str = "" # one fingerprint shared by the whole document
def make_chunk_id(doc_id: str, index: int) -> str:
return f"{doc_id}#c{index + 1:02d}"
def content_hash(raw: str) -> str:
"""Normalize newlines before hashing: the same file from two machines differs in bytes and
matches in content, and without normalization every sync judges it changed and recomputes"""
normalized = raw.replace("\r\n", "\n").strip()
return hashlib.sha256(normalized.encode("utf-8")).hexdigest()[:16]Among the eight, headingPath is the most often written wrongly. It is not this node's previous heading but the complete path down from the level-one heading, such as "Plans and Quotas / Notes." Maintaining it needs only a stack: on reading a level-n heading, truncate the stack to n minus 1 and then append. That truncation is the key — retreating from level three to level two must discard the third level, or the path staggers and a reader following the citation back lands in a different section.
export function pushHeading(stack, level, title) {
const next = stack.slice(0, level - 1) // retreating a level must discard the deeper headings
while (next.length < level - 1) next.push('') // level one jumping to level three; pad to avoid staggering
next.push(title)
return next
}
// On reading "## Swimlanes": pushHeading(['Task Board Guide'], 2, 'Swimlanes')
// gives ['Task Board Guide', 'Swimlanes'], and every later node carries that pathdef push_heading(stack: list[str], level: int, title: str) -> list[str]:
nxt = stack[: level - 1] # retreating a level must discard the deeper headings
nxt += [""] * (level - 1 - len(nxt)) # level one jumping to level three; pad to avoid staggering
nxt.append(title)
return nxt
# On reading "## Swimlanes": push_heading(["Task Board Guide"], 2, "Swimlanes")
# gives ["Task Board Guide", "Swimlanes"], and every later node carries that pathAs for a multi-column PDF's reading order, the code to rebuild it is shorter than expected: find the gap, split the columns, go top to bottom within a column.
// Sort every text block's left edge on a page; the largest gap is the column boundary
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 // the gap is not wide enough; judged single-column
const boundary = (best.left + best.right) / 2
const ratio = runs.filter((r) => r.x >= boundary).length / runs.length
// Both sides need a decent volume of text, or that "gap" is most likely just an indent
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:
"""Sort every text block's left edge on a page; the largest gap is the column boundary"""
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: # the gap is not wide enough; judged single-column
return None
boundary = (left + right) / 2
ratio = sum(1 for r in runs if r["x"] >= boundary) / len(runs)
# Both sides need a decent volume of text, or that "gap" is most likely just an indent
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"]))The boundary of cleaning: what to delete, and what can never be recovered
Cleaning has a trap: deleting feels great and everything looks tidier afterwards. There is one criterion —
Could it be recovered from the original afterwards? If yes, delete boldly; if no, keep it for now.
By that criterion, cleaning actions fall into two classes.
Safe to delete: scripts and styles, navigation bars and footers, page headers repeated on every page, purely decorative whitespace and rules, zero-width characters and invisible control codes. What they share is having no information in the original either, so deleting them any number of times loses nothing. Headers and footers have an extra advantage: their position on each page is fixed, so cutting a band off the top and bottom by y value works — but remember to print how many were removed and confirm no body text was hit; in today's lab a two-page single-column PDF should remove exactly four.
Unrecoverable once deleted: page numbers (D6's citations point back to a page), heading levels (lost, and a node becomes an isolated stretch of text with no context), relative links and source addresses, a table's row-column relationships, and the update time. Once any of those is lost at parse time, the only remedy is reparsing the original — and three years later you most likely no longer have the original, only an index.
One class sits between the two: newlines and whitespace. Collapsing whitespace is right in body text and a disaster in a code block. So cleaning rules cannot be applied globally and must branch by node type — one of the reasons the unified intermediate representation records a block type.
Metadata is the foundation of every later feature
Now revisit those eight fields and say who each serves — this section is today's most important, because every later day comes back to draw on one of them.
docIdandchunkId: achunkIdshaped likedoc-005#c07is the anchor of the whole course's citation system. When D6 has the model mark a source, that id is what it marks; when citation validation checks back against the original, that is what it checks. Without a stable id there is no verifiable citation, and an answer's credibility falls straight back to whatever the model says.text: the cleaned body, consumed by retrieval and generation.headingPath: one line summarizing the node's position in the whole document. It has two uses: letting a citation tell the user which section a sentence came from, and giving D4's structure-based chunking natural semantic boundaries in the heading levels — provided it was not dropped today.page: only PDF sources have it. D6's citations need page precision so a user opening the original lands in the right place.department: the permission label. D13 on access control proves one thing — permission filtering must sink into the retrieval query, since filtering at generation time means the content already leaked. This field is that filter condition, and not stamping it today means reparsing the whole store then.updatedAt: D1's lab already showed its use: when two documents' conclusions clash, the prompt requires the model to list both with their update dates and let the user judge.contentHash: the fingerprint of the whole document's content. D13's incremental sync uses it to decide whether a document needs recomputing — reparse, rechunk, and re-embed only what changed. Without it every sync is a full rebuild, and recomputing a three-thousand-document knowledge base daily makes the embedding bill alone worth looking at.
// A collapsed table: one table parsed into a shape that does not line up
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 // the whole table collapsed into one line
const widths = new Set(tableLines.map((l) => l.split('|').length))
return widths.size > 1 // rows with disagreeing column counts have crossed
}
// The empty text ratio: almost no characters extracted usually means a scan with no text layer
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:
"""A collapsed table: one table parsed into a shape that does not line up"""
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 # the whole table collapsed into one line
widths = {len(l.split("|")) for l in table_lines}
return len(widths) > 1 # rows with disagreeing column counts have crossed
def empty_ratio(nodes: list) -> float:
"""The empty text ratio: almost no characters extracted usually means a scan with no text layer"""
if not nodes:
return 1.0
return sum(1 for n in nodes if len(n.text.strip()) < 4) / len(nodes)One last note on something today does not do: chunking. A node in those eight fields is a parse unit — a paragraph, a list, a table — with boundaries set by the document's own structure. It is not necessarily the chunk used for retrieval: a paragraph may be too short and a table too long. How nodes are recombined into retrieval chunks is tomorrow's whole subject, and tomorrow proves that decision must rest on evaluation rather than intuition. Today does one thing only: turn documents into a stream of nodes with structure, provenance, and metadata, losing not one field.
Source Reading
Hands-On Lab
The lab's PDF and web page samples are not downloaded; they are manufactured from D1's corpus by a piece of plain JavaScript: a minimal PDF byte stream written by hand, with text plus page numbers, coordinates, and font sizes in the content stream, deliberately laid out in two columns with that already-crossed table pressed in. That has two benefits — clean provenance, and offline reproducibility, so it runs on a plane. The scan is likewise simulated: no OCR engine is installed, and a noisy plain-text version is manufactured by a rule of visually-similar substitution plus structure flattening, with the errors injected deterministically so every run matches.
starter/ has four exercise points cut out: the heading stack, two-column reading order, the collapsed table assertion, and the content fingerprint. Run as-is, every node shows "(no heading path)", the two-column PDF's disorder rate will not come down, and that broken table sails through — the four exercises each map to a real class of failure, and only finishing them turns everything green.
- Run the starter once and read all seven sections of output, noting which assertions are red and on which document each one is.
- Complete the heading stack, rerun, and watch the sample line go from "(no heading path)" to a full path such as "Task Board Guide / Swimlanes".
- Open the output for the generated two-column PDF sample, reorder the reading order by column, and watch the disorder rate fall from 0.33 to 0.00 while the "first four paragraphs in order" line becomes coherent.
- Implement the collapsed table assertion, rerun, and see the table missing a column separator flagged red on both the Markdown original and the PDF path.
- Complete the content fingerprint, confirm the three sources' fingerprints all differ, then append one sentence to one of them and recompute, watching the fingerprint change — the entire basis of day 13's incremental sync.
Interview Questions
Today's 4 questions are in the question bank below, weighted toward how parsing quality transmits into retrieval, metadata design, and detecting and blocking dirty data. Expand each one and read the analysis before the key points — practicing the derivation beats memorizing bullets. The high-frequency labels for the domestic and overseas markets are there so you can pick by target market.
Checklist and Tomorrow
- Name three typical PDF parsing failures — multi-column reordering, tables collapsing into one line, headers and footers leaking into the body — and give a fix for each
- Design a document metadata structure that gives later citation labeling, permission filtering, and incremental updates something to rely on
- Write a parsing pipeline that normalizes documents from multiple sources into one intermediate representation, and run an assertable quality check on the parsed result
- Say for each of the eight fields which feature of which later day it serves, especially the permission label and the content fingerprint
- State the "could it be recovered from the original" cleaning criterion, with two examples each of what to delete and what to keep
- All 5 acceptance criteria of the lab pass, with
out/nodes.jsongenerated - Answer at least 3 of the 4 interview questions without looking at the key points
Tomorrow (D4) we recombine today's stream of nodes into retrieval chunks — chunking — implementing five approaches: fixed length, recursive, structure-based, parent-child, and semantic. The order is deliberate: many chunking options exist only because parsing preserved the structure, "cut by heading level" being exactly one whose precondition is today's headingPath. More importantly, tomorrow establishes a rule that never moves again: how large a chunk should be cannot rest on intuition and can only rest on running an evaluation and reading the numbers.
Interview questions
The text extracted from a PDF comes out in the wrong order. How do you diagnose and fix it?一份 PDF 解析出来的文字顺序是乱的,你会怎么排查和修复?
Common in ChinaCommon overseasIntermediate#pdf-parsing#ingestion#data-qualityHow 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。区分度在第一句:能不能说出「PDF 里根本没有阅读顺序」这个前提。答不出这句的人,后面只会说「换个库试试」。
- 先给排查顺序:把抽出来的文本片段连同页码、坐标、字号一起打印出来,别只看拼好的字符串。乱序的原因几乎都藏在坐标里,看纯文本永远看不出来。
- 然后按现象分三类。左右两栏一行一行地交替,是多栏没识别;同一段话被拆成很多短片段且 y 值有回跳,是内容流按绘制顺序写的;文字整体没问题但夹着重复出现的短句,那不是乱序,是页眉页脚没剔。
- 修法对应着来:多栏就重建阅读顺序——把每页文字块的左边界排序找最大空隙当分栏线,再按「栏号、y 从大到小、x 从小到大」重排;页眉页脚按固定的 y 值带切掉,并打印剔除条数确认没误伤。
- 补一条能证明你在生产里干过的话:修完要有可回归的判据,不能靠肉眼。用乱序疑似度——顺着排好的顺序走一遍,统计「同栏内往回跳」和「从右栏跳回左栏」的比例,它不需要标准答案,可以挂进流水线天天跑。
- 可预期的追问:多栏识别错了怎么办?回答分两头——把分栏判定做保守(空隙不够宽、或者一侧内容占比太低就按单栏处理),并且让断言在双栏被误判成单栏时同样会报警,宁可漏修也不要悄悄改错。
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.
答题要点
- 前提先说清:PDF 只存「在某页某坐标画某段文字」,段落和阅读顺序都是解析时推出来的。
- 排查时把片段连同页码、坐标、字号一起打印,纯文本看不出乱序的原因。
- 三种典型成因:多栏没识别、内容流按绘制顺序写、页眉页脚没剔除。
- 多栏的修法是找最大 x 空隙定分栏线,再按「栏号、y 降序、x 升序」重排。
- 修完要有不依赖标准答案的回归指标,比如乱序疑似度,能挂进摄取流水线。
Which metadata should a document parsing stage preserve, and which downstream feature breaks if you drop each one?文档解析阶段应该保留哪些元数据?少了其中某一项会在哪个环节出问题?
Common in ChinaCommon overseasIntermediate#metadata#ingestion#access-controlHow 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.
分析过程 · 先想清楚再作答
- 这题最容易答成列清单。区分度不在你能列出几个字段,而在能不能给每个字段配一个具体的下游功能——列了八个字段却说不出谁在用,等于没设计过。
- 用一条判据把字段选出来:删掉之后还能不能从原件重新恢复。不能恢复的,解析时就必须留;能恢复的(比如格式、空白)可以放心丢。
- 然后一一对应地说:块编号支撑可验证的引用,没有它引用就只能靠模型自觉;标题路径支撑「这句话出自哪一节」和按结构切块;页码支撑引用精确到页;权限标签支撑检索层过滤;更新时间支撑材料冲突时的取舍;内容指纹支撑增量同步。
- 挑两个讲透代价。权限标签少了,等到要做访问控制时只能全量重新解析一遍;更糟的是有人会图省事在生成阶段过滤,那等于内容已经进了上下文,泄露已经发生。
- 内容指纹少了,每次同步都是全量重建:重新解析、重新切块、重新向量化。一份几千篇的知识库每天重算一次,光 embedding 的账单就够说服任何人。
- 可预期的追问:字段拿不准要不要留怎么办?答保守——存储是整条链路上最便宜的一环,加一个字段的代价远小于重跑一次全量解析。
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 localization 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?扫描件走光学字符识别之后错字率不低,这些噪声会怎样影响检索和生成?怎么缓解?
Common in ChinaCommon overseasDeep dive#ocr#data-quality#hybrid-searchHow 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 tokenization 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 会有错字」这句废话。判据是有没有分别说清「检索侧怎么错」和「生成侧怎么错」——它们的失效方式完全不同。
- 先说检索侧。中文 OCR 的错主要是形近字,「已」认成「己」、「板」认成「版」。关键词检索是字面匹配,一个字错了这个词就查不到;更隐蔽的是二元组分词会连带毁掉相邻两个词元,一个错字影响的其实是两处。这一路的表现是召回悄悄掉下去,而且不报错。
- 再说生成侧。错字进了上下文,模型往往能读懂大意,但一旦是关键实体(人名、型号、金额、日期)出错,它会照着错的答,而且答得很自信。更麻烦的是引用校验也会跟着失效——原文本身就是错的,校验通过了也没意义。
- 缓解按三层说。入口层:先用空文本比例这类断言判断这份 PDF 有没有文本层,有就别走 OCR;真要走,保留原图链接以便人工复核。
- 检索层:靠混合检索兜底,向量一路对个别错字不敏感,能补上关键词一路的失手,这是 D9 那套东西在这里的具体价值。生成层:把低置信度的页面标出来,让模型在引用它们时明确提示「该材料来自扫描件,可能有识别误差」。
- 可预期的追问:能不能自动纠错?可以但要克制——用词典或模型做后处理会修好一批,也会「修」坏一批原本正确的专有名词。稳妥的做法是只对置信度低的片段做纠错,并且保留原文以便回退。
Key points
- Retrieval: visually similar characters break literal matching, and bigram tokenization 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.
答题要点
- 检索侧:形近字让字面匹配直接查不到,二元组分词还会让一个错字毁掉相邻两个词元,表现是召回悄悄下降且不报错。
- 生成侧:模型能读懂大意,但关键实体出错时会自信地答错,引用校验也失去意义。
- 入口层缓解:先判断有没有文本层再决定要不要 OCR,并保留原图链接供人工复核。
- 检索层缓解:混合检索里的向量一路对个别错字不敏感,能兜住关键词一路的失手。
- 生成层缓解:标出低置信度来源,让回答显式提示可能存在识别误差;自动纠错只对低置信片段做并保留原文。
Why is parsing quality the ceiling on retrieval quality? Walk through one concrete chain of propagation.为什么说解析质量决定了检索质量的上限?举一个具体的传导链条。
Common in ChinaCommon overseasBasic#ingestion#data-quality#failure-analysisHow 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 neighboring 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 optimization, 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.
分析过程 · 先想清楚再作答
- 这是一道送分题,但很多人答成口号。判据只有一个:有没有给出一条能落到具体现象上的链条,而不是重复一遍「垃圾进垃圾出」。
- 先说清位置:解析在切块、建索引、检索、组装、生成这五环之前,是第零环。它的错误会被后面每一环放大,而且后面每一环都无法察觉——它们只是在忠实地处理一段已经错了的文字。
- 给一条具体链条:一张套餐配额表在 PDF 里丢了一列分隔符,抽出来串了行;切块照着错误的边界切,「专业版」和隔壁那一栏的值被切进同一块;索引把错误的词对记进倒排表;用户问「专业版存储配额多少」,这一块分数很高被排到第一;模型只依据给定材料回答,于是给出一个错误但带着正确引用编号的答案。
- 点破最要命的一句:这条链上没有任何一环会报错,回答甚至是带出处的,看起来比平时更可信。所以解析的错误不能靠事后发现,只能靠入口处的断言拦。
- 反过来说明「上限」二字:后面所有优化——向量、混合检索、重排、查询改写——优化的都是「从候选里挑得更准」。材料本身错了,挑得再准也是错的,所以它们的天花板由解析封死。
- 可预期的追问:那怎么证明是解析的锅?答案接回 D1 那条习惯——排查从右往左看,把检索出来的原文打印出来自己读一遍,如果原文本身就是串行的,那就不用再往生成侧查了。
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.
答题要点
- 解析是五个环节之前的第零环,它的错误会被后面每一环放大,而后面每一环都察觉不到。
- 具体链条:表格串行 → 切块按错误边界切 → 倒排表记进错误词对 → 检索把它排第一 → 模型据此给出带引用的错误答案。
- 最危险的是全程零报错,且答案带着出处,看起来比平时更可信。
- 后面所有优化解决的是「挑得更准」,材料本身错了就都无效,所以上限由解析封死。
- 定位方法是排查从右往左:先把检索到的原文打印出来读一遍,原文错了就不必再查生成侧。