HardПрактика13 min

Typeahead autocomplete

Проектирование автодополнения поиска: trie, ranking, персонализация, trending, низкая latency при 10M QPS

Пользователь вводит "how" и сразу видит подсказки: how to tie a tie, how to screenshot on mac. За каждым нажатием -- RPC на автокомплит-сервер. Ниже -- как сделать такой сервис, чтобы он отвечал за 20 мс даже при 10M QPS.

Функциональные требования

  1. По префиксу длиной 1-20 символов вернуть топ-K (обычно K=10) подсказок.
  2. Подсказки ранжированы по популярности (частоте запросов в последнее время).
  3. Персонализация: если пользователь часто ищет "python" -- подскажем ему первым.
  4. Trending: подсказки с резким ростом частоты (выборы, матч, новость) поднимаются.
  5. Multi-language: английский, русский, китайский, эмодзи.
  6. Контекст: подсказки зависят от вертикали (shop/news/video).
  7. Опечатки: "hwo" -> предложить "how" (fuzzy).

Нефункциональные требования

  1. Latency: p99 < 50 ms end-to-end, p50 < 10 ms (внутри сервиса < 5 ms).
  2. Throughput: 10M QPS на поиске.
  3. Freshness: новые популярные запросы должны появляться в подсказках за минуты.
  4. Availability: 99.99%.
  5. Cost: обновление словаря -- offline job, не нагружает read path.

Оценки нагрузки

  • Запросов: 5B/день на поиск, в среднем 6 символов пользователь печатает -> 30B autocomplete запросов/день, ~350K QPS средний, 1M QPS peak. После кэширования частых префиксов -- реальная нагрузка до trie-сервера 10% (~35K-100K QPS).
  • Словарь: 200M уникальных запросов, средняя длина 25 байт + score + payload = ~100 байт -> 20 GB словаря.
  • Trie в памяти: с компрессией (patricia) ~10-15 GB на shard.
  • Offline rebuild: 200M записей, агрегация по дням -- пара часов на Spark / ClickHouse.

High-Level Architecture

        ┌─────────┐ type char     ┌────────────────┐
        │ Browser │──────────────>│  Edge Cache    │
        │         │<── suggestions│  (Varnish/     │
        └─────────┘               │   CDN, 100ms)  │
                                  └───────┬────────┘
                                          │ miss
                                          ▼
                                  ┌────────────────┐
                                  │  AC Gateway    │
                                  │  (auth, rate   │
                                  │   limit, merge)│
                                  └───┬───────┬────┘
                                      │       │
                               shard by prefix │
                      ┌───────────────┼───────┼───────────────┐
                      ▼               ▼                       ▼
              ┌──────────────┐ ┌──────────────┐       ┌──────────────┐
              │ Trie Server  │ │ Trie Server  │  ...  │ Trie Server  │
              │ (in-memory   │ │              │       │              │
              │  trie, RCU)  │ │              │       │              │
              └──────┬───────┘ └──────┬───────┘       └──────┬───────┘
                     │                │                       │
                     └────────────┬───┴───────────────────────┘
                                  ▼
                         ┌─────────────────┐
                         │ Personalization │
                         │ Ranker (user    │
                         │ profile + ML)   │
                         └────────┬────────┘
                                  │
                                  ▼
                         ┌─────────────────┐
                         │ Response merge  │
                         │ + diversity     │
                         └─────────────────┘

  Offline:
  ┌───────────────┐   ┌─────────────┐   ┌─────────────┐   ┌─────────────┐
  │ Search Logs   │──>│ Kafka       │──>│ Aggregator  │──>│ Dict Builder│
  │ (all queries) │   │ (stream)    │   │ (per hour)  │   │ (trie snap) │
  └───────────────┘   └─────────────┘   └─────────────┘   └──────┬──────┘
                                                                  │
                                                                  ▼
                                                       Trie Server reload (RCU)

API дизайн

