HardКейс24 min

Поисковая система

Проектирование поисковой системы: индексирование, inverted index, ранжирование, автокомплит и полнотекстовый поиск

Поисковая система -- один из самых сложных компонентов, требующий знаний информационного поиска, структур данных и алгоритмов ранжирования.

Шаг 1: Требования

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

  1. Полнотекстовый поиск по коллекции документов
  2. Ранжирование результатов по релевантности
  3. Автокомплит (подсказки при вводе)
  4. Фасетный поиск (фильтры по категориям, цене и т.д.)
  5. Поиск с учётом опечаток (fuzzy search)
  6. Поддержка нескольких языков

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

  1. Latency поиска < 200ms (p99)
  2. Latency автокомплита < 50ms
  3. Индекс обновляется за < 5 минут после изменения документа
  4. Масштабирование до 1B документов

Шаг 2: High-Level архитектура

┌───────────┐     ┌────────────────┐     ┌──────────────────────────────┐
│  Client   │────>│  API Gateway   │────>│  Search Service              │
│           │     │                │     │  ┌────────────────────────┐  │
└───────────┘     └────────────────┘     │  │  Query Parser          │  │
                                         │  │  Tokenizer + Analyzer  │  │
                                         │  │  Query Planner         │  │
                                         │  └────────────┬───────────┘  │
                                         └───────────────┼──────────────┘
                                                         │
                              ┌───────────────────────────┼─────────────────┐
                              │                           │                 │
                       ┌──────▼──────┐          ┌─────────▼──────┐  ┌──────▼──────┐
                       │  Shard 1    │          │  Shard 2       │  │  Shard N    │
                       │  (A-F)      │          │  (G-M)         │  │  (T-Z)      │
                       │  ┌────────┐ │          │  ┌────────┐    │  │  ┌────────┐ │
                       │  │Inverted│ │          │  │Inverted│    │  │  │Inverted│ │
                       │  │ Index  │ │          │  │ Index  │    │  │  │ Index  │ │
                       │  └────────┘ │          │  └────────┘    │  │  └────────┘ │
                       └─────────────┘          └────────────────┘  └─────────────┘

┌────────────────┐     ┌────────────────┐
│  Data Source   │────>│  Indexer       │────> Updates to Shards
│  (DB/Crawler)  │     │  Pipeline      │
└────────────────┘     └────────────────┘

Шаг 3: Inverted Index

Inverted Index -- основная структура данных поисковой системы.

Документы:
  Doc1: "PHP framework for web applications"
  Doc2: "Web development with PHP and JavaScript"
  Doc3: "Building applications with Laravel framework"

Inverted Index:
  "php"          -> [Doc1, Doc2]
  "framework"    -> [Doc1, Doc3]
  "web"          -> [Doc1, Doc2]
  "applications" -> [Doc1, Doc3]
  "development"  -> [Doc2]
  "javascript"   -> [Doc2]
  "building"     -> [Doc3]
  "laravel"      -> [Doc3]

Реализация

<?php

declare(strict_types=1);

final class InvertedIndex
{
    /** @var array<string, array<int, PostingEntry>> */
    private array $index = [];

    /** @var array<int, DocumentMeta> */
    private array $documents = [];

    private int $totalDocuments = 0;

    public function addDocument(int $docId, string $content, array $metadata = []): void
    {
        $tokens = $this->tokenize($content);
        $termFrequencies = array_count_values($tokens);

        $this->documents[$docId] = new DocumentMeta(
            id: $docId,
            length: count($tokens),
            metadata: $metadata,
        );

        foreach ($termFrequencies as $term => $frequency) {
            if (!isset($this->index[$term])) {
                $this->index[$term] = [];
            }

            $this->index[$term][$docId] = new PostingEntry(
                docId: $docId,
                frequency: $frequency,
                positions: $this->getPositions($tokens, $term),
            );
        }

        $this->totalDocuments++;
    }

    /**
     * Search with TF-IDF scoring
     */
    public function search(string $query, int $limit = 10): array
    {
        $queryTokens = $this->tokenize($query);
        $scores = [];

        foreach ($queryTokens as $term) {
            if (!isset($this->index[$term])) {
                continue;
            }

            $postings = $this->index[$term];
            $idf = $this->calculateIdf($term);

            foreach ($postings as $docId => $posting) {
                $tf = $this->calculateTf($posting->frequency, $this->documents[$docId]->length);
                $scores[$docId] = ($scores[$docId] ?? 0) + ($tf * $idf);
            }
        }

        // Sort by score descending
        arsort($scores);

        // Return top N results
        $results = [];
        $count = 0;

        foreach ($scores as $docId => $score) {
            if ($count >= $limit) {
                break;
            }

            $results[] = new SearchResult(
                docId: $docId,
                score: round($score, 4),
                metadata: $this->documents[$docId]->metadata,
            );
            $count++;
        }

        return $results;
    }

