Skip to main content

pagesift_frontier/focus/
adaptive.rs

1//! 自适应停止:每个查询词攒够证据、或者连续几页没有新信息就停;一直找不到相关内容、
2//! 能走的又都是没有线索的链接时也停(见 [`Adaptive::observe`])。
3//! 证据只从相关度过线的页面里取(见 [`MIN_RELEVANCE`]):新闻页提一句 "students" 不算证据,
4//! 否则 12 页预算会在垃圾上提前"覆盖完成"。
5
6use std::collections::HashSet;
7
8use pagesift_api::types::AdaptiveOptions;
9use pagesift_extract::Query;
10
11/// 相关度低于它的页面算无关:不计入证据,而且找到相关内容之后连着出现就算"抓完了"。
12/// 附件收割也用它。0.81 换成按方面打分的相关度([`super::facet`])后,两条门槛按 1,833 个
13/// 人工判过的页面换算:保留下来的有用页比例和换算前一样(0.12 → 0.05 都约 90%,
14/// 0.3 → 0.15 都约 52%),保留下来的页面里有用的比例略高。
15// [策略] 先验包 v0:0.81 的现值
16pub const MIN_RELEVANCE: f32 = 0.05;
17/// 相关度不低于它的页面才贡献证据、才参与"有没有新信息"的判断;两者之间的页面不算数
18/// (沾了点边的页面:既不能让覆盖度提前达标,也不能把相关的部分判成抓完了)。
19// [策略] 先验包 v0:0.81 的现值
20pub const EVIDENCE_RELEVANCE: f32 = 0.15;
21
22/// 还没有收获时,连续盲探多少倍 patience 页才放弃。在 69 所大学 × 5 个方向、上限 100 的结果上
23/// 模拟(benchmarks/history/0.80.10):2 倍(默认 10 页)省下约四分之一的页数,丢掉的有用页
24/// 不到 5%,丢的都是盲探了几十页才偶然撞上内容的站点。
25const COLD_PATIENCE: usize = 2;
26
27/// 覆盖度达标至少要有这么多页贡献过证据。清华的住宿方向在第 2 页(一张"住宿与餐饮"综合页)
28/// 就"覆盖完成"停下,后面的申请、收费页都没抓。
29const MIN_SOURCES: usize = 2;
30
31/// 自适应爬取的状态:每个查询词的证据数、已经见过的相关段落、连续没有新信息的页数。
32pub struct Adaptive {
33    opts: AdaptiveOptions,
34    evidence: Vec<usize>,
35    /// 相关段落内容的哈希,同样的段落(导航、页脚、转载)只算一次。
36    seen: HashSet<u64>,
37    /// 已收集的相关内容,按 token 估算。
38    collected: usize,
39    stale: usize,
40    /// 还没有收获时,连续"盲探"(见 [`Adaptive::observe`])而且无关的页数。
41    cold: usize,
42    /// 贡献过新证据的页数。
43    sources: usize,
44    /// 上一页带来了新的切题内容。
45    last_fresh: bool,
46}
47
48impl Adaptive {
49    pub fn new(opts: AdaptiveOptions, terms: usize) -> Self {
50        Self {
51            opts,
52            evidence: vec![0; terms],
53            seen: HashSet::new(),
54            collected: 0,
55            stale: 0,
56            cold: 0,
57            sources: 0,
58            last_fresh: false,
59        }
60    }
61
62    /// 覆盖度:各查询词 `min(1, 证据 / evidencePerTerm)` 的平均值。
63    pub fn coverage(&self) -> f64 {
64        if self.evidence.is_empty() {
65            return 0.0;
66        }
67        let target = self.opts.evidence_per_term.max(1) as f64;
68        self.evidence
69            .iter()
70            .map(|&e| (e as f64 / target).min(1.0))
71            .sum::<f64>()
72            / self.evidence.len() as f64
73    }
74
75    pub fn evidence(&self) -> &[usize] {
76        &self.evidence
77    }
78
79    /// 已经有过收获(某一页带来了新的切题内容)。
80    pub fn has_found(&self) -> bool {
81        self.collected > 0
82    }
83
84    /// 查询已经覆盖:覆盖度到阈值,而且至少两页贡献过证据。
85    pub fn covered(&self) -> bool {
86        self.coverage() >= self.opts.coverage_threshold && self.sources >= MIN_SOURCES
87    }
88
89    /// 上一次 [`Self::observe`] 的页面带来了新的切题内容(有收获)。
90    pub fn last_fresh(&self) -> bool {
91        self.last_fresh
92    }
93
94    /// 连续没有新信息的页数。
95    pub fn stale(&self) -> usize {
96        self.stale
97    }
98
99    /// 撤销"连续没有新信息"的计数:队列里还有分数高的链接没抓时,不能因为先抓完的那几页没用就停。
100    pub fn forgive(&mut self) {
101        self.stale = 0;
102    }
103
104    /// 记下一页的正文(`relevance` 是这一页的相关度);信息够了时返回停止原因。
105    /// `blind` 说这一页是不是"盲探"来的:链接本身没有查询词的线索,分数只是栏目保底分
106    /// (见 [`super::is_blind`])。
107    ///
108    /// 还没有收获时,无关页不算"没有新信息"(门户、新闻集群挡在前面是常态),但连着
109    /// 2 × patience 页都是盲探、都无关,就是站点上找不到查询的线索:停(`no_relevant_pages`)。
110    /// 一路有线索的链接可走时不停,那是内容藏得深,不是没有内容。
111    pub fn observe(
112        &mut self,
113        query: &Query,
114        markdown: &str,
115        relevance: f32,
116        blind: bool,
117    ) -> Option<&'static str> {
118        self.last_fresh = false;
119        use std::hash::{BuildHasher, BuildHasherDefault, DefaultHasher};
120        if self.collected == 0 {
121            if blind && relevance < MIN_RELEVANCE {
122                self.cold += 1;
123                if self.cold >= COLD_PATIENCE * self.opts.patience.max(1) {
124                    return Some("no_relevant_pages");
125                }
126            } else {
127                self.cold = 0;
128            }
129        }
130        if relevance < MIN_RELEVANCE {
131            // 已经有收获之后连着抓到无关页,说明相关的部分抓完了
132            if self.collected > 0 {
133                self.stale += 1;
134                return self.verdict();
135            }
136            return None;
137        }
138        if relevance < EVIDENCE_RELEVANCE {
139            return None;
140        }
141        let hasher = BuildHasherDefault::<DefaultHasher>::default();
142        let mut fresh = 0usize;
143        for block in markdown.split("\n\n") {
144            let block = block.trim();
145            if block.is_empty() {
146                continue;
147            }
148            let mask = query.hit_mask(block);
149            if mask == 0 || !self.seen.insert(hasher.hash_one(block)) {
150                continue;
151            }
152            for (i, e) in self.evidence.iter_mut().enumerate() {
153                if mask & (1 << i) != 0 {
154                    *e += 1;
155                }
156            }
157            fresh += pagesift_content::estimate_tokens(block).max(1);
158        }
159        self.collected += fresh;
160        self.sources += usize::from(fresh > 0);
161        let gain = fresh as f64 / self.collected.max(1) as f64;
162        if gain < self.opts.min_gain || fresh == 0 {
163            self.stale += 1;
164        } else {
165            self.stale = 0;
166            self.last_fresh = true;
167        }
168        self.verdict()
169    }
170
171    /// 按现在的覆盖度和"连续没有新信息"的页数决定停不停。
172    /// 覆盖够了也不马上停:页面还在带来新的相关内容(曼大图书馆一个个分馆的开放时间)就接着抓,
173    /// 连着几页没有新东西才停,比没覆盖时的耐心短一半。一页就可能把每个词都提够次数
174    /// (栏目首页、综合介绍页),所以覆盖要有两页以上的证据才算。
175    fn verdict(&self) -> Option<&'static str> {
176        let covered = self.covered();
177        if covered && self.stale >= self.opts.patience.div_ceil(2).max(1) {
178            Some("coverage_reached")
179        } else if self.stale >= self.opts.patience.max(1) {
180            Some("saturated")
181        } else {
182            None
183        }
184    }
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190
191    fn opts(threshold: f64, per_term: usize, patience: usize) -> AdaptiveOptions {
192        AdaptiveOptions {
193            coverage_threshold: threshold,
194            evidence_per_term: per_term,
195            patience,
196            min_gain: 0.05,
197        }
198    }
199
200    #[test]
201    fn adaptive_stops_once_covered_and_nothing_new_comes() {
202        let q = Query::new("refund policy");
203        let mut a = Adaptive::new(opts(1.0, 3, 5), q.len());
204        assert_eq!(
205            a.observe(
206                &q,
207                "Our refund policy.\n\nAnother refund policy line.",
208                1.0,
209                false
210            ),
211            None
212        );
213        assert!(a.coverage() < 1.0);
214        // 覆盖够了,但页面还在带来新内容:接着抓
215        assert_eq!(
216            a.observe(&q, "The policy covers every refund.", 1.0, false),
217            None
218        );
219        assert!(a.coverage() >= 1.0);
220        assert_eq!(
221            a.observe(&q, "Refunds for gift cards: policy.", 1.0, false),
222            None
223        );
224        // 覆盖之后连着 ceil(5 / 2) = 3 页没有新东西才停
225        assert_eq!(a.observe(&q, "Our refund policy.", 1.0, false), None);
226        assert_eq!(a.observe(&q, "Our refund policy.", 1.0, false), None);
227        assert_eq!(
228            a.observe(&q, "Our refund policy.", 1.0, false),
229            Some("coverage_reached")
230        );
231    }
232
233    #[test]
234    fn adaptive_stops_when_pages_add_nothing_new() {
235        let q = Query::new("refund policy");
236        let mut a = Adaptive::new(opts(1.0, 100, 2), q.len());
237        assert_eq!(a.observe(&q, "Refund policy text.", 1.0, false), None);
238        assert_eq!(a.observe(&q, "Refund policy text.", 1.0, false), None);
239        assert_eq!(
240            a.observe(&q, "Refund policy text.", 1.0, false),
241            Some("saturated")
242        );
243    }
244
245    #[test]
246    fn irrelevant_pages_neither_count_as_evidence_nor_as_stale() {
247        let q = Query::new("refund policy");
248        let mut a = Adaptive::new(opts(1.0, 1, 1), q.len());
249        // 新闻页提了一句 refund:相关度低,不算证据,也不算"没有新信息"
250        assert_eq!(
251            a.observe(&q, "A refund was mentioned in passing.", 0.03, false),
252            None
253        );
254        assert_eq!(a.evidence(), &[0, 0]);
255        assert_eq!(a.observe(&q, "Nothing here.", 0.0, false), None);
256        // 一页就提够了每个词:还要第二页的证据;覆盖之后一页没有新东西(patience 1)就停
257        assert_eq!(a.observe(&q, "Refund policy in full.", 0.9, false), None);
258        assert_eq!(a.observe(&q, "The refund policy, again.", 0.9, false), None);
259        assert_eq!(
260            a.observe(&q, "Refund policy in full.", 0.9, false),
261            Some("coverage_reached")
262        );
263        // 有了收获之后连着几页无关:相关的部分抓完了;沾点边的页面(两个门槛之间)不算数
264        let mut a = Adaptive::new(opts(1.0, 100, 2), q.len());
265        assert_eq!(a.observe(&q, "Refund policy in full.", 0.9, false), None);
266        assert_eq!(a.observe(&q, "Team page.", 0.0, false), None);
267        assert_eq!(a.observe(&q, "Refund mentioned once.", 0.1, false), None);
268        assert_eq!(a.evidence(), &[1, 1]);
269        assert_eq!(
270            a.observe(&q, "Careers page.", 0.0, false),
271            Some("saturated")
272        );
273    }
274
275    #[test]
276    fn gives_up_when_only_blind_links_are_left_and_nothing_was_found() {
277        let q = Query::new("refund policy");
278        let mut a = Adaptive::new(opts(1.0, 100, 2), q.len());
279        // 有线索的链接抓回来没用:不算盲探
280        for _ in 0..10 {
281            assert_eq!(a.observe(&q, "Team page.", 0.0, false), None);
282        }
283        // 盲探 3 页、中间一页沾边:重新数
284        for _ in 0..3 {
285            assert_eq!(a.observe(&q, "About us.", 0.0, true), None);
286        }
287        assert_eq!(a.observe(&q, "Refund mentioned once.", 0.1, true), None);
288        for _ in 0..3 {
289            assert_eq!(a.observe(&q, "Careers.", 0.0, true), None);
290        }
291        assert_eq!(
292            a.observe(&q, "Contact.", 0.0, true),
293            Some("no_relevant_pages")
294        );
295        // 已经有收获时不放弃,按"没有新信息"算
296        let mut a = Adaptive::new(opts(1.0, 100, 5), q.len());
297        assert_eq!(a.observe(&q, "Refund policy in full.", 0.9, false), None);
298        for _ in 0..4 {
299            assert_eq!(a.observe(&q, "Careers.", 0.0, true), None);
300        }
301    }
302}