TGViewer
dmedovich notes dmedovich notes @dmedovich_notes · 156 subscribers
Post #29 189
Прунинг сегментов в Amber: Ribbon, CQF и SuRF 😴

Хватит пока с нас индексов — поговорим немного о фильтрах и о том, где и как они позволяют отсекать максимум сегментов до полного скана.

Сегменты у нас append-only и запечатываются один раз. Запрос проходит по ним каскадом: на каждом шаге можно отсечь сегмент целиком.

Step 1: отсечение по времени

SparseIndex хранит для каждого сегмента [MinTS, MaxTS]. Time-bounded-запрос отсечёт всё, что не пересекается по времени, ещё до открытия индексного файла — достаточно простой проверки метаданных в памяти:
// Lookup returns the segments whose span overlaps [from, to].
func (s *SparseIndex) Lookup(from, to int64) []SegmentTimeRange {
result := make([]SegmentTimeRange, 0)
for _, r := range s.ranges {
if r.MaxTS < from || r.MinTS > to {
continue // сегмент вне диапазона — пропускаем
}
result = append(result, r)
}
return result
}


Step 2: membership-проверка по полю


Здесь уже подключаются sidecar-индексы, а то, какие именно индексы будут построены, зависит от кардинальности поля. level, service, host — поля низкой кардинальности, представленные инвертированными битмапами (MultiFieldIndex).

А вот trace_id из битмапа был исключён. Узнать о причинах можно тут, но если в двух словах: почти каждый trace_id имеет df = 1, а значит, хранить битмап для каждого trace_id попросту невыгодно. Вместо этого используются RibbonFilter и posting list для точных ID, если фильтр вернул ложноположительный результат.

В executor это выглядит как цепочка ранних выходов:
if !model.IsZeroTraceID(q.TraceID) {
if ribbon, ok := e.logRibbon(seg.FileName); ok {
if !ribbon.Contains(q.TraceID[:]) {
return 0, nil // сегмент целиком мимо, дальше не читаем
}
}
if pl, ok := e.logPosting(seg.FileName); ok {
ids := pl.Lookup(q.TraceID[:])
if len(ids) == 0 {
return 0, nil
}
// ...
}
}

Для токенов FTS работает тот же принцип.

Step 3: пропустить декомпрессию там, где сегмент всё равно придётся читать

Зачем читать то, что мы всё равно будем читать при совпадении? Давайте просто пропустим этот этап.

Для trace-summary-запросов (service / operation / duration) есть CoverIndex (.cidx) — колоночная, страйдовая проекция нужных полей в том же отсортированном порядке, что и posting list по service. Она отвечает на агрегирующий запрос без обращения к основному row store.

Все эти sidecar-индексы строятся за один проход при запечатывании сегмента. Изначально Bitmap, FTS, Ribbon и FTSRibbon строились каждый своим отдельным сканом. При пяти независимых декодированиях одних и тех же данных (и особенно при двойных токенизации и стемминге для FTS) это стало узким местом: seal не успевал за ingest.

Сейчас один проход кормит все индексы сразу, а Ribbon для FTS переиспользует токены непосредственно из состояния построения FTS-индекса, без повторной токенизации.

Ribbon Filter

Про Ribbon можно почитать подробнее тут и тут. Тем более что именно его, с небольшой модификацией, я и использую у себя в БД (подкрутил ширину окна — just for my case).

Counting Quotient Filter (CQF)

Ribbon и Bloom по сути умеют только одно: сказать, встречался ключ или нет. CQF идёт чуть дальше — он добавляет счётчик кратности ключа. А еще можно добавлять и удалять записи без полной пересборки.

В чём идея: ключ хешируется и режется на две части — quotient (q бит, индекс слота в массиве) и remainder (r бит, то, что реально хранится в этом слоте):
func (qf *CountingQF) split(key []byte) (q0, r0 uint64) {
h := hashKey(key) & maskBits(qf.q+qf.r)
return h >> qf.r, h & maskBits(qf.r)
}

Дальше — массив слотов размером 2^q, и на каждый слот три метабита вместо привычных Bloom-битов:
- occupied[i] — у quotient i где-то в массиве есть свой run;
- used[i] — слот i физически занят;
- runend[i] — слот i последний в своём run.

При коллизии по q (два разных ключа попали в один и тот же i) элементы физически сдвигаются вправо, формируя непрерывный run — отсортированный по r участок массива:
func (qf *CountingQF) locate(qIdx uint64) uint64 {
if !qf.isUsed(qIdx) {
return qIdx
}
start := qf.findClusterStart(qIdx)
runsBefore := 0
for k := start; k < qIdx; k++ {
if qf.isOccupied(k) {
runsBefore++
}
}
pos := start
for runsBefore > 0 {
for !qf.isRunEnd(pos) {
pos++
}
pos++
runsBefore--
}
return pos
}

