简介:这是一套面向Python毕业设计、期末大作业场景的PDF识别与分析项目,覆盖PDF解析、信息抽取、知识图谱构建与检索等多个环节,适合希望通过完整项目快速完成课程设计或毕业设计的学生使用。项目源码包含详细注释,强调可读性与易部署性,系统功能完整,界面简洁,操作路径清晰,便于二次开发与功能扩展。包内共119个文件,压缩包大小约4.8MB,其中包括47个Python源码文件、28个编译后的pyc文件,以及15个JavaScript脚本、9个JSON配置、2个Vue组件等前端资源,另有SQLite数据库、说明文档和测试数据,代码结构清晰,按功能模块划分,便于快速定位与修改。目前已有106人浏览学习,说明该题目需求具有一定的普遍性。项目经严格调试,可运行性有保障,除核心算法外还配备前后端交互、数据库存储和基础管理功能,对于需要展示完整技术链路的同学有直接参考价值。
1. 一份PDF到可检索知识图谱,比想象的更依赖工程细节
拿到导师给的一堆PDF文献,最耗时的不是阅读,而是想知道“哪个团队在用同一个数据集”时得一篇篇翻。这份毕业设计把整条链路拉通了:先用Python解析PDF版面,再对正文做实体识别和关系抽取,把三元组写入Neo4j形成知识图谱,最后通过一个Web检索框把图谱查出来。它的价值在于把“不可检索的PDF”变成可查询的图结构。适合正在选毕设方向、需要处理PDF语料做检索的读者。整套代码不需要GPU,读懂每个环节后,自己复现一遍也就一周。
2. PDF文本抽取:用pdfplumber先拿到带坐标的文本行
2.1 选型:pdfplumber、PyPDF2、pdfminer.six各管一段
很多入门教程会用PyPDF2直接extract_text(),遇到简单PDF确实可以用,但一旦遇到表格、双栏、页眉页脚,输出顺序就会错乱。原因是PyPDF2只做内容流解析,缺少版面上的位置计算。这个项目里我习惯把pdfplumber作为主力,它底层依赖pdfminer.six,却把字符坐标、表格线、单词的位置都暴露成了结构化字段。
| 库 | 适合场景 | 缺点 | 常见败因 |
|---|---|---|---|
| pdfplumber | 规则排版、表格、需要坐标的场景 | 对扫描件无能为力 | 误拿图片型PDF直接抽取 |
| PyPDF2 | 快速拼接、合并、简单取文本 | 没有可靠坐标和表格 | 双栏PDF会从左栏末尾接到右栏 |
| pdfminer.six | 深度定制版面分析 | 接口较低层,开发量大 | 用它做简单抽取,事倍功半 |
| pytesseract | 扫描件OCR | 识别速度和准确率都有限 | 不预处理灰度直接识别 |
PDF文本抽取不是“有字就行”,知识图谱要求每个实体都有出现位置和上下文。所以我先用pdfplumber把页面内的单词聚合为文本行,保留top和x0,后续做信息抽取时才能回溯到原始页面。
2.2 抽取当前页的全部文本行
下面这段是项目里最底层的抽取函数,输入PDF路径,输出带页码和坐标的行列表。
import pdfplumber from collections import defaultdict def extract_pdf_lines(pdf_path: str): lines = [] with pdfplumber.open(pdf_path) as pdf: for page_no, page in enumerate(pdf.pages, start=1): words = page.extract_words( keep_blank_chars=False, use_text_flow=False, ) rows = defaultdict(list) for w in words: # top 是单词顶边到页面顶部的距离,单位是pt # 同一行的 top 值落在一个区间内,先按5pt粒度粗聚合 row_key = round(w['top'] / 5) * 5 rows[row_key].append(w) for row_key in sorted(rows): word_list = sorted(rows[row_key], key=lambda w: w['x0']) line_text = ''.join(w['text'] for w in word_list) lines.append({ 'page': page_no, 'top': row_key, 'x0': min(w['x0'] for w in word_list), 'text': line_text, }) return lines逻辑说明:page.extract_words()内部已经按字符聚合出单词,top是单词矩形上边到页面顶部的距离,x0是左边距。这里用round(w['top'] / 5) * 5把间距在5pt以内的单词归为同一行,避免同一行内字体高度差造成误拆。行内再按x0排序,保证从左到右拼接。
参数说明:keep_blank_chars=False会过滤孤立空白字符,适合中文英文混排;use_text_flow=True会按PDF阅读顺序重排,但对双栏反而有害,所以这里显式设为False。如果需要OCR的扫描件,这一步结果基本为空,后续应该把页面转成图片再交给OCR,而不是继续往下走。
2.3 双栏版面处理:先切栏再排序
学术PDF双栏很常见。如果直接拿整页单词按top聚合,右栏的同一行会跟在左栏后面,因为它们的top值相同。解决的办法是以页面宽度中点为界,把单词分成左右两组,每组单独按top聚合,最后按“左栏从上到下、右栏从上到下”的顺序拼接。
def extract_double_column_lines(page): mid = page.width / 2 left_words = [w for w in page.extract_words() if w['x0'] < mid] right_words = [w for w in page.extract_words() if w['x0'] >= mid] def group(words): rows = defaultdict(list) for w in words: rows[round(w['top'] / 5) * 5].append(w) merged = [] for top in sorted(rows): ws = sorted(rows[top], key=lambda x: x['x0']) merged.append(''.join(w['text'] for w in ws)) return merged return group(left_words) + group(right_words)实际项目里不需要硬编码mid = width / 2,可以统计当前页所有单词x0的分布,取中间空白区域作为分割线,这样能适配左右栏宽度不等的PDF。分栏聚合后,还要记下每个分栏的起始top,因为后面做信息抽取时,需要知道实体出现在页面的哪个位置。
2.4 页眉页脚过滤
页眉页脚是抽取时最常见的噪音。我一般会在拿到整页行后,去掉top < 50和top > page.height - 50的内容。这两个阈值可以放在配置文件里,根据具体PDF调整。另外,页眉上的作者、基金号、期刊名有时恰恰是实体来源,所以更稳妥的做法不是直接删行,而是打上header标签,由后续实体抽取按需决定是否使用。
3. 信息抽取:词典、正则与词性标注的三层策略
3.1 先定义图谱的实体与关系边界
信息抽取不是把所有名词都抽出来,而是先回答“要建什么样子的图谱”。在学术PDF场景下,这个项目的图谱模型聚焦四类实体:论文、作者、机构、技术方法,关系则对应WRITTEN_BY、AFFILIATED_WITH、PROPOSES、USES。
| 实体类型 | 典型示例 | 抽取依据 |
|---|---|---|
Paper | 基于注意力机制的知识图谱补全 | 一级标题或摘要首句 |
Author | 张伟、Li Wei | 词性标注里的nr |
Institute | 山东大学、自动化研究所 | 正则里的“大学/学院/研究所”后缀 |
Method | BERT、知识图谱嵌入 | 自定义词典 + 关键词上下文 |
关系模板也要提前定义清楚,否则抽取结果无法写入Neo4j。
| 谓词 | 关系 | 例子 |
|---|---|---|
| 提出 | PROPOSES | 本文提出一种基于图卷积的模型 |
| 使用/采用 | USES | 该方法使用知识图谱嵌入 |
| 隶属于 | AFFILIATED_WITH | 第一作者隶属于山东大学 |
边界定义清楚后,抽取代码只需要围绕这组实体和关系展开,不会变成“抽了再说”的脏数据工程。
3.2 实体抽取代码示例
下面代码把正则和jieba词性标注结合,优先用正则兜底机构名,再用posseg补人名和机构短语。
import re import jieba import jieba.posseg as pseg jieba.load_userdict("dict_ext.txt") INSTITUTE_PATTERN = re.compile(r"[\u4e00-\u9fa5]{2,}(?:大学|学院|研究所|研发中心)") def extract_entities(text): entities = [] for m in INSTITUTE_PATTERN.finditer(text): entities.append({"label": "Institute", "value": m.group()}) for w, flag in pseg.cut(text): if flag in ("nr", "nt") and len(w) >= 2: entities.append({ "label": "Author" if flag == "nr" else "Institute", "value": w, }) return entities逻辑说明:INSTITUTE_PATTERN用中文后缀词识别机构名,比词性标注更稳;“nr”是人名,“nt”是机构名短语,两者都取长度不小于2的结果。dict_ext.txt里可以放入“BERT”“知识图谱嵌入”等专有词,避免被切碎。
参数说明:jieba.load_userdict需要UTF-8编码的纯文本,每行一个词,可选词频和词性。遇到“张伟涛”被拆成“张伟”和“涛”时,直接把人名整条加入词典,并给一个较高词频,比如张伟涛 100 nr。频繁误切时,还可以调整jieba的user_word_ratio,但通常没有必要,自定义词典已经足够。
3.3 关系抽取规则
实体落到文本之后,还需要给实体连线。项目里的做法是先按句号分号切分句子,然后在句内匹配“谓词 + 左右实体”的模板。下面代码是抽取三元组的最小实现:
PREDICATE_RELATION = [ ("提出", "PROPOSES"), ("使用", "USES"), ("采用", "USES"), ("隶属于", "AFFILIATED_WITH"), ] def pick_entity(segment, entities): # 从已抽出的实体里,取在 segment 中出现且位置最靠后的一个 return seg def extract_triples(sent, entities): triples = [] for pred, rel in PREDICATE_RELATION: pos = sent.find(pred) if pos == -1: continue left = sent[:pos] right = sent[pos + len(pred):] subj = pick_entity(left, entities) obj = pick_entity(right, entities) if subj and obj: triples.append({"subj": subj, "rel": rel, "obj": obj}) return triples这里的pick_entity在项目里是这样实现的:把实体列表转成(实体名, 出现位置)数组,然后在left中取出现位置最大的实体作为主语,在right中取出现位置最小的实体作为宾语。这样处理“本文提出一种模型”时,主语是“本文”,宾语是“模型”,虽然不一定都能在实体列表里命中,但后续可以通过过滤停用词来排除“本文”“方法”等泛化词。
这个模板的缺陷是长句倒装和被动句式会失效。如果句子是“该模型由张伟提出”,sent.find("提出")之前的部分只有“该模型由”,主语实体拿不到。进阶做法是在动词前后各取一定窗口的候选实体,再做规则排序,而不是硬切整句。毕业设计用规则模板完全够,因为PDF文本是书面语,句式比口语规整得多。
4. 知识图谱构建:用 py2neo 批量写入 Neo4j
4.1 图谱模型为什么按实体类型拆分
信息抽取得到的三元组没有区分实体类型,但写入图数据库时必须决定节点标签。如果所有实体都打成一个标签,检索时无法区分作者和机构;如果每个实体都单独建立标签,又会导致图谱碎片化。项目里采用顶层统一Entity标签,把具体类型放进type属性。这样Cypher查询既能按type过滤,又能对全图实体建统一索引。
CREATE INDEX entity_name IF NOT EXISTS FOR (e:Entity) ON (e.name)有了这个索引,后面MERGE按名字去重时才能走索引,不会全库扫描。Neo4j 3.5 以后支持IF NOT EXISTS,旧版本如果报语法错,就去掉这半句。
4.2 批量写入代码
写入前先确认 Neo4j 已经启动,然后初始化索引。演示阶段可以用一句DETACH DELETE n清空旧数据,正式使用不要这样清理。
from py2neo import Graph g = Graph("bolt://localhost:7687", auth=("neo4j", "neo4j")) ALLOWED_REL = {"PROPOSES", "USES", "AFFILIATED_WITH", "WRITTEN_BY"} def init_neo4j(): g.run("MATCH (n) DETACH DELETE n") # 演示阶段清库 g.run("CREATE INDEX entity_name IF NOT EXISTS FOR (e:Entity) ON (e.name)") def save_triples(triples): for rel in ALLOWED_REL: rows = [t for t in triples if t["rel"] == rel] if not rows: continue g.run( """ UNWIND $rows AS row MERGE (s:Entity {name: row.subj, type: row.subj_type}) MERGE (o:Entity {name: row.obj, type: row.obj_type}) MERGE (s)-[:%s]->(o) """ % rel, rows=rows, )逻辑说明:%s插值只用于关系类型,并且rel来自白名单集合,不会引入用户输入。UNWIND $rows把 Python 列表一次性传到Cypher,MERGE保证同名实体只出现一次。注意MERGE关系前,两个节点必须先MERGE,否则关系会建不出来。
参数说明:rows里每项必须包含subj、obj、subj_type、obj_type四个字段。大批量写入时单次UNWIND不要超过1万行,超过就分批提交,否则容易触发事务超时。写入过程如果报Transaction failure,优先检查是否事务太大,再检查连接配置。
4.3 查询预热与索引检查
写入完成后,用一条简单查询验证实体和关系。注意中文实体名称的大小写、空格差异,写入前统一做strip(),否则“山东大学”和“ 山东大学”会被MERGE成两个节点。
EXPLAIN MATCH (n:Entity) WHERE n.name = "BERT" RETURN n如果执行计划里出现NodeIndexSeek,说明索引生效;出现NodeByLabelScan,则要回头检查索引名称是否写对。这个步骤看起来简单,却是项目里最容易踩坑的地方。很多同学的图谱检索慢,不是数据量大,而是少了这一行索引。
5. Web信息检索:Flask提供查询接口,Vue渲染图谱结果
5.1 搜索接口设计
检索部分用一个Flask接口对外暴露。前端传入q参数,后端把关键词转成Cypher,匹配实体名称。如果图谱数据量在几千到几万节点之间,简单CONTAINS加LIMIT就够用。
from flask import Flask, request, jsonify from py2neo import Graph app = Flask(__name__) g = Graph() @app.route("/api/search") def search(): keyword = request.args.get("q", "").strip() if not keyword: return jsonify({"error": "empty keyword"}), 400 records = g.run( """ MATCH (n:Entity) WHERE n.name CONTAINS $keyword OPTIONAL MATCH (n)-[rel]-(m:Entity) RETURN n.name AS source, n.type AS source_type, type(rel) AS rel, m.name AS target, m.type AS target_type LIMIT 50 """, keyword=keyword ).data() return jsonify({"items": records})逻辑说明:OPTIONAL MATCH会保留没有关联边的实体,避免“搜到了节点但看不到任何关系”的情况。LIMIT 50是保护性限制,防止宽泛词把整个图谱返回。前端拿到items后,可以直接在表格或画布上渲染。
参数说明:CONTAINS是子串匹配,对中文没有分词能力。搜“知识图谱”会把所有包含这四个字的实体都返回,但知识图谱越做越细之后,“图谱嵌入”和“知识图谱嵌入”会混在一起。这个接口只作为基础搜索,精确搜索可以换成STARTS WITH或把实体名称建立全文索引。
5.2 Vue + webpack 调用检索接口
项目前端是Vue 2 + webpack工程,目录下的webpack.base.conf.js、webpack.dev.conf.js、webpack.prod.conf.js是vue-cli标准构建文件。开发时前后端分开跑,通过代理解决跨域。
| 命令 | 服务 | 作用 |
|---|---|---|
python app.py | Flask后端 | 提供/api/search |
npm run dev | Webpack Dev Server | 前端开发热更新 |
npm run build | Webpack产线构建 | 输出dist静态文件 |
在webpack.dev.conf.js的devServer里设置代理,把/api转发到Flask端口。
devServer: { proxy: { '/api': { target: 'http://localhost:5000', changeOrigin: true } } }前端组件通过axios请求接口。
methods: { async search() { const res = await axios.get('/api/search', { params: { q: this.keyword } }) this.items = res.data.items } }说明:代理不是必须的,如果Flask直接配置CORS也能工作。但本地开发代理可以在生产环境保持同源部署,避免前端静态文件和后端接口域名不一致的问题。changeOrigin: true会把请求头中的Host改成 target 的地址,是跨域代理最常见的配置项。
5.3 中文查询的容错与结果合并
CONTAINS匹配粒度太粗,所以接口里可以先用jieba把搜索词切一下,再对每个分词结果查一次图谱,最后合并去重输出。实现时注意:不要把“知识图谱”切成“知识”和“图谱”后就去查,这两个词各自匹配到的实体范围完全不同。
容错部分至少处理两类情况:空结果返回{"items": []}而不是报错;输入带空格和换行时先strip()。另外,Cypher里的$keyword参数化能避免用户输入把查询语句打断,这个习惯在搜索接口里必须保持。
6. 进阶:坐标裁剪和阅读顺序,比调模型更有效
6.1 先用一条PDF验证全链路
在调抽取参数之前,先选一篇版式规整的论文,从头跑一遍extract_pdf_lines→extract_entities→extract_triples→ 写入Neo4j。通不过就打印中间结果:看文本行是否被切成碎片,看实体是否被截断,看关系是否出现空宾语。这个闭环检查比盲目调准召快得多。
6.2 用 page.crop 限制正文区域
页眉页脚的干扰,用坐标裁剪比做规则删除更省心。pdfplumber 的crop接受一个(x0, top, x1, bottom)元组,单位是pt,坐标原点在页面左上角。
import pdfplumber with pdfplumber.open("paper.pdf") as pdf: for page in pdf.pages: # 假设页面尺寸是 612x792(Letter) page = page.crop((0, 50, 612, 742)) words = page.extract_words(use_text_flow=False)逻辑说明:(0, 50, 612, 742)表示左侧从0开始,顶部从50pt开始,右侧到底,底部在742pt结束,也就是把上下各50pt的页眉页脚区域去掉。crop只影响后续提取的范围,不会修改原始PDF。
注意不同PDF的页边距差异很大,这两个阈值最好先跑一个调试脚本,打印所有文本行的top分布,再取前10行和后10行的中位数作为裁剪边界。对双栏PDF,还可以在裁剪后按左右栏分别合并行,再交给后续实体抽取,效果比整页顺序好得多。
当抽取结果出现“首列末行接到次列首行”时,不要急着换库,先确认use_text_flow是否为False,再检查分栏逻辑是否正确。把正文区域限定在page.crop的坐标框里,再对分栏做列级排序,可以解决绝大多数PDF布局干扰问题。
本文还有配套的精品资源,点击获取