Part 1 of 6
Фундамент production-рекомендателя ресторанов
Что мы построим: персонализированный движок ресторанных рекомендаций, с нуля: каталог ресторанов города, LLM-pipeline, превращающий реальные отзывы посетителей в структурированные атрибуты и текстовый портрет каждого заведения, semantic search поверх этих профилей и taste profile каждого пользователя, на котором держится персонализированная лента. А поскольку ничему из этого нельзя верить без измерений — набор eval, который выставляет оценку всей системе целиком.
TLDR: Production AI — это не «отправь вопрос модели», а retrieve, rank, generate: база данных дёшево сужает тысячи кандидатов, и модель рассуждает только над тем, что ей вручили. Эта часть разбирает retrieval-половину с нуля: embedding (списки чисел, кодирующие смысл текста), почему запрос и документ должны кодироваться по-разному — иначе поиск молча возвращает generic-совпадения, — и почему на «без глютена» никогда нельзя отвечать похожестью: настроения достаются vector search, а dealbreakers — SQL-клаузам WHERE. Закрывает часть трюк слияния, который объединяет keyword- и vector-ранжирования, используя одни лишь позиции, и арифметика ёмкости, показывающая, что все vector целого города умещаются примерно в двадцать мегабайт того самого PostgreSQL, который у вас уже работает, — отдельная vector database не нужна.
Спросите LLM «Ну и как там Beaver, у пляжа Коньяалты в Анталье?» — и получите то, чего не даст ни один звёздный рейтинг: «уютное место, публика в основном молодая, хорошо для расслабленного вечера». Не 4.3 и стена непрочитанных мнений — настоящий ответ.
Эта серия — рассказ о том, как построить такую способность по уму: production-система ресторанных рекомендаций на реальной AWS-инфраструктуре, с реальными ограничениями по деньгам и с прицелом на реальных пользователей. Стек: Java/Spring GraphQL API рядом с Python ML-сервисом, PostgreSQL с расширением pgvector для retrieval и Claude для языковой части. Для начала один город: Анталья, несколько тысяч ресторанов.
Part 1 — про фундамент: понятия, которые определяют архитектуру ещё до того, как какая-либо архитектура существует.
🎯 Почему «просто спроси LLM» не переживает production
Наивная версия — отправить запрос пользователя напрямую в LLM — проваливается по четырём пунктам:
Галлюцинации. Попросите порекомендовать рестораны — и части названий из ответа не существует. Это не баг, ожидающий патча; это природа модели, которая генерирует правдоподобный текст, ни с чем его не сверяя.
Свежесть. Модель понятия не имеет, что открылось или закрылось в прошлом месяце.
Память. Она ничего не знает о том, что любит именно этот пользователь.
Контроль затрат. Каждый ответ стоит столько, сколько берёт провайдер, — на каждом запросе, всегда.
Ответ индустрии — RAG, Retrieval-Augmented Generation — и он куда менее экзотичен, чем звучит: держите факты в собственной базе, сначала извлеките (retrieve) релевантные записи и дайте LLM рассуждать только над тем, что ей вручили. LLM перестаёт быть источником истины и становится языковым слоем поверх ваших данных. Она не может выдумать ресторан, который ей никогда не показывали.
Почти каждая production-система с AI сводится к одному и тому же трёхстадийному pipeline:
RETRIEVE — дёшево, средствами базы данных, сузить тысячи кандидатов примерно до 30 RANK — потратить настоящие вычисления, чтобы упорядочить эти 30 под конкретного пользователя GENERATE — написать объяснение для человека («уютный meyhane с видом на море — в духе вашей любви к тихим местным заведениям»)
Держите этот pipeline в голове; каждый раздел ниже встаёт в него на своё место.
Ещё одно рамочное решение. Netflix и Spotify работают на collaborative filtering — «пользователям, похожим на вас, понравилось и это», — выученном на миллионах взаимодействий «пользователь-объект». У нового продукта взаимодействий, на которых можно учиться, нет (проблема cold start), поэтому подход переворачивается: богато описать сами объекты и сопоставлять их с тем, что пользователи называют любимым. Это content-based рекомендации, и именно поэтому система так сильно вкладывается в глубокие профили ресторанов, а не в матрицу взаимодействий «пользователь-пользователь».
🧮 Embedding — объяснение, которого мне самому когда-то не хватило
Embedding — это список чисел, представляющий то, что текст значит. Embedding-модель — нейросеть, обученная на миллиардах пар предложений до тех пор, пока похожие смыслы не начинают давать похожие списки чисел. Такой список называется vector; модель, используемая здесь, выдаёт 1024 числа на текст.
В двух измерениях это проще увидеть:
"romantik deniz manzaralı restoran" → [0.92, 0.15]
"quiet seaside dinner spot" → [0.89, 0.21]
"cheap fast-food burger joint" → [0.11, 0.97]
Первые два указывают почти в одном направлении — хотя один текст турецкий, а второй английский. Смысл переживает перевод; направление кодирует смысл. Похожесть измеряется через cosine similarity: косинус угла между двумя vector. 1.0 — одинаковый смысл, 0 — ничего общего. Посчитайте один раз руками (там умножение и сложение) — и мистика испарится навсегда.
Для турецкого travel-продукта это multilingual-свойство решает всё: турецкий запрос должен находить профили ресторанов, которые могут быть написаны по-английски. Отсюда же важность выбора модели: этой системе нужна embedding-модель, обученная на многих языках, а не оптимизированная только под английский.
❓ «Кодируется по токенам или один vector на всё предложение?»
И то и другое, на разных слоях. Внутри модель режет текст на токены (кусочки слов) и выдаёт vector на каждый токен, а финальный шаг — pooling (обычно простое усреднение) — схлопывает их в один. Три слова на входе или три абзаца: на выходе ровно один vector из 1024 чисел.
Отсюда настоящее проектное ограничение: сожмите 10 000 слов в один vector — и каждая деталь растворится в кашу. Поэтому в мире RAG практикуют chunking (разрезать длинные документы, кодировать по кускам), и поэтому текст, который здесь кодируется на каждый ресторан, ограничен примерно 250 словами — комфортная нагрузка для одного vector.
❓ «Если обе стороны становятся vector, разве запрос — не просто маленький документ?»
Я тоже так думал, и ломается это тонко. Смотрите, что делает embedding-модель с этими тремя текстами, если закодировать их все одинаково:
A) "romantic place" ← the user's query
B) "a nice spot for couples" ← some short, generic sentence
C) [250-word portrait of a quiet, candle- ← the restaurant that actually
lit restaurant above the sea] answers the query
Модель ставит A ближе всего к B. Почему? Потому что embedding измеряет похожесть — а A и B действительно похожи: оба короткие, оба размытые, оба звучат как пожелание. C — текст совершенно другого рода: длинный, конкретный, описательный. Но пользователю нужен именно C. Retrieval нужен не ответ на «какой текст похож на мой запрос», а ответ на «какой текст на мой запрос отвечает». Похожесть и релевантность — разные вопросы, и обычный embedding знает только первый.
В production этот провал невидим: ничего не падает, ничего не пишется в логи. Просто результат №1 становится generic-совпадением вместо правильного ресторана, и правильные ответы тихо выскальзывают из верхушки списка — без единого алерта.
Решение: модели вроде Cohere обучены на миллионах реальных пар (запрос → документ, который на него ответил), так что они выучивают обе роли — но модели нужно сказать, какую роль играет ваш текст. Это флаг input_type: search_document при сохранении контента, search_query при поиске. Думайте об этом как о переводе: режим search_query берёт «romantic place» и по сути спрашивает «как выглядело бы описание этого желания?» — и кладёт vector туда, в пространство описаний, вплотную к портрету C. Тот же текст, другой флаг — на выходе действительно другой vector.
Из «другой флаг = другой vector» напрямую следуют два инженерных правила:
- Никогда не смешивать. Каждый vector в index обязан приходить от одной и той же модели и с одним и тем же флагом — иначе вы сравниваете координаты с двух разных карт.
- Кешировать с учётом флага. Ключ embedding-cache обязан включать флаг (hash(model + input_type + text)), иначе однажды будет отдан vector не из того режима — и вы снова в тихом провале.
В этой кодовой базе input_type — обязательный, валидируемый параметр каждого вызова, потому что флаг, который можно забыть, — это production-инцидент с таймером задержки.
❓ «Хорошо, но что именно вы кодируете? JSON ресторана?»
Этот вопрос решает больше качества retrieval, чем index или модель. У каждого ресторана в системе богатые структурированные данные:
{ "vibe": {"cozy": true, "lively": false},
"crowd": {"age_profile": "young"},
"ambiance": {"noise_level": "quiet", "view": "sea"},
"dietary": {"gluten_free_options": false} }
Соблазн — закодировать этот JSON как есть. Не надо. Embedding-модели обучены на естественном языке — предложениях, абзацах, человеческом тексте. Скормите им JSON — и скобки, кавычки и ключи съедят токены, не неся смысла; хуже того, значения вроде "lively": false или "gluten_free_options": false в пространстве смыслов почти не регистрируются (в какую сторону указывает false?). Vector выходит кашей, и — знакомая тема — ничего не падает с ошибкой. Просто проседает качество.
Работает паттерн «один факт — два представления»:
- JSON остаётся JSON — он питает SQL hard filters, UI и аналитику. Структура — то, что базы данных умеют хорошо.
- Для vector пишется проза. Каждый ресторан получает естественно-языковой портрет примерно на 250 слов — «Тихое место при свечах над морем в Коньяалты. Собирает в основном молодую публику на неспешные ужины; шум на уровне разговора даже по выходным...» — написанный LLM из структурированных атрибутов и подтверждающих их evidence. Кодируется именно этот текст, потому что это тот же род текста, на котором embedding-модель выросла, и тот же язык, каким пользователи пишут в поиске.
Полезная рамка: портрет пишется для embedding-модели как для читателя. Плотный по искомому смыслу (vibe, публика, повод, фирменные блюда), ноль маркетинговой ваты («незабываемое кулинарное путешествие» не заслуживает ни одного стоящего направления в пространстве vector) и ограничен длиной, которую один vector унесёт, не разбавившись.
❓ «Рестораны меняются. Модели меняются. Вы что, каждый раз пересчитываете embedding для всего?»
Vector — значение производное, и производное от двух вещей: текста, из которого он получен, и модели, которая его посчитала. В момент, когда меняется любая из двух, сохранённый vector молча протухает. И «молча» — тема всей этой статьи: протухший vector не кидает ошибку, он просто неправильно ранжирует.
Поэтому каждый vector в системе носит с собой двух маленьких спутников:
Fingerprint источника. Когда генерируется портрет ресторана, мы хешируем материал, из которого он собран, и храним этот hash рядом с vector. На следующем прогоне pipeline хешируем текущий материал и сравниваем. Fingerprint совпал → ничего не менялось → пропускаем, не платим ничего. Отличается → перегенерировать портрет, заново закодировать, перезаписать. Один дешёвый трюк превращает «переработать весь каталог за ночь» в «переработать горстку реально изменившихся» — и делает pipeline безопасным для перезапуска в любой момент, что важнее, чем звучит: батчевые джобы падают, и упавшая джоба, которую можно просто запустить заново, стоит десяти страниц recovery-логики.
Метку модели. Каждый vector записывает, какая embedding-модель его произвела. Вспомните правило из раздела про input_type — vector от разных моделей суть координаты на разных картах и не сравнимы никогда. У этого правила есть следствие в жизненном цикле: в день апгрейда на новую embedding-модель мигрировать «постепенно» нельзя — наполовину мигрировавший index сравнивал бы две карты одновременно. Метка делает миграцию честной: сделать backfill каждого vector новой моделью (с соответствующей меткой), проверить качество на новом наборе, затем переключить retrieval одним движением. Метка же отвечает аудитору через полгода: какая модель произвела vector, стоящий за этим результатом?
Никакой AI-магии — это инвалидация cache, старейшая трудная задача из учебника, в новом костюме. Vector — это cache смысла, а cache нужны ключи.
🗺️ Быстро находить ближайшие vector — HNSW
Retrieval теперь означает: дан vector запроса — найти K сохранённых vector с наименьшей cosine-дистанцией. Nearest-neighbor search.
Сначала честное число: на нашем масштабе — около пяти тысяч ресторанов, это немного — brute force, то есть проверка каждого vector, занимает единицы миллисекунд. На этом масштабе index не нужен. На миллионе vector — был бы нужен, и стандартный инструмент — HNSW (Hierarchical Navigable Small World).
Ментальная модель — сеть автодорог 🛣️: верхний слой графа — немного узлов с дальнобойными связями (междугородние трассы); нижние слои плотнее и локальнее (городские улицы). Поиск входит сверху, жадно прыгает в сторону запроса, спускается на уровень, уточняется — как приближение карты. Несколько сотен сравнений вместо миллионов.
Компромисс — в слове approximate: он может пропустить соседей. Метрика — recall@K: из истинного топ-K ближайших сколько index реально вернул? Index здесь всё равно строится (на этом размере он бесплатен), а recall измеряется относительно точного перебора — потому что измеренное число бьёт цитату из блога в любой технической дискуссии.
🧱 Где vector ломаются: громкая пятница, тихий вторник, целиакия — всегда
Лучший стресс-тест этого дизайна пришёл от друга, одним сообщением в Slack:
«В пятницу вечером это громкий, долгий ужин в meyhane со старыми друзьями. Во вторник — тихий угол, обед в одиночку и книга. А у моей партнёрши целиакия: куда бы мы ни пошли вместе, «без глютена» — не предпочтение, а медицина. Один и тот же я — три совершенно разных стола. Твоя штука поймёт их все?»
Три отдельных урока в одном сообщении.
1. Пятница и вторник — embedding в лучшей форме. Косвенный, настроенческий текст («громкий, долгий ужин в meyhane») чисто ложится в область «живо и допоздна» в пространстве смыслов; «тихий угол и книга» приземляется в область «спокойно и днём». Один человек, два настроения — и ни один единый балл похожести не обслужил бы оба. И не должен: каждый запрос несёт свой контекст и находит свою область. Semantic search, работающий как задуман.
2. Целиакию нельзя обслуживать похожестью. Никогда. Vector search возвращает ближайшие совпадения — а обычная пекарня-кафе может быть на 90% похожа на полностью безглютеновую по всем остальным измерениям. Для гостя с целиакией «близко» — катастрофа: неправильный ответ здесь не плохая рекомендация, а вред. Ограничения вроде «без глютена», «веганское», «открыто сейчас», «в пределах 2 км» — это hard filters: SQL-клаузы WHERE, которые гарантируют, а не предполагают.
Вероятностные инструменты — для настроений. Детерминированные — для dealbreakers. Понимать, что есть что, — половина работы.
3. Точные слова всё ещё важны. Поиск по «künefe» обязан вернуть заведение, в чьём текстовом портрете буквально написано künefe. Embedding размывает конкретику; классический full-text search с турецким стеммингом («manzara / manzaralı / manzarası» матчатся все) попадает в неё точно.
Так что production-retrieval — гибридный: vector search И keyword search бегут бок о бок (hard filters поверх обоих), и вы получаете два отдельно отранжированных списка на один и тот же запрос:
Vector search says: Keyword search says:
1. Beaver (cosine 0.83) 1. Künefeci Ali (ts_rank 12.4)
2. Vanilla (cosine 0.79) 2. Beaver (ts_rank 8.1)
3. Künefeci Ali (cosine 0.71) 3. Seraser (ts_rank 3.2)
Теперь их надо слить в один список — и вот ловушка: эти баллы нельзя сравнивать. Cosine живёт между 0 и 1; баллы keyword-поиска — на какой-то произвольной шкале. Что лучше: cosine 0.83 или ts_rank 12.4? (ts_rank — балл релевантности full-text search в Postgres; его единица произвольна — в этом и проблема.) У вопроса нет ответа — это показания двух разных приборов.
Reciprocal Rank Fusion (RRF) решает это, выбрасывая баллы и используя только позицию каждого результата. Каждый ресторан получает очки от каждого списка: 1 / (60 + его позиция). Сложить, отсортировать по сумме:
Beaver : 1/(60+1) + 1/(60+2) = 0.0164 + 0.0161 = 0.0325 ← winner
Künefeci Ali : 1/(60+3) + 1/(60+1) = 0.0159 + 0.0164 = 0.0323
Vanilla : 1/(60+2) + 0 = 0.0161 (only in one list)
Beaver выигрывает, потому что стоял высоко в обоих списках — ровно то поведение, которое и нужно: согласие двух независимых сигналов бьёт превосходство в одном. Константа 60 — просто демпфер из оригинальной статьи (не даёт разрыву между №1 и №3 стать чрезмерно драматичным); её никто не тюнит. Позиции сравнимы между любыми двумя списками, баллы — никогда: в этом весь трюк, и это три строки SQL.
💾 Vector database, которая не понадобилась
Специализированные vector databases — теперь отдельная продуктовая категория: Pinecone, Qdrant, Weaviate, Milvus — плюс managed-варианты вроде AWS OpenSearch. Что это? Базы, построенные специально под то, чтобы хранить vector и отвечать на «найди ближайшие N» чрезвычайно быстро и на чрезвычайном масштабе.
Ключевое слово — масштаб. Свою сложность они отрабатывают, когда у вас десятки миллионов vector, тысячи запросов в секунду или строгая изоляция тенантов. Прежде чем заводить такую, посчитайте ёмкость для своей реальной нагрузки:
около 5 000 ресторанов × 1024 измерения × 4 байта ≈ 20 МБ
Двадцать мегабайт. Меньше, чем картинки, приложенные к этому посту. Тем временем managed-вариант AWS (OpenSearch Serverless) тарифицируется compute-юнитами с месячным полом в сотни долларов — за мощность, от которой эта нагрузка использовала бы доли процента. (В 2026-м AWS всё же выкатил тариф со scale-to-zero, но пользовательский поисковый сервис никогда не простаивает — постоянный трафик держит compute-юниты тёплыми, и счёт возвращается ровно туда, откуда начинал.)
Альтернатива: pgvector, расширение для PostgreSQL — базы, которая уже работает, уже бэкапится, уже оплачена. Один CREATE EXTENSION vector; — и Postgres понимает vector, cosine-дистанцию и HNSW indexes.
И недооценённый выигрыш — не в деньгах. А в том, что один SQL-запрос теперь делает всё — hard filters, гео-радиус, турецкий full-text, vector similarity — в одном месте, в одной транзакции. Никакого второго хранилища, никакого sync-pipeline, никакого дрейфа между «настоящими данными» и «поисковыми данными».
Урок обобщается: делайте арифметику ёмкости до выбора инфраструктуры. Десять минут умножения сэкономили сервис, pipeline и четырёхзначный годовой счёт.
⏭️ Дальше
Фундамент заложен: форма retrieval-слоя и логика за ней. Part 2 — про архитектуру: почему ML-код живёт в Python-сервисе рядом с Java-платформой, как они делят одну базу, не наступая друг другу на ноги, — и про ту часть, которую вам никто не выдаст готовой: названия, локации и часы работы — лёгкая половина ресторанного каталога. Ценная половина — "vibe": "cozy, mostly young crowd" — не существует нигде как данные. Этот слой атрибутов мы производим сами: LLM-pipeline, требование evidence для каждого утверждения и жёсткий потолок бюджета, чтобы pipeline не смог удивить ничью кредитку. Об этом следующий пост.
Бэкенд-инженерам, присматривающимся к этой области: здесь базы данных, indexes, компромиссы и арифметика затрат — работа, которую вы уже делаете, — плюс один действительно новый примитив, который стоит выучить как следует.
Vector — это новая колонка.