ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

python-docx + SQLite FTS5:路怒症量表本地检索

python-docx + SQLite FTS5:路怒症量表本地检索 简介围绕路怒症与驾驶愤怒情绪展开的硕士学位论文文档面向交通心理学、交通安全与人因工程方向的学习者和研究者。论文以问卷调查与因素分析为方法主线完整呈现驾驶人愤怒表达量表的编制流程包括交通情境采集、项目集体讨论修订、预测版试测与正式施测、信度分析与验证性因素分析等环节并借助有序回归和多项Logistic回归量化愤怒驾驶表现与违反交通规则频率、事故形态之间的关联。内容还涉及情绪与行为关系的理论基础、愤怒情绪对驾驶人生理心理的影响以及愤怒情绪下驾驶人操作频率与猛烈程度等行为特征最后讨论了干预策略与未来研究方向。压缩包内仅含1个docx文件约91KB采用标准论文体例含中英文摘要、六章正文与参考文献。目前已有41人学习适合需要借鉴量表编制思路、统计分析路径或愤怒驾驶研究框架的读者参考。1. 从收藏夹到可检索语料路怒症与驾驶愤怒表达量表论文的 docx 批处理起点2021 到 2022 年攒下来的那批资料文件名往往是「心理学路怒症交通情绪驾驶行为交通安全驾驶愤怒表达量表论文.docx」这种长标题一个目录里躺着几十上百份。真正让人难受的不是文件多而是 Word 自带的搜索只能在你已经打开的那一篇里找词跨文件搜「驾驶愤怒表达量表」是搜不出来的量表条目又大量藏在表格和自动编号里复制出来编号全丢。想横向比较几份论文里同一个维度的条目表述、想统计某类驾驶愤怒表达出现的频次靠肉眼翻是翻不完的。这篇内容给出一条能复现的路径先把 docx 批量解析成结构化文本再用 SQLite FTS5 加 jieba 建本地索引然后把量表条目抽出来做去重和维度归类最后用一组已知答案的查询给检索质量做体检。适合手上有一堆中文论文、又不想把原文上传到任何地方的人。2. 用 python-docx 批量抽取驾驶愤怒论文的段落与量表表格先把「解析」这一步做扎实。中文论文的 docx 结构比想象中脏自动编号不带文本、合并单元格重复返回、量表常常以图片或文本框形式插进去。解析阶段偷懒后面索引和条目抽取全是连锁错误。2.1 先摸清 docx 里量表可能藏在哪里同一份路怒症论文里「驾驶愤怒表达量表」的条目可能分布在四个位置抽取方式完全不同。先跑一遍统计脚本确认这批资料的分布再决定要不要花力气处理文本框。结构位置python-docx 取法常见丢失点正文段落doc.paragraphs自动编号不产生文本条目只剩内容表格doc.tables→row.cells合并单元格会把同一段文本返回多次文本框 / 题注需直接解析w:txbxContent节点doc.paragraphs里完全看不到页眉页脚section.header/section.footer默认不遍历期刊名和页码会混进来脚注尾注需读 package 里的 footnotes 部件没有公开 API得走 XML提示解析前先按文件大小和段落数排一下序几百字的 docx 多半是封面或投稿模板直接跳过能省不少事。2.2 批量遍历目录的最小脚本下面这段脚本做三件事跳过 Word 打开文件时产生的~$临时文件、把正文段落和表格分开收、按文件输出成 JSONL 便于后续追加字段。import json from pathlib import Path from docx import Document # pip install python-docx ROOT Path(./papers) # 论文收藏目录可递归 OUT Path(./parsed) OUT.mkdir(exist_okTrue) def iter_docx(root: Path): 递归找 docx跳过 Word 临时文件和隐藏文件 for p in sorted(root.rglob(*.docx)): if p.name.startswith(~$) or p.name.startswith(.): continue yield p def load_docx(path: Path): doc Document(str(path)) # 正文段落去掉空段和纯空白 paras [p.text.strip() for p in doc.paragraphs if p.text.strip()] # 表格逐行逐单元格取文本 tables [] for t in doc.tables: rows [] for r in t.rows: rows.append([c.text.strip().replace(\n, ) for c in r.cells]) tables.append(rows) return paras, tables with (OUT / corpus.jsonl).open(w, encodingutf-8) as fw: for fp in iter_docx(ROOT): try: paras, tables load_docx(fp) except Exception as e: # 加密文件、损坏文件都会走到这里 print(f[SKIP] {fp.name}: {e}) continue for i, txt in enumerate(paras): fw.write(json.dumps({ doc: fp.name, kind: para, idx: i, text: txt }, ensure_asciiFalse) \n) for ti, rows in enumerate(tables): for ri, row in enumerate(rows): fw.write(json.dumps({ doc: fp.name, kind: table, idx: f{ti}-{ri}, text: | .join(row) }, ensure_asciiFalse) \n) print(done)逻辑上段落和表格写进同一个 JSONL靠kind字段区分来源后面做高亮时能直接告诉用户「这句话来自第 3 张表的第 5 行」。几个参数值得留意rglob换成glob就只扫一层资料按年份分子目录时别改单元格里的\n必须替换成空格否则一条量表条目会被切成两行正则匹配直接失效except里不要只pass打印文件名才能在几百份文件里定位到那份损坏的 docx。老的.doc后缀 python-docx 不支持常见做法是先用 LibreOffice 的--convert-to docx批转一遍再进流程。2.3 把量表条目从段落里抠出来的正则条目抽取是整个流程里最需要反复调的一步。中文论文的量表条目一般长这样「12. 有人突然变道插到我前面时我会感到非常生气。」编号可能是12.、12、、12)或(12)长度通常在 8 到 60 字之间。import re # 编号 分隔符 正文 ITEM_RE re.compile(r^[(]?\s*(\d{1,3})\s*[.、,)]\s*(.)$) # 明显不是量表条目的噪声 NOISE re.compile(r(doi|DOI|http|基金项目|收稿日期|第\s*\d\s*卷|参考文献)) def extract_items(rows): hit [] for r in rows: t r[text].strip() if len(t) 8 or len(t) 90: continue if NOISE.search(t): continue m ITEM_RE.match(t) if not m: continue num, body m.group(1), m.group(2).strip() # 正文里必须出现情绪或驾驶相关线索否则很可能是目录行 if not re.search(r[生气愤怒恼火情绪驾驶车速喇叭变道超车], body): continue hit.append({doc: r[doc], num: int(num), text: body}) return hit长度阈值 8 到 90 是经验值低于 8 字的多半是「1. 引言」这类标题超过 90 字的往往是作者对量表的整段说明而不是条目本身。第二个re.search是关键过滤条件中文论文的目录页也长成「3. 驾驶愤怒表达量表」的样子不靠语义线索区分就会把目录当条目收进来。跑完之后建议按num排序人工看前 50 条编号连续性是最好的验证手段——如果发现编号从 1 跳到 8 又回到 2说明这篇论文的量表被拆在多个位置需要单独处理。3. 用 SQLite FTS5 加 jieba 给路怒症语料建本地检索索引解析出来只是文本文件grep能搜但没法排序、没法高亮、没法做中文分词。这一步把语料装进真正能查的引擎。3.1 为什么是 SQLite FTS5 而不是别的这批数据的规模决定选型。上百份论文全文加起来通常几十兆段落数在十万量级这个体量下上独立搜索引擎属于过度设计要维护服务进程、要处理中文分词插件、要迁数据。SQLite 的 FTS5 是单文件、零部署、自带 BM25 排序和snippet()高亮唯一缺的就是中文分词——而这个缺口用 jieba 在写入前预切词就能补上。代价是索引里存的是空格分隔的词串原始文本要另存一份用于展示这一点在写入时顺手解决即可。3.2 用 jieba 预分词后写入 FTS5先建两张表docs存原始文本和来源seg是 FTS5 虚表存分词结果。import json, sqlite3, jieba # 专业词加进自定义词典否则驾驶愤怒表达量表会被切碎 for w in [驾驶愤怒表达量表, 驾驶愤怒, 路怒症, 攻击性驾驶, 交通情绪, 驾驶行为, 交通安全, 适应性表达, 言语攻击]: jieba.add_word(w, freq2000) def cut_for_index(s: str) - str: 搜索引擎模式切词长词会额外切出子词提升召回 return .join(w for w in jieba.cut_for_search(s) if w.strip()) conn sqlite3.connect(papers.db) conn.executescript( CREATE TABLE IF NOT EXISTS docs( id INTEGER PRIMARY KEY, doc TEXT, kind TEXT, idx TEXT, raw TEXT ); CREATE VIRTUAL TABLE IF NOT EXISTS seg USING fts5( text, -- 分词后的文本 doc_id UNINDEXED, -- 关联 docs.id不参与检索 tokenize unicode61 -- 已预分词用最简单的切分器即可 ); ) with open(parsed/corpus.jsonl, encodingutf-8) as fr: for line in fr: r json.loads(line) cur conn.execute( INSERT INTO docs(doc, kind, idx, raw) VALUES(?,?,?,?), (r[doc], r[kind], r[idx], r[text]) ) conn.execute( INSERT INTO seg(text, doc_id) VALUES(?,?), (cut_for_index(r[text]), cur.lastrowid) ) conn.commit() print(conn.execute(SELECT count(*) FROM seg).fetchone())tokenize unicode61这个参数是刻意的文本进来之前已经用空格切好了词FTS5 不需要再理解中文用最轻量的切分器性能最好也避免了不同分词器版本带来的索引不一致。freq2000是让自定义词在 jieba 的 HMM 判定里优先成词频率给太低不生效。cut_for_search而不是cut是因为搜索模式会对长词额外输出子词比如「驾驶愤怒表达量表」同时产出「驾驶」「愤怒」「量表」用户只打「量表」也能命中。如果要重建索引直接删掉papers.db重跑脚本即可几十兆的数据几十秒能跑完没必要写增量更新逻辑。3.3 查询语法、BM25 权重与高亮参数建完索引就能用命令行直接查不用写代码。sqlite3 -readonly papers.db SELECT d.doc, d.idx, snippet(seg, 0, 【, 】, … , 12) AS ctx, round(bm25(seg, 1.0), 3) AS score FROM seg JOIN docs d ON d.id seg.doc_id WHERE seg MATCH 驾驶愤怒表达量表 OR (路怒症 AND 量表) ORDER BY score LIMIT 20;bm25(seg, 1.0)里的数字是列权重。seg表只有text一列参与索引权重写 1.0 保持默认如果以后把标题单独拆成一列标题列给 3.0 到 5.0、正文列给 1.0是最常见的加权做法。snippet()的五个参数依次是表名、列号、左标记、右标记、省略号、上下文词数——12 表示高亮片段左右各取约 12 个词中文场景下调到 8 到 15 之间比较合适再大就会出现整段整段返回。round(bm25(...), 3)只是为了看排序是否合理正式查询里ORDER BY rank等价且更快。Python 侧调用时注意查询串也要过一遍cut_for_index否则用户输入的「驾驶愤怒表达量表」在索引里是分词后的形态直接拿原串去 MATCH 会因为词边界不一致而漏召回。def search(conn, q, limit20): qs cut_for_index(q) # 用双引号包住每个词避免用户输入里的符号被当成 FTS5 语法 terms AND .join(f{w} for w in qs.split()) sql SELECT d.doc, d.idx, d.raw, snippet(seg, 0, b, /b, … , 10) ctx FROM seg JOIN docs d ON d.id seg.doc_id WHERE seg MATCH ? ORDER BY rank LIMIT ? return conn.execute(sql, (terms, limit)).fetchall()这里把用户输入拆成词之后用AND连接并逐个加双引号是为了防止输入里出现OR、*、-这类 FTS5 语法字符把查询语句搞坏。查不到东西的时候先把AND换成OR看是否召回能快速判断是分词问题还是语料里真的没有。4. 驾驶愤怒表达量表条目的抽取、去重与维度归类索引解决「找得到」这一步解决「看得懂」。手上几十份论文的量表条目往往是同一套工具的不同翻译或修订版本条目会高度重叠又措辞略有差异人工比对的成本极高。4.1 条目去重的三步走先做规范化再做近似匹配。规范化包括去掉首尾标点、全角转半角、把「驾驶时」和「开车时」这类同义短语统一。近似匹配用字符级 n-gram 的 Jaccard 相似度就够中文短句上比词向量更稳、更快、不需要模型。import re from collections import defaultdict def norm(s: str) - str: s s.strip() s re.sub(r[。、\()《》\s], , s) s s.replace(驾驶时, 开车时).replace(行驶时, 开车时) return s def ngrams(s: str, n: int 2): return {s[i:in] for i in range(len(s) - n 1)} def jaccard(a: str, b: str) - float: A, B ngrams(norm(a)), ngrams(norm(b)) return len(A B) / len(A | B) if A and B else 0.0 def dedup(items, thr0.72): groups, kept defaultdict(list), [] for it in items: for k in kept: if jaccard(it[text], k[text]) thr: groups[k[text]].append(it) break else: kept.append(it) return kept, groups阈值 0.72 是从中文短句的实践里调出来的设到 0.85 以上意思相同但主谓顺序不同的两条会被判成不同设到 0.6 以下「我会按喇叭」和「我会骂他」这种完全不同的表达会被合并反而更糟。跑完之后把groups里每个代表条目和它的成员一起打印出来看一遍比盯着相似度数字靠谱。4.2 用 TF-IDF 加层次聚类给条目分维度去重后的条目可以自动归类。中文短句做聚类有两个坑一是词太少导致 TF-IDF 矩阵稀疏二是通用词「开车」「时候」「感到」会主导距离。用max_df压掉高频词再加一层字符 n-gram 补充效果明显更好。import jieba from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.cluster import AgglomerativeClustering def cut(s): return [w for w in jieba.cut(s) if len(w) 1] texts [it[text] for it in kept] vec TfidfVectorizer( tokenizercut, token_patternNone, # 用了自定义 tokenizer 必须显式关掉默认正则 max_df0.85, # 丢掉开车时候这类高频词 min_df2, # 只出现一次的词不进词典 sublinear_tfTrue, # 对长条目做对数压缩避免长度主导 ) X vec.fit_transform(texts) cl AgglomerativeClustering( n_clustersNone, distance_threshold0.75, # 越小簇越细0.6~0.8 之间来回试 metriccosine, linkageaverage, ) labels cl.fit_predict(X.toarray()) # 条目量小稠密化代价可接受distance_threshold是这里唯一需要反复调的参数。条目总量在几百条以内时把它从 0.6 试到 0.85看哪一档的簇内条目读起来是同一个意思。linkageaverage比ward更适合余弦距离ward只对欧氏距离成立。X.toarray()在条目上万条时要改成稀疏友好的做法几百条的量级用稠密矩阵反而更快。跑完输出每个簇最靠前的五个关键词用vec.get_feature_names_out()[X[簇内索引].toarray().sum(axis0).argsort()[-5:]]就能拿到。4.3 把条目映射到驾驶愤怒表达的分量表聚类给出的是无标签的簇还需要把簇对应到量表维度上。常见做法是维护一张关键词到维度的映射表用正则或词表命中来打标聚类只用来校验人工映射有没有漏掉成片的条目。目标维度关键词或模式命中示例言语攻击表达骂、吼、喊、嘟囔、说难听话我会摇下车窗骂他身体攻击表达下车、追、拦、动手、理论我会想下车跟他理论使用车辆表达别车、按喇叭、闪灯、紧跟、超回去我会故意按喇叭提醒他适应性表达深呼吸、冷静、换个想法、听音乐我会告诉自己别较真频率与强度经常、总是、几乎、非常、极其这种情况我经常遇到映射命中的条目数和聚类结果的簇大小应该有大致对应关系。如果某个簇很大却几乎没有条目被映射表命中说明这批论文里出现了映射表没覆盖的表达方式把那个簇的代表条目补进词表即可。反过来如果某个维度命中数量远超其他维度先怀疑是不是「非常」「经常」这类词被误当作维度词用把它们从维度词表里剔出去只保留在频率统计里。5. 检索质量校验与关键词回捞让交通情绪类查询不漏条目建好索引和映射表之后还有一个容易被跳过的问题你怎么知道搜「交通情绪」不会漏掉用「驾驶情绪」「行车情绪」表述的论文。做一次小规模的质量体检比事后翻半天找不到资料划算得多。5.1 用一组已知答案的查询做召回体检从语料里挑 10 到 20 个查询每个查询你明确知道应该命中哪几份文档然后跑一遍算召回率和首位命中。import json GOLD { 驾驶愤怒表达量表: {A.docx, B.docx, C.docx}, 路怒症 量表: {A.docx, D.docx}, 交通情绪 驾驶行为: {B.docx, E.docx}, } def recall_at(conn, gold, k20): rows [] for q, expect in gold.items(): got {r[0] for r in search(conn, q, limitk)} inter got expect rows.append({ query: q, recall: round(len(inter) / len(expect), 3), first_hit: 是 if expect and list(got)[:1] and list(got)[0] in expect else 否, }) return rows for r in recall_at(conn, GOLD): print(r)如果某条查询召回率低于 0.6通常不是排序问题而是分词问题。最有效的定位手段是把查询串的切词结果打印出来看cut_for_index(交通情绪 驾驶行为)如果切成了「交通 / 情绪 / 驾驶 / 行为」那篇论文里如果用的是「行车情绪」索引里存在的词是「行车」两边对不上。这时候就要走同义词扩展。5.2 一个查询改写的实用技巧同义词 OR 扩展加重排序不要在 FTS5 里做复杂的中文同义词分析器那会很重。更实用的做法是在查询改写层维护一张小的同义词表把每个词扩展成OR分组同时用 BM25 的列权重把「命中原词」的排前面。SYN { 路怒: [路怒, 驾驶愤怒, 愤怒驾驶, 攻击性驾驶], 交通情绪: [交通情绪, 驾驶情绪, 行车情绪, 驾驶心境], 量表: [量表, 问卷, 量表工具, 标准化测量], } def rewrite(q: str) - str: groups [] for w in jieba.cut(q): w w.strip() if not w: continue alts SYN.get(w, [w]) # 每个同义词也走一次分词保证和索引侧一致 terms [] for a in alts: terms.extend(f{t} for t in cut_for_index(a).split()) groups.append(( OR .join(sorted(set(terms))) )) return AND .join(groups) # 词之间 AND同义词之间 OR改写之后MATCH的表达式变成(原词 OR 同义词) AND (原词 OR 同义词)的形状词间用 AND 保证查询主题不漂移词内用 OR 保证不漏召回。同义词表只维护二三十条就够覆盖路怒症和驾驶情绪这个方向因为领域词汇的收敛度很高新增的词大多能并进已有分组里。每次往SYN里加词都要重跑一遍 5.1 的体检脚本确认召回率上升的同时首位命中率没有下降——扩词扩得太猛第一条结果会变成只是擦边提到「情绪」的普通段落那比搜不到更浪费时间。真正的收尾动作是把这个体检脚本挂进日常流程新增论文入库之后先跑体检再跑一遍第 4 章的聚类看新出现的簇有没有需要补进同义词表或维度词表的条目。这样索引和词表会随着收藏一起长而不是攒到几百份再回头返工。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

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