Суть Читай реальный код сборки индекса, конвейер анализатора, набросок скоринга BM25 и запрос Postgres tsvector, затем предскажи поведение и выбери самый рычажный фикс.
Высота — путь к senior
НольJuniorMiddleSenior
Ты на senior-высоте — в орбите
◷ 14 min
Баги поиска живут в разрыве между тем, какими ты считаешь токены, и тем, что движок на самом деле сохранил. Читай каждый сниппет, предскажи токены или score и выбери фикс, к которому senior потянется первым.
Цель
Отработай цикл, который ты прогоняешь в каждом инциденте поиска: проведи текст через анализатор, представь posting lists, которые он произведёт, прикинь, как BM25 упорядочит кандидатов, и прочитай запрос Postgres tsvector достаточно хорошо, чтобы понять, может ли он вообще использовать индекс.
Сниппет 1 — сборка inverted index
index = {} # term -> sorted list of doc idsdef add(doc_id, text): for tok in analyze(text): # tokenize + lowercase + stem index.setdefault(tok, []) index[tok].append(doc_id) # append, no dedup, no sortdef search(q): lists = [index.get(t, []) for t in analyze(q)] return set.intersection(*map(set, lists)) # AND of terms
Викторина
Completed
Документ, повторяющий терм (например 'run run run'), добавлен один раз. Что не так с получившимся posting list и каков самый рычажный фикс?
Heads-up Каст в set на чтении прячет дубли для матчинга, но хранимый список всё ещё раздут, а информация о term frequency искажена — а именно она нужна настоящему ранкеру. Чини структуру, а не только путь чтения.
Heads-up Lowercase корректен и намеренен; именно он позволяет 'Run' и 'run' совпадать. Дефект — это append дублей id без счётчика term-frequency.
Heads-up AND vs OR — продуктовое решение о строгости матча, а не баг. Реальный дефект — конструкция posting list, хранящая дубли и теряющая счётчик tf.
Сниппет 2 — анализатор при индексировании vs при запросе
# index timedef index_analyzer(text): return [stem(t) for t in lower(tokenize(text)) if t not in STOPWORDS]# query time (a different service, written later)def query_analyzer(text): return [t for t in lower(tokenize(text))] # no stopword drop, no stem
Викторина
Completed
Документы проиндексированы через index_analyzer; запросы идут через query_analyzer. Пользователь ищет 'running shoes'. Что произойдёт и каков фикс?
Heads-up Общих tokenize/lowercase недостаточно — индекс делал stemming и убирал stopwords, а запрос не делал ни того, ни другого, поэтому уцелевшие токены различаются и никогда не совпадают. Обе стадии должны быть идентичны, а не просто пересекаться.
Heads-up Сохранение stopwords здесь не приводит к over-match; реальный исход — under-match (ноль результатов), потому что контентные токены были stemmed при индексировании и не были при запросе, поэтому они не сходятся.
Heads-up b нормализует длину документа для скоринга совпавших документов; он не может спасти mismatch токенизации, который вообще не даёт совпадения. Фикс — паритет анализаторов, а не ручка скоринга.
Сниппет 3 — набросок term frequency для BM25
def bm25_tf(tf, doc_len, avg_len, k1=1.2, b=0.75): # saturating term frequency with length normalization denom = tf + k1 * (1 - b + b * doc_len / avg_len) return tf * (k1 + 1) / denom
Викторина
Completed
Спам-документ повторяет терм 200 раз; чистый документ имеет его 3 раза при той же длине. Читая эту формулу, какое утверждение верно?
Heads-up tf и в числителе, и в знаменателе, поэтому отношение насыщается к k1+1, а не растёт линейно. Это нелинейное насыщение — весь смысл BM25.
Heads-up b=0 полностью убирает нормализацию длины, поэтому длина документа перестаёт влиять на score — он ни бустит, ни штрафует длинные документы. b=1 применяет полную нормализацию длины.
Heads-up k1 управляет тем, как быстро насыщается term frequency; b управляет нормализацией длины. Две ручки различны, и их смешение — классическая ошибка тюнинга.
Сниппет 4 — полнотекстовый запрос Postgres
-- column: body text; index: CREATE INDEX ON docs USING gin(to_tsvector('english', body));SELECT id, ts_rank(to_tsvector('english', body), q) AS rankFROM docs, plainto_tsquery('english', 'running shoes') qWHERE to_tsvector('english', body) @@ qORDER BY rank DESC LIMIT 10;
Викторина
Completed
Запрос корректен, но делает sequential scan по большой таблице, хотя GIN-индекс существует. Почему и каков фикс?
Heads-up GIN полностью поддерживает оператор матча @@ и быстрее на read-heavy тексте. Проблема в том, что индекс здесь не сматчен/не использован, а не в несовместимости оператора.
Heads-up ts_rank выполняется только на совпавших строках при упорядочивании; он не диктует путь доступа. Скан идёт от того, что выражение WHERE не совпадает с хранимым индексированным tsvector.
Heads-up plainto_tsquery — валидный способ построить tsquery и не отключает индекс. Пересчёт to_tsvector на строку в WHERE — вот что мешает планировщику эффективно использовать GIN-индекс.
Итог
Каждый баг поиска читается обратно к токенам и путям доступа: posting list должен дедупить doc id и нести счётчик term-frequency, а не append вслепую; анализаторы при индексировании и при запросе должны быть побайтово одинаковы, иначе совпадения молча исчезают; tf в BM25 насыщается к k1+1, а b нормализует длину, поэтому спам не может доминировать; а запрос Postgres FTS использует свой GIN-индекс, только когда выражение запроса совпадает с индексированным — храни generated tsvector-колонку, а не пересчитывай на строку. Проследи токены, представь posting lists, затем чини структуру до того, как трогать ручку скоринга.