
上周末整理电脑里的文档我再一次体会到了那种“明明都存了却找不到”的抓狂某个项目的过程记录散在三个txt里命名分别是“v1.txt”“备份-注意”“新建文档(3).txt”。每份文件内容我都记得大概但要定位某一段细节就得逐个打开翻。放下鼠标那一刻我下了决心与其继续忍受这种“存了等于没存”的状态不如写一个本地小工具把这些txt统一扫一遍自动告诉我每份文档在讲什么、高频词是什么、值得先读哪几句。这个东西我给它起名叫rea就是read-extract-annotate三个动作的首字母组合读取全文、抽取关键信息、给文档做标注。rea解决的核心问题并不是“搜索”而是“不带关键词的预览”——搜索需要我知道要找什么而rea反过来它让我在不知道要找什么的情况下也能快速知道手上这堆文档的主题分布。这个工具适合所有经常和本地文本打交道的人尤其是普通开发者、内容运营、文档整理强迫症患者以及不放心把内部资料传到云端的人。1. 先说清楚rea是拿来解决什么问题的1.1 rea的定位不是搜索引擎是文档体检员rea从设计第一天就划清了三件事read、extract、annotate。read负责遍历文件夹把txt读进来extract负责算词频、给句子打分、抽关键词annotate负责把结果整理成一份可阅读的Markdown报告。整个链路都在本地完成不依赖任何网络服务。rea不做什么这一点反而更重要不做全文检索因为那需要建索引不做语义相似度匹配那是另一个深坑不做知识图谱那是给自己找罪受。rea只在一个很窄的范围里发力把一堆纯文本快速变成“摘要关键词重点句子”的索引报告。边界划得小代码才可能短出了问题也才能马上定位。这其实是我做小工具的一个核心心得一开始就把“不做什么”写明白比“能做什么”更省时间。没有边界的工具很快会变成一个装满了各种半成品功能的垃圾桶最后连自己都不敢用了。1.2 为什么不用现成方案非要自己写决定动手前我把能走的路都过了一遍在线摘要API效果确实好但要把内部文本传出去光是第一关我就过不了开源NLP流水线能力很强可为了跑通它得下载模型文件还要解决依赖版本对我“几十个txt要快速出摘要”这个需求来说明显超重至于本地大模型自己部署硬件扛不住调用平台又划不来。最后我给自己定了一条原则所有逻辑必须在本地跑通依赖尽量只用Python标准库每一步生成的结果都要能被人类看懂。这样万一输出不对我能顺着分数和中间结果一路查回去而不是对着一个黑盒模型发呆。rea的定位就是本地、轻量、可解释这个定位决定了后面所有设计。1.3 第一版目标不是“完美”而是“有反馈”最早我在纸上写目标时只有一句话“输入一个文件夹输出一个txt报告别崩就行。”大概二十行代码跑通之后rea的第一版输出虽然很粗糙——把每个文件的最高分两三句拼成摘要、列出几个高频词——但它让我第一次看到了真实的中间反馈。有了反馈才知道该改哪里摘要选出的句子排序是乱的、关键词里有一堆无意义单字、部分文件因为编码问题直接读崩了。这些问题是靠“想象”永远发现不了的只有真正跑起来才会浮出水面。我的习惯是先做一个丑但能跑的最小版把结果亮出来再决定下一步优化方向。2. 第一版跑通读文件、清洗、粗分句2.1 编码问题别让第一关就翻车现实世界里的txt远比我们想的野。尤其从旧系统、某些导出工具里拿到的文本很多是GBK或GB18030编码用utf-8硬读轻则乱码重则直接抛UnicodeDecodeError。rea读文件时按顺序尝试utf-8、gbk、gb18030、latin-1成功就返回内容同时记下实际用的编码。这样不管文件当初怎么存的至少能先打开。这段逻辑是我后来反复受益的rea有一次跑批处理十个文件里混进了一个GBK编码如果没有这层兜底整个批处理会在跑到第三个文件时就中断后面全是白干。多试几种编码的代价几乎为零收益却是“不中断”太值了。def read_file(path): for enc in (utf-8, gbk, gb18030, latin-1): try: with open(path, r, encodingenc) as f: return f.read(), enc except UnicodeDecodeError: continue return , None2.2 清洗和分句做减法比做加法重要清洗这一步我只做了三件事全角空格换成半角、多个连续空格合并、去掉空行。很多人喜欢在这里加一堆复杂规则比如去特殊符号、去超链接、做繁体转简体但我的经验是清洗越重越容易出问题你根本不知道哪些信息后面还有用。分句我用了一个很朴素的正则按中文和英文的句子结束标点切分并且保留标点。面对没有空格的中文这可能是性价比最高的分句方式。虽然偶尔会把引号内的句子拦腰截断但在rea“找重点句”这个场景下完全够用。这也再次印证了那个原则先解决90%的常规情况剩下的5%等真实遇到了再处理。def split_sentences(text): parts re.split(r(?[。!?])\s*, text) return [s.strip() for s in parts if s.strip()]2.3 词频统计从连续汉字块开始rea初始版本没有接任何分词库而是用一个更土的办法把文本里的连续汉字片段找出来然后对每个片段做1到3个字的滑动窗口统计。这样做的好处是不受词典覆盖率限制任何文本进来都能算出一个“频率分布”。代价是会产生一些像“效果图”这种切得不完美的候选但对于统计热点词这种场景半词并不会带来灾难。def collect_candidates(text): candidates collections.Counter() segments re.findall(r[\u4e00-\u9fa5]{2,}, text) for seg in segments: chars list(seg) for n in (1, 2, 3): if len(chars) n: continue for i in range(len(chars) - n 1): cand .join(chars[i:i n]) candidates[cand] 1 return candidates用真实语料测了一下“的数据”“问题”这类泛化的三字片断会排在很前面后来我加了停用词过滤和长度惩罚才逐渐压住它们。3. 句子打分我用的是“局部热度启发式加成”3.1 为什么在小语料上先别用TF-IDF主流的文本摘要方法都会提到TF-IDF它的本意是降低“在越多文档里出现过的词”的权重让只在一处出现的专属词有更高地位。听起来很合理但rea面对的是单篇文档或最多十来篇的文件夹文档集合太小。所谓IDF在这个规模下只代表“这个词恰好没有出现在另一篇里”而不是“这个词在整个语料里少见”数值随机性太高。打个比方学校里某个班就十个人你统计“谁成绩好”本来要看年级排名现在只能看班里排名班里没选那门课的同学根本没进排名池子这个排名自然失真。rea在这里很务实直接使用“词在本文档里的出现次数”作为热度指标也就是局部TF。单篇文档里反复出现的词很大概率就是这篇文档真正在讲的东西。等语料真的庞大到某个量级再引入IDF才有意义。3.2 三个启发式加成词热度、标题词、句长区间给句子打分时rea用了三个成分的加权组合。核心是词热度把句子拆成1到3字的窗口每个窗口去查候选计数器累加它们的频次句子命中主题词的次数越多热度自然越高。第二是标题加成如果句子含标题里的词得分乘一个加倍数这相当于给“直接点名主题”的句子开了绿灯。第三是句长区间10到60字之间的句子给固定加成太短可能信息量不足太长则不适合当摘要句。def score_sentence(sent, candidates, title_words): chars re.findall(r[\u4e00-\u9fa5], sent) hot 0 for n in (1, 2, 3): if len(chars) n: continue for i in range(len(chars) - n 1): w .join(chars[i:i n]) hot candidates.get(w, 0) if any(w in sent for w in title_words): hot * 2.0 if 10 len(sent) 60: hot 1.2 return hot这里面每个数字都不是拍脑袋拍出来的标题加成倍数设2.0是为了让一句“点名主题”的话能明显超过普通高热度句句长只在合适区间给固定加成而不是按长度线性加分是避免那种“又臭又长”的堆砌句靠词多混进TopK。3.3 可解释性也是产品性能的一部分rea坚持用这套简单算法的最大理由是可以解释。当摘要选错句子时我却可以把分数拆开看是词热度项拉高了还是标题加成起了作用还是句长区间加的分刚好把它送进了前三。把误差拆到具体成分之后调权重就变成了一个很安稳的过程而不是对着黑盒反复试。我后来接手过几个基于大模型做摘要的项目效果虽然好但一旦输出不符合预期排查链路长到让人崩溃。rea这个项目反而让我重新感受到一句话的含金量越简单的东西越容易被人接管。4. 关键词抽取先算热度再做去重合并4.1 候选池从1字窗口到3字窗口rea的关键词候选池直接复用collect_candidates的统计结果里面同时包含单字、双字、三字。单字里有大量“的、了、是、在”这类虚词需要用一个尽量小的停用词表过滤。我强烈建议停用词表不要贪大——50到100个常用虚词就够了搞一个几千词的巨型表容易把“的士”“目的地”这种有效组合也一起误伤。我本地维护的停用词表大概是这样一个小集合的了是在有和我你就他都它们这那一个也很于与及把被让向从对为而或若但并。别看就这么点配合后面的长度惩罚已经能压住绝大多数噪声。4.2 打分与同前缀去重候选词的分值按“频次乘以长度指数”计算长度指数取1.4。为什么不是直接取平方或者立方因为指数太大长词会彻底碾压短词导致关键词列表全是三字组合指数太小长度又起不到区分作用。1.4这个值是我在几篇不同风格的文本上调出来的能让同频次的二、三字词稍微排到前面但又不至于喧宾夺主。真正比较关键的是去重策略。中文文本有个特别常见的现象一个核心词会带出一堆同族词比如“效果”“效果图”“效果展示”“效果不错”如果不处理Top10关键词会被这些同前缀词霸占大半看着就很蠢。rea做了一个特别简单的合并只要两个候选的前两个字相同就当作一族只保留热度分更高的那一个。def top_keywords(candidates, topk10): scored [(freq * (len(w) ** 1.4), w) for w, freq in candidates.items()] scored.sort(reverseTrue) seen [] out [] for _, w in scored: if any(w[:2] old[:2] for old in seen): continue seen.append(w) out.append(w) if len(out) topk: break return out虽然“前两个字相同就合并”粗暴得有点不像话但在rea的场景里非常好用。它不依赖任何词向量或语义知识只靠字符串前缀做归并结果关键词列表很快就变得干净了。4.3 什么时候这套玩法会失效要说这套关键词方案的短板也很明显如果输入的是跨领域综述、密集引用型学术文章候选词会出现大量“研究”“分析”“问题”这种泛化词因为它们确实高频但并不能代表文档特征。另外rea不真正理解同义词“数据”和“信息”在它眼里是两个完全独立的词。真遇到这种场景我就得换带词典的分词工具甚至引入词向量做主题聚类那就不在今天这版rea的讨论范围里了。5. 摘要生成与报告输出不只要准还要能读5.1 选句之后的顺序还原拿到句子分数之后最直接的做法是把得分最高的三句按分数从高到低拼在一起。rea第一版就这么干过结果摘要看起来像三块完全独立的碎片拼贴思维跳跃严重人根本没法一次读下来。后来我加了一条规则先按分数选出前K句然后按它们在原文中的先后顺序重新排列。这个“顺序还原”看似简单却是摘要可读性的分水岭。阅读体验遵循的是原文叙事逻辑而不是重要性顺序。句子A是第3段的高分句句子B是第1段的高分句如果按分数拼就是B后A前按原顺序拼才是第1段B在第3段A前面读起来自然很多。def make_summary(sents, scores, title, k3): ranked sorted(range(len(sents)), keylambda i: scores[i], reverseTrue)[:k] ranked.sort() summary .join(sents[i] for i in ranked) title_words re.findall(r[\u4e00-\u9fa5]{2,}, title or ) if title_words and not any(w in summary for w in title_words[:2]): summary sents[0] summary return summary[:500] or 未生成摘要句子长度限制这里也顺便处理了超过60字的句子基本不选除非整篇都找不到短句。长句虽然可能信息密集但塞进摘要里非常难读而且容易把三个重点句的名额全部吃掉。5.2 完整度自查摘要里必须出现标题词rea有一个笨办法做摘要质量兜底如果生成的摘要里没有出现标题里的前两个连续汉字词就把原文第一句拼到最前面。为什么取“前两个”因为中文标题通常把主题词放在最前面比如“数据采集模块的断点续传设计”核心就是“数据采集”或“断点续传”摘要要是连这个都没提到那它根本没抓住文档要义。这个规则有时候会引入不够精准的第一句但它保底的意义更大至少保证摘要与标题有可验证的关联。做内容处理工具我宁可要“每天稳定工作但有5%能看出问题”也不要“偶尔惊艳但不可复现”。稳定性本身就是输出质量的一部分。5.3 把结果落成Markdown报告rea的输出格式从一开始就定成Markdown不搞自定义格式。每篇文档生成一个固定结构的报告块包括标题、摘要、关键词、高分句Top5、基础统计。多个文件跑完所有报告块合并起来就是一份文档索引可以直接存成.md后续丢给其他工具继续加工也方便。输出模板大概是这个样子的# 文件数据采集模块v2.txt ## 摘要 该产品主要用于数据采集支持断点续传…… ## 关键词 数据采集断点续传模块 ## 高分句 Top5 1. 该产品主要用于数据采集…… 2. 断点续传功能在上次联调中已通过……用Markdown还有个额外好处可以用Git做版本管理。我每周跑一次rea报告文件的变化就能通过Git历史看到文本主题是怎么漂移的这个意外收获算是做了一个小小的文本时间轴。6. 实测三份文档被打脸之后我调了三个参数6.1 第一次跑摘要里混进了一句正确的废话第一版rea跑完三份测试文档结果有惊喜也有翻车。惊喜是大部分摘要确实抓到了文档主题翻车集中在第二份“某项目例会记录”上它的摘要里出现了一句“这个功能很不错”。从单句看“这个功能很不错”确实是高分句句子短、长度合适、包含“功能”这个中等热度词三项叠加刚好把它送进前三。但它是一句正确的废话对了解会议重点毫无帮助。这个案例特别典型因为它暴露出一个本质问题句子打分时位置和主题关联度的权重没拉开导致“说起来通顺但信息量低”的句子混进了摘要。机器只看到了词频统计并没意识到这句话是评价语而不是事实陈述。6.2 拆误差是哪个成分把它送进去的我做了个简单的误差剖析。把“这个功能很不错”的得分拆开看词热度中等句长区间拿了固定加成标题加成因为不含标题词而没有触发。三项叠加总分刚好卡进前三。这时候我意识到标题加成和位置加成太弱了弱到没法把真正的主题句和这种“顺口溜式句子”区分开。随后做了三个调整。第一标题词加成倍数从1.5提到2.0让直接说主题的句子明显占优。第二给每个自然段的段首句额外加15%权重中文写作习惯里段首句经常承担“总起”功能。第三句长加成从线性加分改成“只在合适区间给固定分不在区间则为0”避免长句靠词多自然高分也避免短句靠句子短被高估。6.3 调参后的对比肉眼可见的收敛同一个文件夹里再跑一次第二份文档的摘要变成了“本周重点确认数据同步方案计划周五前完成接口联调”关键词也从“功能”“数据”“同步”变成了“数据同步”“接口联调”“方案确认”信息密度明显提高。表格对比一下调参前后的差异维度调整前调整后摘要是否含标题词偶尔缺失完整度自查兜底摘要中口水句比例中等明显下降关键词同族重复常见同前缀合并后基本消失长句误选概率较高句长约束后下降参数敏感性方面我也把三个重要参数都试了一遍选句数量topK在3到5之间最稳太少会漏重点、太多会稀释阅读前两个字相同就合并效果最好前一个字相同会把正常双字词误伤句长下限设在10个字比较稳低于这个值基本都是碎片。这三个参数后来我都暴露成命令行参数方便不同文件夹切换不同配置。7. 从“能跑”到“好用”批量、定时、可视化7.1 批量扫描别让工具只能处理一个文件第一版rea一次只能处理单个文件后来加了一个scan_folder函数用os.walk递归遍历整个文件夹只处理.txt结尾的文件并且主动忽略以“rea_report”开头的输出文件避免把自己生成的报告当成输入又跑一遍。这个细节看似无关紧要实际上非常有价值一旦忽略条件漏了下一次跑批就会把上一份报告也纳入统计关键词和摘要全都会被污染。def scan_folder(folder): for root, _, files in os.walk(folder): for name in files: if name.endswith(.txt) and not name.startswith(rea_report): yield os.path.join(root, name)有了批量能力之后rea就和系统计划任务接上了每周定时跑一次自动扫描指定文档目录生成一份“本周文档速览”报告。我不需要打开任何文件只看报告就知道这周哪些文档新增了、核心话题是什么。7.2 用标准库做可读的HTML报告Markdown报告虽然好但要给别人看还是HTML最通用。rea加了一个非常轻的输出模式把所有Markdown块套进一个简单的HTML模板CSS全部内联不依赖任何CDN资源生成一个双击就能在浏览器打开的单文件页面。这样即使对方电脑里什么都没有也能直接看报告。可视化也做了个特别朴素的字符词云把关键词按频次排序高频词用更大的字号展示甚至可以生成一个字符矩阵让词频决定字符重复次数。虽然丑但在“不想装任何绘图库”的约束下非常实用。做小工具的乐趣就在这种地方——逼自己用最少的依赖把最核心的体验做出来。7.3 后续还想继续做的几个方向rea目前的版本还只处理纯文本我在备忘里记了几个想做的方向一是把会议记录里的“以上”“也就是说”“这块”这类口头过渡句识别出来并降权会议类文档里它们占比极高二是支持docx和md解析让rea的处理范围从txt扩展到日常工作的主流格式三是做相似句合并避免摘要里同一信息点出现两遍。不过我对这些方向有一条底线保持rea的简单绝不因为加功能就背上重型依赖。每加入一个组件都要先问它是否值得用“依赖体积”和“排查复杂度”来换取。到现在为止rea依然能在没有任何第三方库的Python环境里直接跑起来这就是我最满意的状态。如果你也想做一个类似的小工具我的建议是先别纠结模型选型也先别追求一次到位。拿最简单的方式把你的需求跑通让结果先可见再根据真实输出决定调权重、换算法还是加功能。工具是拿来解决问题的不是拿来炫技的。哪怕它丑了点、土了点只要每次都能在关键时刻帮你省下十分钟它就是一个值得继续打磨的好工具。