    /**
     * Term Frequency: tf(t,d) = count(t in d) / |d|
     */
    private function calculateTf(int $termFreq, int $docLength): float
    {
        return $termFreq / $docLength;
    }

    /**
     * Inverse Document Frequency: idf(t) = log(N / df(t))
     */
    private function calculateIdf(string $term): float
    {
        $documentFrequency = count($this->index[$term] ?? []);

        if ($documentFrequency === 0) {
            return 0.0;
        }

        return log($this->totalDocuments / $documentFrequency);
    }

    private function tokenize(string $text): array
    {
        // Lowercase
        $text = mb_strtolower($text);

        // Remove punctuation
        $text = preg_replace('/[^\p{L}\p{N}\s]/u', ' ', $text);

        // Split into tokens
        $tokens = preg_split('/\s+/', $text, -1, PREG_SPLIT_NO_EMPTY);

        // Remove stop words
        $stopWords = ['the', 'a', 'an', 'is', 'are', 'was', 'and', 'or', 'for', 'with', 'in', 'on', 'to'];
        $tokens = array_filter($tokens, fn (string $t) => !in_array($t, $stopWords, true));

        // Stemming (simplified)
        $tokens = array_map(fn (string $t) => $this->stem($t), $tokens);

        return array_values($tokens);
    }

    private function stem(string $word): string
    {
        // Simplified Porter stemming
        $suffixes = ['ing', 'tion', 'ment', 'ness', 'able', 'ful', 'less', 'ous', 'ive', 'ed', 'ly', 'er', 'es', 's'];

        foreach ($suffixes as $suffix) {
            if (str_ends_with($word, $suffix) && strlen($word) - strlen($suffix) >= 3) {
                return substr($word, 0, -strlen($suffix));
            }
        }

        return $word;
    }

    private function getPositions(array $tokens, string $term): array
    {
        $positions = [];
        foreach ($tokens as $pos => $token) {
            if ($token === $term) {
                $positions[] = $pos;
            }
        }
        return $positions;
    }
}

final readonly class PostingEntry
{
    public function __construct(
        public int $docId,
        public int $frequency,
        public array $positions,
    ) {}
}

final readonly class DocumentMeta
{
    public function __construct(
        public int $id,
        public int $length,
        public array $metadata,
    ) {}
}

final readonly class SearchResult
{
    public function __construct(
        public int $docId,
        public float $score,
        public array $metadata,
    ) {}
}
### BM25 -- улучшенное ранжирование
<?php

declare(strict_types=1);

final class BM25Scorer
{
    private const K1 = 1.2;  // Term frequency saturation parameter
    private const B = 0.75;  // Document length normalization

    private float $avgDocLength;

    public function __construct(
        private readonly int $totalDocuments,
        private readonly array $docLengths, // docId -> length
    ) {
        $this->avgDocLength = array_sum($this->docLengths) / max(1, count($this->docLengths));
    }

    /**
     * BM25 score for a term in a document
     *
     * score(t,d) = IDF(t) * (tf(t,d) * (k1 + 1)) / (tf(t,d) + k1 * (1 - b + b * |d| / avgdl))
     */
    public function score(string $term, int $docId, int $termFreq, int $docFreq): float
    {
        $idf = $this->idf($docFreq);
        $docLength = $this->docLengths[$docId] ?? $this->avgDocLength;

        $numerator = $termFreq * (self::K1 + 1);
        $denominator = $termFreq + self::K1 * (1 - self::B + self::B * $docLength / $this->avgDocLength);

        return $idf * ($numerator / $denominator);
    }

    private function idf(int $docFreq): float
    {
        return log(($this->totalDocuments - $docFreq + 0.5) / ($docFreq + 0.5) + 1);
    }
}
## Шаг 4: Автокомплит (Typeahead)
<?php

declare(strict_types=1);

final class AutocompleteService
{
    public function __construct(
        private readonly \Redis $redis,
    ) {}

    /**
     * Build autocomplete index using sorted sets
     */
    public function index(string $phrase, float $score = 1.0): void
    {
        $normalized = mb_strtolower(trim($phrase));

        // Add all prefixes
        for ($i = 1; $i <= mb_strlen($normalized); $i++) {
            $prefix = mb_substr($normalized, 0, $i);
            $this->redis->zIncrBy("autocomplete:{$prefix}", $score, $normalized);
        }

        // Keep only top N suggestions per prefix
        $this->redis->zRemRangeByRank("autocomplete:{$normalized[0]}", 0, -101);
    }

