ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

从NLP到检索优化:陶渊明诗作数字化实战记录

从NLP到检索优化:陶渊明诗作数字化实战记录

前几天翻后台搜索日志,看到一条 query:"采菊东南下"。五个字错了一个,但我很笃定用户要找的是"采菊东篱下,悠然见南山"。这种笃定不是来自运气,是前面大半年一直在做陶渊明诗作数字化的结果。归隐意象挖掘、检索优化、多场景部署,这三件事我完整跑了一遍,现在的系统连错别字都能纠回来。

这个项目说大不大,是一个面向古诗爱好者的线上检索工具,数据源没有贪大,只覆盖陶渊明现存诗作一百多首。当时选这个范围,理由其实很朴素:在古籍数字化里,规模小反而是一种优势——你能把所有输出逐条人工核对,而不是扔给评测集说一句"差不多得了"。如果你也想给自己的诗词站加搜索引擎,或者打算用 NLP 做古籍文本挖掘,又或者只是好奇"归隐"这种抽象主题怎么变成可计算的坐标,这篇实战记录应该能提供一些直接能抄作业的思路。

1. 为什么偏偏是陶渊明:语料小,坑不少

1.1 陶诗的量级刚好适合做全量人工校验

陶渊明传世诗作大致在一百二十首上下,比全唐诗那种四万多首的体量小了两个数量级。体量小,处理压力不大,但语义负担一点不轻。像"采菊东篱下,悠然见南山",十个字里包含了动作、对象、地点、情态,主谓宾一个不少;而"晨兴理荒秽,带月荷锄归"这种句子,根本没有主语,现代 NLP 的依存分析工具在这里几乎直接失效。古诗词是典型的"高密度压缩语义图",每一个字都可能承担超出字面的意思。

这给数字化带来的核心问题是:通用 NLP 管线在古诗上表现极差,你不能靠"调个大模型"糊弄过去。诗里省略主语、省略谓语、倒装、用典,各种非现代汉语句式密集出现。也正是因为语料小,我可以把每一句都拆出来人工过一遍,看看哪些坑是语料本身的,哪些是工具造成的。这种全量校验的能力,在做一个面向公众的诗词检索服务时非常重要——用户搜不到东西可以忍,搜到错误结果一定是灾难。

所以我的第一个经验是:如果只是想跑通一套诗词数字化流程,别一上来就上全唐诗,先挑一个体量可控、语义密度高的诗人做样本。陶渊明这种一位诗人一个小世界,恰恰是试错成本最低的选择。

1.2 清洗陶诗时最不能跳过的版本问题

很多人做古籍数字化,第一步就栽在文本质量和版本差异上。陶诗版本不算最复杂的,但已经足够让人长记性。我用的数据源是公开整理的公版古籍文本,以其中一个注本为底本,再参考其他版本做交叉校对。清洗流程我固定成了五步:

  1. 统一编码和格式,全部转成 UTF-8 纯文本,去掉与正文无关的序跋页。
  2. 繁体转简体,统一标点,按句切分,给每一句分配唯一 ID,保留诗题和篇目信息。
  3. 建立异文表,记录不同版本之间的用字差异。
  4. 去掉注疏和赏析内容,只留原文。
  5. 按比例人工抽查,重点看 OCR 容易出错的字。

这个流程里最值得说的坑是繁简转换。繁体"余"在陶诗里大多作"我"讲,表示"剩余"的"餘"也会被转成"余",两个字在简体里长一样。表面看没什么问题,但你如果拿转好的简体文本去统计词频或做向量训练,语义就被污染了。类似情况还有"發"和"髮"都转成"发",古诗里"怒发冲冠"和"百发百中"完全是两码事。

异文的问题更隐蔽。"悠然见南山",有的版本写作"悠然望南山",一个字之差,意象完全不同:"见"强调无意中看到,有偶遇的意味;"望"则是主动观望。放到检索系统里,用户输入"悠然望南山"也必须能命中"悠然见南山"。所以我建了一张别名映射表,把所有异文和通行文本挂在一起,索引时一并处理。这一步不做,后面检索优化做得再好都白搭。

2. 归隐意象挖掘:从词典标签到语义簇

2.1 第一版是词典匹配:能标注,但看不懂语境

归隐意象挖掘,我最初没用任何机器学习。手动整理了一份意象词表:菊、东篱、南山、归鸟、羁鸟、孤云、闲云、旧林、故渊、松、酒、琴、素琴、柴门、丘山、衡门、幽人、羲皇,大约四十多个词。然后写正则扫描每一句,统计命中情况,得到"篇目 x 意象"矩阵。这个方法的好处是可控、所见即所得,跑完一看结果就知道哪些句子被打了标签。

