news 2026/9/17 17:09:27

从docx到可检索结构化表:Python解析、打标签与FTS5索引实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
从docx到可检索结构化表:Python解析、打标签与FTS5索引实战

简介:第十三届挑战杯全国大学生课外学术科技作品竞赛部分获奖作品名单汇编,面向备赛的高校学生与指导教师,也适合关注大学生科技创新动态的读者。文档按奖项层级收录特等奖至未入围作品,覆盖新材料研发、能源环保、信息技术、生命科学、工程技术、社会治理与生态研究等方向,并标注获奖高校与项目名称,如液相合成与性质研究、双层TiO2基网膜、可变冲程发动机、西瓜无损检测算法等,便于按学科快速检索定位。包内为1个docx文件,约88KB,复制查阅方便,已有375人学习下载。读者可借此梳理往届评审偏好与热门技术方向,比对不同高校的选题切口、命名方式与团队构成,为选题立项、申报书撰写和答辩准备提供参照,也可作为科创社团培训与赛事宣讲的素材。

1. 一份获奖名单文档,为什么值得当成结构化数据集来拆

很多人拿到《第十三届挑战杯全国大学生课外学术科技作品竞赛部分获奖作品.docx》之后,翻一遍就关掉了。但这份文档真正麻烦的地方在于:它是典型的"人读着顺、机器读着乱"——《》包裹的作品名、"特等奖/一等奖/二等奖/三等奖"的等级标记、穿插在标题之间的高校名称,全部挤在同一段超长文本里,没有表格、没有字段、没有换行。想统计新材料类作品占多少、想按学校反查、想把互联网与信息技术方向的条目单独拎出来横向对比,靠 Ctrl+F 一条条数是做不到的。换个思路,把它当成一个几百条规模的小型文本数据集,走完解析、清洗、打标签、建索引四步,docx 就变成了随时可查的结构化表。下面的流程在本地 Python 环境即可复现,不需要任何在线服务,也不需要预先准备训练数据。

2. 用 python-docx 拆开名单:段落切分与作品名正则抽取

2.1 先看 docx 的物理结构,别急着写正则

docx 本质是个 zip 包,正文在word/document.xml里,python-docx 把它包装成 Paragraph 序列。很多人第一步就踩坑:直接用doc.paragraphs拿到段落文本,以为是"一条作品一行",实际上这份文档里一个段落塞进了几十件作品,段落数远远小于作品数。所以正确的做法是先摸清段落粒度,再决定在段落内做二次切分。

import zipfile from docx import Document path = "第十三届挑战杯全国大学生课外学术科技作品竞赛部分获奖作品.docx" doc = Document(path) # 只看非空段落,观察粒度:一个段落可能含多件作品 paragraphs = [p.text for p in doc.paragraphs if p.text.strip()] print("段落数:", len(paragraphs)) print("首段前 120 字:", paragraphs[0][:120]) # 段落拿不全时(作品名在文本框/表格里),退回读原始 XML with zipfile.ZipFile(path) as z: names = z.namelist() xml = z.read("word/document.xml").decode("utf-8") print([n for n in names if n.startswith("word/")][:8]) print("xml 长度:", len(xml))

第一段代码负责判断"要不要做段内切分",第二段是兜底:如果len(paragraphs)明显小于肉眼看到的作品条数,说明内容被放进了文本框、表格或w:txbxContent里,python-docx 的p.text读不到,必须直接对document.xml全文跑正则。我一般两个结果都打印出来对比,差值大就用 XML 路线。

2.2 以《》为锚点切作品名,用位置关系绑定获奖等级

作品名有天然边界《》,等级标记有固定词形(特等/一等/二等/三等/未入围 + "作品"),两者都不需要分词。难点在于它们的相对位置:等级标记通常出现在作品名左侧,但一份文档里会出现"二等奖作品 三等奖作品《XXX》"这种连续标记却只跟一个标题的情况。

