ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

考试真题 PDF 结构化实战:双栏切分、题库建模与检索闭环

考试真题 PDF 结构化实战:双栏切分、题库建模与检索闭环 简介这份《2007—2011山东选调生考试真题》PDF合集面向准备报考山东选调生的应届毕业生与基层备考者用于摸清五年命题脉络、校准复习方向。压缩包内仅1个PDF文件约517KB可直接在手机或电脑上查阅、打印。内容以考生回忆版整理汇总为主覆盖行测与申论两大板块。行测共80题常识判断占30题侧重乡镇政府职能定位、企业社会责任、法律体系完善标志、微博问政、转变经济发展方式等贴近基层的考点另含数学运算、图形推理、逻辑推理、定义判断、言语理解与资料分析等题型申论收录2011年首次分设的A、B卷A卷聚焦互联网时代的社会服务与远程教育平台在基层的应用B卷以《让青春在基层闪光》为题均为概括、对策与大作文三道。已有108人学习适合据此梳理高频考点、模拟限时作答并总结答题节奏。1. 一份带回忆版噪声的真题 PDF值不值得花时间拆去年帮一个备考的学弟翻这份《2007-2011山东选调生考试真题.pdf》他抱怨搜不到东西检索微博问政零结果搜乡镇政府职能只跳出一行残句。打开文本层才发现是双栏拼版稿文字按版面横切左栏题干和右栏选项交错在同一行题号、选项标记、答案解析混成一团。这是 2007 到 2011 年山东选调生考试的回忆版汇总行测 80 题、常识判断 40 题打底2011 年起申论分 A/B 卷正文里夹着参考答案与解析。对考生它是五年断代样本对做数据的人它是一份典型的中文非结构化考试语料——题干残缺、选项错位、答案编号和题号对不上。拆它的收益不只是刷题试卷 PDF 的抽取、切分、建模套路能复用到任何中文题库场景而它自带的考点分布本身就可以量化成复习优先级。2. PDF 到结构化题目版面切分与题号正则怎么配合2.1 先验文本层再决定抽取路径拿到 PDF 的第一件事不是写正则是判断它有没有文本层。扫描件里 pdftotext 出来是空串正则再优雅也白搭。常见的四类路径差异很大方案调用方式适合版面输出粒度主要坑pdftotext -layoutpoppler-utils 命令行单栏、规则排版保留空白的纯文本双栏按横切串行pdfplumberPython 库有文本层、要坐标单词级 x0/x1/top大文件慢需自己聚类PyMuPDF(fitz)Python 库快速预检、定位页block 带 bbox段落合并要手写PaddleOCR/tesseractOCR 引擎纯扫描件行文本 置信度数字与标点错认率高先用几十行代码把每页的字符数打出来一眼就能看出哪些页要走 OCR 分支import fitz # PyMuPDF doc fitz.open(2007-2011山东选调生考试真题.pdf) for i, page in enumerate(doc): text page.get_text(text) # 文本层字符数低于 50 基本可判定为扫描页或纯图片页需要转 OCR flag OCR if len(text.strip()) 50 else TEXT print(fpage{i1:03d} chars{len(text.strip()):5d} mode{flag})get_text(text)拿的是阅读顺序文本速度快但不保证版面顺序做预检够用。真正的抽取换成get_text(words)或 pdfplumber 的extract_words()因为后面要靠坐标切栏。2.2 双栏切列用 x 中位数找分栏线这份稿子的栏宽并不相等硬按页面宽度一半切会把长题干腰斩。稳妥做法是先估分栏线再按 y 坐标做行聚类import pdfplumber, statistics def split_columns(page, gap20): words page.extract_words(use_text_flowFalse, keep_blank_charsFalse) if not words: return [] mid statistics.median(w[x1] for w in words) # 分栏线估计 left [w for w in words if w[x1] mid gap] right [w for w in words if w[x1] mid gap] def to_lines(ws): ws sorted(ws, keylambda w: (round(w[top] / 3), w[x0])) lines, cur, last [], [], None for w in ws: if last is not None and abs(w[top] - last) 3: lines.append(.join(x[text] for x in cur)) cur [] cur.append(w) last w[top] if cur: lines.append(.join(x[text] for x in cur)) return lines return to_lines(left) to_lines(right)gap是分栏容差20 左右能吃掉跨栏排版的题号round(top/3)是行聚类的粗粒度阈值比直接用浮点 top 稳同一行内按x0升序拼接避免选项字母跑到题干前面。启用前先做一次判定左列最大x1是否小于右列最小x0且间隔大于 20否则说明是单栏页强切只会把题干拆碎。2.3 题号切分与选项回收文字顺序理顺之后剩下的活是切题。三段正则分别抓题干起头、选项标记和答案import re Q_HEAD re.compile(r(?m)^\s*(\d{1,3})\s*[.、]\s*) # 58. 59、60 OPT re.compile(r(?:^|(?[\s。]))([A-D])\s*[.、]\s*) # A. B、 C ANS re.compile(r【答案】\s*([A-D])) def split_questions(text): marks [(m.start(), int(m.group(1))) for m in Q_HEAD.finditer(text)] items [] for idx, (pos, no) in enumerate(marks): end marks[idx 1][0] if idx 1 len(marks) else len(text) body Q_HEAD.sub(, text[pos:end].strip(), count1) opts {k: for k in ABCD} hits list(OPT.finditer(body)) if hits: stem body[:hits[0].start()].strip() for j, h in enumerate(hits): stop hits[j 1].start() if j 1 len(hits) else len(body) opts[h.group(1)] body[h.end():stop].strip() else: stem body # 图形推理、资料分析可能没有 ABC选项 items.append({no: no, stem: stem, options: opts}) return itemsQ_HEAD的^加re.M是关键分值表里大量出现0.5分/个5个40题这类数字如果允许行内匹配题号会被切得稀碎。OPT的前置边界限定空格、句号、分号防止把选项 D 错误这种表述当成选项起点。ANS只认方括号答案标记回忆版里正文偶尔出现答案为 A那个只能靠人工抽检兜底。注意同一题号常常出现两次一次是题目、一次是答案解析。切完必须按 (年份, 卷别, 题号) 去重保留题干更长的那条否则题库里会出现大量答案题。2.4 抽取完必须跑的三道自查切分代码本身不会报错错误都藏在数据里。固定跑一个审计函数把三类问题打印出来def audit(items, year, module): nos [it[no] for it in items] dup {n for n in nos if nos.count(n) 1} miss_opt [it[no] for it in items if sum(bool(v) for v in it[options].values()) 4] gaps [b - a for a, b in zip(nos, nos[1:]) if b - a 1] print(f{year}-{module}: 题数{len(nos)}) print(f 重号 {len(dup)} 处: {sorted(dup)[:10]}) print(f 选项不足4项 {len(miss_opt)} 题: {miss_opt[:10]}) print(f 跳号 {len(gaps)} 处)重号说明去重没做干净选项不足 4 项既可能是回忆缺失也可能是选项被切到了题干里——后者要回头调OPT的边界跳号在模块分段编号的卷子里是正常的比如 2008 年那套把 26~40 题、66~70 题设成每题 1 分编号天然不连续。三项里最该盯的是选项数它直接决定后续能不能做自动判分。3. 题库建模题型、分值、知识点三张主键怎么定3.1 一次建表把题干、选项、答案、分值拆开把题目、选项混在一行存是最省事也最坑的做法——回忆版经常只补出两三个选项塞进题干后既没法查、也没法判分。拆开存CREATE TABLE questions ( id INTEGER PRIMARY KEY, year INTEGER NOT NULL, paper TEXT NOT NULL, -- 行测 / 申论A / 申论B module TEXT NOT NULL, -- 常识判断 / 言语理解 ... qno INTEGER NOT NULL, stem TEXT NOT NULL, fingerprint TEXT NOT NULL, UNIQUE(year, paper, qno) ); CREATE TABLE options ( qid INTEGER NOT NULL REFERENCES questions(id), label TEXT NOT NULL, -- A/B/C/D text TEXT NOT NULL, PRIMARY KEY (qid, label) ); CREATE TABLE answers ( qid INTEGER PRIMARY KEY REFERENCES questions(id), answer TEXT, -- 客观题选项字母申论为空 explain TEXT, source TEXT -- 原文 / 回忆 / 缺失 ); CREATE TABLE scoring ( year INTEGER, paper TEXT, module TEXT, qnum INTEGER, per_q REAL, -- 题量与每题分值 PRIMARY KEY (year, paper, module) );UNIQUE(year, paper, qno)是防重号的第一道闸导入时用INSERT OR REPLACE就能让后来的解析覆盖掉残缺题干。source字段别省回忆版和原文的可信度差异很大后面算权重、做抽样核对都要靠它区分。3.2 分值表先进库再让它自证总分2010 年那套卷子的分值分布是整份资料里最有价值的结构化信息直接抄成表模块题量每题分值小计常识判断400.520.0言语理解200.48.0数字推理50.52.5数学运算50.52.5图形推理50.63.0定义判断50.63.0类比推理50.63.0排序事件50.63.0逻辑推理50.63.0资料分析50.42.0录入前跑一行断言分值对不上就直接报错别让错表污染后面的加权统计rows [(常识判断, 40, 0.5), (言语理解, 20, 0.4), (数字推理, 5, 0.5), (数学运算, 5, 0.5), (图形推理, 5, 0.6), (定义判断, 5, 0.6), (类比推理, 5, 0.6), (排序事件, 5, 0.6), (逻辑推理, 5, 0.6), (资料分析, 5, 0.4)] total_q sum(r[1] for r in rows) # 100 题 total_s sum(r[1] * r[2] for r in rows) # 50.0 分 assert abs(total_s - 50) 1e-6, f分值合计 {total_s}与行测 50 分口径不符 print(total_q, total_s)分值合计正好 50 分与行测 50 分 申论 50 分的总口径吻合但题量合计是 100和资料里其它年份反复提到的80 个小题对不上。这种自相矛盾正是回忆版的常态建模时把它当数据缺陷标记出来而不是二选一硬信。用哪套口径取决于你要算的是分值权重还是题量分布。3.3 知识点标签用两级别一上来就上多标签模型标签体系铺得太细会直接烂尾两级就够一级跟试卷模块对齐二级是可检索的考点词。一级模块二级标签示例触发关键词常识判断法律基础宪法、行政复议、地方性规章、公务员辞职常识判断省情与区域山东半岛蓝色经济区、一区三带、亿吨港口常识判断科技与信息云计算、光子通信、基因污染、臭氧常识判断时政与政策文件三农、中央经济工作会议、政府工作报告数量关系工程与浓度水泵抽水、牛吃草、标准电量数量关系行程与时钟环形相遇、快钟慢钟、敲钟次数判断推理逻辑真假话只有一人说真话、猜测皮球颜色言语理解主旨概括主旨、主要内容、反映什么资料分析增长率与占比环比、比重、比上年增长标注用规则打底、人工收口命中关键词自动打二级标签命中数超过 3 个的题推给人复核。五年样本量不大人工过一遍两三小时就能干完比训模型划算。3.4 指纹去重全角转半角是必做项同一道题在回忆版里常有措辞差异指纹只能解决完全一致的复制粘贴import re, hashlib def normalize(stem: str) - str: s re.sub(r[\s\u3000], , stem) # 去空白与全角空格 s re.sub(r[。、()【】\[\]“”\], , s) # 去标点 table str.maketrans(, 0123456789ABCD) return s.translate(table) def fingerprint(stem: str) - str: return hashlib.sha1(normalize(stem).encode(utf-8)).hexdigest()[:16]全角转半角不是可选项这份稿子里分2009年40题混着全角和半角不统一的话同一条分值记录能进两次库。指纹之外的近似重复交给difflib.SequenceMatcher相似度阈值取 0.9 先跑一遍把候选对打出来人工判定——阈值再低就会把牛吃草和水泵抽水这种同套路不同数据的题误判成同一道。4. 真题趋势分析考点迁移、AB 卷差异怎么量化4.1 年份 × 模块交叉表用占比不用绝对值各年题量和分值口径不一致50 分、80 题、100 题的说法混着出现做趋势一定看占比import pandas as pd, sqlite3 con sqlite3.connect(xds.db) df pd.read_sql(SELECT year, module, COUNT(*) AS n FROM questions WHERE paper行测 GROUP BY year, module, con) pivot df.pivot_table(indexmodule, columnsyear, valuesn, fill_value0) share pivot.div(pivot.sum(axis0), axis1).round(3) print(share.sort_values(by2011, ascendingFalse))fill_value0保证某年没出现的模块也留在表里否则某一年缺一个模块会让整列错位。div(pivot.sum(axis0))是按列归一得到的是模块在各年内部的相对权重。行里数值明显抬升的模块就是复习时间该往哪挪的信号。4.2 常识考点的关键词提取先把题干套话剔掉常识判断占了近一半题量看它的考点词最有意义。TF-IDF 比纯词频更合适但必须先挂停用词表import jieba.analyse jieba.analyse.set_stop_words(exam_stopwords.txt) # 每行一个词 text \n.join(stems_of_common_sense) for w, weight in jieba.analyse.extract_tags(text, topK30, withWeightTrue, allowPOS(n, nz, vn)): print(f{w}\t{weight:.3f})exam_stopwords.txt里至少要放下列、表述、正确、不正确、说法、关于、选项、符合、一项。不放的话排在最前面的永远是这些模板词真正的考点词被压到十几名开外。allowPOS限定名词和动名词能把的是而且这类功能词顺带滤掉。跑出来的词表形态很清晰早期年份散得开从地理、历史到科技什么都有越往后越集中在基层治理、区域经济、公共服务信息化这几个方向。这个结论不是靠读卷子读出来的是靠词表权重排序排出来的。4.3 申论 A/B 卷差异客观指标比主题描述更有用2011 年首次分 AB 卷两卷差异可以从可量化的维度对比维度A 卷B 卷主题方向互联网时代的社会服务基层岗位的踏实工作题量3 题3 题题型结构概括 对策 大作文引述概括 价值分析 倡议书字数要求200 / 300 / 800300 / 200 / 800单题分值未标注12 / 8 / 30限时90 分钟90 分钟B 卷三题分值合计 50 分和申论 50 分口径自洽A 卷的题目里没写分值只能从字数要求反推权重。字数要求可以用正则批量抽出来import re WORD re.compile(r(?:不超过|不少于|左右|大约)\s*(\d{3,4})\s*字) for qid, stem in subject_rows: # 申论题目行 nums WORD.findall(stem) print(qid, nums, 总字数, sum(int(n) for n in nums))真正值得注意的是文体差异B 卷第三题要求写倡议书这是事务性文书格式分比论点分更容易拿到A 卷第三题是议论文评分重心回到论证结构。题库里给申论的module打上事务文书和议论文两个二级标签练习时按标签组题比整卷重做有效。4.4 把分析结论折算成复习权重分析结果要能落到一个数字上才有用。用一个简单的加权式# 复习优先级 每题分值 × 近三年该模块题量占比 × (1 个人错误率) priority per_q * recent_share * (1 error_rate)per_q从scoring表读recent_share从 4.1 的占比表读error_rate从错题记录算。三项相乘之后排序常识判断通常稳居第一不是因为它难而是因为 0.5 分 × 40 题的分值基数在那摆着——这也是这份真题集最反直觉的一个结论花在常识上的边际收益比死磕数量关系高得多。5. 静态题库怎么变成能检索、能组卷、能复习的闭环5.1 FTS5 中文检索trigram 与两字词兜底SQLite 内置的unicode61分词器把连续汉字当整体处理乡镇政府能命中政府职能就搜不到。SQLite 3.34 之后可以用 trigramCREATE VIRTUAL TABLE q_fts USING fts5( stem_tok, contentquestions, content_rowidid, tokenizetrigram ); INSERT INTO q_fts(rowid, stem_tok) SELECT id, stem FROM questions; SELECT q.id, q.year, q.module, snippet(q_fts, 0, [, ], …, 12) AS hit FROM q_fts JOIN questions q ON q.id q_fts.rowid WHERE q_fts MATCH 乡镇政府职能 ORDER BY rank LIMIT 10;trigram 要求查询串至少 3 个字符两字词得走LIKE %微博%兜底snippet的参数依次是列号、左标记、右标记、省略号、片段长度。索引体积会比裸表大两三倍五年真题这种量级无所谓题库上到十万题再考虑外部索引。5.2 按分值 × 错误率加权组卷组卷不是随机抽题是按分值表的模块配额抽模块内再按错误率加权保证薄弱题更容易被抽中import random, sqlite3 def build_paper(con, seedNone): rnd random.Random(seed) paper [] for module, qnum, per_q in con.execute( SELECT module, qnum, per_q FROM scoring WHERE year2010 AND paper行测): pool con.execute( SELECT id, COALESCE(err_rate, 0) FROM v_question_stat WHERE year2010 AND module?, (module,)).fetchall() if not pool: continue weights [1 e for _, e in pool] # 错得多的权重高 picked set() while len(picked) min(qnum, len(pool)): picked.add(rnd.choices([p[0] for p in pool], weightsweights, k1)[0]) paper.extend(sorted(picked)) return paperv_question_stat是一个视图把answers表和练习记录records表按 qid 聚合出每题的正确率。min(qnum, len(pool))是在防回忆版缺题——某个模块实际入库题量不足分值表声明的题量时循环会死等加上这个限位就直接按实际数量出卷。5.3 错题闭环用 SQLite 的日期修饰符算到期间隔取 1、3、7、15、30 天五档答错回第一档CREATE TABLE records ( qid INTEGER, ts TEXT, correct INTEGER, stage INTEGER DEFAULT 0, PRIMARY KEY (qid, ts) ); SELECT q.id, q.stem, r.stage, datetime(r.ts, || (CASE r.stage WHEN 0 THEN 1 WHEN 1 THEN 3 WHEN 2 THEN 7 WHEN 3 THEN 15 ELSE 30 END) || days) AS due FROM records r JOIN questions q ON q.id r.qid WHERE r.ts (SELECT MAX(ts) FROM records x WHERE x.qid r.qid) AND due datetime(now, localtime) ORDER BY due;datetime(ts, ||n|| days)这个拼接写法里加号、数字、days之间的空格都不能漏漏一个就返回 NULL整批复习队列会静默变空。内层子查询取每题最近一次作答避免历史记录把旧到期时间翻出来。题库更新之后别忘了一句INSERT INTO q_fts(q_fts) VALUES(rebuild);FTS5 的外部内容表不会跟着questions自动同步重建索引比逐条删改稳得多通常挂在每周的增量导入脚本末尾跑一次。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进