问题也很直接:词典标注只能证明"词出现了",解释不了"词在这个位置想表达什么"。"鸟"在陶诗里有时是归鸟,指向归隐;有时就是自然景物,并不承担主题功能。更典型的是"酒",陶渊明写酒写得多,但"酒能祛百虑"里的酒是消愁工具,"漉我新熟酒"里的酒是田园日常,硬要把所有带"酒"的句子都归类为归隐意象,人工复核时就会看到大量误报。

所以词典版本最大的价值不是直接用来生产,而是让我看到意象识别的真正难点:归隐不是由一个词构成的,而是由一组词在具体语境中的合力构成的。这个词表后来变成了语义簇挖掘的种子集,不是终点。

2.2 共现分析把意象"簇"捞出来

要搞清楚哪些词经常一起出现,我用了点互信息(PMI,Pointwise Mutual Information),而不是简单的共现频次。频次会偏向高频词,一堆常用词永远排在最前面,看不出意象结构。PMI 的计算方式是:

PMI(w1, w2) = log( P(w1, w2) / (P(w1) * P(w2)) )

P(w1, w2) 是两词在同一窗口内共现的概率,P(w1) 和 P(w2) 是各自的出现概率。因为语料小,所有概率估计都做了 Laplace 平滑,避免低频词被高估。我把窗口设成诗句级,也就是同一句内的词算共现,跨句不算。

跑出来的结果很有意思。"东篱""菊""南山""采"形成了一个紧密的词团;"酒""琴""松""闲"是另一个团;"鸟"被分成两支——"归鸟""飞鸟""相与还"是一支,"羁鸟""樊笼"是另一支。前者是归隐的向往,后者是对入仕受困的追述。这就验证了一件事:归隐意象不是一个词,是一组互相锁定的语义簇。纯粹在词典里孤立地看任何单个意象词,都会丢掉重要信息。

语义簇的产出也很有用。我从中提炼了三组典型意象簇:东篱采菊簇、返自然簇、琴酒松闲簇。后续查询扩展一拍即合:搜索"归隐"直接把这三组词作为扩展词丢进去。

意象簇 | 代表性诗句 东篱采菊簇 | 采菊东篱下,悠然见南山 返自然簇 | 久在樊笼里,复得返自然 琴酒松闲簇 | 息交游闲业,卧起弄书琴

2.3 自训词向量与聚类:把"归隐"变成坐标

共现分析解决的是词与词之间的关系,但要处理"归隐"这种抽象概念,还需要把它表示成一个可计算的向量坐标。我试过网上的通用中文词向量,结果不理想:"东篱"和"菊花"很近,但和"归隐"很远;"南山"在现代语料里经常出现在地名或房地产文案里,语义完全偏到另一个方向。用现代语料训练的向量来理解古代诗歌,天然存在语境错位。

解决方法是直接在汉魏六朝诗歌语料上自训练一个 50 维 Word2Vec,词表很小,训练速度以秒计。我把陶诗全量加进去,再补了一些同时代诗人的作品作背景语料,加大古诗词语境占比。模型参数上,窗口设成 3,因为诗句本身就短,窗口太大容易把跨句的词拉进来;min_count 设成 2,过滤只出现一次的生僻字。做完之后,用 TF-IDF 加权的词向量平均来生成诗句向量:

句向量 = Σ(词向量 * TF-IDF权重) / Σ(TF-IDF权重)

然后用 PCA 降维到二维看一眼分布,再用 KMeans 聚类,k 取 6。人工检查聚类结果后,典型归隐句如"采菊东篱下,悠然见南山"和"久在樊笼里,复得返自然"被分到了同一个簇,田园诗和咏怀诗落在邻近但不同的簇里。这说明语义方向是成立的,"归隐"作为查询词,在语义空间里确实能找到自己的坐标点。

我还顺手定义了一个启发式的"归隐指数":把某句所有词的向量投影到归隐簇中心方向上求平均长度。这个指数后来没用进正式检索排序,因为启发式评分在个别句子上的解释力不够稳定,但在做数据可视化和意象热力图时很有参考价值。

3. 检索优化:用户搜的是"意境",不是字面

3.1 分词和输入容错:用户只记得半个句子

先不碰抽象语义,最基础的检索是字面匹配。古诗词没有空格,分词是第一步。jieba 在古诗上表现得不太理想,比如"五柳先生"容易被切成"五柳/先生","东篱"可能被切散成"东/篱","羲皇上人"这种典故词根本不在通用词表里。所以建自定义词典是必须的。我把意象词表、异文词、典故词全都塞了进去,保证检索"黔娄"能直接命中,而不是先被错误切碎。