    /**
     * Get suggestions for a prefix
     */
    public function suggest(string $prefix, int $limit = 10): array
    {
        $normalized = mb_strtolower(trim($prefix));

        // Get top scored suggestions
        $results = $this->redis->zRevRange(
            "autocomplete:{$normalized}",
            0,
            $limit - 1,
            true, // with scores
        );

        return array_map(
            fn (string $phrase, float $score) => [
                'text' => $phrase,
                'score' => $score,
            ],
            array_keys($results),
            array_values($results),
        );
    }

    /**
     * Trie-based approach for more efficient memory usage
     */
    public function suggestWithTrie(string $prefix, int $limit = 10): array
    {
        $key = "trie:" . mb_strtolower(trim($prefix));

        // ZRANGEBYLEX for prefix matching
        $results = $this->redis->zRangeByLex(
            'autocomplete:trie',
            "[{$prefix}",
            "[{$prefix}\xff",
            0,
            $limit,
        );

        return $results;
    }
}
## Шаг 5: Indexing Pipeline
<?php

declare(strict_types=1);

final class IndexingPipeline
{
    public function __construct(
        private readonly DocumentSource $source,
        private readonly TextAnalyzer $analyzer,
        private readonly IndexWriter $indexWriter,
        private readonly AutocompleteService $autocomplete,
    ) {}

    /**
     * Process a batch of documents for indexing
     */
    public function processBatch(array $documentIds): IndexingResult
    {
        $indexed = 0;
        $errors = 0;

        foreach ($documentIds as $docId) {
            try {
                $document = $this->source->getDocument($docId);

                // 1. Analyze text
                $analyzed = $this->analyzer->analyze($document->content);

                // 2. Build index entry
                $entry = new IndexEntry(
                    docId: $document->id,
                    tokens: $analyzed->tokens,
                    termFrequencies: $analyzed->termFrequencies,
                    metadata: [
                        'title' => $document->title,
                        'category' => $document->category,
                        'created_at' => $document->createdAt,
                    ],
                );

                // 3. Write to index
                $this->indexWriter->write($entry);

                // 4. Update autocomplete
                $this->autocomplete->index($document->title, 1.0);

                $indexed++;
            } catch (\Throwable $e) {
                $errors++;
            }
        }

        return new IndexingResult($indexed, $errors);
    }
}

final class TextAnalyzer
{
    public function analyze(string $text): AnalyzedText
    {
        // Pipeline: lowercase -> tokenize -> remove stops -> stem
        $text = mb_strtolower($text);
        $tokens = $this->tokenize($text);
        $tokens = $this->removeStopWords($tokens);
        $tokens = array_map(fn (string $t) => $this->stem($t), $tokens);

        return new AnalyzedText(
            tokens: $tokens,
            termFrequencies: array_count_values($tokens),
        );
    }

    private function tokenize(string $text): array
    {
        return preg_split('/[\s\p{P}]+/u', $text, -1, PREG_SPLIT_NO_EMPTY);
    }

    private function removeStopWords(array $tokens): array
    {
        $stops = ['the', 'a', 'is', 'are', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for'];
        return array_values(array_filter($tokens, fn (string $t) => !in_array($t, $stops, true)));
    }

    private function stem(string $word): string
    {
        // Use Snowball stemmer in production
        return $word;
    }
}
## Шаг 6: Шардирование индекса
Стратегия Описание Плюсы Минусы
Document-based Документы распределяются по шардам Простая индексация Scatter-gather для каждого запроса
Term-based Термины распределяются по шардам Эффективный поиск одного термина Сложная индексация, hotspots

Рекомендуется document-based шардирование (как в Elasticsearch).

Возможные вопросы интервьюера

  1. TF-IDF vs BM25?

    • BM25 лучше: saturation для TF, нормализация длины документа
    • BM25 -- стандарт в современных поисковых системах
  2. Как обрабатывать fuzzy search?

    • Edit distance (Levenshtein)
    • N-gram индекс
    • Phonetic encoding (Soundex, Metaphone)
  3. Как масштабировать до миллиарда документов?

    • Шардирование индекса (100+ шардов)
    • Реплики для каждого шарда
    • Scatter-gather pattern для поиска
  4. Как обновлять индекс без downtime?

    • Near-real-time indexing (segment-based, как в Lucene)
    • Dual-write: primary + search index
    • Change Data Capture (CDC) из основной БД
  5. Как реализовать фасетный поиск?

    • Дополнительные inverted indexes для facet полей
    • Document values для агрегаций
    • Bitmap indexes для категориальных данных