Частина 1. Задача

Звідки в індексі сміття

Типова ситуація: є пошуковий індекс, який наповнив краулер. Сотні тисяч або мільйони сторінок, з кожної HTML-розмітку вже викинуто, лишився текст. Цей текст індексують, за ним рахують релевантність, із нього вирізають сніпети, а останніми роками — ще й чанки для ембедингів і RAG.

Проблема в тому, що чимала частина цього тексту — не контент. Це:

Частину цього відрізають DOM-евристики ще під час парсингу. Але тільки частину: дисклеймер, який редактор вставив у тіло кожної статті, з погляду розмітки нічим не відрізняється від контенту. А якщо індекс збирали давно, різними версіями парсера, з різних сайтів, — що саме в ньому лишилося, вже ніхто до ладу не знає.

Чим це шкодить

Ідеться не лише про місце на диску.

Тож запитання, з якого все почалося, було просте: скільки в нашому індексі такого сміття і що це за фрагменти? Не «як його вирізати», а спершу просто побачити.

Чому не DOM-евристики

Класична відповідь на «як прибрати boilerplate» — аналіз окремої сторінки: щільність тексту в блоці, частка посилань, довжина абзаців, позиція в DOM. На цьому побудовано Boilerpipe, jusText, Trafilatura та їхні нащадки.

