PDF解析到Anki词表:考研词汇语料库构建实战
发布时间:2026/9/17 20:07:47来源:尧图网络
简介专为考研英语词汇与阅读备考整理的PDF资料以30篇文章串联考研核心词汇适合基础到强化阶段的考生做精读与词汇巩固。开篇《The Permit》讲述外来移民打工、躲避警察盘查的经历穿插permit、grain、winter store等词汇和农场、干果、粮食等农事表达并对摩洛哥、休达、厄瓜多尔等地名逐一注释像“the building must have been used as a farmers winter store”这类长句可用于句型分析与语法训练同时兼顾阅读理解与跨文化背景。整份资料共1个PDF文件约103KB手机与电脑均可直接打开便于随身携带学习。目前已有626人学习。读者可借30篇文章滚动记忆高频词练习长难句拆解与翻译积累写作和完形所需搭配并借助中英对照完成自测与复盘。1. 从一份考研词汇PDF到可检索语料库值不值得动手很多人拿到《精编星火30篇文章贯通考研词汇资料.pdf》的第一反应是打开对着屏幕背坚持三天然后放弃。问题不在毅力在于这份资料的形态30 篇文章、Unit 编号、每篇后面的注释词条还有中文译文和中英对照的地名注释全被压成扁平的纯文本你没法查、没法筛、没法排序。对做 IT 的人来说把它当电子书翻是最亏的用法。它天然是一份小语料库30 篇带上下文的长文加一份人工标注过的生词注释正好够跑一遍 PDF 解析、词形还原、词频打分、间隔重复调度的完整流水线。跑完你会得到一张按权重排序的词表、一份能导入 Anki 的 apkg以及一个本地全文检索索引。适合谁正在备考又不想手抄单词的工程师要给团队做技术英语阅读训练的人以及想练手 PDF 文本抽取的开发者。下面从版面还原一路做到复习调度。2. PDF 版面还原pdfplumber 抽取 30 篇文章正文拿到 PDF 后第一步不是写代码是先翻几页判断版面类型。这份资料的排布比想象的麻烦目录页是「Unit 1 The Permit⋯ ⋯ ⋯ 1」这种点线加页码正文段落里夹着像摩洛哥塞维利亚这样的中文注解每篇末尾有一整块注释区用箭头加星号的标记把派生词和词根串起来。这些结构决定了抽取策略。2.1 抽取前先做的三类判断我从三个维度判断要不要上 OCR文字能不能选中、分栏是不是单栏、连字符断行多不多。这份 PDF 文字层完整直接跳过 OCR能省掉 Tesseract 调参的大半时间。结构元素识别特征处理方式目录点线行行尾连续⋯ 数字正则丢弃或提取为章节索引页眉页脚纯数字 / 固定短串按页统计高频行后剔除行末断词govern-\nment合并连字符并拼接中文行内注解全角括号包中文抽出存放进 notes 字段注释区行首为词条 箭头星号标记按行切分转成结构化词条判断完之后你会发现这份资料是单栏、无表格、无图属于最容易处理的类型——但行内中文注解和断行会污染后面的分词结果必须在抽取阶段就切干净。2.2 用 pdfplumber 逐页抽文字常见做法是用 pdfplumber理由是它保留字符的坐标信息出问题时能直接画框定位比 PyMuPDF 好排查。import pdfplumber def extract_pages(pdf_path): pages [] with pdfplumber.open(pdf_path) as pdf: for i, page in enumerate(pdf.pages, start1): # x_tolerance 调小可避免把相邻列的字符粘成一行 # y_tolerance 调小可让行切分更贴合实际行高 text page.extract_text(x_tolerance1.5, y_tolerance3) if not text: continue pages.append({page: i, text: text}) return pages pages extract_pages(精编星火30篇文章贯通考研词汇资料.pdf) print(pages[3][text][:300])参数说明x_tolerance默认 3控制同一行内两个字符合并的横向阈值这份资料字符间距紧调到 1.5 更稳y_tolerance默认 3控制上下两行是否算作不同行如果抽出来出现整段粘成一行的情况把它降到 2。如果某些页extract_text返回空改用page.extract_words()拿词级坐标自己重组比盲目换库有效。2.3 清洗断行、连字符与页眉页脚抽出来的原始文本不能直接喂给分词器先过一遍清洗管道。import re def clean_article(raw: str) - str: # 1) 还原行末连字符断词govern-\nment - government raw re.sub(r(\w)-\n(\w), r\1\2, raw) # 2) 段内单换行合并为空格连续换行保留为段落边界 raw re.sub(r(?!\n)\n(?!\n), , raw) # 3) 删除独占一行的纯页码 raw re.sub(r^\s*\d{1,3}\s*$, , raw, flagsre.M) # 4) 压缩三个以上连续空格 raw re.sub(r[ \t]{3,}, , raw) return raw.strip()第 1 条正则只处理「字母 连字符 换行 字母」的组合避免误伤well-known这类本身就是连字符词的写法。第 2 条用的是前后否定断言单换行合并、双换行不动这是保段落结构最省事的写法。第 3 条注意加flagsre.M否则^$只匹配全文首尾。实际跑的时候先把清洗前后的字符数打出来对比掉幅超过 15% 通常意味着页码正则误杀了正文里的数字行。2.4 按 Unit 切分并输出为 JSON清洗之后按 Unit 标题切块。标题行的形态是Unit 数字 英文标题 点线 页码用一个正则就够了。import json UNIT_RE re.compile(r^Unit\s(\d)\s(.?)\s*[⋯.]{3,}, re.M) def split_units(full_text): marks list(UNIT_RE.finditer(full_text)) units [] for idx, m in enumerate(marks): end marks[idx 1].start() if idx 1 len(marks) else len(full_text) units.append({ unit: int(m.group(1)), title: m.group(2).strip(), body: full_text[m.end():end].strip(), }) return units with open(units.json, w, encodingutf-8) as f: json.dump(split_units(\n.join(p[text] for p in pages)), f, ensure_asciiFalse, indent2)切分逻辑靠的是把每个 Unit 标题的起始位置记下来下一段到下一个标题之前都归当前 Unit。切完要做的第一件事是校验len(units)应该等于 30如果只有 29 或者 31说明目录页里的Unit 1 The Permit⋯也被匹配进去了加一个「页码在该行末尾」的过滤条件即可。ensure_asciiFalse不能省否则中文注解会变成转义序列后面读起来很痛苦。3. 词频统计与生词筛选spaCy 词形还原加权重打分拿到units.json之后核心问题变成30 篇文章里几千个词哪些值得背。直接统计原始词形没用——permit、permits、permitting会被算成三个词词表里出现三条背的时候纯属浪费卡片。3.1 为什么必须做词形还原而不是简单 splitsplit()只按空白切分permitted和permit是两条记录。词形还原lemmatization会把它们收敛到同一个 lemma。和词干提取stemming的区别在于stemming 用 Porter 算法粗暴裁后缀studies变成studi还原出来不是真词lemmatization 查词典加词性判断studies回到study。做词表必须用后者否则导进 Anki 的词条一半查不到。3.2 spaCy 流水线配置与词性过滤import spacy from collections import Counter # 关掉 ner 和 parser速度能快 3 到 5 倍词形还原用不到这两个组件 nlp spacy.load(en_core_web_sm, disable[ner, parser]) DROP_POS {DET, ADP, CCONJ, SCONJ, PRON, AUX, PART, PUNCT, SYM, X, NUM, INTJ} def lemmatize_counter(texts): counter Counter() for doc in nlp.pipe(texts, batch_size8): for tok in doc: if (tok.is_alpha and not tok.is_stop and tok.pos_ not in DROP_POS and len(tok.lemma_) 2): counter[tok.lemma_.lower()] 1 return counterdisable[ner, parser]是提速的关键实测 30 篇长文从十几秒降到三四秒。batch_size8配合nlp.pipe能减少重复初始化开销文本量再大可以考虑加n_process4走多进程。len(tok.lemma_) 2过滤掉a、of、be这种高频短词但会误杀use、pay这类四字母以下的有用词所以更稳的做法是长度阈值和停用词表配合用不要只靠长度。3.3 生词权重怎么算单纯按词频排序会得到一堆say、think、come。真正想看的是「这篇里出现、但不常见、且不在已知词表里」的词。我用四个信号加权信号来源取值区间权重方向Zipf 频率wordfreq 库0 到 7 左右越低分越高篇内频次本文统计1 到 N越高越可能是主题词是否在大纲自备考研词表0 或 1命中则降权词长len(lemma)字符数长词略微提权from wordfreq import zipf_frequency def score(lemma, count, in_syllabus): z zipf_frequency(lemma, en) # 7 附近是 the3 以下是生僻词 base (7 - z) * 1.0 min(count, 5) * 0.6 if in_syllabus: base - 0.8 # 已在大纲内的词降权 base 0.05 * max(0, len(lemma) - 6) # 长词略微提权 return round(base, 3)min(count, 5)是加了个上限避免某个词在一篇里出现 20 次就把整张表压垮。in_syllabus的那 0.8 降权要按自己的基础来调如果你大纲词表覆盖率高可以降到 0.3否则生词会被压得一个都冒不出来。3.4 落盘成可复现的生词表import csv rows [] for unit in units: counter lemmatize_counter([unit[body]]) for lemma, cnt in counter.items(): rows.append({ unit: unit[unit], lemma: lemma, count: cnt, score: score(lemma, cnt, lemma in SYLLABUS), }) rows.sort(keylambda r: (-r[score], r[unit])) with open(vocab.csv, w, newline, encodingutf-8) as f: w csv.DictWriter(f, fieldnames[unit, lemma, count, score]) w.writeheader() w.writerows(rows)按score降序、unit升序排是为了让同一篇文章的高分生词聚在一起复习时按篇推进比跳着背更省切换成本。跑完先head -50 vocab.csv看前 50 行如果全是人名地名说明专有名词没滤掉在 lemmatize 阶段加上tok.ent_type_ 判断或者维护一张小停用表。4. 例句抽取与 Anki 卡片批量生成词表出来了但只有词没有上下文背起来还是干。这份资料最有价值的其实是原文句子——每个词都能在 30 篇文章里找到真实语境这是它比纯词表强的地方。4.1 按命中词抽上下文句import re SENT_RE re.compile(r(?[.!?])\s(?[A-Z\])) def pick_sentence(body, lemma, max_len220): for sent in SENT_RE.split(body): if lemma in sent.lower() and len(sent) max_len: return sent.strip() return 正向回顾断言(?[.!?])保证只在句末标点后切避免把Mr.之类的缩写切开——虽然这份资料里这种坑不多但加上不亏。max_len220是卡片的可读上限超过这个长度的句子在手机屏幕上要划三次体验很差。找不到合适句子时返回空串后面生成卡片时跳过不要让空字段进 Anki。4.2 用 genanki 生成 apkgimport genanki MODEL genanki.Model( 1607392319, # 模型 ID固定住避免重复导入 Lexicon Basic, fields[{name: Word}, {name: Sentence}, {name: Source}], templates[{ name: Card 1, qfmt: div classw{{Word}}/div, afmt: {{FrontSide}}hr idanswerdiv{{Sentence}}/div div classsrc{{Source}}/div, }], css.w{font-size:28px;font-weight:600}.src{color:#888;font-size:13px}, ) deck genanki.Deck(2059400110, 考研词汇 30 篇) for r in rows: if r[score] 2.0: continue sent pick_sentence(UNIT_BODY[r[unit]], r[lemma]) if not sent: continue deck.add_note(genanki.Note( modelMODEL, fields[r[lemma], sent, fUnit {r[unit]}], guidgenanki.guid_for(r[lemma], r[unit]), )) genanki.Package(deck).write_to_file(kaoyan_vocab.apkg)两个 ID 用固定数字是关键每次重新生成时 ID 不变Anki 才会认成同一个牌组做增量更新否则你复习了几周的调度记录会因为牌组重建而全丢。guid_for同理用 lemma 加 unit 生成稳定 guid同一个词重复生成不会变成两张卡。score 2.0的阈值按你的词汇基础调基础弱就降到 1.5基础好就提到 3.0。4.3 SM-2 调度参数与预习验证卡片进 Anki 之后走的是 SM-2 系调度。参数不透明容易让人误判「间隔怎么突然跳到两个月」写个几十行的模拟器先跑一遍心里有数。参数含义常用取值EF 初始值难度因子2.5EF 下限最低难度因子1.3首次间隔第一次答对后的间隔1 天第二次间隔第二次答对6 天q 值回忆质量0 到 5def sm2(ef, interval, n, q): if q 3: return ef, 1, 0 # 答错重来 if n 0: interval 1 elif n 1: interval 6 else: interval round(interval * ef) ef ef (0.1 - (5 - q) * (0.08 (5 - q) * 0.02)) return max(1.3, ef), interval, n 1模拟一笔EF 2.5、连续 q5间隔序列是 1、6、16、42、109……三个月后你的复习队列里全是老卡新卡挤不进来。所以新卡上限这类设置比算法本身更影响结果每天新卡控制在 20 到 30 张配这份资料 30 篇文章的体量两个月能过完一轮。注意q 3时把 n 重置为 0这里返回的第三项是重置后的计数别写成n本身否则答错的卡会被当成熟练卡继续拉长间隔。5. 进阶把 30 篇文章做成可检索语料卡片是被动复习想主动查的时候需要索引。30 篇的体量不值得上向量库BM25 足够几百毫秒能扫完。from rank_bm25 import BM25Okapi import re def tok(s): return re.findall(r[a-z], s.lower()) corpus [tok(u[body]) for u in units] bm25 BM25Okapi(corpus) def search(query, topk5): scores bm25.get_scores(tok(query)) idx sorted(range(len(scores)), keylambda i: -scores[i])[:topk] return [(units[i][unit], units[i][title], round(scores[i], 3)) for i in idx] print(search(work permit rejected))BM25Okapi的默认 k11.5、b0.75 对短文档够用语料再长可以调 k1 到 1.2 降低词频饱和影响。想加语义召回就补一层句向量但这份资料总共不到两万字加 embedding 的收益主要在「同义改写」的查询上日常查词没必要。真正有用的是按生词密度重排阅读顺序。先算每篇的生词密度再决定先读哪篇。density {} for unit in units: toks tok(unit[body]) hits sum(1 for t in toks if zipf_frequency(t, en) 4.0) density[unit[unit]] round(hits / max(len(toks), 1), 3) order sorted(density, keylambda u: density[u]) print(建议阅读顺序, order)按密度从小到大读前几篇几乎不查词就能读完读到后面难度缓慢爬升比顺着 Unit 1 硬啃的完成率高得多。密度超过 0.18 的篇目先跳过等前面几篇过完一轮再回来这时候你会发现它们没那么难了。本文还有配套的精品资源点击获取
网站建设高端定制企业官网