import re TITLE_RE = re.compile(r"《([^《》]{2,80})》") LEVEL_RE = re.compile(r"(特等奖|一等奖|二等奖|三等奖|未入围)作品") def parse_paragraph(text, school=None): """在一个段落内,把每件作品绑定到它左边最近的等级标记上""" marks = [(m.start(), m.group(1)) for m in LEVEL_RE.finditer(text)] rows = [] for m in TITLE_RE.finditer(text): level = None for pos, lv in marks: # marks 按出现顺序排列,天然有序 if pos < m.start(): level = lv # 持续覆盖,最后留下的是左边最近的那个 else: break rows.append({"title": m.group(1).strip(), "level": level, "school": school}) return rows

参数上有几个刻意的选择。[^《》]{2,80}里的字符类排除了书名号本身,避免嵌套时把两件作品吞成一条;长度上限 80 是个经验值,用来挡住跨段落误配产生的超长串;marks不做二分查找,因为段内标记数量通常个位数,线性扫描更快也更直观。等级为None的记录不要直接丢,它是"标题存在但前面没有等级标记"的信号——多半是文档开头漏字或标记写成了别的词形,值得单独捞出来人工看。

2.3 学校名识别、去重与字段落表

学校名没有标记,但词形很稳定,用后缀就能捞。注意要先做全角空格、不间断空格、破折号的归一化,否则——的不同写法会让同一条记录在去重时认不出来。

import unicodedata from collections import Counter SCHOOL_RE = re.compile(r"[\u4e00-\u9fa5]{2,10}(?:大学|学院)") def normalize(s): s = unicodedata.normalize("NFKC", s) # 全角转半角 s = s.replace("\u3000", " ").replace("\xa0", " ") return re.sub(r"\s+", " ", s).strip() def parse_document(paragraphs): records, counter = [], Counter() for raw in paragraphs: text = normalize(raw) # 段落里出现的学校名,全部归属给该段解析出的记录(多数段落是一校一条) schools = SCHOOL_RE.findall(text) school = schools[0] if schools else None records.extend(parse_paragraph(text, school)) for r in records: counter[(r["title"], r["level"])] += 1 # 同标题同级只保留一条,标题不同则视为不同作品 seen, deduped = set(), [] for r in records: key = (r["title"], r["level"]) if key not in seen: seen.add(key) deduped.append(r) return deduped records = parse_document(paragraphs) print("抽取条数:", len(records), "| 标题总数:", len(TITLE_RE.findall(" ".join(paragraphs))))

打印的两个数字是这套流程的对账指标:len(TITLE_RE.findall(...))是全文书名号出现次数,len(records)是成功绑定成记录的条数。如果前者明显大于后者且不是去重造成的,就说明有标题被{2,80}的长度限制或字符类挡掉了,回头放宽上限再跑一遍即可。落表时我一般固定这几个字段:

字段类型说明
titleTEXT作品名,保留"——"副标题,作为业务主键
levelTEXT特等/一等/二等/三等/未入围,可能为 NULL
schoolTEXT由后缀正则识别的学校名,可能为 NULL
categoryTEXT领域标签,第 3 章生成
tokensTEXTjieba 预分词结果,供第 4 章建索引

提示:不要用作品名当数据库主键。同一件作品在不同届次或不同校区可能重名,重名时主键冲突会静默丢数据;用自增 id + 标题哈希做唯一约束更稳。

3. 领域标签体系:从"新材料/环保/信息"到可计算的多标签

3.1 标签体系先定层级,再定命中词

这份名单天然横跨材料、能源环保、信息技术与互联网、生物医学、机械工程、社科公共政策、生态环境七大类,而且一个标题经常跨两类——比如"基于 Android 系统的西瓜无损检测算法及软件开发"同时沾"信息技术与互联网"和"农业工程"。所以不要用互斥的单标签分类,用"命中词打分取 Top-N"的多标签方案,标签之间允许重叠。定标签的顺序是自上而下:先列大类,再为每类挑 8~15 个高辨识度领域词,词必须能在标题里原样出现,不要用需要推理的上位词。

