从《无机化学复习题集》PDF到可检索题库:解析、切题与增量更新
2026/9/18 3:49:10 网站建设 项目流程

简介:《暨南大学无机化学复习题集》是一份面向高校化学专业低年级学生及考研复习者的PDF文档,紧扣无机化学课程核心考点,可用于期末备考、课堂同步练习与知识自测。文档以选择题为主,覆盖化学反应速率与活化能、化学平衡与反应商、缓冲溶液、溶解度与溶度积常数、分子轨道理论、电极电势与平衡常数关系、氢键以及标准生成焓等高频章节,题目设置贴近课程重点与常见易错点。整包仅含1个PDF文件,约375KB,轻量易携带,支持打印与标注,适合反复刷题与错题整理。目前已有167人学习下载,可作为无机化学基础概念的快速复盘材料,帮助读者在有限时间内串联零散知识点、定位薄弱环节。

1. 从《无机化学复习题集》PDF 到可检索题库,卡点在哪

期末前后,很多人手上会多一份「暨南大学无机化学复习题集-0615.pdf」这样的文件:几百道选择、填空、判断和计算题挤在几十页里,文件名后缀的日期说明它只是某一版。用阅读器翻没问题,一旦想按知识点筛题就卡住了——Ctrl+F 搜H2O搜不到写成H₂O的那批,搜「配位」又会把一堆化合物名称一起带出来,题号和 A/B/C/D 选项复制进 Word 常串行,参考答案在文末还得手动对号。更麻烦的是 0615 之后还会有新版本,手工整理的表格撑不过一次更新。

要打通的是这么一条链路:判版式、抽文本、切题、归一化化学式、入库、建检索、按章节组卷,最后一环是版本更新时不推倒重来。适合自己搭题库的助教、做教学工具的前后端,也适合任何需要把 PDF 试卷转成结构化数据的人。

2. 解析《无机化学复习题集》PDF:先判版式,再选抽取工具

2.1 用字符数和图片数判断复习题集是文本层还是扫描件

复习题集一般有两个来源:用排版软件写好直接导出,PDF 里带完整文本层;或者纸质印刷品扫描后合成,里面的字全是像素。这两条路差别很大,前者十几分钟能抽干净,后者得先 OCR,还要接受上下标识别出错、再人工校对的成本。动手写解析脚本前先花一分钟做判断。

判据是「每页能抽出多少字符」配合「每页有几张图」。带文本层的页面通常有几百到几千字符,扫描件的字符数接近于零但图片数稳定在一到两张。

# probe_layout.py # 统计每页可抽取字符数、图片数和页面尺寸, 用于判断 PDF 类型 import fitz # PyMuPDF, pip install pymupdf doc = fitz.open("暨南大学无机化学复习题集-0615.pdf") for i, page in enumerate(doc): text = page.get_text("text") # 纯文本模式, 速度最快 images = page.get_images(full=True) # full=True 返回图片的完整元信息 w, h = page.rect.width, page.rect.height print(f"page={i:03d} chars={len(text):5d} images={len(images):2d} size={int(w)}x{int(h)}")

chars长期低于 50、images稳定为 1 的,按扫描件处理;chars在 800 以上、图片偶尔出现的,说明正文有文本层,插图是装置图或结构式,可以跳过 OCR。顺带看一眼size:如果长宽比接近 1:1.41 而宽度只有 400 多,说明这份 PDF 被缩放或重排过,后面按坐标切栏的阈值要跟着调。

提示:文件名里的 0615 这类日期后缀,建议在脚本里单独解析成file_version字段存下来,做增量比对时它就是版本号,不用再猜。

2.2 pdfplumber 抽带坐标的词,保住题号和选项边界

纯文本模式抽出来是一整块字符串,题号、A.、空格和换行糊在一起,靠正则切题会非常痛苦。要保住版面信息,用 pdfplumber 的extract_words,每个词都带x0/top/x1/bottom坐标,可以按行、按栏还原阅读顺序。

# dump_words.py # 抽取第 11 页的每个词及其坐标和字体属性, 用于摸清题干与选项的版式差异 import pdfplumber with pdfplumber.open("暨南大学无机化学复习题集-0615.pdf") as pdf: page = pdf.pages[10] # 页码从 0 开始 words = page.extract_words( keep_blank_chars=False, # 空格不单独成词, 减少噪声 use_text_flow=False, # 按页面坐标排序, 而不是 PDF 内部内容流顺序 extra_attrs=["size", "fontname"], # 额外带上字号和字体, 用于识别题干层级 ) for w in words[:40]: print(f'{w["top"]:7.1f} {w["x0"]:7.1f} {w["size"]:5.1f} {w["fontname"][:18]:18s} {w["text"]}')