GET /autocomplete?q=how&ctx=web&lang=en&limit=10
<?php

declare(strict_types=1);

final readonly class AutocompleteRequest
{
    public function __construct(
        public string $query,
        public string $context = 'web',
        public string $lang = 'en',
        public int $limit = 10,
        public ?string $userId = null,
    ) {}
}

final readonly class Suggestion
{
    public function __construct(
        public string $text,
        public float $score,
        public ?string $highlight = null,
        public ?string $type = null, // query|entity|shortcut
    ) {}
}

final readonly class AutocompleteResponse
{
    /**
     * @param list<Suggestion> $suggestions
     */
    public function __construct(
        public array $suggestions,
        public int $tookMicros,
        public string $version,
    ) {}
}
## Схема данных

Offline dictionary

-- Хранится в Spark / ClickHouse, не в OLTP.
CREATE TABLE query_stats (
    query       TEXT NOT NULL,
    lang        TEXT NOT NULL,
    context     TEXT NOT NULL,
    day         DATE NOT NULL,
    count       BIGINT NOT NULL,
    PRIMARY KEY (query, lang, context, day)
);

-- Итоговая витрина с EMA score (exponential moving average).
CREATE TABLE query_scores (
    query       TEXT NOT NULL,
    lang        TEXT NOT NULL,
    context     TEXT NOT NULL,
    score       DOUBLE PRECISION NOT NULL,
    trending    DOUBLE PRECISION NOT NULL,  -- velocity
    updated_at  TIMESTAMPTZ NOT NULL,
    PRIMARY KEY (query, lang, context)
);

В памяти trie-сервера хранится только query, score, trending, payload.

User profile

CREATE TABLE user_search_profile (
    user_id      UUID PRIMARY KEY,
    top_queries  JSONB,       -- top-50 queries + counts
    top_entities JSONB,
    updated_at   TIMESTAMPTZ NOT NULL
);

Либо в Redis: user:{id}:top = ZSET.

Ключевые компоненты

1. Trie in memory

Trie -- классическая структура префиксного поиска. Но наивный trie (node с map) тратит много памяти. Используют:

  • Patricia trie (radix tree): сжимаем цепочки однопотомочных узлов.
  • Double-array trie: 2 массива, O(1) переходы, очень компактно.
  • FST (finite-state transducer): как в Lucene, минимальный DFA.

В каждом узле храним топ-K предыдущих (precomputed), чтобы при поиске не идти по всему поддереву. Запрос: идём по префиксу -> в найденном узле берём готовый топ-K.

package trie

import (
    "sort"
    "sync/atomic"
)

// TopK is a precomputed slice of top suggestions under a node.
type Suggestion struct {
    Text  string
    Score float64
}

type node struct {
    children map[byte]*node
    topK     []Suggestion // size == K, sorted DESC by score
}

// Trie is an immutable snapshot. Writers build a new tree and atomically
// swap the pointer (RCU pattern), readers never block.
type Trie struct {
    root    *node
    topK    int
}

// Snapshot is an atomic pointer to the current Trie.
type Snapshot struct {
    current atomic.Pointer[Trie]
}

// Load returns the current trie; always non-nil after init.
func (s *Snapshot) Load() *Trie { return s.current.Load() }

// Swap replaces the active trie atomically. Old trie can be GC-ed once
// all in-flight readers are done.
func (s *Snapshot) Swap(t *Trie) { s.current.Store(t) }

// Search returns precomputed top-K under the given prefix.
func (t *Trie) Search(prefix string, k int) []Suggestion {
    n := t.root
    for i := 0; i < len(prefix); i++ {
        c, ok := n.children[prefix[i]]
        if !ok {
            return nil
        }
        n = c
    }
    if k >= len(n.topK) {
        return n.topK
    }
    return n.topK[:k]
}