标签代表作品命中词示例
新材料《石墨烯柔性超级电容器研制》石墨烯、纳米、MOF、陶瓷、荧光粉、导电浆料
能源与环保《可用于油水分离和水净化处理的双层 TiO2 基网膜》油水分离、水质净化、吸附、太阳能电池、锂离子电池
信息技术与互联网《移动端云计算虚拟三维效果展示》云计算、移动端、Android、识别算法、三维可视化、云盘
生命科学与医学《乙型肝炎相关慢加急性肝衰竭预后预测模型的研究》基因、细胞、传感器、预测模型、康复
工程与机械《V-stroke 可变冲程发动机的设计、制造与研究》机器人、发动机、电机、飞行器、平台、装置
社科与公共服务《大调解:探寻社会矛盾纠纷的体系化治理》调查、制度、政策、养老、教育、治理
环境与生态《青藏高原拟步甲的自然选择与适应进化》生态、污染、PM2.5、监测、进化

3.2 jieba 自定义词典:领域词不进词典,TF-IDF 全是噪声

通用词典对"钛酸锂""染料敏化太阳能电池""超疏水超亲油"这类词一律切碎,TF-IDF 排出来的关键词会是"基于""研究""一种"这种废词。先把标题里高频出现的领域词灌进词典,再抽关键词。

import jieba import jieba.analyse DOMAIN_WORDS = [ "石墨烯", "钛酸锂", "油水分离", "超级电容器", "染料敏化太阳能电池", "超声电机", "拟步甲", "磁法检测", "塞棒", "气凝胶", "导电浆料", "三维可视化", "无损检测", "云盘", "脑电", "肝衰竭", "MOF", ] for w in DOMAIN_WORDS: jieba.add_word(w, freq=2000, tag="n") # freq 抬高,防止被 HMM 拆开 STOP = set("基于 一种 研究 设计 新型 系统 技术 方法 应用 及其 的 与 和".split()) def keywords(title, topk=8): kws = jieba.analyse.extract_tags(title, topK=topk * 2, withWeight=True) return [(w, round(p, 3)) for w, p in kws if w not in STOP][:topk] for t in ["基于 Android 系统的西瓜无损检测算法及软件开发", "石墨烯柔性超级电容器研制"]: print(t, "->", keywords(t))

add_wordfreq参数决定这个词在图谱里的权重,默认值往往压不住 HMM 新词发现,设到 2000 左右能稳定生效。extract_tags默认用的是 jieba 自带的一份 IDF 语料,跟这份名单的领域分布差得远,所以我还习惯做一次语料内 DF 统计,把"几乎每条标题都出现"的词直接当停用词剔除,比调 IDF 文件省事:

from collections import Counter all_titles = [r["title"] for r in records] df = Counter() for t in all_titles: df.update(set(jieba.lcut(t))) common = {w for w, c in df.items() if c > len(all_titles) * 0.05} # 出现率超 5% STOP |= common print("高频废词样本:", sorted(common, key=lambda w: -df[w])[:15])

阈值 5% 是经验值:太低会把"机器人""纳米"这类真领域词误伤,太高则挡不住"基于""装置"。跑完先看样本列表,人工确认一遍再固定下来。

3.3 规则打分打多标签,留出人工复核的口子

把命中词和 TF-IDF 权重合在一起算分,取分最高的前两类作为标签,同时把"最高分低于阈值"的记录标记成待复核,而不是硬塞一个标签。

LABEL_RULES = { "新材料": ["石墨烯", "纳米", "MOF", "陶瓷", "荧光粉", "导电浆料", "气凝胶", "复合材料"], "能源与环保": ["油水分离", "水质净化", "吸附", "太阳能电池", "锂离子电池", "超级电容器", "净水"], "信息技术与互联网": ["云计算", "移动端", "Android", "识别算法", "三维", "云盘", "数据", "软件"], "生命科学与医学": ["基因", "细胞", "肝衰竭", "检测仪", "康复", "医学", "诊断"], "工程与机械": ["机器人", "发动机", "电机", "飞行器", "平台", "装置", "系统设计"], "社科与公共服务": ["调查", "制度", "政策", "养老", "教育", "治理", "研究——"], "环境与生态": ["生态", "污染", "PM2.5", "监测", "进化", "保护"], } def label_of(title, topn=2, floor=1.0): scores = {} kw_map = dict(keywords(title, 6)) for label, words in LABEL_RULES.items(): s = 0.0 for w in words: if w in title: # 标题里直接出现,权重最高 s += 2.0 if w in kw_map: # TF-IDF 也认可,加权 s += kw_map[w] * 3 if s: scores[label] = round(s, 2) ranked = sorted(scores.items(), key=lambda x: -x[1])[:topn] return ranked, (bool(ranked) and ranked[0][1] >= floor) for r in records[:5]: tags, ok = label_of(r["title"]) r["category"] = tags[0][0] if ok else "待复核" print(r["level"], r["title"][:40], "->", tags, "复核" if not ok else "")

