简介这是一套面向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, start1): words page.extract_words( keep_blank_charsFalse, use_text_flowFalse, ) 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], keylambda 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_charsFalse会过滤孤立空白字符适合中文英文混排use_text_flowTrue会按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], keylambda 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词性标注里的nrInstitute山东大学、自动化研究所正则里的“大学/学院/研究所”后缀MethodBERT、知识图谱嵌入自定义词典 关键词上下文关系模板也要提前定义清楚否则抽取结果无法写入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 批量写入 Neo4j4.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, rowsrows, )逻辑说明%s插值只用于关系类型并且rel来自白名单集合不会引入用户输入。UNWIND $rows把 Python 列表一次性传到CypherMERGE保证同名实体只出现一次。注意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 , keywordkeyword ).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.pyFlask后端提供/api/searchnpm run devWebpack Dev Server前端开发热更新npm run buildWebpack产线构建输出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: # 假设页面尺寸是 612x792Letter page page.crop((0, 50, 612, 742)) words page.extract_words(use_text_flowFalse)逻辑说明(0, 50, 612, 742)表示左侧从0开始顶部从50pt开始右侧到底底部在742pt结束也就是把上下各50pt的页眉页脚区域去掉。crop只影响后续提取的范围不会修改原始PDF。注意不同PDF的页边距差异很大这两个阈值最好先跑一个调试脚本打印所有文本行的top分布再取前10行和后10行的中位数作为裁剪边界。对双栏PDF还可以在裁剪后按左右栏分别合并行再交给后续实体抽取效果比整页顺序好得多。当抽取结果出现“首列末行接到次列首行”时不要急着换库先确认use_text_flow是否为False再检查分栏逻辑是否正确。把正文区域限定在page.crop的坐标框里再对分栏做列级排序可以解决绝大多数PDF布局干扰问题。本文还有配套的精品资源点击获取