// Build constructs a trie from a dictionary of (text -> score) pairs and
// precomputes top-K at every node. Done offline, then hot-swapped.
func Build(dict map[string]float64, topK int) *Trie {
    t := &Trie{root: &node{children: map[byte]*node{}}, topK: topK}
    type entry struct {
        text  string
        score float64
    }
    items := make([]entry, 0, len(dict))
    for k, v := range dict {
        items = append(items, entry{k, v})
    }
    // Sort DESC so we propagate top scores first.
    sort.Slice(items, func(i, j int) bool { return items[i].score > items[j].score })

    for _, e := range items {
        n := t.root
        for i := 0; i < len(e.text); i++ {
            c := e.text[i]
            child, ok := n.children[c]
            if !ok {
                child = &node{children: map[byte]*node{}}
                n.children[c] = child
            }
            n = child
            // Only append if topK not full (items are sorted DESC, so we
            // never need to re-sort at this node).
            if len(n.topK) < topK {
                n.topK = append(n.topK, Suggestion{Text: e.text, Score: e.score})
            }
        }
    }
    return t
}
RCU (read-copy-update) снимает мьютексы с read path полностью. Build нового trie -- обычно 30-60 секунд на shard, выполняется на отдельной машине, результат раздаётся по shard-ам.

2. Sharding

По первым 1-2 символам префикса. "how*" -> shard 23, "why*" -> shard 24. Плюсы: запрос идёт ровно на один shard. Минус: hot shard (один язык / популярные буквы).

Более продвинутый вариант -- consistent hashing по хэшу первого символа, с перераспределением hot prefixes. Но чаще хватает банального алфавитного деления.

Каждый shard -- replicas (обычно 3) за балансировщиком. Trie раздаётся через S3 + периодический pull.

3. Ranking

Score = взвешенная сумма:

score = α * log(1 + count_7d)
      + β * recency_decay(last_seen)
      + γ * trending(velocity)
      + δ * personalization(user_similarity)
      + ε * quality_signal (CTR от позиции в выдаче)

Коэффициенты подбирают через A/B. Trending считается отдельно: берём скользящее окно 1 час и сравниваем со вчерашним -- если рост > threshold, суффиксуем "trend" флаг.

Персонализация делается после trie lookup: сервер ранжирования получает топ-100 от trie и ре-ранжирует топ-10 с учётом профиля пользователя (light GBDT model или простая линейная комбинация). Это держит latency низкой.

4. Real-time updates

Для trending нужно реагировать за минуты. Пайплайн:

Search logs -> Kafka -> Stream aggregator (Flink/ksqlDB)
                              │
                              ▼
                       Hot query Redis ZSET
                              │
                              ▼
                   Δ-updates в trie servers (в отдельной "hot tier")

Trie-сервер держит два trie: base (ежечасный snapshot) и delta (топ-10K свежих, обновляется каждую минуту). При запросе мержим оба топа, выбираем K лучших. Delta маленький (< 100 MB), его можно пересобирать часто.

5. Cache

Edge cache на gateway: (query, ctx, lang) -> suggestions, TTL 60-300s. Для коротких префиксов ("a", "an") hit rate -- 95%. Это главный способ не перегружать trie-серверы.

На CDN кэшировать осторожно: персонализация делает ответы разными. Либо кэшируем только impersonal layer, либо ставим Vary: X-User-Segment.

6. Клиент (пример интеграции)

<?php

declare(strict_types=1);

final class AutocompleteClient
{
    public function __construct(
        private readonly \GuzzleHttp\Client $http,
        private readonly string $endpoint,
        private readonly \Redis $cache,
        private readonly LoggerInterface $logger,
    ) {}

