
前几天翻后台搜索日志看到一条 query采菊东南下。五个字错了一个但我很笃定用户要找的是采菊东篱下悠然见南山。这种笃定不是来自运气是前面大半年一直在做陶渊明诗作数字化的结果。归隐意象挖掘、检索优化、多场景部署这三件事我完整跑了一遍现在的系统连错别字都能纠回来。这个项目说大不大是一个面向古诗爱好者的线上检索工具数据源没有贪大只覆盖陶渊明现存诗作一百多首。当时选这个范围理由其实很朴素在古籍数字化里规模小反而是一种优势——你能把所有输出逐条人工核对而不是扔给评测集说一句差不多得了。如果你也想给自己的诗词站加搜索引擎或者打算用 NLP 做古籍文本挖掘又或者只是好奇归隐这种抽象主题怎么变成可计算的坐标这篇实战记录应该能提供一些直接能抄作业的思路。1. 为什么偏偏是陶渊明语料小坑不少1.1 陶诗的量级刚好适合做全量人工校验陶渊明传世诗作大致在一百二十首上下比全唐诗那种四万多首的体量小了两个数量级。体量小处理压力不大但语义负担一点不轻。像采菊东篱下悠然见南山十个字里包含了动作、对象、地点、情态主谓宾一个不少而晨兴理荒秽带月荷锄归这种句子根本没有主语现代 NLP 的依存分析工具在这里几乎直接失效。古诗词是典型的高密度压缩语义图每一个字都可能承担超出字面的意思。这给数字化带来的核心问题是通用 NLP 管线在古诗上表现极差你不能靠调个大模型糊弄过去。诗里省略主语、省略谓语、倒装、用典各种非现代汉语句式密集出现。也正是因为语料小我可以把每一句都拆出来人工过一遍看看哪些坑是语料本身的哪些是工具造成的。这种全量校验的能力在做一个面向公众的诗词检索服务时非常重要——用户搜不到东西可以忍搜到错误结果一定是灾难。所以我的第一个经验是如果只是想跑通一套诗词数字化流程别一上来就上全唐诗先挑一个体量可控、语义密度高的诗人做样本。陶渊明这种一位诗人一个小世界恰恰是试错成本最低的选择。1.2 清洗陶诗时最不能跳过的版本问题很多人做古籍数字化第一步就栽在文本质量和版本差异上。陶诗版本不算最复杂的但已经足够让人长记性。我用的数据源是公开整理的公版古籍文本以其中一个注本为底本再参考其他版本做交叉校对。清洗流程我固定成了五步统一编码和格式全部转成 UTF-8 纯文本去掉与正文无关的序跋页。繁体转简体统一标点按句切分给每一句分配唯一 ID保留诗题和篇目信息。建立异文表记录不同版本之间的用字差异。去掉注疏和赏析内容只留原文。按比例人工抽查重点看 OCR 容易出错的字。这个流程里最值得说的坑是繁简转换。繁体余在陶诗里大多作我讲表示剩余的餘也会被转成余两个字在简体里长一样。表面看没什么问题但你如果拿转好的简体文本去统计词频或做向量训练语义就被污染了。类似情况还有發和髮都转成发古诗里怒发冲冠和百发百中完全是两码事。异文的问题更隐蔽。悠然见南山有的版本写作悠然望南山一个字之差意象完全不同见强调无意中看到有偶遇的意味望则是主动观望。放到检索系统里用户输入悠然望南山也必须能命中悠然见南山。所以我建了一张别名映射表把所有异文和通行文本挂在一起索引时一并处理。这一步不做后面检索优化做得再好都白搭。2. 归隐意象挖掘从词典标签到语义簇2.1 第一版是词典匹配能标注但看不懂语境归隐意象挖掘我最初没用任何机器学习。手动整理了一份意象词表菊、东篱、南山、归鸟、羁鸟、孤云、闲云、旧林、故渊、松、酒、琴、素琴、柴门、丘山、衡门、幽人、羲皇大约四十多个词。然后写正则扫描每一句统计命中情况得到篇目 x 意象矩阵。这个方法的好处是可控、所见即所得跑完一看结果就知道哪些句子被打了标签。问题也很直接词典标注只能证明词出现了解释不了词在这个位置想表达什么。鸟在陶诗里有时是归鸟指向归隐有时就是自然景物并不承担主题功能。更典型的是酒陶渊明写酒写得多但酒能祛百虑里的酒是消愁工具漉我新熟酒里的酒是田园日常硬要把所有带酒的句子都归类为归隐意象人工复核时就会看到大量误报。所以词典版本最大的价值不是直接用来生产而是让我看到意象识别的真正难点归隐不是由一个词构成的而是由一组词在具体语境中的合力构成的。这个词表后来变成了语义簇挖掘的种子集不是终点。2.2 共现分析把意象簇捞出来要搞清楚哪些词经常一起出现我用了点互信息PMIPointwise 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.2 BM25 参数不是玄学短文本要把 b 调低倒排索引实现简单真正的排序问题在相关性。我第一版直接用 TF-IDF后来换成了 BM25。BM25 有两个重要参数k1 控制词频饱和程度b 控制文档长度惩罚。默认参数通常说 k11.2、b0.75但放在古诗上不合适。原因在于诗句长度差异极小长度惩罚意义不大b 过高反而会压制那些短而精准的句子。我把 b 调到 0.5k1 保持 1.2检索结果明显改善。纯 BM25 还有个天然缺陷它依赖字面词项重合。用户搜归隐陶诗里归隐这个词本身出现得极少纯 BM25 几乎拿不到像样的结果。这暴露出一个本质问题用户搜的是意境不是字面。拆解意境必须靠语义召回。有一点要注意停用词别删得太狠。现代检索系统通常会把之乎者也当停用词但古诗里心远地自偏的自、问君何能尔的尔都有实际语气含义。我最后保留了大部分虚词只去掉标点和纯语气词宁可让分词结果多几个 token也不能为了降噪把语义关节切掉。3.3 语义召回加上 RRF 融合让归隐能搜到心远地自偏语义召回的做法是把 query 也编码成句向量。具体到我这里归隐先做查询扩展从意象簇表里抽出归隐簇的成员归鸟、东篱、菊、南山、旧林、故渊、自然扩展成一组词再过一遍 BM25 召回 top50。与此同时query 向量单独走一遍向量检索也取 top50。最后用 RRFReciprocal Rank Fusion把两路结果合并def rrf_fusion(bm25_hits, semantic_hits, k60): 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(), keylambda x: x[1], reverseTrue)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 我接下来在做的两件事这套基础索引稳定下来之后我暂时没有继续扩语料而是把精力放在两件更聚焦的事上。第一件是把同一套意象簇方法迁移到《归去来兮辞》等辞赋作品上看跨文体的稳定性是否和诗一样。第二件是给浏览器做一个划词组件用户在任意页面选中一句古诗后台通过同一套检索链路返回它的意象簇标签和语义近邻诗句。这两件事都直接复用现在已经完成的检索模块不需要另起炉灶。算下来投入不大但能验证这套意象簇 语义召回的思路到底能走多远。