跳到主要内容

内容过滤:fit_markdown 的两条路

本章讲清 Crawl4AI 的「杀手锏」:怎么从一整页里只留正文、扔掉导航/广告/侧栏,产出精简的 fit_markdown。这是「LLM 友好」最核心的一环。

1. 这一章解决什么

清洗后的 Markdown 还是太多:页脚、相关推荐、面包屑、广告位都还在。喂给模型既费 token 又稀释信号。内容过滤要回答:哪些块是「正文」,哪些是「边角料(boilerplate)」?

Crawl4AI 给三种过滤器,都继承 RelevantContentFilter(content_filter_strategy.py:33),实现 filter_content(html) -> List[str]:

过滤器怎么判断正文要查询?要钱?
PruningContentFilter启发式打分(密度+标签+class)不需要免费
BM25ContentFilter与「查询」的相关度需要查询免费
LLMContentFilter让 LLM 判断不需要要钱

本章重点讲前两个(纯 Python、免费、最常用)。

2. Pruning:用密度启发式「剪枝」

2.1 直觉

正文块有什么共性?文字多、链接少、在语义标签里(<article>/<p>)、class 名不像广告。 反过来,导航/页脚是「一堆链接、几乎没正文、class 叫 nav/footer/sidebar」。PruningContentFilter 就是把这些直觉量化成分数,低于阈值的 DOM 节点直接砍掉。

2.2 五个信号

_compute_composite_score(content_filter_strategy.py:737)把五个加权信号合成一个分(默认权重见 :609-615):

信号含义权重
text_density文本长度 / 标签总长度 → 文字越密越像正文0.4
link_density1 - 链接文字/总文字 → 链接越少分越高0.2
tag_weight<article>/<p> 高、<div>/<span>0.2
class_id_weightclass/id 匹配 `navfooter
text_lengthlog(文本长度) → 长块加分0.1

原理演示(# 示意,非源码):

# 文本密度 = 正文字符 / 标签总字符;链接密度反向计分
text_density = text_len / tag_len # 越接近 1 越像正文
link_density = 1 - (link_text_len / text_len) # 链接占比越低越高
score = 0.4*text_density + 0.2*link_density + 0.2*tag_weight + ...

class/id 的负面词表是预编译正则(content_filter_strategy.py:113-115):nav|footer|header|sidebar|ads|comment|promo|advert|social|share,命中扣 0.5 分(_compute_class_id_weight,:774)。

2.3 剪枝:递归砍树

_prune_tree(content_filter_strategy.py:685)从 <body> 开始递归:算每个节点的分,低于阈值就 decompose() 删掉,否则下钻它的孩子

# content_filter_strategy.py:730-735(精简)
if should_remove:
node.decompose() # 整块砍掉
else:
for child in node.children: # 保留,继续看孩子
self._prune_tree(child)

阈值有两种模式(:713-728):

  • fixed(默认 0.48):一刀切。
  • dynamic:按节点的「重要性/文本比/链接比」动态调阈值——重要标签(<article>)阈值打 8 折(更易留),链接比高的阈值乘 1.2(更易砍)。

剩下的节点拼成 HTML 块返回(:664-672),再被 markdown generator 转成 fit_markdown

3. BM25:按「查询相关度」过滤

3.1 直觉

Pruning 不管你想要什么,只按「像不像正文」筛。但如果你有明确的查询(比如「公司的退货政策」),你想要的是和查询相关的块,哪怕它不是页面主体。这时用 BM25ContentFilter(content_filter_strategy.py:381)。

BM25 是经典信息检索打分(把每个文本块当一篇「文档」,算它对查询的相关分)。

3.2 流程

filter_content(content_filter_strategy.py:440):

① 取查询:user_query,没有就从页面 metadata 兜底(extract_page_query) :466
② 把 body 切成候选文本块(extract_text_chunks) :472
③ 分词 + 词干化(snowball stemmer)+ 去停用词(clean_tokens) :485-505
④ BM25Okapi 给每块打分(rank_bm25 库) :507-508
⑤ 乘标签权重:h1/h2/title/strong 等加权(priority_tags) :510-515
⑥ 过阈值(bm25_threshold,默认 1.0)+ 按文档顺序排 + 去重 :517-538

标签加权是细节:标题里命中查询比正文里命中更重要,所以 h1=5.0h2=4.0title=4.0(:425-437)。

3.3 用起来

from crawl4ai import DefaultMarkdownGenerator, BM25ContentFilter
# 把过滤器塞进 markdown 生成器,fit_markdown 就只剩相关块
gen = DefaultMarkdownGenerator(
content_filter=BM25ContentFilter(user_query="refund policy")
)
# 之后 result.markdown.fit_markdown 就是「和退货政策相关」的精简文本

4. LLMContentFilter(简述)

LLMContentFilter(content_filter_strategy.py:788)把内容分块后交给 LLM 判断/重写成干净 Markdown,用 PROMPT_FILTER_CONTENT(prompts.py)。效果好但要联网、要钱、要 LLMConfig。适合前两种启发式搞不定的复杂版面。

5. 怎么选

有明确查询?
├─ 是 → BM25ContentFilter(相关度过滤)
└─ 否 → 只要「正文 vs 边角料」?
├─ 是 → PruningContentFilter(免费,默认推荐)
└─ 版面太刁钻、不差钱 → LLMContentFilter

6. 巧妙之处

  • Pruning 不用模型、不用查询就能去 boilerplate,纯启发式,极快极省——这是默认能直接产出好 fit_markdown 的原因。
  • 密度信号选得准:text_density + link_density 两条几乎抓住了「正文 vs 导航」的本质区别。
  • BM25 标签加权:把「标题命中比正文命中更重要」这个检索常识编进了打分。

7. 边界

  • Pruning 是启发式,版面诡异(比如正文被切成无数小 <div>)时可能误砍或漏砍;可调 threshold / 换 dynamic 模式。
  • BM25 是词面匹配,不懂同义/语义;查询词和页面用词不一致就召回差(语义版要上 embedding,见 06)。

8. 代码地图

主题文件路径符号名
过滤器基类crawl4ai/content_filter_strategy.pyRelevantContentFilter
启发式剪枝crawl4ai/content_filter_strategy.pyPruningContentFilter._prune_tree_compute_composite_score
class/id 扣分crawl4ai/content_filter_strategy.pyPruningContentFilter._compute_class_id_weight
BM25 过滤crawl4ai/content_filter_strategy.pyBM25ContentFilter.filter_content
LLM 过滤crawl4ai/content_filter_strategy.pyLLMContentFilter.filter_content
接入 Markdowncrawl4ai/markdown_generation_strategy.pyDefaultMarkdownGenerator.generate_markdown