Kohlschütter, Christian; Fankhauser, Peter and Nejdl, Wolfgang — Boilerplate Detection using Shallow Text Features (WSDM '10, 2010, 10.1145/1718487.1718542). Boilerpipe: класифікація блоків сторінки за поверхневими ознаками — довжиною, щільністю посилань, щільністю тексту.
Barbaresi, Adrien — Trafilatura: A Web Scraping Library and Command-Line Tool for Text Discovery and Extraction (ACL-IJCNLP 2021 System Demonstrations, 10.18653/v1/2021.acl-demo.15). Сучасний представник тієї самої родини: евристики над однією сторінкою.

Цей підхід має два обмеження, принципові для нашої задачі:

  1. Він дивиться на одну сторінку. Він не знає, що абзац, який має вигляд звичайного тексту, дослівно повторюється ще на 40 тисячах сторінок.
  2. Йому потрібна розмітка. А в індексі розмітки вже немає — тільки текст.

Визначення, яке працює без розмітки й без знання про конкретний сайт, звучить так: сміття — це те, що повторюється. Не окремі слова (слово «і» теж повторюється), а довгі фрагменти, що дослівно трапляються в багатьох різних документах. Саме це ми й шукатимемо.

Ідея не нова: пошуковики шукали повторювані фрагменти в колекціях документів щонайменше із середини 2000-х.

Zelenkov, Yury and Segalovich, Ilya — Comparison of Methods for Recurring Fragment Detection in a Corpus of Documents (RCDL 2007). Порівняння методів пошуку повторюваних фрагментів від команди Яндекса — та сама постановка, погляд із боку пошуковика.
Нове — те, наскільки великим став корпус, і те, що на ньому ламається.

Частина 2. Формулювання

Перш ніж обирати алгоритм, треба точно сказати, що ми шукаємо. Кожне слово у визначенні нижче потім позначиться на складності алгоритму.

Документи й токени

Документ — це послідовність токенів. Токенізація найпростіша: звести до нижнього регістру, розбити на кожному символі, що не є літерою чи цифрою. Пунктуація зникає, «Усі права захищено.» і «усі права захищено» — одне й те саме. Кожен токен замінюється цілим числом зі словника, тож далі ми працюємо не з рядками, а з масивами int.

Фраза

Фраза — це неперервний шматок документа: токени з i-го до (i+k−1)-го, без пропусків. Це важливо: «безкоштовна доставка» й «безкоштовна … доставка» — різні речі, друге нас не цікавить.

Підтримка: у скількох документах, а не скільки разів

Підтримка фрази — кількість документів, у яких вона трапляється хоча б раз. Позначмо її df(P), як document frequency у пошуку.

Чому не загальна кількість входжень? Бо фраза, повторена 500 разів усередині одного довгого документа (приспів пісні, однаковий рядок таблиці), — це не шаблон. Шаблон — це те, що проходить крізь документи.

Поріг σ (сигма) — мінімальна підтримка, починаючи з якої фраза нас цікавить. Фразу з df ≥ σ називають частою.

Максимальність

Якщо фраза «безкоштовна доставка для замовлень від 1000 гривень» трапляється в 1000 документах, то й кожен її шматок — «безкоштовна доставка», «для замовлень від», «1000 гривень» — трапляється щонайменше в 1000. Перелічувати їх усі безглуздо: замість однієї знахідки ми отримаємо десятки її обрізків.

Тому шукаємо лише максимальні часті фрази: часті, які не можна подовжити ні ліворуч, ні праворуч так, щоб фраза лишилася частою.

У літературі це називають MCFP — Maximal Contiguous Frequent Phrase. Задача: дано корпус і поріг σ, знайти всі MCFP. Зазвичай додають ще мінімальну довжину: фрази, коротші за 5–10 токенів, нас не цікавлять, навіть якщо вони часті.

Іграшковий приклад, який пройде крізь усю статтю

Чотири «сторінки» інтернет-магазину, σ = 3:

Д1: новий ноутбук acer swift безкоштовна доставка для замовлень від 1000 гривень
Д2: чохол для iphone 15 безкоштовна доставка для замовлень від 1000 гривень
Д3: безкоштовна доставка для замовлень від 1000 гривень навушники sony
Д4: навушники sony wh 1000 безкоштовна доставка

Відповідь:

Зверніть увагу на другий пункт: у підфрази підтримка вища, ніж у максимальної фрази, і все одно до відповіді вона не потрапляє. Максимальність — не «найчастіша», а «найдовша з усе ще частих». Це знадобиться, коли будемо перевіряти максимальність в алгоритмі.

Частина 3. Від простого до складного

Крок 0. Порахувати n-грами

Найочевидніше рішення: обрати довжину n, пройти вікном по всіх документах, скласти всі n-грами в хеш-таблицю «n-грама → множина документів», відфільтрувати за σ.

Для n = 2 на нашому прикладі:

безкоштовна доставка {Д1, Д2, Д3, Д4}   df=4  ✓
доставка для         {Д1, Д2, Д3}       df=3  ✓
для замовлень        {Д1, Д2, Д3}       df=3  ✓
замовлень від        {Д1, Д2, Д3}       df=3  ✓
від 1000             {Д1, Д2, Д3}       df=3  ✓
1000 гривень         {Д1, Д2, Д3}       df=3  ✓
навушники sony       {Д3, Д4}           df=2
новий ноутбук        {Д1}               df=1
... ще з півтора десятка з df=1

Працює. Проблем три, і всі три ведуть до наступних кроків.

Проблема 1: яке n? Шаблонна фраза може мати довжину 6 токенів, а може й 300 (цілий абзац дисклеймера). Фіксоване n або ріже довгі фрази на шматки, або пропускає короткі.

Проблема 2: пам'ять. Кількість різних n-грам у природній мові зростає майже так само, як кількість токенів: починаючи з n = 3–4, переважна більшість n-грам унікальні. На корпусі в 3 млрд токенів це мільярди ключів у хеш-таблиці, і в кожного — список документів. Жодної пам'яті не вистачить, і майже вся вона піде на n-грами, які трапилися один раз і нам точно не потрібні.

Проблема 3: обрізки. Навіть якщо перебрати всі n, відповідь складатиметься з вкладених один в одного шматків тієї самої фрази. Ось реальний фрагмент виводу мого раннього прототипу без перевірки максимальності (корпус — вихідний код Java, тому «шаблон» тут — це анотації та імпорти):

df=2985  len=9  import org springframework beans factory annotation required public class
df=2985  len=8         org springframework beans factory annotation required public class
df=2985  len=7             springframework beans factory annotation required public class
df=2985  len=6                             beans factory annotation required public class
df=2985  len=5                                   factory annotation required public class

П'ять рядків, одна знахідка. У тому прогоні таких рядків було 77 тисяч.

Крок 1. Apriori: рости від частого до частого

Ключове спостереження, на якому тримається вся галузь: підтримка антимонотонна. Якщо фраза P трапляється в документі, то й будь-який її шматок там трапляється. Отже, df(шматок) ≥ df(P). Наслідок: якщо коротка фраза не часта, то жодна фраза, яка її містить, теж не часта.

Це дає стратегію: почати з частих коротких фраз і подовжувати лише їх. Нечасте подовження відкидається разом з усім, що з нього могло б вирости. Це ідея Apriori/GSP — класики data mining середини 90-х.

Agrawal, Rakesh and Srikant, Ramakrishnan — Mining Sequential Patterns (ICDE 1995, 10.1109/ICDE.1995.380415). Початкова постановка sequential pattern mining і порівневий перебір на антимонотонності.

На прикладі: з усіх біграм частих шість. «навушники sony» відкинуто — отже, і «навушники sony wh», і все далі можна не розглядати. А «безкоштовна доставка» подовжується до «безкоштовна доставка для» (df=3), та — до «безкоштовна доставка для замовлень» (df=3), і так аж до «… 1000 гривень». Далі подовжувати нічим: праворуч у Д1 і Д2 кінець документа, у Д3 — «навушники», df = 1.

Проблему 1 (яке n) розв'язано: довжина вже не параметр, фраза росте сама, доки лишається частою. Проблему 3 (обрізки) розв'язує перевірка максимальності на виході. А проблему 2 (пам'ять) — ні: на першому рівні однаково треба порахувати всі біграми, щоб дізнатися, які з них часті.

Крок 2. Sequential pattern mining: надто загальний інструмент

Для задачі «знайти часті послідовності» існує ціла родина готових алгоритмів: GSP, PrefixSpan, SPADE, BIDE+, VMSP, CloSpan. Багато з них зібрано в бібліотеці SPMF, їх легко взяти й запустити.

Pei, Jian; Han, Jiawei; Mortazavi-Asl, Behzad et al. — PrefixSpan: Mining Sequential Patterns Efficiently by Prefix-Projected Pattern Growth (ICDE 2001, 10.1109/ICDE.2001.914830). Ріст патерну через «проєційовані бази» — те, від чого BloomSpan свідомо відмовляється.
Wang, Jianyong and Han, Jiawei — BIDE: Efficient Mining of Frequent Closed Sequences (ICDE 2004, 10.1109/ICDE.2004.1319986). Двонапрямна перевірка розширень — предок перевірки максимальності в BloomSpan.
Fournier-Viger, Philippe; Wu, Cheng-Wei; Gomariz, Antonio and Tseng, Vincent S. — VMSP: Efficient Vertical Mining of Maximal Sequential Patterns (Canadian AI 2014, 10.1007/978-3-319-06483-3_8). Максимальні послідовні патерни у вертикальному поданні.
Fournier-Viger, Philippe et al. — The SPMF Open-Source Data Mining Library Version 2 (ECML PKDD 2016, 10.1007/978-3-319-46131-1_8). Звідки в бенчмарках узято BIDE+, VMSP і GST.

Заковика в тому, що вони розв'язують іншу, загальнішу задачу. У класичному sequential pattern mining між елементами патерну допускаються пропуски: патерн ⟨A, B, C⟩ вважається присутнім у документі, якщо там є A … будь-що … B … будь-що … C. Для аналізу купівельних кошиків це саме те, що потрібно. Для тексту — комбінаторний вибух: у довгому документі майже будь-які три часті слова знайдуться в потрібному порядку десь на відстані кількох тисяч токенів одне від одного. Кількість кандидатів зростає на порядки, і майже всі вони безглузді.

Можна обмежити ці алгоритми неперервними розширеннями (у SPMF є, наприклад, варіант BIDE+ «contiguous»), але їхні внутрішні структури даних — проєційовані бази, списки позицій для кожного префікса — спроєктовано для загального випадку, і пам'яті вони споживають відповідно. У наших бенчмарках усі SPMF-алгоритми (BIDE+, VMSP, GST) впали через брак пам'яті вже на 1000 книжках — це близько 60 мільйонів токенів за купи в 40 ГБ. До мільярдів їм далеко.

Висновок: потрібен інструмент, який від самого початку знає, що пропусків немає.

Крок 3. Суфіксний масив: точна відповідь зі стрингології

Задача «знайти всі повторювані підрядки» — класика стрингології, і вона має класичне розв'язання.

Суфіксний масив — це всі суфікси тексту, відсортовані лексикографічно (зберігаються, звісно, не самі суфікси, а їхні початкові позиції). LCP-масив поруч із ним зберігає для кожної пари сусідніх суфіксів довжину їхнього спільного префікса.

Manber, Udi and Myers, Eugene W. — Suffix Arrays: A New Method for On-Line String Searches (SIAM Journal on Computing, Vol.22, 1993, 10.1137/0222058). Сама структура даних.
Nong, Ge; Zhang, Sen and Chan, Wai Hong — Two Efficient Algorithms for Linear Time Suffix Array Construction (IEEE Transactions on Computers, Vol.60, #10, 2011, 10.1109/TC.2010.188). SA-IS: побудова суфіксного масиву за лінійний час, стандарт де-факто.

Чому це розв'язує задачу: усі входження будь-якого підрядка у відсортованому масиві суфіксів лежать поспіль — це суфікси, які з нього починаються. А LCP-масив показує, де такі блоки починаються й закінчуються.

Невеликий приклад. Склеїмо три документи в один текст із роздільниками (літери — це токени):

Д1: A B C D      Д2: X A B C      Д3: A B C Y

Відсортовані суфікси (показую лише початок кожного) і LCP із попереднім:

  #   суфікс           док   LCP
  1   A B C $          Д2    —
  2   A B C D $        Д1    3
  3   A B C Y $        Д3    3
  4   B C $            Д2    0
  5   B C D $          Д1    2
  6   B C Y $          Д3    2
  7   C $              Д2    0
  ...

Рядки 1–3 утворюють LCP-інтервал: три сусідні суфікси зі спільним префіксом довжиною ≥ 3, тобто «A B C». Суфікси з трьох різних документів — df = 3. Рядки 4–6 — інтервал для «B C», теж df = 3, але «B C» міститься в «A B C» з тією самою підтримкою, тож вона не максимальна.

Обхід усіх LCP-інтервалів знизу вгору (за допомогою стека) перелічує всі повторювані підрядки, а підрахунок різних документів у кожному інтервалі дає df. На цьому побудовано алгоритм Фішера–Хойна–Крамера (FHK) — він розв'язує задачу за час, асимптотично оптимальний щодо розміру входу. Є й альтернатива з обходом згори вниз і раннім відсіканням піддерев — DFI (Deferred Frequency Index).

Fischer, Johannes; Heun, Volker and Kramer, Stefan — Optimal String Mining Under Frequency Constraints (PKDD 2006, LNCS 4213, 10.1007/11871637_17). FHK: основний точний бейзлайн.
Weese, David and Schulz, Marcel H. — Efficient String Mining under Constraints via the Deferred Frequency Index (Industrial Conference on Data Mining 2008, LNCS 5077, 10.1007/978-3-540-70720-2_29). DFI: другий точний бейзлайн, обхід згори вниз.
Dhaliwal, Jasbir; Puglisi, Simon J. and Turpin, Andrew — Practical Efficient String Mining (IEEE TKDE, Vol.24, 2012, 10.1109/TKDE.2010.242). Інженерний бік тієї самої лінії робіт.

Це точний і красивий метод. Його проблема — константи. Суфіксний масив і LCP-масив — це по одному цілому числу на кожен токен корпусу, плюс сам текст, плюс масив «якому документу належить позиція», плюс допоміжні структури обходу. І все це будується для всього корпусу, зокрема для мільярдів позицій, з яких не починається жодна часта фраза. У наших вимірюваннях FHK доходить до 30 тисяч книжок (≈1,7 млрд токенів), а далі падає через брак пам'яті, а DFI зі своєю sparse-таблицею для RMQ — уже на 5 тисячах.

Стрингологія відповідає на запитання «де повторюється будь-який підрядок». Нам потрібна відповідь на вужче запитання: «де повторюється частий підрядок». А більшість підрядків природної мови — не часті.

Частина 4. BloomSpan

Головна ідея

Погляньмо на корпус крізь призму закону Ципфа. Розподіл n-грам у природній мові — довгий хвіст: переважна більшість трапляються один-два рази. Це стосується й біграм. На корпусі Gutenberg за σ = 20 з усіх біграм поріг проходять близько 2%.

Звідси ідея: спершу дешево викинути 98% корпусу, а дорогі структури будувати лише для решти 2%.

«Дешево» тут означає — не зберігаючи самих n-грам. Для цього є ймовірнісні структури даних.

Фільтр Блума з лічильниками

Звичайний фільтр Блума відповідає на запитання «чи траплявся цей елемент?» — бітовий масив, елемент хешується, відповідні біти встановлюються в 1. Він може помилитися в один бік: сказати «так» про елемент, якого не було (колізія хешів). Але ніколи не скаже «ні» про елемент, який був.

Bloom, Burton H. — Space/Time Trade-offs in Hash Coding with Allowable Errors (Communications of the ACM, Vol.13, #7, 1970, 10.1145/362686.362692). Початкова структура.

Лічильний фільтр Блума (Counting Bloom Filter) замість бітів зберігає невеликі лічильники. Додати елемент — збільшити лічильник у комірці, куди він хешується. Запитати «скільки разів траплявся» — прочитати лічильник.

Fan, Li; Cao, Pei; Almeida, Jussara and Broder, Andrei Z. — Summary Cache: A Scalable Wide-Area Web Cache Sharing Protocol (IEEE/ACM Transactions on Networking, Vol.8, #3, 2000, 10.1109/90.851975). Де з'явився лічильний варіант фільтра Блума.
Cormode, Graham and Muthukrishnan, S. — An Improved Data Stream Summary: The Count-Min Sketch and its Applications (Journal of Algorithms, Vol.55, #1, 2005, 10.1016/j.jalgor.2003.12.001). Близький родич: частотний скетч, який теж помиляється лише вгору.

Властивість та сама: помилка лише вгору. Якщо в комірку потрапили дві різні біграми, лічильник покаже суму — тобто більше, ніж у кожної. Менше, ніж насправді, він не покаже ніколи.

Іграшкова ілюстрація: масив із 8 лічильників, біграми хешуються в комірки.

комірка:    0   1   2   3   4   5   6   7
лічильник:  3   1   4   0   3   2   3   5
                    ↑                   ↑
        «безкоштовна доставка»    сюди потрапили три біграми:
              (4 рази)            «1000 гривень»  (3 рази)
                                  «новий ноутбук» (1 раз)
                                  «sony wh»       (1 раз)

«новий ноутбук» трапилася один раз, але через колізію фільтр каже «5». На етапі фільтрації вона пройде як кандидат — і її буде відсіяно пізніше, під час точного підрахунку. Зворотна ситуація — часта біграма, якій фільтр нарахував менше за σ, — неможлива.

Чому фільтр не губить жодної частої фрази

Це центральна властивість алгоритму, і доводиться вона в три рядки.

Фільтр рахує не документну частоту df, а загальну кількість входжень c — так простіше: не треба пам'ятати, в якому документі ми вже збільшували лічильник. Нехай ĉ(S) — те, що показує фільтр для n-грами S.

  1. Колізії лише додають: ĉ(S) ≥ c(S).
  2. Кожен документ, де є S, дає щонайменше одне входження: c(S) ≥ df(S).
  3. Отже, ĉ(S) ≥ df(S), і якщо df(S) ≥ σ, то фільтр гарантовано покаже ≥ σ.

Таким чином, усі справді часті n-грами проходять фільтр. Лишилося переконатися, що з них можна виростити всі часті довгі фрази. Це випливає з антимонотонності: будь-яка часта фраза довжиною ≥ n містить n-граму, яка теж часта (з не меншою підтримкою). Отже, кожна часта фраза має щонайменше один «сід», що пережив фільтр.

Хибнонегативних результатів на етапі відбору немає. Хибнопозитивні є, і їх чимало: фільтр рахує входження, а не документи, тому біграма, яка 20 разів трапилася в одній книжці, теж пройде за σ = 20. Їх відсіє наступний, уже точний крок.

Ось як це має вигляд у коді (Java-реалізація, скорочено):

// Розмір: 8 байтів на токен корпусу, але не більше ніж 512 МБ
byte[] filter = new byte[filterSize];

// Один лінійний прохід: кожна біграма збільшує свій лічильник
for (int p = 0; p <= seq.length - n; p++) {
    int idx = (int) (Math.abs(hashTokens(seq, p, n)) % filter.length);
    int c = filter[idx] & 0xFF;
    if (c < 255) filter[idx] = (byte) (c + 1);   // насичення на 255
}

Кілька інженерних деталей. Хеш — FNV-1a над послідовністю цілочисельних токенів. Функція одна, тож формально це один рядок Count-Min Sketch, а не «справжній» багатохешевий фільтр Блума; на гарантію це не впливає, впливає лише на частку хибнопозитивних. Лічильник займає один байт і насичується на 255; тому лічильник порівнюють із min(σ, 255), і гарантія «помилка лише вгору» зберігається.

Другий фільтр — за окремими словами

Крім фільтра за біграмами, є другий, зовсім дешевий: точна документна частота кожного окремого токена (їх у словнику мільйони, а не мільярди, тож це просто хеш-таблиця). Якщо хоча б одне слово біграми трапляється менш ніж у σ документах, біграма не може бути частою — та сама антимонотонність.

Кандидат-сід проходить, лише якщо пройдено обидва фільтри. На корпусі Gutenberg-BookCorpus за σ = 20 разом вони відсікають 98% усіх біграм.

Злиття сідів і точна підтримка

Для 2%, що вижили, ми нарешті можемо дозволити собі точність. Кожне входження кандидата записується як (біграма, документ, позиція); біграма з двох 32-бітних токенів пакується в один long. Масив паралельно сортується, і однакові біграми опиняються поруч. Один прохід по ньому — і для кожної біграми відомі точний список входжень і точний df. Усі біграми, у яких df < σ, викидаються: це хибнопозитивні результати фільтра.

На цьому етапі ми маємо сіди: часті біграми зі списками всіх їхніх входжень.

Порядок обробки: спершу найважче

Сіди сортуються за оцінкою

Ψ(P) = |P| × df(P)

— довжина, помножена на підтримку, тобто приблизно «скільки токенів корпусу покриває ця фраза». За рівності — довша раніше, потім лексикографічно (щоб результат був детермінованим).

Навіщо: першими обробляються сіди, з яких виростуть наймасивніші шаблони — «хребти» корпусу. Коли такий хребет знайдено, всі його шматочки, які трапляться пізніше, швидко розпізнаються як уже покриті.

Ріст у глибину безпосередньо в корпусі

Кожен сід росте праворуч пошуком у глибину зі стеком. Для поточної фрази дивимося на токен, що йде після кожного її входження, і групуємо входження за цим токеном. Кожна група, яка покриває ≥ σ документів, — це часте подовження, його кладуть у стек.

Важливо, де це відбувається: просто у вихідному масиві токенів. Жодних проєційованих баз, жодних копій суфіксів: входження — це пара (документ, позиція), а «наступний токен» — це просто doc[pos + len].

На прикладі: сід «безкоштовна доставка» (входження в Д1, Д2, Д3, Д4). Наступні токени: Д1 → «для», Д2 → «для», Д3 → «для», Д4 → кінець документа. Група «для» покриває 3 документи ≥ σ → у стек кладеться «безкоштовна доставка для» з трьома входженнями. І так далі аж до «… 1000 гривень», де наступні токени — кінець, кінець, «навушники» — і жодна група не дотягує до σ. Далі подовжувати не можна.

Раннє відсікання: якщо всі сусіди ліворуч однакові

Перш ніж подовжувати фразу праворуч, алгоритм перевіряє одну річ: чи в усіх входжень той самий токен ліворуч?

Якщо так, фраза гарантовано не максимальна: її можна подовжити ліворуч без втрати підтримки. І цю довшу версію алгоритм і так знайде — з іншого сіда, який починається на токен лівіше (він теж частий, з тією самою підтримкою). Тож поточну гілку можна викинути цілком, з усім, що з неї виросло б.

На прикладі: сід «доставка для» — в усіх трьох входженнях ліворуч стоїть «безкоштовна». Гілку відсікають одразу; фразу цілком буде знайдено із сіда «безкоштовна доставка».

Це дешева перевірка (один прохід по входженнях), і вона прибирає більшу частину дублювальної роботи: без неї кожен довгий шаблон довжиною L «вирощувався» б майже L разів — з кожного свого внутрішнього сіда.

Перевірка максимальності

Коли фраза більше не росте праворуч, треба вирішити, чи потрапляє вона до відповіді. Два запитання:

(А) Чи не є вона шматком уже знайденої фрази? Наївно — порівняти з усіма знайденими. Це дорого: знайдених можуть бути десятки тисяч. Тому ведеться інвертований індекс «токен → знайдені фрази, де він є». Для кандидата береться його найрідкісніший (за цим індексом) токен, і порівнювати треба лише з фразами з його списку. Якщо надфраза існує, вона мусить містити й цей токен, тож нічого не губиться, а список зазвичай дуже короткий.

(Б) Чи не поглинає вона щось знайдене раніше? Якщо нова фраза довша й містить раніше знайдену, стару видаляють із відповіді та з індексу.

Це саме та ситуація, що й із фразою «безкоштовна доставка» в Частині 2: підфраза може мати більшу підтримку, її можуть знайти раніше — і все одно зрештою вона має поступитися місцем надфразі.

Маска покриття

Останній механізм — глобальна бітова маска завдовжки з увесь корпус: по одному прапорцю на кожну позицію. Коли фразу підтверджено як максимальну, всі позиції всіх її входжень позначаються. Якщо в чергового сіда всі входження лежать в уже позначених позиціях, його пропускають, навіть не почавши вирощувати.

Завдяки сортуванню за Ψ це спрацьовує часто: хребти знаходять першими, і більшість наступних сідів — їхні нутрощі.

Ця маска має й другий сенс, важливіший для початкової задачі, ніж перший. До цього повернемося в Частині 6.

Увесь алгоритм

Схема BloomSpan: завантаження й частоти слів, прохід фільтра Блума, збирання й злиття сідів, сортування за Ψ, ріст у глибину з раннім відсіканням, перевірка максимальності через інвертований індекс, маска покриття
  1. Завантажити корпус як масиви int, порахувати точний df кожного слова.
  2. Один лінійний прохід: усі біграми — в лічильний фільтр.
  3. Другий паралельний прохід: залишити біграми, що пройшли обидва фільтри.
  4. Відсортувати входження, злити, порахувати точний df, викинути хибнопозитивні.
  5. Відсортувати сіди за Ψ.
  6. Для кожного сіда (якщо його не покриває маска) — ріст у глибину з раннім відсіканням.
  7. Перевірка максимальності через інвертований індекс, оновлення маски.

Скільки це коштує

Нехай W — кількість токенів у корпусі, M — розмір фільтра, C — кількість входжень сідів, що вижили, P — кількість знайдених фраз.

Пам'ять: O(W + M + C + P). Корпус і маска — по O(W), фільтр — рівно M байтів (у нас до 512 МБ), сіди та індекс — пропорційно тому, що пережило фільтр. Ключова відмінність від суфіксного масиву: дорогі структури будуються не для всіх W позицій, а лише для C, а C — це кілька відсотків від W.

Час: фільтр і збирання сідів — лінійні проходи плюс сортування сідів, Θ(W + C log C). Фаза росту залежить від даних, і чесної оцінки найгіршого випадку для неї немає. Натомість — вимірювання в наступній частині.

Якщо коротко про чесність: у найгіршому випадку BloomSpan не кращий за точні методи, а іноді й гірший. Він виграє на певній «формі» даних — і саме ця форма типова для природного тексту.

Частина 5. Вимірювання

Як вимірювали

Усі алгоритми реалізовано на Java й запущено на одній машині (Intel Core Ultra 9, 64 ГБ RAM, OpenJDK 21, купа -Xmx40g). FHK і DFI — власні реалізації (SA-IS + LCP за Касаї), BIDE+, VMSP і GST — із SPMF. Час — від виклику алгоритму до результату, включно з побудовою суфіксного масиву для FHK/DFI. Кожна конфігурація — 6 прогонів, перший викидається як розігрів JVM, у таблицях — медіана решти п'яти.

Набори даних:

набір що це σ мін. довжина
Синтетика 1 документи з випадково згенерованих «слів» (не англійських), у які вставлено три фіксовані фрази 3 3
Синтетика 2 300 фраз спадної довжини, що сильно перекриваються, — найгірший випадок для BloomSpan — —
Gutenberg-8k 8544 книжки з Project Gutenberg, 535 млн токенів 10 5
Gutenberg-BookCorpus-Cleaned до 50 000 книжок, до 3,07 млрд токенів 20 10

Як улаштована Синтетика 1. «Слова» в ній — не слова англійської мови, а випадкові рядки: кожне — 8 випадкових малих латинських літер (qhzbtwla, mfoxkrdu тощо). Генератор працює так:

  1. Згенерувати N × 5000 рядків по 8 випадкових літер, відкидаючи повтори, так щоб усі рядки були унікальними в межах усього корпусу. Варіантів 26⁸ ≈ 2·10¹¹, тож справжнє англійське слово серед них — рідкісна випадковість, а осмислених сполучень немає взагалі.
  2. Порізати їх поспіль по 5000 на документ. У результаті кожне «слово» фону трапляється рівно в одному документі, df = 1: повторів у фоні немає за побудовою.
  3. Взяти із CSV три короткі англійські фрази із заданою підтримкою («This is the first test sentence» — 3 документи, «Another unique phrase for testing» — 5, «A third sentence» — 4). Для кожної обрати стільки різних випадкових документів і вставити фразу цілком у випадкове місце між словами фону.

Виходить корпус, для якого правильна відповідь відома заздалегідь: рівно три часті фрази й нічого більше. Це перевірка коректності та стрес-тест на «пласкому» розподілі без повторів, але на природний текст такий корпус не схожий. Генератор лежить у репозиторії (corpus-miner/test-gen/generate_test_dataset.py).

Коректність перевіряли безпосередньо: на Gutenberg-8k за всіх розмірів, де дораховує хоча б один точний бейзлайн (до 5000 книжок), множини фраз BloomSpan, FHK і DFI відсортували й порівняли — вони повністю збігаються. Ні пропусків, ні зайвого. Понад 5000 книжок порівнювати нема з чим, і еквівалентність там не стверджується.

Великий корпус: те, для чого BloomSpan і задумували

Gutenberg-BookCorpus-Cleaned, σ = 20, мінімальна довжина 10. Час у секундах, пам'ять у МБ, OOM — не вистачило 40 ГБ.

книжок BloomSpan, с BloomSpan, МБ FHK, с FHK, МБ DFI, с DFI, МБ прискорення фраз
1 000 1,73 5 095 19,08 6 591 20,17 9 138 11,0× 31
3 000 4,16 7 836 60,34 7 856 67,25 29 393 14,5× 120
5 000 6,64 6 977 101,33 11 238 OOM OOM 15,3× 217
8 000 10,41 8 853 160,37 16 708 OOM OOM 15,4× 375
10 000 13,59 12 044 199,34 20 764 OOM OOM 14,7× 516
15 000 20,80 14 979 308,67 31 549 OOM OOM 14,8× 1 034
20 000 27,67 20 839 — — OOM OOM — 1 964
30 000 44,61 29 385 662,24 38 461 OOM OOM 14,8× 3 673
50 000 383,27 38 873 OOM OOM OOM OOM — —
Час роботи на Gutenberg-BookCorpus-Cleaned залежно від кількості книжок
Пікова пам'ять на Gutenberg-BookCorpus-Cleaned залежно від кількості книжок

Що тут видно:

Де BloomSpan програє

Було б нечесно показувати лише зручну таблицю. Є три режими, де BloomSpan не найкращий.

Синтетика 1 (рідкісні вставки в унікальний фон). До 10 000 документів усі стрингологічні алгоритми масштабуються приблизно лінійно й ідуть майже нарівні. На 12 000 документах відбувається перелом, і далі FHK швидший та ощадливіший: на 30 000 документах FHK — 15,3 с і 38,9 ГБ, BloomSpan — 38,2 с і впирається в ліміт купи.

Час на Синтетиці 1
Пам'ять на Синтетиці 1

Синтетика 2 (щільні вкладені перекриття). Спеціально побудований найгірший випадок. FHK — 10,5 с, BloomSpan — 12,6 с, DFI — 14,9 с, BIDE+ (неперервний, максимальний) — 16,8 с. Решта SPMF-алгоритмів — OOM.

Gutenberg-8k за м'якого порогу σ = 10. До 1000 книжок BloomSpan на ≈20% швидший за FHK/DFI. Починаючи з 3000 книжок FHK/DFI на 10–20% швидші: за м'якого порогу частих фраз стає дуже багато (≈218 тисяч на 5000 книжках), і знову домінує перевірка максимальності. На 8000 книжках BloomSpan падає через брак пам'яті, а FHK і DFI дораховують.

книжок BloomSpan, мс FHK, мс DFI, мс BIDE+, мс GST, мс
100 713 625 655 1 275 2 373
500 3 201 3 896 4 016 13 241 27 170
1 000 6 608 8 425 8 313 OOM OOM
3 000 28 550 25 427 25 185 OOM OOM
5 000 141 023 117 228 111 864 OOM OOM
8 000 OOM 2 214 653 1 461 332 OOM OOM

Чому так: «форма» корпусу

Різницю пояснює не розмір, а структура даних. Зручна міра — кількість фраз у розбитті Лемпеля–Зіва, поділена на кількість токенів (c/n): грубо кажучи, наскільки текст «стискається» завдяки повторам.

Практичне правило: суворий поріг і великий корпус природного тексту — BloomSpan. М'який поріг, багато знайденого або дані зі щільними вкладеними повторами — суфіксні методи.

Частина 6. Назад до індексу

Від списку фраз до відповіді «скільки сміття»

Список максимальних фраз — це відповідь на запитання «що повторюється». Але початкове запитання було «скільки».

Тут стає в пригоді маска покриття з Частини 4. Після завершення роботи алгоритму в ній позначено саме ті позиції корпусу, які належать хоча б одному входженню якоїсь знайденої фрази. Отже:

Тобто та сама структура, яка прискорює алгоритм, після його завершення стає основною метрикою.

Як обирати σ і мінімальну довжину

Два параметри працюють як дві ручки.

На що звернути увагу

Точний збіг — це обмеження. Шаблон зі змінною частиною («Показано 1–20 із 345 товарів», «© 2019», «© 2024», ім'я автора в підписі) розпадається на кілька фраз між змінними вставками. Кожну з них окремо буде знайдено, але як шматки, а не як один шаблон. Урахування «майже однакових» фраз (з допуском на редакційну відстань) — наступний крок, якого в BloomSpan поки немає.

Повторюється — не завжди означає сміття. В e-commerce той самий опис нерідко повторюється в картках усіх кольорів і розмірів одного товару. У новинах — цитата з офіційної заяви в усіх замітках на цю тему. У законодавстві — типові формулювання, які й є контентом. Алгоритм знаходить повтори; рішення, які з них шум, — доменне, і його треба ухвалювати, дивлячись на верхівку списку.

Токенізація губить пунктуацію та регістр. Для пошуку шаблонів це радше плюс (дрібні відмінності в оформленні не заважають збігу), але під час зворотного мапінгу знайденої фрази на вихідний текст це треба враховувати.

Підсумок

Задача «знайти всі довгі фрагменти, що дослівно повторюються в багатьох документах» має вигляд вправи на хеш-таблицю. На мільярдах токенів вона перетворюється на вибір між трьома поганими варіантами: хеш-таблиця n-грам не вміщується в пам'ять, алгоритми sequential pattern mining тонуть у комбінаториці пропусків, а точні суфіксні методи будують дорогі структури для кожного токена корпусу, навіть для тих 98%, з яких не починається жодна часта фраза.

BloomSpan спирається на те, що природний текст підпорядковується закону Ципфа:

На 3 млрд токенів це в 11–15 разів швидше за FHK, удвічі ощадливіше щодо пам'яті, і алгоритм дораховує там, де інші не вміщуються в 40 ГБ. На м'яких порогах і синтетиці зі щільними повторами він програє суфіксним методам, і це варто знати заздалегідь.

А для початкової задачі головне навіть не швидкість: після роботи алгоритму маска покриття дає саме ту відповідь, заради якої все й затівалося, — яка частка індексу складається з повторюваних фрагментів і де саме вони лежать.

Aliev, Rauf and Beel, Joeran — BloomSpan: Memory-Efficient Maximal Frequent Substring Mining for Large-Scale Text (University of Siegen, 2026). Код і бенчмарки: github.com/raliev/bloomspan.