Куда уходили тридцать секунд
Ассистент отвечал 30–40 секунд. Виновата была не языковая модель, а помогли четыре старые идеи: сначала измерь, фильтруй раньше, жди параллельно и напрягайся только на трудных вопросах.
Чат-ассистент отвечал медленно — тридцать-сорок секунд на вопрос. Так нельзя: пользователь успевает заскучать. Мы сели разбираться. История вышла поучительной: проблема была не там, где мы её искали, а все решения оказались старыми идеями в новой обёртке.
Сначала измерь
Первое правило оптимизации: не оптимизируй вслепую. Каждый ход в чате мы помечаем трассировочным идентификатором с той секунды, как он уходит с телефона. В системе трассировки (Langfuse) ход виден как единая шкала времени, а этапы внутри разложены по одному.
Первый сюрприз: дело было не в генерации текста. Модель выдавала ответ примерно за четыре секунды. Остальные тридцать с лишним уходили до того, как модель заговорит, — на извлечение, смысловой поиск нужных фрагментов лекций. Прежде чем ответить, система находит отрывки по теме вопроса. Один этот поиск занимал двенадцать-шестнадцать секунд, иногда больше.
Значит, сама модель обходилась дёшево. Дорого стоило всё, что готовило для неё данные. В этом развороте — вся статья.
Идея 1 — фильтруй до поиска, а не после
Мы думали, что тормозит сам векторный поиск. Нет: индекс отдавал ближайших соседей за десятки миллисекунд. Время уходило на то, как мы сужали выдачу.
У каждого отрывка в корпусе есть тип и язык: транскрипт лекции, стих, комментарий; английский, русский. Вопросу нужен только срез из всего этого: русскому вопросу о стихе нужны русские стихи. Беда была в порядке действий. Поиск сначала искал ближайших по смыслу соседей по всему корпусу и лишь потом выбрасывал лишний тип и язык. А когда нужные отрывки — меньшинство корпуса, это дорого: чтобы оставить пару десятков подходящих, поиск перебирал сотни ненужных кандидатов. В продакшене этот перебор доходил до пяти, десяти, двенадцати секунд. Он и замедлял ход сильнее всего.
Решение — старый приём из баз данных: не фильтруй после работы, фильтруй до неё. Вместо одного большого индекса по всему корпусу мы строим отдельный частичный индекс под каждый срез, который может понадобиться запросу, — этот тип, этот язык. Поиск начинается внутри нужного подмножества и не заглядывает туда, что всё равно пришлось бы выбросить. (Документация pgvector прямо называет эту ловушку и советует частичные индексы.)
-- a dedicated index per (type, language) slice, so the filter
-- is the index itself rather than a step after it
CREATE INDEX … ON … USING hnsw (embedding vector_cosine_ops)
WHERE kind = 'track_transcript' AND lang = 'en';
Тот же поиск в худшем случае упал с пары секунд примерно до 84 миллисекунд.
Ещё две неприметные настройки значили не меньше. Первое: держи индекс в оперативной памяти. На настройках по умолчанию Postgres сбрасывал индекс на диск, и обычный поиск гулял от 300 мс до целой секунды. Мы выделили базе столько памяти, чтобы индекс целиком помещался в кэш страниц, — и время упало до 30–150 мс. Второе: у фильтрованного векторного поиска есть известная ловушка. С фильтром он может вернуть ноль строк, если не сказать движку сканировать дальше, а пул кандидатов по умолчанию слишком мал, когда фильтр срезает верхние попадания. Две однострочные настройки на запрос починили и то и другое. Ничего хитрого — просто такое находишь, только когда начнёшь измерять.
В поиске по лекциям от начала до конца вышло 5,8 с → 0,5 с — почти в десять раз быстрее.
Идея 2 — жди параллельно
Ход в чате — это не один запрос. Планировщик разбивает вопрос на несколько подвопросов, и каждый ищет сразу по нескольким дорожкам: транскрипты лекций, стихи, остальная библиотека. Запустишь их по очереди — получится длинная цепочка обращений встык.
Поэтому мы перестали ждать по очереди. Все дорожки всех подвопросов стартуют
одновременно, и ход ждёт только самую медленную из них — max(), а не sum().
Ту же идею мы двигаем ещё раньше по конвейеру и работаем на опережение: как
только приходит вопрос, мы сразу строим для него эмбеддинг и вытаскиваем темы — ещё
до того, как узнаем, понадобятся ли они. Так секунда-другая задержки прячется за
работой, которую всё равно предстояло сделать, а на дешёвом пути результат просто
выбрасываем. Один опережающий эмбеддинг срезает 150–300 мс с каждого хода.
Агрессивный параллелизм безопасен благодаря одному правилу: дедлайн на каждый внешний вызов. Медленный эмбеддер или зависшая дорожка не держат весь ход в заложниках. Когда этап выходит за свой бюджет, конвейер идёт дальше с теми частичными результатами, что есть, а не замирает. Чуть более скупой ответ лучше крутящегося спиннера. (Одно мы намеренно не прикрываем — недоступность провайдера модели. Сгладить это значило бы выдать уверенный на вид ответ, за которым ничего нет, а это хуже честной ошибки.)
Идея 3 — напрягайся только на трудных вопросах
Главное открытие было не про то, как ускорить дорогой поиск. А про то, что большинству вопросов он вообще не нужен.
Это приём Corrective-RAG: сначала оцени то, что уже есть, а потом иди за новым. На очень многие вопросы у нас уже есть проверенный, готовый ответ — или они почти совпадают с тем, на что мы отвечали раньше. Гонять ради них полный широкий проход — разбить на темы, искать по каждой дорожке в несколько раундов, собрать сотню с лишним отрывков, всё переранжировать — пустая трата. Поэтому конвейер теперь выносит дешёвое суждение заранее, по тому, что уже под рукой, и без лишнего вызова модели: есть ли уже сильный ответ? Если да, идёт по короткому пути — взять готовый материал, сделать небольшой ограниченный поиск, готово. За полный проход в ~20 секунд платят только по-настоящему открытые вопросы.
Тот же приём «закончил — остановись» работает и внутри дорогого пути. После каждого раунда поиска мы проверяем, достаточно ли хороши результаты, и, если да, выходим раньше. Второй раунд — это ещё один вызов планировщика и ещё один веер запросов, около девяти секунд реального времени, и он почти никогда не улучшает ответ, где уже есть уверенное попадание. Выходим мы и в другую сторону: если первый раунд не дал ничего по теме, второй — лишь бросок костей за те же деньги, так что мы его не делаем.
Идея 4 — по умолчанию дёшево, дорого только там, где это видно
Конвейер извлечения — это не один вызов модели, а целая стопка: планирование, извлечение тем, итоговый ответ. Почти все они — небольшие шаги со структурированными данными, где быстрая дешёвая модель отлично справляется. Сильная модель нужна только на итоговом синтезе. Поэтому мы раскладываем шаги по уровням, и главная статья расходов отпадает. Уровни мы подбирали по опыту, а не по догме: один шаг в середине конвейера ранжирует пару десятков отрывков-кандидатов и на самой дешёвой модели упорно выдумывал битые ссылки — его пришлось поднять обратно. Экономишь везде, а за качество платишь только там, где на это указывает измерение.
То же с кэшем: горстка типовых вопросов и формулировок повторяется у разных пользователей, поэтому мы кэшируем их эмбеддинги и извлечённые темы — но намеренно не кэшируем то, что повторяется редко. Кэш выигрывает только там, где переиспользование реально.
Что не сработало
- Более новая, «быстрая» модель планировщика. Самая свежая оказалась в три- четыре раза медленнее. Откатили.
- Троттлинг эмбеддингов. Мы подозревали, что при поиске упираемся в лимиты запросов у провайдера. Трассировки не показали ни одного повтора на пути запроса: троттлинг был, но только при массовой индексации, а не при живом ответе. Ложный след стоил нам целого дня.
И то и другое — тот же урок, что и первый: трассировка говорила правду, а догадка — нет.
Где мы сейчас
От начала до конца — 35–50 с → ~20–25 с, и самый тяжёлый этап, извлечение, теперь почти мгновенный. Дальше рычаги тоньше: более умная адаптивная логика, чтобы дорогой путь включался ещё реже, и перенос базы данных на отдельный сервер, чтобы тяжёлый ход не мешал всему, что делит с ним железо.
Часть проекта
Слушай Садху