标题内直配给 2 分、TF-IDF 加权给 3 倍系数,是因为标题命中是强证据、关键词只是佐证。floor=1.0意味着至少要有一个词原样出现在标题里才算可信。如果后面标注量攒够了,可以拿这套规则结果当弱监督标签,用sklearnTfidfVectorizer+OneVsRestClassifier(LinearSVC())训一个多标签模型替换规则,但要保留规则分支做兜底——样本几百条时,模型在长尾标签上的表现通常还不如词表。

4. 建索引做检索:SQLite FTS5 与查询接口

4.1 为什么不用 LIKE:中文场景下倒排索引的三个优势

WHERE title LIKE '%石墨烯%'在这份数据上不是不能用,但会撞三堵墙:一是全表扫描,条数上千后每次查询都要重新匹配;二是没法做"石墨烯 AND 电池""石墨烯 NOT 薄膜"这类组合条件;三是没有相关性排序,命中十条你只能按插入顺序看。FTS5 是 SQLite 内建的倒排索引扩展,支持布尔语法和 bm25 排序,单文件数据库、零部署,正好适配这种中小型名单。

中文有个额外坑:FTS5 内置的unicode61分词器按字符类切分,对中文基本等于逐字切,会导致"石墨烯"被拆成三个单字,检索时召回大量噪声。常见做法是写入前用 jieba 把标题预分词成空格分隔的字符串,单独存一列tokens,FTS 表只对tokens建索引,原始标题作为展示字段保留。

4.2 建表、写入与分词列的维护

CREATE TABLE works ( id INTEGER PRIMARY KEY, title TEXT NOT NULL, level TEXT, school TEXT, category TEXT, tokens TEXT ); CREATE UNIQUE INDEX idx_works_title ON works(title); CREATE VIRTUAL TABLE works_fts USING fts5( tokens, -- 只对预分词列建倒排索引 title, -- 原文保留,用于高亮与展示 level UNINDEXED, -- 这些列可查可回显,但不参与全文匹配 school UNINDEXED, tokenize = 'unicode61' );
import sqlite3, hashlib, jieba conn = sqlite3.connect("challenge_cup.db") cur = conn.cursor() def title_hash(t): # 标题哈希做幂等键:同一份文档重复导入不会产生重复行 return hashlib.md5(normalize(t).encode("utf-8")).hexdigest() for r in records: tokens = " ".join(w for w in jieba.lcut(r["title"]) if w.strip()) cur.execute( "INSERT OR IGNORE INTO works(title, level, school, category, tokens) VALUES(?,?,?,?,?)", (r["title"], r["level"], r["school"], r["category"], tokens), ) if cur.rowcount: # 只有真正插入了才同步索引表 cur.execute( "INSERT INTO works_fts(rowid, tokens, title, level, school) VALUES(?,?,?,?,?)", (cur.lastrowid, tokens, r["title"], r["level"], r["school"]), ) conn.commit() print("入库条数:", cur.execute("SELECT COUNT(*) FROM works").fetchone()[0])

UNINDEXED是个关键参数:level 和 school 是精确过滤字段,放进全文索引只会稀释相关性得分,所以只存不索引,过滤时直接在works表或子查询里做等值判断。INSERT OR IGNORE配合标题唯一索引,保证了二次导入的幂等性。写入顺序上,rowid必须是普通表的 id,FTS 表才有内容可回显。

4.3 MATCH 语法与 bm25 排序

语法含义示例
"石墨烯"短语匹配,避免逐字拆散works_fts MATCH '"石墨烯"'
A AND B同时包含'"石墨烯" AND "电池"'
A OR B任一包含'"机器人" OR "飞行器"'
A NOT B包含 A 不含 B'"传感器" NOT "标定"'
列名 : 词限定列匹配title : "锂离子电池"
词*前缀匹配'"超级电容"*'