比分词更常见的问题是用户输入"脏"。我观察到的检索日志里,三种错误最多:错别字、繁体字、只记得读音。应对方法分三路:

  1. 索引时对每个词同时存简体、繁体、拼音和拼音首字母。
  2. 查询先做标准化:繁体转简体,再去空格和标点。
  3. 字面匹配结果做编辑距离筛选,允许 1 到 2 个字符误差。

"采菊东南下"能纠正到"采菊东篱下",就是靠这层编辑距离容错:字面匹配先锁定"采菊*下"附近的结果,然后允许"东"和"南"一个字符的偏差,最终命中"采菊东篱下,悠然见南山"。

3.2 BM25 参数不是玄学,短文本要把 b 调低

倒排索引实现简单,真正的排序问题在相关性。我第一版直接用 TF-IDF,后来换成了 BM25。BM25 有两个重要参数:k1 控制词频饱和程度,b 控制文档长度惩罚。默认参数通常说 k1=1.2、b=0.75,但放在古诗上不合适。原因在于诗句长度差异极小,长度惩罚意义不大,b 过高反而会压制那些短而精准的句子。我把 b 调到 0.5,k1 保持 1.2,检索结果明显改善。

纯 BM25 还有个天然缺陷:它依赖字面词项重合。用户搜"归隐",陶诗里"归隐"这个词本身出现得极少,纯 BM25 几乎拿不到像样的结果。这暴露出一个本质问题:用户搜的是"意境",不是字面。拆解意境必须靠语义召回。

有一点要注意,停用词别删得太狠。现代检索系统通常会把"之""乎""者""也"当停用词,但古诗里"心远地自偏"的"自"、"问君何能尔"的"尔"都有实际语气含义。我最后保留了大部分虚词,只去掉标点和纯语气词,宁可让分词结果多几个 token,也不能为了降噪把语义关节切掉。

3.3 语义召回加上 RRF 融合:让"归隐"能搜到"心远地自偏"

语义召回的做法是:把 query 也编码成句向量。具体到我这里,"归隐"先做查询扩展,从意象簇表里抽出归隐簇的成员:归鸟、东篱、菊、南山、旧林、故渊、自然,扩展成一组词,再过一遍 BM25 召回 top50。与此同时,query 向量单独走一遍向量检索,也取 top50。最后用 RRF(Reciprocal Rank Fusion)把两路结果合并:

def rrf_fusion(bm25_hits, semantic_hits, k=60): score = {} for rank, doc_id in enumerate(bm25_hits): score[doc_id] = score.get(doc_id, 0) + 1.0 / (k + rank) for rank, doc_id in enumerate(semantic_hits): score[doc_id] = score.get(doc_id, 0) + 1.0 / (k + rank) return sorted(score.items(), key=lambda x: x[1], reverse=True)

RRF 的好处是两路打分体系尺度不同,加权平均对参数非常敏感,而排名融合不依赖评分绝对值,鲁棒得多。k 取 60 是经验值,太大拉不开差距,太小则容易让单路 top 排名主导结果。

融合以后,搜"归隐"的结果从"几乎没有直接命中"变成:第一页能给出"归鸟恋旧林,池鱼思故渊""久在樊笼里,复得返自然",再往下一点能出现"采菊东篱下,悠然见南山"。这就是把意象簇和向量语义接进检索链路后的实际提升。

4. 多场景部署:一套索引,三种形态

4.1 在线 API:哪怕是小服务也要先全局加载

在线检索我选了 FastAPI,最直接的原因是它自带参数校验和接口文档,省事。部署时最容易犯的错误是每次请求都重新加载索引和向量表,一百多首诗规模小无所谓,但放到生产环境,一旦流量涨起来,加载耗时就是灾难。我的做法是在进程启动时用全局变量初始化一次,检索接口只读全局对象,然后用 lru_cache 把常见 query 的结果缓存起来,容量设在 512,命中率相当可观。

接口返回结构也做了一个统一模式:query、suggestions、hits、matched_poems四段。hits里包含诗句原文、篇目名、意象簇标签,方便前端直接渲染。实际压测时,向量检索一次在一千多句语料上是毫秒级,真正的耗时瓶颈反而是日志输出和 JSON 序列化。吞吐不够时优先考虑横向加实例,而不是在业务代码里做无谓的微优化。

4.2 CLI:做批处理和研究的人更需要命令行动线

