← 返回文章选择

MediAC 入库:一条横线也被切成了 chunk

一条 Markdown 横线单独成了 chunk:切块规则漏了什么,入库后该看哪里。

接 /retrieve 之前,我先把资料入库做了。输入是 data/hybrid_sources 里的 Markdown,同一批片段分别生成向量写进 Qdrant,并存成 jsonl 给本地 BM25 用。这样之后比较两路召回,至少能找到同一个 chunk,看它在两边各排第几。

Markdown 切块后分流双索引,横线也单独成块

文字版
Markdown → 切块 → 同一批 chunk
├→ 向量与 payload → Qdrant
└→ chunks.jsonl → 本地 BM25

两路都值得认真看。BEIR 的结论是 BM25 是很稳的基线,稠密检索更省算力,却常常打不过其他方案(BEIR,arXiv:2104.08663)。所以看同一个 chunk 在两边各排第几,是真有用的对照。

“按标题切块”漏了两个条件

粗看可以概括成“按标题切块”,但会漏掉两个条件。九月整理时重读 scripts/ingest_hybrid.py:

  • 函数还会在空行处结束缓冲区;
  • 识别的是所有以 # 开头的行,不只二级标题。

标题进入 metadata.section,没有拼进片段正文。所以检查切块时,标题和正文要分开看:正文里没有标题文字,信息未必丢了;只看 section,也看不出正文有没有被空行切得太碎。

标题放进 metadata 本身不算怪招。LangChain 的 MarkdownHeaderTextSplitter 默认 strip_headers=True,也是把标题从正文里拿掉、写进 chunk 的 metadata(LangChain 源码 markdown.py)。

只认 # 开头的行,在 Markdown 语义里其实还漏了一路。按 CommonMark 规范,三个以上的 - 单独一行是分隔线;可如果它紧贴在一行正文下面,中间没有空行,就要当成 setext 风格的二级标题(CommonMark 0.31.2)。同样一串 ---,前面有没有空行,意思完全不同。下面那份例子前后都有空行,所以它是横线。

一条横线单独成了块

用一份自拟的极短文档说明当前函数的行为:

## 导入说明
第一段正文。
---
第二段正文。

它会得到三个正文片段:第一段正文。、---、第二段正文。,section 都是“导入说明”。横线没有特殊处理,前后又有空行,于是单独入了块。项目里的样本文件也有这种分隔线;只看“成功导入多少条”,很难注意到。

这类碎块有现成的处理办法。Unstructured 的 by_title 切块有个 combine_text_under_n_chars 参数,把连续的短段落合并到填满切块窗口,另有 max_characters 当硬上限,默认 500 字符(Unstructured 切块文档)。当前函数缺的就是这一步:先合并太短的块,横线和被空行拆开的条件句就不会各自漂着。

这一步应该先把 jsonl 打开读几条。正文只剩横线,embedding 调用成功也对回答没帮助;条件和结论被空行分开,检索只命中其中一块,同样丢上下文。标题存进 metadata 是个回查入口,还要看后续检索和 prompt 有没有把它带给模型,不能入库时存了就算一路保留。

“导入了多少条”也确实不是该看的指标。Chroma 用 token 级召回比较切块策略,同一批文档换种切法,召回能差到 9%,一些流行策略的默认参数表现还偏差;块太小,单个块里装不下需要的上下文(Chroma:Evaluating Chunking Strategies)。

标题存了,还得带回正文

标题只躺在 metadata 里,模型看不到。Anthropic 的 Contextual Retrieval 走的是反方向:嵌入前给每个 chunk 前面补一段 50–100 token 的说明,top-20 检索失败率从 5.7% 降到 3.7%,降了 35%;再加一路带上下文的 BM25,降幅到 49%(Anthropic:Contextual Retrieval)。它同样是 embedding 加 BM25 两路,和这里的入库结构几乎能一一对上。对 MediAC 来说,最便宜的一步是把 section 标题拼回正文再嵌入,用不着先上模型生成说明。

本地 BM25 那路还多一个变量:分词。rank_bm25 不做任何预处理,只收一串 token 列表,查询也得用同一套处理(rank_bm25 README)。不清理的话,---、## 这些符号照样会变成 token,块切得碎不碎会直接进 BM25 的统计。

图数据另走一条路

图数据另走 ingest_graph.py 和 export_youtu_graph.py,先把节点、关系写到 Neo4j。这时还没有一份覆盖完整的医学知识图谱,图侧与文本侧的内容也不完全相同,后面比较召回时,得把资料覆盖一起考虑。

整批重建,换模型就整批重导

当前脚本发现 collection 已存在会先删除,再按首个 embedding 的维度创建并导入,所以会覆盖旧数据。

换模型后不能把新旧向量混在一起,维度恰好相同也不行。Qdrant collection 的向量配置只约束了其中一部分条件:维度相同只说明两批向量一样长。换一个 embedding 模型,同一句话会落在另一套坐标里,两批放一起算距离,数值不再表示语义远近。这个流程是整批重导,旧向量不会留着和新的一起算。

麻烦在于混用不会报错。Qdrant 对同一 collection 的约束只有两条:维度相同,度量相同(Qdrant Collections)。DataStax 的 Astra DB 文档说得更直白:同一 collection 的向量应该出自同一个模型,接口只检查维度,不检查模型(Astra DB 向量字段文档)。长度对上就能写进去,结果悄悄变差。

重新导入之前,我会先检查切块输出。那条 --- 没清掉,重跑多少次都会原样再来。

留下几句话 读者来信

读到这里,有什么想法?欢迎留下你的看法。