用户输入不能直接扔进 MATCH,否则一个多余的引号就会抛fts5: syntax error。先做一层查询串构造:

import re def build_query(user_input): """把自然输入转成安全的 FTS5 短语查询,词间默认 AND""" parts = [p for p in re.split(r"\s+|、|,", user_input.strip()) if p] if not parts: return None return " AND ".join(f'"{p}"' for p in parts) def search(user_input, level=None, limit=10): q = build_query(user_input) if not q: return [] sql = """ SELECT w.title, w.level, w.school, w.category, bm25(works_fts) AS score FROM works_fts JOIN works w ON w.id = works_fts.rowid WHERE works_fts MATCH ? AND (? IS NULL OR w.level = ?) ORDER BY score LIMIT ? """ return cur.execute(sql, (q, level, level, limit)).fetchall() for row in search("石墨烯 电池", level="一等奖"): print(row[1], row[0][:36], "/", row[2], "| bm25=", round(row[4], 3))

bm25 的值越小表示越相关,所以ORDER BY score用升序就是相关性降序,不需要加DESC——这是 FTS5 里最容易写反的一处。(? IS NULL OR w.level = ?)是为了让同一个 SQL 既能按等级过滤也能全量查询,省掉拼接字符串带来的注入风险。

5. 增量更新与结果校验:三个能省下返工的具体技巧

5.1 用"书名号总数对账"定位漏抽

解析类脚本最大的风险是静默丢数据。我习惯在每次跑完解析后做一次三方对账:全文书名号配对数、解析出的记录数、works表实际行数。三者不一致时,先把差值按段落打印出来,定位到具体是哪个段落出了问题,再针对性放宽正则,而不是盲目调{2,80}的上限。

raw_text = " ".join(paragraphs) total_pairs = len(TITLE_RE.findall(raw_text)) # 全文书名号配对数 parsed = len(records) in_db = cur.execute("SELECT COUNT(*) FROM works").fetchone()[0] print(f"对账: 《》={total_pairs} 解析={parsed} 入库={in_db}") if total_pairs - in_db > 0: # 找出"有《》但没进库"的段落,逐段排查 for p in paragraphs: titles = TITLE_RE.findall(p) if titles and not any(r["title"] in p for r in records): print("疑似漏抽段落:", p[:100])

5.2 标题哈希做主键,支持重复导入

名单类文档往往隔一段时间就更新一版(补充未入围、调整等级),如果每次重建库,之前手工修正过的category就全丢了。解决办法是给标题算 md5 做稳定键,导入时走 upsert,只更新等级、学校这类会变的字段,手工标注过的标签不动。

INSERT INTO works(title, level, school, category, tokens) VALUES (?, ?, ?, ?, ?) ON CONFLICT(title) DO UPDATE SET level = excluded.level, school = excluded.school, tokens = excluded.tokens; -- category 故意不覆盖,保护人工修正

5.3 抽查 5% 验证等级字段,别信整体准确率

等级字段是错位重灾区,因为文档里存在"二等奖作品 三等奖作品《XXX》"这种连续标记。整体准确率在这里没有意义——错的大多集中在少数段落。有效做法是按学校分组,每组随机抽 5% 人工核对,重点看两类:等级为 NULL 的记录,以及同一段落里被连续两个等级标记夹住、且被判定为"就近左侧"的记录。真去抽一遍会发现绝大多数错误都出在这两处,比全量核对省力得多。抽完把确认结果写回一个verified标记列,下次导入时跳过已验证行,人工成本只花一次。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/17 17:09:12

RoboMaster电控硬件实战讲义:从电源设计到故障排查

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/17 17:07:36

BS EN 26-2015在即热式燃气热水器研发测试中的实战应用

简介&#xff1a;BS EN 26-2015中文版聚焦家用即热式燃气热水器&#xff0c;是一份面向产品设计、制造、测试及认证人员的欧洲标准中文译本&#xff0c;适用于燃气器具行业的技术对标与合规工作。资源系统覆盖热水器的结构要求&#xff0c;包括燃烧室、热交换器、控制系统与安全…

作者头像 李华
网站建设 2026/9/17 17:06:04

Datalog入门指南:用声明式逻辑编程搞定递归推理与图分析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华