夜雨聆风学习资料网

ARTICLE · 1266754

我们真的会搭建材料智能体吗(七):从 PDF 到带出处的文本块

我们真的会搭建材料智能体吗(七):从 PDF 到带出处的文本块

本篇要点

· 前六篇的"材料"都是手写的;这一篇,让它从一篇真 PDF 里长出来

· PDF 里没有"文字",只有"在坐标处画这个字形"——顺序和分栏,全得自己重建

· 每个文本块领一张身份证:页码 + 字符区间,凭它能把原文逐字切回来

· 真跑一遍:11 页的论文切成 30 块,没有一块切不回原文

第 5 篇,材料是你手写的三段;第 6 篇,样品库是你手写的三条。它们都在替你干活,但都依赖同一件事:原文已经整整齐齐地摆在那儿了。

现实里没有这种好事。你手里有的是 PDF。这一篇,就把它拆开。

拆开之后你会发现一件有点意外的事:让机器"读"一篇文章,最难的不是读懂——恰恰相反,第一道坎是让它先把字认对、把顺序排对。这一步过不去,后面所有的检索、答题、引用,全是假的。

一、打开就能读?PDF 不是文本文件

很多人以为读 PDF 就是把它当 txt 打开。不是。

PDF 里存的不是"一段段的话",而是一条条绘图指令:在坐标 (x, y) 这个位置,画这个字形。一页纸上有几百个这样的坐标,而"谁先谁后、哪一栏接哪一栏",在文件里根本不存在——它藏在坐标里,得你自己排。

再加几样日常的干扰:页边竖排的下载水印、每页都变着页码出现的期刊页眉、双栏排版里被劈成两截的单词。直接把它们和正文拼在一起,垃圾便跟着进了下游。

所以"把 PDF 变成文本"这件事,压根不是 pdf.read() 一行。它是一道独立的工序——这一篇要亲手做的,就是这道工序。

二、先看一眼:不重建会得到什么

我们拿一篇真论文来试:Advanced Functional Materials 上的一篇锂硫电池单原子催化剂论文,11 页。先看它的第 8 页长什么样:

第 8 页顶部局部 · 原文:Adv. Funct. Mater. 2024, 35, 2412279

再用 pdfplumber 打开它,顺手调一下它自带的文本提取,看它吐出来什么:

www.advancedsciencenews.com www.afm-journal.dearea,manifestingthehighestutilizationofactivesubstance.[32] barePPandCoPNCweredisassembled.ItcanbeseenthattheInthedischargestage,thesecondreductionpeak(2.05V)rep- LimetalsurfaceofthebarePPcellisunevenandcoveredwith

看出来了吗?上图里,左栏的头两行和右栏的头两行,到了下面这份输出里被两两焊在了一起:rep- Limetalsurface 就是把左栏行尾和右栏行头拼到了一起,单词之间的空格也丢了。

(这段是原样输出,我一个字没改。)它不是坏了,它是老老实实按它以为的顺序把字吐了出来。谁让 PDF 里本来就没有"顺序"这个属性呢。

三、第一层:把行重建出来

重建这一步,说穿了只有三个动作:

· 聚行:先扔掉"躺倒"的字(页边 90° 的水印),再按纵坐标把字符聚成一行一行;

· 找走道:双栏排版的两栏之间,有一条竖直的"走道"——绝大多数行都不往那儿放字。找到它,把每行切成左右两半;横跨走道的,就是通栏行(标题、图表题注);

· 排顺序:通栏行把页面切成上下几段,每一段里先左栏、后右栏。

页眉页脚则分两层清。明显是网址、版权、DOI、纯页码的,用一组正则直接干掉;没见过的,靠一个更聪明的线索——"跨页重复":页边那种每页都出现、只有页码在变的短行,归一化之后数一数它出现在多少页上,够多就清掉。这样哪怕换一家出版社,也不用再加规则。

下面是这一步的前半段:最上面的准备(常量、"补空格"、找走道):