use_text_flow=False是关键:双栏排版下它不会把左右两栏串在一起。extra_attrs里的字号和字体名用来区分层级——题干常比选项大一号或加粗,节标题又比题干大,据此刻画「这是节标题 / 这是题干 / 这是选项」的规则,比只靠正则稳得多。

常见参数与取值:

参数作用建议取值踩坑点
keep_blank_chars空格是否单独成词FalseTrue会多出大量空格 token,聚类时还要再过滤
use_text_flow是否按内容流顺序False双栏文档设True会左右串行
extra_attrs附加字体属性["size","fontname"]属性名写错不报错,只是取不到值
x_tolerance词间水平合并阈值默认 3,双栏调 2调大会把相邻两个选项粘成一个词
y_tolerance词间垂直合并阈值默认 3上标数字可能被并进上一行

2.3 按 top 坐标聚类成行,清掉页眉页脚再谈切题

拿到词列表后先把同一行的词聚起来,否则A. H2O B. CO2这种一行两个选项的排版会被拆散。做法是按top排序,差值小于阈值的并进同一行。

# lines.py # 把词按纵向坐标聚成行, 并按 x0 排序, 还原阅读顺序 def group_lines(words, y_tol=3.0): rows, cur = [], [] for w in sorted(words, key=lambda w: (round(w["top"], 1), w["x0"])): if cur and abs(w["top"] - cur[-1]["top"]) > y_tol: rows.append(sorted(cur, key=lambda x: x["x0"])) cur = [] cur.append(w) if cur: rows.append(sorted(cur, key=lambda x: x["x0"])) return [" ".join(w["text"] for w in row) for row in rows] # 页眉页脚过滤: 位置靠边且含固定关键词的行直接丢掉 def is_noise(line, top, page_height, keywords=("暨南大学", "无机化学", "第", "页")): near_edge = top < 60 or top > page_height - 60 return near_edge and any(k in line for k in keywords)

y_tol取 3.0 是经验值:太小会把同一行拆成两行,字号混排的选项最容易中招;太大则会把相邻两行合并。判断标准很直接——聚类后的行数应该和你在阅读器里看到的行数差不多。

页脚过滤不能只看关键词,必须叠加位置条件。因为「无机化学」四个字在正文里也会出现,只判关键词会把整段正文删掉。near_edge把范围压到上下各 60 像素,基本只命中页眉页脚。清完之后剩下的行序列,才是切题的输入。

2.4 扫描件兜底:OCR 只做局部,化学式交给归一化补

探测结果显示是扫描件时就得走 OCR。但整页识别对这份材料并不友好:下标数字容易丢,离子电荷的正负号经常混,结构式根本无法用文字表达。务实的做法是「文字区域 OCR + 结构图保留原图」。

# 把第 12 页按 300dpi 转成 PNG, 再交给 tesseract 做中文+英文识别 pdftoppm -r 300 -png -f 12 -l 12 暨南大学无机化学复习题集-0615.pdf page tesseract page-12.png out -l chi_sim+eng --psm 6

-r 300是 OCR 的下限分辨率,低于 200 时下标数字基本识别不出来。--psm 6表示「假设页面是一整块统一排版的文本」,适合题干连排的页;分栏页改成--psm 3让它自动分析版式。-l chi_sim+eng同时加载中英文模型,因为题干是中英混排加化学符号,只加载中文模型会把NaCl认成汉字。

OCR 结果仍要跑一遍第 3 章的归一化流程,再人工抽检十到二十页。如果错误率超过 5%,与其花时间调参数,不如换回文本层版本,或者重新扫描。

3. 切题与归一化:把《无机化学复习题集》切成可入库的记录

3.1 先用节标题和题型锚点摸清结构

复习题集的层级通常很规整:按章节或专题分块,块内按「一、选择题 二、填空题 三、判断题 四、简答题 五、计算题」排列。所以切题分两层——先按节标题切块,再在块内按题号切题。锚点特征可以整理成这样:

锚点类型行首特征落到哪个字段注意
章节块第一章一、选择题chapter/qtype中文数字和阿拉伯数字混用要归一
题干1.12、3.qno/stem_raw全角点和半角点都要覆盖
选项A.B、C)options_json同一行可能有两个选项
答案区参考答案答案与解析answer/analysis常在文末,需要按题号回填
import re SECTION = re.compile(r"^\s*(?:第[一二三四五六七八九十]+[章节]|[一二三四五六七八九十]+)[、\.]\s*(\S{1,12}?题)") NUMBER = re.compile(r"^\s*(\d{1,3})\s*[\.、,.]\s*(?=\S)") OPTION = re.compile(r"^\s*([A-D])\s*[\.、\).]\s*(?=\S)")

NUMBER末尾的(?=\S)是前瞻断言,作用是排除「1.5 mol」这类以数字开头的小数——它们后面跟的是空白或其他符号,不满足「点号后紧跟非空白字符」的条件,不会被误判成题号。

3.2 用状态机切题:从题号行开始,到下一个题号或节标题结束

有了锚点就能写一个简单的状态机。它维护「当前题型」和「当前题目」两个状态,遇到节标题更新题型,遇到题号就收束上一题、开启新题。

# split_questions.py # 状态机切题: 节标题换 qtype, 题号行开始新题, 其余行归入当前题的题干或选项 def split_questions(lines): records, cur, qtype = [], None, None for ln in lines: sec = SECTION.match(ln) if sec: if cur: records.append(cur); cur = None qtype = sec.group(1) # 例如 "选择题" "填空题" continue num = NUMBER.match(ln) if num: if cur: records.append(cur) cur = {"qtype": qtype, "qno": int(num.group(1)), "stem": ln[num.end():].strip(), "options": []} continue opt = OPTION.match(ln) if opt and cur is not None: cur["options"].append(ln.strip()) # 选项整行保留, 后面再解析 elif cur is not None: cur["stem"] += " " + ln.strip() # 跨页续行归上一题 if cur: records.append(cur) return records

跨页是这里最容易出问题的地方。上一题的最后一行和下一题的第一行之间如果夹着页眉页脚,续行会被错误地拼进题干;所以lines.py里的噪声过滤必须在切题之前跑完。另外题号常常每章从 1 重排,qno不能当唯一键,必须用(chapter, qtype, qno)三元组。

3.3 化学式与上下标归一化:raw 和 norm 各留一份

同一道题在 PDF 里可能是H₂O,抽取后变成H2OH 2 O,OCR 出来的又可能是H₂0(下标 0 被认成字母 O)。检索列必须统一写法,但展示时用户想看原样,所以两份都留。

原始形态归一化结果处理方式
H₂OFe²⁺H2OFe2+上下标字符映射到 ASCII
H 2 OFe 3+H2OFe3+删除元素符号与数字间的空格
NaClNaClNFKC 全角转半角
[Cu(NH₃)₄]²⁺[Cu(NH3)4]2+括号统一半角后整体归一
import re, unicodedata SUB = str.maketrans("₀₁₂₃₄₅₆₇₈₉", "0123456789") SUP = str.maketrans("⁰¹²³⁴⁵⁶⁷⁸⁹⁺⁻", "0123456789+-") def normalize(text: str) -> str: t = unicodedata.normalize("NFKC", text) # 全角转半角, 兼容字符统一 t = t.translate(SUB).translate(SUP) t = t.replace("(", "(").replace(")", ")") t = re.sub(r"(?<=[A-Za-z\)\]])\s+(?=\d)", "", t) # 化学式里多余空格: Fe 3+ -> Fe3+ t = re.sub(r"\s+", " ", t).strip() return t

有一条红线:不要做大小写折叠。CO是一氧化碳,Co是钴;HfHF也完全不同。归一化只处理编码、空格和括号,字母大小写原样保留。

3.4 合并参考答案并落库到 SQLite

答案区通常在文末,格式是题号加答案,判断题还会写「对/错」。用正则扫一遍,按(chapter, qno)回填。

# merge_answers.py answer_pat = re.compile(r"(\d{1,3})\s*[\.、]\s*([A-D]|对|错|正确|错误)") answers = {int(m.group(1)): m.group(2) for m in answer_pat.finditer(answer_text)} for rec in records: rec["answer"] = answers.get(rec["qno"]) # 找不到就是 None, 后面用自检脚本暴露 rec["stem_norm"] = normalize(rec["stem"])

建表时把唯一索引压在三元组上,脚本重复跑也不会写重:

CREATE TABLE questions ( id INTEGER PRIMARY KEY, file_version TEXT NOT NULL, -- 来自文件名, 例如 0615 chapter TEXT, qtype TEXT CHECK(qtype IN ('选择题','填空题','判断题','简答题','计算题')), qno INTEGER, stem_raw TEXT, -- 展示用, 保留原始写法 stem_norm TEXT, -- 检索用, 已归一化 options_json TEXT, -- json.dumps(options) answer TEXT, analysis TEXT, source_page INTEGER, fp TEXT -- 题目指纹, 用于版本比对 ); CREATE UNIQUE INDEX idx_q ON questions(file_version, chapter, qtype, qno);

file_version放进唯一索引有两个好处:不同版本的同一道题可以共存,方便对比;同一版本重复导入直接触发冲突,改成INSERT OR REPLACE就变成幂等操作。options_json用 JSON 字符串存,避免为选项单独建表,查询时在应用层json.loads

4. 检索与组卷:让《无机化学复习题集》按知识点调得出来

4.1 SQLite FTS5 建全文索引,中文按字切也够用

题干和解析加起来通常只有几万字,没必要上独立搜索引擎,SQLite 的 FTS5 足够。

CREATE VIRTUAL TABLE q_fts USING fts5( stem_norm, answer, analysis, content='questions', content_rowid='id', tokenize='unicode61 remove_diacritics 2' ); INSERT INTO q_fts(rowid, stem_norm, answer, analysis) SELECT id, stem_norm, answer, analysis FROM questions; -- 查配位相关的单选, 按相关度排序并返回高亮片段 SELECT q.id, q.chapter, q.qtype, snippet(q_fts, 0, '[', ']', '…', 12) AS hit, bm25(q_fts) AS score FROM q_fts JOIN questions q ON q.id = q_fts.rowid WHERE q_fts MATCH '配位 OR 络合' AND q.qtype = '选择题' ORDER BY score LIMIT 20;

snippet(表, 列号, 前缀, 后缀, 省略号, 片段长度)里的列号 0 对应stem_norm,返回的片段会带上下文,直接拿去做搜索结果的预览。bm25()返回的分数越小越相关,排序方向别写反。unicode61对中文是按字切分的,搜「配位」等于「配」和「位」两个字同时命中,召回比英文分词松,但对两三个字的中文短查询反而正好。SQLite 3.34 以上可以用tokenize='trigram'支持任意子串匹配,代价是索引体积涨几倍、查询变慢,题库规模在一万题以内可以接受。

查询写法命中效果适用场景
MATCH '配位'含「配」和「位」的题全出宽召回,先看大概分布
MATCH '"配位化合物"'精确短语定位专有名词
MATCH '配位 NOT 命名'排除命名题缩小范围
stem_norm LIKE '%Cu2+%'走全表扫描题量小、条件复杂时的兜底

4.2 化学式查询:直接 LIKE 会误伤,先加边界再匹配

LIKE '%H2O%'会把H2O2里的片段也捞出来,搜CO2会命中CO2-Na2CO3。简单有效的对策是在入库时把化学式识别出来,前后补空格做词边界。

import re # 匹配由两个以上元素符号加可选系数组成的式子, 末尾允许带电荷 CHEM = re.compile(r"(?:[\(\[]?[A-Z][a-z]?\d*[\)\]]?){2,}(?:\d*[+-])?") def mark_chem(text: str) -> str: return CHEM.sub(lambda m: f" {m.group(0)} ", text)

sub里前后加空格的作用是给 FTS 的分词器一个断点,这样H2O独立成一个 token,不会和H2O2混在一起。注意这条规则用起来有误伤——普通英文单词里连续的大写字母组合也可能被当成化学式,水合物写法CuSO4·5H2O里的点号也需要额外处理。所以只把mark_chem的结果写进stem_norm,展示和导出时一律用stem_raw,误伤的代价仅仅是某次搜索高亮位置略偏。

4.3 按章节和题型配比随机组卷

组卷本质上是一次带约束的分层抽样。约束有三个:每章出多少题、题型怎么配、总题量多少。

import sqlite3, random def build_paper(db, plan, version, seed=None): """plan: [{'chapter': '第三章', 'qtype': '选择题', 'n': 10}, ...]""" rng = random.Random(seed) # 固定 seed 保证同一份卷子可复现 conn = sqlite3.connect(db) paper = [] for item in plan: rows = conn.execute( "SELECT id, stem_raw FROM questions " "WHERE file_version=? AND chapter LIKE ? AND qtype=?", (version, f"%{item['chapter']}%", item['qtype']) ).fetchall() if len(rows) < item['n']: raise ValueError(f"{item['chapter']} {item['qtype']} 只有 {len(rows)} 题") paper.extend(rng.sample(rows, item['n'])) return paper