API 适合在线调用,但如果你要做数据研究,比如批量导出每首诗的意象分布,或者跑一个"诗人—意象—主题"的关联网络,一个个调接口就太累了。所以我把核心检索逻辑抽成了一个独立模块,API 和命令行共用同一份代码。命令行工具的形态是这样:

poem search "归隐" --top 10 --format json poem topic "归隐" --output cluster_ciyuan.txt poem dump --static-dir dist/

第一条命令直接输出检索结果,格式支持 json 和 markdown。markdown 格式是专门给快速出报告用的,丢给协作同事看一眼很方便。第二条命令输出意象簇词表。第三条命令导出静态站点。命令行工具的价值在于可脚本化:研究者可以把检索批量跑完,再对结果做人工标注,效率比 Web 端高很多。

4.3 纯静态导出:没有后端也能提供"古诗词在线"

这套数据总量很小,文本、索引、向量矩阵打包成一个 JSON,大概几百 KB 大小。前端预加载这个 JSON,在浏览器内存里做检索,页面几乎秒开。于是我又加了一种部署形态:把所有检索逻辑写成 worker.js,发布到任意静态空间,不需要服务器,也不用担心接口被打爆。这就是"古诗词在线"这个场景下性价比最高的形态——数据量决定了它可以完全不需要后端。

静态化当然有边界:它不适合做需要全量语料的在线更新和复杂用户系统。但如果需求就是"一个能搜、能看意象聚类、能承载日常浏览的诗词库",纯静态导出已经足够。三类部署形态的取舍,我整理成了这样一张表:

部署形态 | 适用场景 | 资源占用 | 瓶颈 在线 API | App、小程序、在线站点 | 中 | 并发和 JSON 序列化 CLI 工具 | 批量研究、数据导出 | 低 | 无 纯静态导出 | 展示型诗词站、轻量检索 | 最低 | 无法支持在线数据更新

5. 实测效果、五个坑和下一步可以做的事

5.1 一组检索效果对比

为了让自己心里有底,我拿一批真实场景 query 做了对比,每一轮结果都人工核对过。挑几条典型的:

query | 策略 | 实际效果 采菊东南下(错别字) | 编辑距离容错 | 命中"采菊东篱下,悠然见南山" 归隐 | 词典 BM25 | 几乎无有效结果 归隐 | 意象扩展 + 语义 RRF | "归鸟恋旧林,池鱼思故渊""久在樊笼里,复得返自然" 悠然望南山(异文) | 异文映射 | 命中"悠然见南山" 羁鸟 | BM25 字面 | 返回"羁鸟恋旧林,池鱼思故渊"

关于"采菊东南下",我加了编辑距离容错后,曾经出现两个候选:"采菊东篱下"和"采菊东庐下",人工复核后确认前者正确。这提醒我一件事:字面纠错只能生成候选,最终的准确性依赖排序算法把正确答案推到第一位,所以语义召回那一路不能省。

5.2 五个没人提前告诉你的坑

第一个坑是繁简转换里的"余""餘"问题,前面已经详细说过。表面看不出来,一旦统计词频就翻车,专做古籍数字化的人肯定都遇到过。

第二个坑是外部词向量语义偏移。现代中文词向量在"东篱""南山"这类词上学到的都是现代语义,直接拿来处理古诗等于让古人说现代话。必须在古诗语料上重新训练,而且语料领域不能偏。

第三个坑是 PMI 在低频词上会被高估。一首诗里"靴"这样的词只出现一次,和另一个只出现一次的词共现,PMI 会吓人地高。必须平滑,或者用频次过滤掉太低频的词,否则意象簇会被冷僻词污染。

第四个坑是停用词不要贪多。现代搜索里很多虚词可以直接丢,但古诗里的虚词往往承担语气和结构功能,乱删会把"心远地自偏"这类句子切成残废。

第五个坑是 BM25 默认参数不适合短古诗。b 值默认 0.75 偏大,短文本语料调到 0.5 左右更稳妥。这种参数不像吹出来的"调参玄学",它是文档长度分布不同导致的必然选择。

5.3 我接下来在做的两件事

这套基础索引稳定下来之后,我暂时没有继续扩语料,而是把精力放在两件更聚焦的事上。第一件是把同一套意象簇方法迁移到《归去来兮辞》等辞赋作品上,看跨文体的稳定性是否和诗一样。第二件是给浏览器做一个划词组件:用户在任意页面选中一句古诗,后台通过同一套检索链路返回它的意象簇标签和语义近邻诗句。这两件事都直接复用现在已经完成的检索模块,不需要另起炉灶。算下来投入不大,但能验证这套"意象簇 + 语义召回"的思路到底能走多远。

RELATED READING

延伸阅读

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