"""最小解析:把一篇 PDF 切成"带出处的文本块"。只依赖 pdfplumber 和标准库。"""import hashlibimport reimport sysfrom bisect import bisect_rightfrom collections import Counterimport pdfplumberMAX_CHARS = 1500     # 单块字符上限WORD_GAP = 0.8       # 相邻两字间隙超过它 → 中间本该有个空格MIN_GUTTER = 6       # 页宽中段最宽的"空带"比它还宽 → 认定这页分栏# 已知的页眉页脚模样:网址、版权、DOI、文章类型行、纯页码NOISE = re.compile(r"www\.|https?://|[©\u00a9]|^doi\b|\b10\.\d{4,}/"                   r"|^\s*(research article|review|communication|full paper)\s*$"                   r"|^\s*\d{1,4}\s*$", re.I)# ---------- 第一层:把 PDF 读成"按阅读顺序排列、去掉页眉页脚的行" ----------def _text_of(chars):    """相邻两字间隙够大就补一个空格——PDF 里本来并没有"空格"这个字符。"""    out = ""    for i, c in enumerate(chars):        if i and c["x0"] - chars[i - 1]["x1"] > WORD_GAP:            out += " "        out += c["text"]    return out.strip()def _seg(chars, col):    return {"text": _text_of(chars), "col": col, "top": sum(c["top"] for c in chars) / len(chars)}def _gutter(lines, width):    """找"走道":两栏之间那条绝大多数行都不放字符的竖直空带;找不到就是单栏。"""    lo, hi, total = int(0.25 * width), int(0.75 * width), len(lines)    empty = [0] * (hi - lo)    for cs in lines:        cov = bytearray(int(width) + 1)        for c in cs:            for x in range(int(c["x0"]), min(int(c["x1"]) + 1, len(cov))):                cov[x] = 1        for k, x in enumerate(range(lo, hi)):            if not cov[x]:                empty[k] += 1    best, i = (0, None, None), 0    while i < len(empty):        if empty[i] >= 0.8 * total:                      # 八成以上的行,这里都空着            j = i            while j < len(empty) and empty[j] >= 0.8 * total:                j += 1            if j - i > best[0]:                best = (j - i, lo + i, lo + j)            i = j        else:            i += 1    return (best[1] + best[2]) / 2 if best[0] >= MIN_GUTTER else None

接着是后半段:按走道切栏、排阅读顺序;再把全文拼起来,顺手清掉页眉页脚:

def _segments(chars, width):    """字符 → 行段:先按纵坐标聚成行;整行横跨走道的算通栏行(S),    其余按走道切成左栏(L) / 右栏(R)。"""    chars = sorted(chars, key=lambda c: (c["top"], c["x0"]))    lines, cur = [], []    for c in chars:        if cur and c["top"] - cur[0]["top"] > 4:            lines.append(cur)            cur = []        cur.append(c)    if cur:        lines.append(cur)    gutter = _gutter(lines, width)    segs = []    for cs in lines:        if gutter is None or any(abs(c["x0"] - gutter) <= 6 for c in cs):            segs.append(_seg(cs, "S"))                   # 通栏行:标题、题注、宽公式            continue        left = [c for c in cs if c["x0"] < gutter]        right = [c for c in cs if c["x0"] >= gutter]        if left:            segs.append(_seg(left, "L"))        if right:            segs.append(_seg(right, "R"))    return segsdef _order(segs):    """阅读顺序:通栏行把页面切成上下几段,每段里先左栏、后右栏。"""    slot = {"S": 0, "L": 1, "R": 2}    spans = [s for s in segs if s["col"] == "S"]    return sorted(segs, key=lambda s: (sum(1 for w in spans if w["top"] < s["top"]),                                       slot[s["col"]], s["top"]))def _norm(text):    """小写、空白折叠、任意数字串→#:这样逐页变化的页码也不影响比对。"""    return re.sub(r"\s+", " ", re.sub(r"\d+", "#", text.lower())).strip()def read_pdf(path):    with pdfplumber.open(path) as pdf:        pages = [{"height": pg.height,                  "segs": _order(_segments([c for c in pg.chars if c["upright"]], pg.width))}                 for pg in pdf.pages]    with open(path, "rb") as fh:        doc_id = hashlib.sha1(fh.read()).hexdigest()[:12]     # 身份证:文件内容哈希    # 跨页重复的页眉页脚:页边区域(上 25% / 下 20%)的短行,归一化后数出现页数    seen = Counter()    for p in pages:        for s in p["segs"]:            if len(s["text"]) <= 100 and (s["top"] < 0.25 * p["height"]                                          or s["top"] > 0.80 * p["height"]):                seen[_norm(s["text"])] += 1    running = {k for k, n in seen.items() if n >= 3}         # 出现在 3 页以上 → 判为页眉页脚    lines, dropped = [], 0    for i, p in enumerate(pages, 1):        for s in p["segs"]:            if NOISE.search(s["text"]) or (len(s["text"]) <= 100 and _norm(s["text"]) in running):                dropped += 1                                  # 已知模样或跨页重复 → 丢掉                continue            lines.append({"page": i, "text": s["text"]})    pos = 0    for ln in lines:                                          # 拼全文,同时记下每行的字符区间        ln["start"] = pos        pos += len(ln["text"]) + 1                            # +1 是行间那个换行符    return doc_id, "\n".join(ln["text"] for ln in lines), lines, dropped

四、第二层:切块,给每块发一张身份证

行重建好,全文就是一串字符了。接下来切块,有两个讲究。

第一,按句子边界切,不按字数硬切。最省事的做法是"攒够字数就封块",但那样切出来的块,十有八九是从半句话开始的——对人工抽查、对后面要抽取的内容,都是灾难。

第二,每块都要领一张身份证。身份证上有两样东西:起始页码,和在全文里的字符区间(start, end)。块文本为full_text[start:end]原样切出来的,一个字符都不改、空白也不"美化"。

这就是这张身份证的全部意义:把块文本和原文的那一段钉死在一起。凭它,任何时候都能从原文逐字切回这一块。这一步的行话叫 roundtrip(回切),它是整个溯源链条的第一环。

