这篇文章整理一下我们在课程项目里做的一个播客检索系统:基于 Elasticsearch,对 Spotify 的播客数据集建立索引,并支持按用户查询返回“可直接收听的播客片段”,而不是只返回整期播客。
这个项目最有意思的地方不只是“把文本搜出来”,而是我们想解决一个更具体的问题:
- 用户搜索一个主题时,不只是想知道“哪一期播客提到了它”
- 更希望直接拿到“从哪里开始听最合适”
也就是说,系统返回的不是 episode 级别结果,而是 clip 级别结果。
一、项目目标
这个项目的目标可以概括成一句话:
- 给定一个用户查询,返回和该查询最相关的播客片段,并允许用户指定片段时长
例如用户搜索:
AI ethicsFinancial planning retirementRemote work productivity
系统希望返回的是:
- 某一期播客里,从第几分钟开始的一段内容最相关
- 这段内容长度是多少
- 这段内容的文本是什么
相比传统的“返回整个播客节目”,这种形式更适合真实使用场景,因为用户可以直接从最相关的位置开始收听。
二、数据集
项目使用的是 Spotify 提供的播客数据集,这个数据集也被用于 TREC Podcasts Track。
这个数据集的几个关键特点是:
- 规模很大,大约包含 10 万个英文播客节目
- 主题非常广,包括新闻、体育、健康、文化、故事、生活方式等
- 每一期播客都有转写文本 transcript
- transcript 又被切成了多个较短 segment,通常每段大约 30 秒
- 每个词还有对应的开始和结束时间
- 此外还带有元数据,例如 show name、episode name、description、ID 等
在我们的实现里,并没有使用全部数据,而是只用了数据集的前两部分,也就是 0-2 和 3-5。主要原因很现实:
- 数据量太大
- 全量索引成本较高
- 对课程项目来说,使用约三分之二的数据已经足够完成系统设计与评测
三、为什么选择 Elasticsearch
这个项目的后端核心是 Elasticsearch。
选择它的原因并不复杂:
- 它本身就是成熟的全文检索引擎
- 支持倒排索引,适合大规模文本搜索
- 支持 BM25 排序
- DSL 灵活,便于快速实验不同查询策略
- 同时具备索引、检索、过滤和存储能力
对于播客检索这种任务,Elasticsearch 很合适,因为我们要解决的是:
- 大规模文本检索
- 查询速度不能太慢
- 检索结果需要带时间信息和附加字段
这些能力它都比较自然地支持。
四、系统整体架构
整个系统可以粗略分成三部分:
- 数据索引模块
- 检索模块
- 用户界面模块
1. 数据索引模块
这一部分负责:
- 读取播客 transcript 和 metadata
- 将 episode 按 segment 切分
- 为每个 segment 构建 Elasticsearch 文档
- 建立索引
2. 检索模块
这一部分负责:
- 接收用户查询
- 执行 Elasticsearch 检索
- 对命中的 30 秒 segment 做后处理
- 拼接出用户指定时长的 clip
3. 用户界面模块
我们使用 PyQt5 做了一个桌面 GUI,主要功能包括:
- 输入查询
- 指定返回 clip 的时长
- 指定返回结果数量
- 选择搜索模式
- 展示结果列表
- 双击查看具体 transcript 内容
这部分不是检索算法的核心,但对完整体验很重要,因为它让系统从“离线实验”变成了“可交互原型”。
五、索引设计
这个项目里最关键的设计之一就是:我们索引的最小单位不是整期播客,而是 segment。
也就是说,Elasticsearch 中的每一条文档代表的是播客中的一小段 transcript,而不是整条 episode。
1. 每条文档包含哪些字段
每个被索引的 segment 文档大致包含:
- show ID
- show name
- episode ID
- episode description
- segment ID
- text
- start time
- end time
- words
其中 words 这个字段比较特殊,因为它保存了 segment 中每个词的起止时间。
这对后面的 clip 拼接非常重要,因为我们不仅想返回固定 segment,还想在最后几秒做更细粒度的截断和补齐。
2. 为什么 segment 级索引比 episode 级更合适
如果直接把整期播客作为一条文档去索引,会有几个问题:
- 文档太长,查询相关性粒度太粗
- 即使命中,也很难知道从哪里开始听
- 排序时只能判断“这一期播客是否相关”,而不能判断“这一段是否相关”
而用 segment 级索引后:
- 检索粒度更细
- 排序更接近用户真实需求
- 更容易把结果映射回播客中的具体时间点
这也是整个系统能做“片段检索”的基础。
3. 分词与 analyzer
在索引设计中,我们对 transcript 的 text 字段使用了英文 analyzer。
这个 analyzer 会做几件事:
- lowercasing
- stemming
- stop word removal
这意味着:
- 大小写差异不会影响检索
- 单复数、词形变化更容易被统一处理
- 一些停用词会被忽略
同时,这个 analyzer 也会作用在查询上,从而保证:
- 索引时怎么分词
- 查询时就怎么分词
两边保持一致。
这个选择的好处是:
- 检索更稳
- 索引更紧凑
- 搜索速度更快
但它也有代价,后面讨论局限时会再讲。
六、搜索模式设计
为了让系统支持不同检索需求,我们设计了四种查询方式:
- Union search
- Intersection search
- Phrase search
- Fuzzy search
1. Union search
Union search 的逻辑是:
- 查询中至少有一个 token 命中即可
这类搜索更宽松,召回通常更高。
在 Elasticsearch 中,我们主要使用的是 multi_match。
2. Intersection search
Intersection search 的逻辑是:
- 查询中的所有 token 都必须出现在结果里
这比 union 更严格,通常会减少一些噪声结果。
实现上,我们使用了 match 查询并设置 operator = and。
3. Phrase search
Phrase search 的逻辑是:
- 不只是词要出现
- 还要求词序基本一致
这个模式对精确短语搜索非常有用,因此我们用了 Elasticsearch 的 match_phrase。
4. Fuzzy search
Fuzzy search 的逻辑是:
- 即使用户拼错单词,也尽量给出合理结果
实现上,我们在 union search 的基础上增加了:
fuzziness = AUTO
这样系统就可以容忍一定程度的拼写错误。
5. 底层排序
无论哪种查询方式,本项目里文档排序的核心仍然是:
- BM25
也就是说,Elasticsearch 返回的 _score 本质上就是 BM25 相关性分数。
七、如何从 30 秒 segment 拼成 n 分钟 clip
这是整个项目最有特点的部分。
因为我们的索引单位是大约 30 秒的 segment,但用户想要的是:
- 指定长度的片段,比如 80 秒、2 分钟等
所以系统不能直接把 Elasticsearch 命中的 segment 原样返回,而必须做后处理。
1. 核心思路
整体流程是:
- 先用 Elasticsearch 找到最相关的小 segment
- 以该 segment 为起点
- 继续向后取同一期播客中的后续 segment
- 直到累计时长接近用户想要的 clip 长度
也就是说:
- 排序基于最相关的起始 segment
- clip 本身则通过后续 segment 补齐
2. 具体算法
对每个检索结果:
- 取当前命中的 segment 作为 clip 起点
- 读取它在同一 episode 中的下一个 segment
- 如果加入下一个 segment 后总时长还没有超过目标长度,就继续加
- 如果加完整个下一个 segment 会超长,就不整段加入,而是开始按词累加
- 按词累加时,利用
words字段中的词级时间信息 - 当达到目标长度后,如果当前句子还没结束,就继续补到句末
这个设计有两个很明显的好处:
- 不需要为了不同 clip 长度反复重建索引
- 结果的第一部分总是来自最相关的命中 segment
也就是说,最相关的信息通常会出现在 clip 开头,更符合用户实际使用习惯。
3. 为什么不直接在索引阶段生成固定长度 clip
我们也考虑过另一种方案:
- 直接在索引阶段把播客切成固定长度 clip,比如每段 2 分钟
这样做的好处是:
- 查询时更简单
- 不需要实时拼接
但问题也很明显:
- clip 长度会被固定死
- 如果用户想看 80 秒、120 秒、180 秒结果,就得重新构建索引
- 索引过程本来就是项目里最耗时的步骤之一
所以最终我们选择了:
- 小 segment 建索引
- 搜索时动态拼接 clip
这个方案更灵活,也更适合交互式系统。
八、用户界面设计
前端部分我们用的是 PyQt5。
UI 的主要功能包括:
- 输入查询文本
- 指定 clip 长度
- 指定返回结果数
- 选择 union / intersection / phrase / fuzzy 四种搜索模式
- 在表格中展示结果
- 双击后查看 transcript
为了避免检索时间过长导致界面卡死,搜索是在单独线程里执行的。
这一点虽然不是算法创新,但对可用性很重要。因为哪怕检索只要几百毫秒,如果在 GUI 主线程里阻塞,也会让交互体验很差。
九、评测设计
为了评估系统效果,我们选了三条查询:
AI ethicsFinancial planning retirementRemote work productivity
并比较了两种搜索模式:
- union
- intersection
对于每种查询和每种搜索方式,我们取:
- top 30
- clip 长度为 80 秒
也就是说,每位评估者总共需要评估:
6 × 30 = 180条结果
1. 人工标注标准
每个结果都被打成四档:
- Bad = 0
- Fair = 1
- Good = 2
- Excellent = 3
这个打分标准参考了 TREC Podcast Track 的定义。
大致理解可以是:
- Excellent:高度相关,而且是很好的切入点
- Good:相关,但起点不是最优
- Fair:有一定相关性,但上下文不够好
- Bad:不相关
2. 使用的评测指标
项目里用了以下几类指标:
- Precision@10
- Precision@30
- Average Precision
- NDCG@30
- MRR
- Search Time
之所以没有重点看 Recall,是因为:
- 我们只人工评估了 top 30
- 很难估计所有未返回结果中的 false negatives
在这种前提下,Recall 信息量不大。
3. 两种聚合评估方式
三位评估者的标注结果,我们尝试了两种聚合方式。
方法一:Aggregate-then-evaluate
先把三个评估者对同一结果的分数做平均,再基于平均分计算指标。
例如:
- mean relevance >= 0.5 的结果可视为 relevant
方法二:Evaluate-then-aggregate
先对每位评估者分别计算所有指标,再把这些指标做平均。
这两种方法的区别很重要,因为它会影响 precision 等二值指标的最终结果。
十、实验结果与观察
从结果上看,系统整体表现是比较令人满意的。
最核心的观察有几个:
1. Union 和 Intersection 差异不大
对于 AI ethics 这条查询:
- union 和 intersection 得到了完全一样的结果
对于另外两条查询:
- union 在大多数指标上略好一些
- 但差距并不大
所以从这个项目的实验规模来看,可以认为:
- union 和 intersection 的差别存在,但不显著
2. 整体指标较高
无论是 precision、NDCG 还是 MRR,整体结果都比较高,说明:
- BM25 + transcript segment 检索
- 再加上 clip 后处理
这个基本方案本身是可行的。
3. 搜索速度可接受
所有查询在两种搜索模式下的时间都低于:
- 0.5 秒
这个速度对于一个课程项目原型来说已经很舒服,说明 Elasticsearch 在这个任务里性能完全够用。
十一、结果背后的问题
虽然结果总体不错,但在评测方式上我们也发现了一些有意思的问题。
1. Aggregate-then-evaluate 可能让 precision 看起来过高
我们发现:
- 三位评估者都标出了一些“不相关结果”
- 但这些不相关结果并不总是重合
于是当我们先平均分、再计算 precision 时,一些原本对个别评估者来说“不相关”的结果,可能因为平均分不低而被视为 relevant。
结果就是:
- aggregate-then-evaluate 的 precision 看起来会非常高
但这未必完全真实。
2. Evaluate-then-aggregate 更保守
相比之下,先对每个评估者单独算指标,再取平均,会更保守,也更能反映不同评估者之间的分歧。
这给我们的启发是:
- 评测不只是算公式
- 标注人数太少时,指标解释必须谨慎
换句话说,结果虽然不错,但由于评估者只有三人,我们不能把这些指标过度解读成“系统已经非常成熟”。
十二、系统局限
这个项目虽然已经能工作,但局限也很明显。
1. 只用了 transcript,没有用 metadata
这是我们刻意做的选择。
这样做的好处是:
- 可以更纯粹地观察 transcript 检索效果
但问题在于:
- 有些播客的标题、节目描述本身就能提供很强信号
根据相关工作,metadata 和 transcript 结合通常会优于单独使用 transcript。
2. analyzer 有利也有弊
我们使用 English analyzer 做:
- stop word removal
- stemming
这在很多情况下是合理的,因为:
- 索引更小
- 检索更快
- 词形变化更容易匹配
但它也可能带来问题:
- stemming 有时会引入歧义
- 某些 stop words 在特定查询里并不是完全没意义
所以 analyzer 选择并不是无脑越 aggressive 越好。
3. clip 排序只看起始 segment
目前 clip 的相关性排序,本质上还是由:
- 起始 segment 的 BM25 得分
决定的。
这意味着:
- clip 的开头很相关时,它就会排得很靠前
- 但 clip 后半部分的信息并没有参与重新打分
这种设计是合理的,因为用户确实更在意“从哪里开始听”;但从检索系统角度看,仍然有改进空间。
十三、可以继续改进的方向
如果继续做下去,我觉得至少有三个方向很值得扩展。
1. 引入 metadata
可以把下面这些字段一起纳入检索:
- show title
- episode title
- description
这样很多查询会更容易命中真正相关的节目。
2. 做 reranking
当前系统的基础排序是:
- Elasticsearch BM25
这很强,但仍然是第一阶段召回/排序方法。
后续完全可以做成两阶段:
- 用 BM25 召回 top-K 候选
- 再用更强模型做 reranking
例如:
- BERT reranker
- cross-encoder
- 其他语义匹配模型
这也是 TREC 相关工作里效果更强的路线。
3. 研究不同 clip 构造策略
目前的 clip 构造策略是:
- 从最相关 segment 开始,向后拼接
这很自然,但未必总是最优。
后续可以探索:
- 是否允许向前补上下文
- 是否基于句子边界而不是 segment 边界构造
- 是否基于完整 clip 内容重新打分
十四、小结
这个项目的核心不只是“用 Elasticsearch 做搜索”,而是把一个传统全文检索问题变成了一个更贴近用户真实需求的任务:
- 不返回整期播客
- 而返回可直接收听的相关片段
从实现上看,它有几个关键点:
- 用 segment 而不是 episode 做索引
- 用 BM25 做基础检索
- 支持 union、intersection、phrase、fuzzy 多种搜索模式
- 在搜索时动态拼接出用户指定长度的 clip
- 用人工标注和 NDCG、MRR、Precision 等指标进行评估
最终结果表明,这种“segment 检索 + 动态 clip 拼接”的方案是可行的,而且在搜索速度和效果上都达到了比较满意的水平。
如果后续再加上:
- metadata
- reranking
- 更细致的 clip 重打分
这个系统其实还有很大的提升空间。