locate считает, сколько занятых q находится перед искомым внутри кластера, и проходит мимо ровно такого количества run. Отсюда и требование: run физически никогда не может оказаться левее своего канонического индекса. Иначе locate его просто не найдёт — он ищет только вправо.

Реализовал CQF с двумя упрощениями относительно статьи три метабита на слот вместо двух битов, получаемых через rank/select в RSQF, unary-счётчик вместо escape-кодирования.
В обоих случаях гарантии сохраняютс, структура получается немного дороже по памяти, но проще в реализации и тестах. И если с основными операциями проблем не возникло, то вот с Delete пришлость повозиться, ибо руки из жопы

Succinct Range Filter (SuRF)

Второе ограничение Ribbon — точное совпадение, prefix-поиск отсутствует. Поэтому рассмотрим еще и структуру, которая обещает range-фильтрацию почти по цене обычного membership-фильтра.

SuRF — это сжатое префиксное дерево над отсортированным множеством ключей, усечённое до минимально необходимой глубины.

В чём идея:
- Строим обычный trie по байтам ключей.
- Урезаем каждую ветку в точке, где ключ уже однозначно отличим от соседних: дальше можно ничего не хранить, оставшийся хвост ключа для membership-проверки не нужен.
- Кодируем получившееся дерево в succinct-представлении. Для структуры дерева используем битовые последовательности LOUDS, а метки рёбер храним отдельно — по одному байту на символ. Дальше rank/select позволяют быстро перемещаться по этой компактной структуре.

По памяти получается порядка 10! бит на узел в зависимости от конфигурации, вместо десятков байт на узел в обычном trie с указателями. ! - размер сильно зависит от глубины усечения и того как именно у вас хранятся labels и доп суффикс/хэш биты

Дальше есть три варианта:
SuRF-Base — просто усечённое дерево, без дополнительных гарантий для точечных запросов. Дешёвый вариант, но false positive rate при membership-проверке зависит от того, насколько глубоко пришлось урезать дерево.

SuRF-Hash — добавляет к каждому листу несколько бит хеша полного ключа, чтобы довести false positive rate до нужного уровня для точечных запросов. По сути, это замена Bloom/Ribbon-фильтру.

SuRF-Real — вместо хеша хранит реальные оставшиеся байты ключа. Что делает возможными range-запросы типа: («все ключи между X и Y»), а не только membership-проверку.

У обычного Bloom/Ribbon-фильтра ключи представлены только хешами: порядок и структура исходного ключа полностью теряются. SuRF сохраняет порядок — а значит, в теории может быть одновременно и компактным (как Ribbon), и полезным для диапазонных и префиксных запросов, чего Ribbon не может в принципе, ни при каком бюджете памяти.

Итог:🛑

CQF добавляет возможность менять фильтр после построения.
SuRF добавляет поддержку prefix- и range-запросов. Оба закрывают ограничения Ribbon.

Но для нас это не то чтобы проблема. В Amber сегменты append-only и запечатываются один раз, поэтому после построения их уже не нужно менять. А большая часть полей — короткие структурированные ключи умеренной кардинальности, где точного совпадения вполне достаточно.

Оба фильтра — тяжёлые и сложные, и оба платят за возможности, которых у Ribbon нет, но которые нам, по-хорошему, не особо то и нужны. Возможно SuRF сможет подружиться с FTS, ибо последний работает с токенами, а не с лексикографическим порядком исходных ключей, но пока такого запроса нет, но карандашиком запишу😶‍🌫️😶‍🌫️
  • 🏆 5
  • 🔥 4
More from @dmedovich_notes
  1. Sep 9, 2026В погоне за ядрами: история про микрооптимизацию хеш-функции Иногда значительная часть CPU…
  2. Aug 9, 2026Learned Index, или хоть кто-то умный... Провёл небольшой эксперимент с индексом в Amber. И…
  3. Jun 16, 2026honey@lab:~$ cat amber/notes-2 😗😗😗 amber уже хранит логи и трейсы — почти бесплатно, по…
  4. May 24, 2026honey@lab:~$ cat queen/dev-blog/01 ☺️☺️☺️ Queen v0.7.0 Большая обновочка прикатила в мою q…
  5. May 21, 2026honey@lab:~$ cat amber/dev-blog/01 🏠Bitmap, ribbon, posting list — или как усидеть на трё…
  6. May 3, 2026@lab:~$ cat post/honey-garden 🧑‍💻🧑‍💻🧑‍💻 2 недели, golang и react итоги: Каждый раз,…
Threads Profile ViewerView any public Threads profile without an account.Open ThreadLook →Writing with AI? Make it sound human.Metric37 rewrites AI drafts so they read naturally. Free AI detector, 1,500 words free.Try Metric37 →