第二层的代码:

# ---------- 第二层:按句子边界切块,给每块发"身份证" ----------ABBR = {"fig", "figs", "no", "eq", "vs", "approx", "cf", "ref", "al", "i.e", "e.g", "etc"}def _last_sentence_end(text):    """缓冲里最后一个可靠的句子边界;宁可块大一点,也绝不切错。"""    best = None    for m in re.finditer(r"[.!?]", text):        tail = text[m.end():]        ref = re.match(r"\s*\[[0-9,\s\-]+\]", tail)           # 句点紧跟或被空白隔开的引用        if ref is None and tail and not tail[0].isspace():            continue                                          # 0.92 这种小数点,不是句边界        rest = (tail[ref.end():] if ref else tail).lstrip()        if not (rest and (rest[0].isupper() or rest[0].isdigit() or rest[0] in "\"'([")):            continue                                          # 后面不像新句子的开头,不算        word = re.findall(r"([A-Za-z.]+)$", text[:m.start()])        if word and word[0].strip(".").lower() in ABBR:            continue                                          # Fig. / et al. 这种缩写,不算        if word and len(word[0].strip(".")) == 1:            continue                                          # 孤零零一个首字母(G. Cai),不算        best = m.end() + (ref.end() if ref else 0)    return bestdef chunk(full_text, lines, limit=MAX_CHARS):    starts = [ln["start"] for ln in lines]                    # 每行起点:用于把位置换算成页码    pages = [ln["page"] for ln in lines]    chunks, pos, n = [], 0, len(full_text)    while pos < n:        end = min(pos + limit, n)        if end < n:            cut = _last_sentence_end(full_text[pos:end])            if cut is not None and cut < end:                end = pos + cut                               # 缓冲超限 → 在最后一个句边界封块            else:                nl = full_text.rfind("\n", pos, end)          # 找不到句点 → 退到行边界                end = nl + 1 if nl > pos else end             # 连行边界都没有 → 硬切上限        chunks.append({"index": len(chunks), "page": pages[bisect_right(starts, pos) - 1],                       "span": (pos, end), "text": full_text[pos:end]})        pos = end        while pos < n and full_text[pos].isspace():            pos += 1                                          # 句间空白不进任何一块    return chunks

五、跑一遍

把论文路径传进去,跑:

python parse.py "你的某篇论文.pdf"

真实的输出,一个字没改:

11 页的论文,重建出 38387 个字符、609 行;切成 30 块,没有一块切不回原文。第 6 块的身份证是"页码 2、区间 (7252, 8668)",把它拿回全文里切一刀,得到的正是这一块本身。

页眉探针那行也值得看:www.advancedsciencenews.com 这种页眉,原始提取里出现了 10 次,重建之后一次都不剩——它们没进任何一块。

六、为什么不能"攒够字数就切"

句边界这一关,看着简单,其实要放一组"守卫",宁可块大一点,也绝不切错。下面这几行,就是守卫们在干活(输出为实跑结果):

拆开看就两句话:

· 句点后面得跟着"新句子真的开始了"的证据(大写字母、数字、引号、括号)。所以 0.92 这种小数点不算边界。

· 句点前面那个词不能是缩写(Fig.、et al.),也不能是孤零零一个首字母(作者的 G. Cai)。句点紧跟的引用括号(.[1,2])则要随句子一起收尾。

万一整段文本一个可靠的句点都找不到(公式区、表格残片就是这样),就逐级往后退:先退到行边界,连行边界都没有,才按上限硬切。三级退路都落在上限之内,所以块大小永远不超限。

七、这一步做到了什么,还差什么

· 做到了:不可信的外部 PDF,变成带身份证的内部文本块;每一块都能逐字符切回原文。溯源的链条,从这儿才算真正扣上。

· 还差一点:PDF 里的上下标本就是独立定位的字,所以 mAh g−1 常被拆成几截。这一层先原样保留,交给后面的归一化去修。

· 还差一点:这一版没做章节过滤,参考文献、支持信息也跟着进了块。正式项目里只保留摘要、引言、实验、结果、结论这些目标章节。

· 还差一大块:块还只是"原料"——它不知道哪句话重要、哪个数字才是结论。让大模型登场,从块里抽出"断言 + 证据",是第 8 篇的事。

但请记住这一篇真正立起来的那条规矩:看见,和理解,要分开。这一层只管"这段文字在原文哪个位置",一个字都不解释;"哪句话重要"是下一层的事。分得越干净,后面越不敢乱来。

排错清单:撞上问题先查这里

它不知道哪句话重要,它只知道这句话在第几页、哪个位置。

把"出处"钉死在每一步,后面盖起来的东西才站得住。

下一篇:第 8 篇 · 概念、证据、数值——知识分三本账来存,让大模型从这些块里抽出"断言 + 证据"。

关注 材智工场,我们从零开始,把这个智能体一层一层搭出来。

END

欢迎加入AI4S学习交流群

对文章内容有疑问或没看懂的地方

欢迎进群讨论反馈

也欢迎一起交流AI for Science新方向新技术

相关学习资料