計到可解釋的報告生成)
上周末整理電腦里的文檔我再一次體會到了那種“明明都存了卻找不到”的抓狂某個項目的過程記錄散在三個txt里命名分別是“v1.txt”“備份-注意”“新建文檔(3).txt”。每份文件內(nèi)容我都記得大概但要定位某一段細節(jié)就得逐個打開翻。放下鼠標那一刻我下了決心與其繼續(xù)忍受這種“存了等于沒存”的狀態(tài)不如寫一個本地小工具把這些txt統(tǒng)一掃一遍自動告訴我每份文檔在講什么、高頻詞是什么、值得先讀哪幾句。這個東西我給它起名叫rea就是read-extract-annotate三個動作的首字母組合讀取全文、抽取關(guān)鍵信息、給文檔做標注。rea解決的核心問題并不是“搜索”而是“不帶關(guān)鍵詞的預(yù)覽”——搜索需要我知道要找什么而rea反過來它讓我在不知道要找什么的情況下也能快速知道手上這堆文檔的主題分布。這個工具適合所有經(jīng)常和本地文本打交道的人尤其是普通開發(fā)者、內(nèi)容運營、文檔整理強迫癥患者以及不放心把內(nèi)部資料傳到云端的人。1. 先說清楚rea是拿來解決什么問題的1.1 rea的定位不是搜索引擎是文檔體檢員rea從設(shè)計第一天就劃清了三件事read、extract、annotate。read負責遍歷文件夾把txt讀進來extract負責算詞頻、給句子打分、抽關(guān)鍵詞annotate負責把結(jié)果整理成一份可閱讀的Markdown報告。整個鏈路都在本地完成不依賴任何網(wǎng)絡(luò)服務(wù)。rea不做什么這一點反而更重要不做全文檢索因為那需要建索引不做語義相似度匹配那是另一個深坑不做知識圖譜那是給自己找罪受。rea只在一個很窄的范圍里發(fā)力把一堆純文本快速變成“摘要關(guān)鍵詞重點句子”的索引報告。邊界劃得小代碼才可能短出了問題也才能馬上定位。這其實是我做小工具的一個核心心得一開始就把“不做什么”寫明白比“能做什么”更省時間。沒有邊界的工具很快會變成一個裝滿了各種半成品功能的垃圾桶最后連自己都不敢用了。1.2 為什么不用現(xiàn)成方案非要自己寫決定動手前我把能走的路都過了一遍在線摘要API效果確實好但要把內(nèi)部文本傳出去光是第一關(guān)我就過不了開源NLP流水線能力很強可為了跑通它得下載模型文件還要解決依賴版本對我“幾十個txt要快速出摘要”這個需求來說明顯超重至于本地大模型自己部署硬件扛不住調(diào)用平臺又劃不來。最后我給自己定了一條原則所有邏輯必須在本地跑通依賴盡量只用Python標準庫每一步生成的結(jié)果都要能被人類看懂。這樣萬一輸出不對我能順著分數(shù)和中間結(jié)果一路查回去而不是對著一個黑盒模型發(fā)呆。rea的定位就是本地、輕量、可解釋這個定位決定了后面所有設(shè)計。1.3 第一版目標不是“完美”而是“有反饋”最早我在紙上寫目標時只有一句話“輸入一個文件夾輸出一個txt報告別崩就行?!贝蟾哦写a跑通之后rea的第一版輸出雖然很粗糙——把每個文件的最高分兩三句拼成摘要、列出幾個高頻詞——但它讓我第一次看到了真實的中間反饋。有了反饋才知道該改哪里摘要選出的句子排序是亂的、關(guān)鍵詞里有一堆無意義單字、部分文件因為編碼問題直接讀崩了。這些問題是靠“想象”永遠發(fā)現(xiàn)不了的只有真正跑起來才會浮出水面。我的習慣是先做一個丑但能跑的最小版把結(jié)果亮出來再決定下一步優(yōu)化方向。2. 第一版跑通讀文件、清洗、粗分句2.1 編碼問題別讓第一關(guān)就翻車現(xiàn)實世界里的txt遠比我們想的野。尤其從舊系統(tǒng)、某些導出工具里拿到的文本很多是GBK或GB18030編碼用utf-8硬讀輕則亂碼重則直接拋UnicodeDecodeError。rea讀文件時按順序嘗試utf-8、gbk、gb18030、latin-1成功就返回內(nèi)容同時記下實際用的編碼。這樣不管文件當初怎么存的至少能先打開。這段邏輯是我后來反復受益的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 清洗和分句做減法比做加法重要清洗這一步我只做了三件事全角空格換成半角、多個連續(xù)空格合并、去掉空行。很多人喜歡在這里加一堆復雜規(guī)則比如去特殊符號、去超鏈接、做繁體轉(zhuǎn)簡體但我的經(jīng)驗是清洗越重越容易出問題你根本不知道哪些信息后面還有用。分句我用了一個很樸素的正則按中文和英文的句子結(jié)束標點切分并且保留標點。面對沒有空格的中文這可能是性價比最高的分句方式。雖然偶爾會把引號內(nèi)的句子攔腰截斷但在rea“找重點句”這個場景下完全夠用。這也再次印證了那個原則先解決90%的常規(guī)情況剩下的5%等真實遇到了再處理。def split_sentences(text): parts re.split(r(?[。!?])\s*, text) return [s.strip() for s in parts if s.strip()]2.3 詞頻統(tǒng)計從連續(xù)漢字塊開始rea初始版本沒有接任何分詞庫而是用一個更土的辦法把文本里的連續(xù)漢字片段找出來然后對每個片段做1到3個字的滑動窗口統(tǒng)計。這樣做的好處是不受詞典覆蓋率限制任何文本進來都能算出一個“頻率分布”。代價是會產(chǎn)生一些像“效果圖”這種切得不完美的候選但對于統(tǒng)計熱點詞這種場景半詞并不會帶來災(zāi)難。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用真實語料測了一下“的數(shù)據(jù)”“問題”這類泛化的三字片斷會排在很前面后來我加了停用詞過濾和長度懲罰才逐漸壓住它們。3. 句子打分我用的是“局部熱度啟發(fā)式加成”3.1 為什么在小語料上先別用TF-IDF主流的文本摘要方法都會提到TF-IDF它的本意是降低“在越多文檔里出現(xiàn)過的詞”的權(quán)重讓只在一處出現(xiàn)的專屬詞有更高地位。聽起來很合理但rea面對的是單篇文檔或最多十來篇的文件夾文檔集合太小。所謂IDF在這個規(guī)模下只代表“這個詞恰好沒有出現(xiàn)在另一篇里”而不是“這個詞在整個語料里少見”數(shù)值隨機性太高。打個比方學校里某個班就十個人你統(tǒng)計“誰成績好”本來要看年級排名現(xiàn)在只能看班里排名班里沒選那門課的同學根本沒進排名池子這個排名自然失真。rea在這里很務(wù)實直接使用“詞在本文檔里的出現(xiàn)次數(shù)”作為熱度指標也就是局部TF。單篇文檔里反復出現(xiàn)的詞很大概率就是這篇文檔真正在講的東西。等語料真的龐大到某個量級再引入IDF才有意義。3.2 三個啟發(fā)式加成詞熱度、標題詞、句長區(qū)間給句子打分時rea用了三個成分的加權(quán)組合。核心是詞熱度把句子拆成1到3字的窗口每個窗口去查候選計數(shù)器累加它們的頻次句子命中主題詞的次數(shù)越多熱度自然越高。第二是標題加成如果句子含標題里的詞得分乘一個加倍數(shù)這相當于給“直接點名主題”的句子開了綠燈。第三是句長區(qū)間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這里面每個數(shù)字都不是拍腦袋拍出來的標題加成倍數(shù)設(shè)2.0是為了讓一句“點名主題”的話能明顯超過普通高熱度句句長只在合適區(qū)間給固定加成而不是按長度線性加分是避免那種“又臭又長”的堆砌句靠詞多混進TopK。3.3 可解釋性也是產(chǎn)品性能的一部分rea堅持用這套簡單算法的最大理由是可以解釋。當摘要選錯句子時我卻可以把分數(shù)拆開看是詞熱度項拉高了還是標題加成起了作用還是句長區(qū)間加的分剛好把它送進了前三。把誤差拆到具體成分之后調(diào)權(quán)重就變成了一個很安穩(wěn)的過程而不是對著黑盒反復試。我后來接手過幾個基于大模型做摘要的項目效果雖然好但一旦輸出不符合預(yù)期排查鏈路長到讓人崩潰。rea這個項目反而讓我重新感受到一句話的含金量越簡單的東西越容易被人接管。4. 關(guān)鍵詞抽取先算熱度再做去重合并4.1 候選池從1字窗口到3字窗口rea的關(guān)鍵詞候選池直接復用collect_candidates的統(tǒng)計結(jié)果里面同時包含單字、雙字、三字。單字里有大量“的、了、是、在”這類虛詞需要用一個盡量小的停用詞表過濾。我強烈建議停用詞表不要貪大——50到100個常用虛詞就夠了搞一個幾千詞的巨型表容易把“的士”“目的地”這種有效組合也一起誤傷。我本地維護的停用詞表大概是這樣一個小集合的了是在有和我你就他都它們這那一個也很于與及把被讓向從對為而或若但并。別看就這么點配合后面的長度懲罰已經(jīng)能壓住絕大多數(shù)噪聲。4.2 打分與同前綴去重候選詞的分值按“頻次乘以長度指數(shù)”計算長度指數(shù)取1.4。為什么不是直接取平方或者立方因為指數(shù)太大長詞會徹底碾壓短詞導致關(guān)鍵詞列表全是三字組合指數(shù)太小長度又起不到區(qū)分作用。1.4這個值是我在幾篇不同風格的文本上調(diào)出來的能讓同頻次的二、三字詞稍微排到前面但又不至于喧賓奪主。真正比較關(guān)鍵的是去重策略。中文文本有個特別常見的現(xiàn)象一個核心詞會帶出一堆同族詞比如“效果”“效果圖”“效果展示”“效果不錯”如果不處理Top10關(guān)鍵詞會被這些同前綴詞霸占大半看著就很蠢。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的場景里非常好用。它不依賴任何詞向量或語義知識只靠字符串前綴做歸并結(jié)果關(guān)鍵詞列表很快就變得干凈了。4.3 什么時候這套玩法會失效要說這套關(guān)鍵詞方案的短板也很明顯如果輸入的是跨領(lǐng)域綜述、密集引用型學術(shù)文章候選詞會出現(xiàn)大量“研究”“分析”“問題”這種泛化詞因為它們確實高頻但并不能代表文檔特征。另外rea不真正理解同義詞“數(shù)據(jù)”和“信息”在它眼里是兩個完全獨立的詞。真遇到這種場景我就得換帶詞典的分詞工具甚至引入詞向量做主題聚類那就不在今天這版rea的討論范圍里了。5. 摘要生成與報告輸出不只要準還要能讀5.1 選句之后的順序還原拿到句子分數(shù)之后最直接的做法是把得分最高的三句按分數(shù)從高到低拼在一起。rea第一版就這么干過結(jié)果摘要看起來像三塊完全獨立的碎片拼貼思維跳躍嚴重人根本沒法一次讀下來。后來我加了一條規(guī)則先按分數(shù)選出前K句然后按它們在原文中的先后順序重新排列。這個“順序還原”看似簡單卻是摘要可讀性的分水嶺。閱讀體驗遵循的是原文敘事邏輯而不是重要性順序。句子A是第3段的高分句句子B是第1段的高分句如果按分數(shù)拼就是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 完整度自查摘要里必須出現(xiàn)標題詞rea有一個笨辦法做摘要質(zhì)量兜底如果生成的摘要里沒有出現(xiàn)標題里的前兩個連續(xù)漢字詞就把原文第一句拼到最前面。為什么取“前兩個”因為中文標題通常把主題詞放在最前面比如“數(shù)據(jù)采集模塊的斷點續(xù)傳設(shè)計”核心就是“數(shù)據(jù)采集”或“斷點續(xù)傳”摘要要是連這個都沒提到那它根本沒抓住文檔要義。這個規(guī)則有時候會引入不夠精準的第一句但它保底的意義更大至少保證摘要與標題有可驗證的關(guān)聯(lián)。做內(nèi)容處理工具我寧可要“每天穩(wěn)定工作但有5%能看出問題”也不要“偶爾驚艷但不可復現(xiàn)”。穩(wěn)定性本身就是輸出質(zhì)量的一部分。5.3 把結(jié)果落成Markdown報告rea的輸出格式從一開始就定成Markdown不搞自定義格式。每篇文檔生成一個固定結(jié)構(gòu)的報告塊包括標題、摘要、關(guān)鍵詞、高分句Top5、基礎(chǔ)統(tǒng)計。多個文件跑完所有報告塊合并起來就是一份文檔索引可以直接存成.md后續(xù)丟給其他工具繼續(xù)加工也方便。輸出模板大概是這個樣子的# 文件數(shù)據(jù)采集模塊v2.txt ## 摘要 該產(chǎn)品主要用于數(shù)據(jù)采集支持斷點續(xù)傳…… ## 關(guān)鍵詞 數(shù)據(jù)采集斷點續(xù)傳模塊 ## 高分句 Top5 1. 該產(chǎn)品主要用于數(shù)據(jù)采集…… 2. 斷點續(xù)傳功能在上次聯(lián)調(diào)中已通過……用Markdown還有個額外好處可以用Git做版本管理。我每周跑一次rea報告文件的變化就能通過Git歷史看到文本主題是怎么漂移的這個意外收獲算是做了一個小小的文本時間軸。6. 實測三份文檔被打臉之后我調(diào)了三個參數(shù)6.1 第一次跑摘要里混進了一句正確的廢話第一版rea跑完三份測試文檔結(jié)果有驚喜也有翻車。驚喜是大部分摘要確實抓到了文檔主題翻車集中在第二份“某項目例會記錄”上它的摘要里出現(xiàn)了一句“這個功能很不錯”。從單句看“這個功能很不錯”確實是高分句句子短、長度合適、包含“功能”這個中等熱度詞三項疊加剛好把它送進前三。但它是一句正確的廢話對了解會議重點毫無幫助。這個案例特別典型因為它暴露出一個本質(zhì)問題句子打分時位置和主題關(guān)聯(lián)度的權(quán)重沒拉開導致“說起來通順但信息量低”的句子混進了摘要。機器只看到了詞頻統(tǒng)計并沒意識到這句話是評價語而不是事實陳述。6.2 拆誤差是哪個成分把它送進去的我做了個簡單的誤差剖析。把“這個功能很不錯”的得分拆開看詞熱度中等句長區(qū)間拿了固定加成標題加成因為不含標題詞而沒有觸發(fā)。三項疊加總分剛好卡進前三。這時候我意識到標題加成和位置加成太弱了弱到?jīng)]法把真正的主題句和這種“順口溜式句子”區(qū)分開。隨后做了三個調(diào)整。第一標題詞加成倍數(shù)從1.5提到2.0讓直接說主題的句子明顯占優(yōu)。第二給每個自然段的段首句額外加15%權(quán)重中文寫作習慣里段首句經(jīng)常承擔“總起”功能。第三句長加成從線性加分改成“只在合適區(qū)間給固定分不在區(qū)間則為0”避免長句靠詞多自然高分也避免短句靠句子短被高估。6.3 調(diào)參后的對比肉眼可見的收斂同一個文件夾里再跑一次第二份文檔的摘要變成了“本周重點確認數(shù)據(jù)同步方案計劃周五前完成接口聯(lián)調(diào)”關(guān)鍵詞也從“功能”“數(shù)據(jù)”“同步”變成了“數(shù)據(jù)同步”“接口聯(lián)調(diào)”“方案確認”信息密度明顯提高。表格對比一下調(diào)參前后的差異維度調(diào)整前調(diào)整后摘要是否含標題詞偶爾缺失完整度自查兜底摘要中口水句比例中等明顯下降關(guān)鍵詞同族重復常見同前綴合并后基本消失長句誤選概率較高句長約束后下降參數(shù)敏感性方面我也把三個重要參數(shù)都試了一遍選句數(shù)量topK在3到5之間最穩(wěn)太少會漏重點、太多會稀釋閱讀前兩個字相同就合并效果最好前一個字相同會把正常雙字詞誤傷句長下限設(shè)在10個字比較穩(wěn)低于這個值基本都是碎片。這三個參數(shù)后來我都暴露成命令行參數(shù)方便不同文件夾切換不同配置。7. 從“能跑”到“好用”批量、定時、可視化7.1 批量掃描別讓工具只能處理一個文件第一版rea一次只能處理單個文件后來加了一個scan_folder函數(shù)用os.walk遞歸遍歷整個文件夾只處理.txt結(jié)尾的文件并且主動忽略以“rea_report”開頭的輸出文件避免把自己生成的報告當成輸入又跑一遍。這個細節(jié)看似無關(guān)緊要實際上非常有價值一旦忽略條件漏了下一次跑批就會把上一份報告也納入統(tǒng)計關(guān)鍵詞和摘要全都會被污染。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就和系統(tǒng)計劃任務(wù)接上了每周定時跑一次自動掃描指定文檔目錄生成一份“本周文檔速覽”報告。我不需要打開任何文件只看報告就知道這周哪些文檔新增了、核心話題是什么。7.2 用標準庫做可讀的HTML報告Markdown報告雖然好但要給別人看還是HTML最通用。rea加了一個非常輕的輸出模式把所有Markdown塊套進一個簡單的HTML模板CSS全部內(nèi)聯(lián)不依賴任何CDN資源生成一個雙擊就能在瀏覽器打開的單文件頁面。這樣即使對方電腦里什么都沒有也能直接看報告。可視化也做了個特別樸素的字符詞云把關(guān)鍵詞按頻次排序高頻詞用更大的字號展示甚至可以生成一個字符矩陣讓詞頻決定字符重復次數(shù)。雖然丑但在“不想裝任何繪圖庫”的約束下非常實用。做小工具的樂趣就在這種地方——逼自己用最少的依賴把最核心的體驗做出來。7.3 后續(xù)還想繼續(xù)做的幾個方向rea目前的版本還只處理純文本我在備忘里記了幾個想做的方向一是把會議記錄里的“以上”“也就是說”“這塊”這類口頭過渡句識別出來并降權(quán)會議類文檔里它們占比極高二是支持docx和md解析讓rea的處理范圍從txt擴展到日常工作的主流格式三是做相似句合并避免摘要里同一信息點出現(xiàn)兩遍。不過我對這些方向有一條底線保持rea的簡單絕不因為加功能就背上重型依賴。每加入一個組件都要先問它是否值得用“依賴體積”和“排查復雜度”來換取。到現(xiàn)在為止rea依然能在沒有任何第三方庫的Python環(huán)境里直接跑起來這就是我最滿意的狀態(tài)。如果你也想做一個類似的小工具我的建議是先別糾結(jié)模型選型也先別追求一次到位。拿最簡單的方式把你的需求跑通讓結(jié)果先可見再根據(jù)真實輸出決定調(diào)權(quán)重、換算法還是加功能。工具是拿來解決問題的不是拿來炫技的。哪怕它丑了點、土了點只要每次都能在關(guān)鍵時刻幫你省下十分鐘它就是一個值得繼續(xù)打磨的好工具。