chapter LIKE ?用模糊匹配是为了容忍「第三章」和「第 3 章」两种写法,抽题前最好先跑一次章节名的取值统计,确认没有把「第三章」和「第三章 化学反应速率」拆成两批。数量不足时直接抛异常而不是静默少出题——一份缺题的卷子比报错更难排查。

组卷参数建议值说明
总题量30~50超过 50 题一份卷子的完成率明显下降
选择题占比40% 左右批改成本低,适合做覆盖
计算题占比20% 左右数量受题库限制,别硬凑
seed日期或学号同一 seed 同一份卷,方便复盘错题
章节覆盖每章不少于 15%保证不出现整章缺失

4.4 导出成可打印的试卷

抽完题直接渲染成 Markdown,再交给 pandoc 之类的工具转 PDF。模板里用stem_raw,保留读者熟悉的上下标写法。

TPL = """## {chapter} {qtype} {qno}. {stem} {options} """ def render(paper, options_map): out = [] for r in paper: opts = "\n".join(options_map.get(r["id"], [])) out.append(TPL.format(chapter=r.get("chapter", ""), qtype=r.get("qtype", ""), qno=r["qno"], stem=r["stem_raw"], options=opts)) return "\n".join(out)

答案单独出一份文件,避免一份文档里既能看题又能看答案,打印时按需选择。导出前记得按章节和题号排序,抽样过程打乱了顺序,直接渲染会得到一份跳来跳去的卷子。

5. 校核与增量更新:从 0615 到下一版不用重做

5.1 对账法:题号集合求差集,漏题一眼可见

切题脚本最怕的是静默漏题。校验方式很朴素:把 PDF 里扫出来的题号集合和数据库里的题号集合各算一份,求差集。

# 从原始行序列里扫出所有 (chapter, qno), 与库中记录比对 seen = {(ch, qno) for ch, qno in scan_raw(lines)} stored = {(ch, qno) for ch, qno in db_rows} print("漏题:", sorted(seen - stored)) # 有输出说明切题规则漏判 print("多出:", sorted(stored - seen)) # 有输出说明跨页续行被误判成新题

漏题集合不为空,先去看这些位置的原始行是不是题号后跟了特殊符号,或者题号和题干之间夹了图片;多出集合不为空,通常是上一题的续行里出现了形如「2.5 mol」的片段,被NUMBER误判。

5.2 用题目指纹做版本比对

拿到新一版复习题集时,不想重做全部标注,就用指纹比对。指纹取题目归一化文本的哈希,去掉所有空白后计算,这样排版微调不会触发变更。

import hashlib, re def fp(text: str) -> str: return hashlib.sha1(re.sub(r"\s+", "", text).encode("utf-8")).hexdigest()[:16]
变更类型判定方式处理动作
新增题新版本三元组不在旧版本里插入,标记待校对答案
修改题三元组相同但fp不同保留旧标注,只更新题干并标记
删除题旧版本三元组不在新版本里软删除,错误记录可能还引用它
仅答案变化题干fp相同、答案fp不同直接覆盖答案字段

比对时一定要用(chapter, qtype, qno)而不是题号单独做主键,因为每章都从 1 开始编号,只用题号会让整本书的题撞在一起。

5.3 入库前跑一次自检,把低级错误挡在外面

切题结果落地前跑一个校验脚本,比事后翻几十页 PDF 找问题便宜得多。检查项包括:题干长度是否低于 15 个字符(多半是切残了)、选择题选项数是否在 2 到 6 之间、判断题答案是否是「对/错/正确/错误」之一、答案字段为空的比例是否超过阈值、归一化后是否残留[₀-₉⁰-⁹]这类上下标字符、以及fp是否有重复。

def self_check(records, empty_answer_limit=0.15): problems = [] for r in records: if len(r["stem"].strip()) < 15: problems.append(("stem_too_short", r["qno"])) if r["qtype"] == "选择题" and not 2 <= len(r["options"]) <= 6: problems.append(("option_count", r["qno"])) if r["qtype"] == "判断题" and r.get("answer") not in ("对", "错", "正确", "错误"): problems.append(("judge_answer", r["qno"])) empty = sum(1 for r in records if not r.get("answer")) / max(len(records), 1) if empty > empty_answer_limit: problems.append(("answer_missing_ratio", round(empty, 3))) return problems

把结果写成check_YYYYMMDD.csv,下次更新题库只 diff 前后两份报告,真正需要人看的通常就是新增和变更的那几十行,剩下的交给INSERT OR REPLACE自动完成。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询