    public function suggest(string $q, string $ctx, string $lang): AutocompleteResponse
    {
        $q = mb_strtolower(trim($q));
        if ($q === '') {
            return new AutocompleteResponse([], 0, 'v1');
        }

        $cacheKey = "ac:{$lang}:{$ctx}:" . substr($q, 0, 8); // хеш по первым 8 символам
        $cached = $this->cache->get($cacheKey);
        if (is_string($cached)) {
            return $this->decode($cached);
        }

        try {
            $resp = $this->http->get($this->endpoint, [
                'query' => ['q' => $q, 'ctx' => $ctx, 'lang' => $lang, 'limit' => 10],
                'timeout' => 0.05, // 50ms hard timeout
            ]);
            $body = (string) $resp->getBody();
            $this->cache->setex($cacheKey, 60, $body);
            return $this->decode($body);
        } catch (\Throwable $e) {
            $this->logger->warning('autocomplete failed', ['error' => $e->getMessage()]);
            return new AutocompleteResponse([], 0, 'fallback');
        }
    }

    private function decode(string $body): AutocompleteResponse
    {
        $data = json_decode($body, true, flags: JSON_THROW_ON_ERROR);
        $suggestions = array_map(
            fn(array $s) => new Suggestion($s['text'], (float) $s['score']),
            $data['suggestions'] ?? [],
        );
        return new AutocompleteResponse($suggestions, (int) ($data['took_us'] ?? 0), (string) ($data['version'] ?? ''));
    }
}
Hard timeout 50ms -- если сервис тормозит, клиент просто не показывает подсказки, но не блокирует ввод.

Масштабирование и bottlenecks

Bottleneck 1: памяти на shard

200M записей в trie даже после компрессии -- десятки GB. Митигации:

  • Patricia/double-array trie -- в 3-5 раз компактнее обычного.
  • Sharding: каждый shard держит 1/N словаря.
  • Хранить только топ-N entries (обычно 200M -> 20M покрывают 95% трафика).

Bottleneck 2: fan-out на несколько shards

Если запрос "go" попадает на shard "g", то всё хорошо. А если нужно мержить несколько языков или вертикалей? Параллельный fan-out с scatter-gather, merge на gateway. Следим, чтобы 99-й перцентиль самого медленного shard'a не разрушил общий p99 (hedged requests).

Bottleneck 3: cold start

При деплое trie-сервера нужно загрузить 15 GB из S3 -- это 30-120 секунд. Пока сервер не готов -- не принимаем трафик. Используем:

  • rolling deploy по 1-2 инстанса;
  • warm pool (spare capacity);
  • preload через initContainer в K8s.

Выборы -> резко 10x запросов на "election". Митигации:

  • shed low-priority traffic (неперсонализированные > персонализированные);
  • circuit breaker на персонализацию (падаем на base top-K);
  • scale-out trie-серверов (stateless read replicas, легко добавить).

Trade-offs и альтернативы

Решение Плюс Минус
In-memory trie < 5 ms read Нужна память
ES completion suggester Готовое, с fuzzy Сложнее тюнить ranking, выше latency
Redis ZSET + prefix scan Очень просто Нет топ-K на узле, медленнее
ML ranker Персонализация, точность Больше latency, модель поддерживать
Fuzzy (Levenshtein) в trie Ловит опечатки В 3-5 раз медленнее
Offline rebuild Предсказуемо Задержка в словаре
Real-time via delta trie Trending за минуты Сложнее, два источника

Альтернатива trie -- Lucene Completion Suggester (FST). Хорошо работает, но ranker поверх него усложняет жизнь. Для больших масштабов свой trie-сервер гибче.

Fuzzy matching: можно класть в trie ключи с одной ошибкой (expand на этапе индексации), либо использовать Symmetric Delete / BK-tree как second-stage. Дорого по памяти, но даёт нормальное UX.

Выводы

Typeahead -- задача, где 99% успеха даёт правильная архитектура чтения: in-memory structure, precomputed top-K в узлах, RCU без блокировок, edge cache. Всё остальное (personalization, trending, fuzzy) -- накручивается слоями, каждый из которых можно деградировать при перегрузке, не ломая базовую функциональность. Offline pipeline отвечает за свежесть и ранжирование, online pipeline -- только за скорость.