Эксперт попросил бота, который отвечает его голосом и только по его собственным материалам: курсам в PDF, расшифровкам видео и записям занятий. Если ответа в материалах нет, бот должен честно отправить человека к живому эксперту и ничего не придумывать. Я собрал такого бота на Python и Telegram, рассчитывая повозиться с промптами, но на первых же живых вопросах выяснилось, что бот просто не находит нужный материал, и почти всё время ушло на поиск.
Расскажу, как устроен поиск в этом боте и как я построил стенд для замера (метрики recall@6 и MRR). Ещё будет про гибридный поиск, который все советуют и который у меня проиграл чистому векторному, и про то, что пришлось поменять, когда я упёрся в лимиты провайдера эмбеддингов. Заказчик согласился на публикацию только без имён, поэтому эксперт, его материалы и тема здесь не названы, а примеры вопросов в статье придуманы мной и к его текстам отношения не имеют. Код ниже взят из рабочих файлов, лишнее я убрал.
Что за бот и как он отвечает
Стек простой: Python 3.12, aiogram 3 в режиме long-polling (бот сам ходит за обновлениями в Telegram, входящие порты не нужны), модель claude-sonnet-5-5 через официальный SDK, SQLite для лога пользователей. Бот крутится в Docker на небольшом VPS, где живут и другие мои боты, поэтому лишняя память и новые сервисы для меня были проблемой.
Корпус материалов лежит в репозитории markdown-файлами. При старте бот режет их на «страницы» примерно по 4500 символов по границам абзацев (константа TARGET_PAGE_CHARS) и строит из них индекс для полнотекстового поиска SQLite FTS5. На момент последнего замера в индексе было 53 материала и 630 страниц.
Дальше работает обычный tool-use цикл. Всю базу модель не видит, у неё есть только два инструмента, search_materials(query, k) и read_page(page_id). Она сама формулирует запрос, получает список страниц, читает те, что выглядят подходящими, и отвечает строго по прочитанному. Системный промпт (персона плюс карта материалов) лежит в кэше Anthropic через cache_control.
Положить всё в контекст я даже не пробовал: после чисток корпус занимает около 2,25 млн знаков, это порядка миллиона токенов, и даже влезь он в окно, платить за него в каждом ответе я не хотел.
Отдельно про заземление. Одних правил в промпте оказалось мало: на уточняющий вопрос («то есть это нормально?») модель однажды ответила из истории диалога, вообще не обращаясь к поиску, и потеряла оговорки эксперта. Поэтому в коде стоит стопор: если модель собралась ответить, ни разу не вызвав search_materials, код выбрасывает ответ и отправляет ход заново с системным сообщением «сначала поищи в материалах». Принудительный tool_choice Sonnet 5.5 для этого отвергает с ошибкой 400, так что стопор только подталкивает модель и ничего не гарантирует. После введения стопора на семи тестовых парах «вопрос → уточнение» число ответов без поиска упало с одного до нуля.
Всё остальное в статье про то, как инструмент search_materials находит страницы.
Первая версия поиска и почему она не работала
Сначала я сделал FTS5. Запрос разбивается на слова, стоп-слова выкидываются, остальное склеивается через OR, результаты ранжирует bm25. На мини-тесте из восьми вопросов всё работало: 8 из 8. Потом пришла жалоба: пользователь спросил что-то вроде «я смотрю материалы, но в голове ничего не откладывается, что делать?», а нужная страница с ответом не нашлась вообще. Страница называла процесс через слова «получил, понял, принял, применил», а в вопросе таких слов не было. FTS5 ищет по словам, и разговорный вопрос с авторской терминологией у него не стыкуется. Сменой модели это не лечилось: я проверял и Sonnet, и Opus, оба материал не доставали, потому что искать им нечем.
Тот первый мини-тест, как я потом понял, был нечестным: вопросы в нём были сформулированы близко к названиям материалов, примерно как «Почему вреден такой-то продукт?» к файлу с таким же названием. Живые люди так не пишут.
Стенд: как я мерил качество
Прежде чем что-то менять, я сделал стенд, потому что без цифр я бы не отличил улучшение поиска от собственного впечатления. Стенд состоит из файла с кейсами и скрипта, который прогоняет их через выбранный режим поиска.
Кейс это вопрос, список материалов, где лежит ответ, и тип:
lexical: в вопросе есть слова из нужного материала, FTS5 должен справляться, это страховка от регресса;semantic: ответ в материале есть, но написан другими словами, это то самое слепое пятно;negative: ответа в корпусе нет, и правильное поведение бота это отправить к эксперту. Такие кейсы не считаются в метриках, их я только читаю глазами.
Всего было 24 кейса: 8 lexical, 12 semantic и 4 negative. Главное правило набора: все вопросы я написал до того, как смотрел в корпус, разговорным языком, с опечатками и просторечием, и только потом искал, где лежит ответ. Ни один вопрос я не переписывал под то, что нашёл поиск. Структура кейсов выглядит так (вопросы в примере выдуманные, настоящие не привожу):
CASES = [ # (вопрос, [материалы, где лежит ответ], тип) ("почему я постоянно откладываю дела на потом?", ["material-a"], "semantic"), ("не могу нормально заснуть, это вообще лечится как-то?", ["material-b"], "lexical"), ("как выбрать ноутбук для монтажа?", [], "negative"), # в корпусе такого нет ]
Метрик две. Recall@k: попал ли нужный материал хотя бы куда-то в первые k результатов. MRR (mean reciprocal rank): среднее значение 1/позиция первого верного результата, то есть 1,0 означает, что нужный материал всегда стоит первым. Recall отвечает на вопрос «увидит ли модель нужную страницу вообще», а MRR на вопрос «как высоко она стоит». Попадание я засчитываю по материалу, конкретная страница не важна: модель всё равно может дочитать нужную страницу через read_page, если видит материал.
Ядро замера выглядит так:
rank = next((i for i, s in enumerate(slugs, start=1) if s in wanted), None) if rank: stats["hits"] += 1 stats["rr"] += 1.0 / rank
Первый вывод от стенда оказался неприятным. Базовая линия FTS5 при k=6:
тип | recall@6 | MRR |
|---|---|---|
lexical (8) | 38% | 0,18 |
semantic (12) | 17% | 0,04 |
То есть даже когда слова вопроса в материале есть, нужный документ находился чуть больше чем в трети случаев. Режим --verbose показал три причины. Нет стемминга: «клиентов» не находит «клиенты». Все слова склеены через OR, поэтому длинные материалы (две записи практикумов были огромными) забивают топ почти на каждый вопрос просто объёмом. И разговорный мусор вроде «ваще» попадает в запрос наравне с темой.
Векторный поиск без векторной базы
Идея понятная: превратить каждую страницу и каждый вопрос в вектор (список из тысячи с лишним чисел, который отражает смысл текста) и искать страницы, векторы которых ближе всего к вектору вопроса. Близость считается как косинус угла между векторами. Если векторы заранее нормировать, косинус превращается в обычное скалярное произведение.
Векторная база мне не понадобилась. У меня 1050 страниц по 1024 числа, это массив на 4 МБ, и скалярное произведение вопроса со всей матрицей занимает около миллисекунды. Поэтому индекс это один массив numpy в памяти:
class _VectorIndex: """1050 units x 1024 dims = 4 MB, so a brute-force dot product over the whole matrix takes ~1 ms: no vector database needed.""" def __init__(self, page_ids: np.ndarray, vectors: np.ndarray) -> None: self.page_ids = page_ids self.vectors = vectors def search(self, query: str, k: int) -> list[tuple[str, float]]: q = embed_texts([query], "query") if q.size == 0: return [] scores = self.vectors @ q[0] order = np.argsort(-scores) best: dict[str, float] = {} for i in order: pid = str(self.page_ids[i]) if pid not in best: # best-first order: the first hit IS the best best[pid] = float(scores[i]) return _diversify(list(best.items()), k)
Эмбеддинги считает внешний API. Сначала я взял voyage-4: проверил, что он принимает до 32K токенов на вход (страница около 1800 токенов влезает целиком), а цена на тот момент была 0,06 доллара за миллион токенов. Локальную модель я отверг: она добавляла бы 1,5-3,5 ГБ к образу и столько же памяти на сервере с 4 ГБ, где живут другие боты. Ради задачи, которая через API почти ничего не стоит, рисковать соседями не хотелось. SDK провайдера я тоже не ставил: он тянул кучу зависимостей ради одного запроса, и я написал прямой HTTP-вызов через httpx.
Векторы считаются один раз и кладутся в кэш на диске, который лежит на томе Docker и переживает пересборку образа. Ключ кэша это хеш содержимого страницы плюс имя модели:
def _unit_hash(text: str) -> str: h = hashlib.sha256() h.update(MODEL.encode()) h.update(b"\x00document\x00") h.update(text.encode()) return h.hexdigest()
Так рестарт контейнера ничего не стоит: при старте код сопоставляет страницы с кэшем по хешу и грузит готовые векторы, а при добавлении материала досчитывает только изменившееся. Если же сменить модель, весь кэш честно инвалидируется, потому что векторы из разных моделей лежат в разных пространствах и сравнивать их друг с другом бессмысленно. Позже я сам на этом споткнулся, расскажу ниже.
Поиск по смыслу я с самого начала сделал необязательным. Если нет ключа API или файла с векторами либо API лежит, каждая точка входа возвращает пустой результат без исключения, и бот продолжает работать на FTS5. Он живёт 24/7 без присмотра, так что падать ему нельзя.
def search(query: str, k: int) -> list[tuple[str, float]]: """Up to k (page_id, similarity) pairs, best first. Returns [] whenever semantic search is unavailable, never raises, so hybrid search can always fall back to FTS5 alone.""" index = get_index() if index is None or not query.strip(): return [] try: return index.search(query, k) except Exception as e: # API down, key revoked, rate limit exhausted print(f"[embeddings] query failed ({e}), keyword-only for this call", flush=True) return []
Отказоустойчивость я проверял на стенде отдельно: с пустым ключом цифры совпали с базовой линией FTS5 до знака.
Гибрид, который оказался хуже
Дальше я сделал то, что рекомендует большинство статей: гибрид. Результаты двух каналов (слова и смысл) сливаются формулой RRF (reciprocal rank fusion): у каждой страницы берётся 1/(60 + позиция) из каждого списка, баллы складываются, и страница, высоко стоящая хотя бы в одном канале, поднимается в общем. Идея в том, что точные совпадения слов и совпадения по смыслу дополняют друг друга. Я ожидал, что гибрид будет не хуже вектора, а на редких словах лучше.
Стенд показал обратное. Результаты на тех же 24 кейсах при k=6:
режим | lexical recall | lexical MRR | semantic recall | semantic MRR |
|---|---|---|---|---|
fts (база) | 38% | 0,18 | 17% | 0,04 |
vector | 100% | 0,84 | 42% | 0,29 |
hybrid (RRF) | 100% | 0,73 | 25% | 0,10 |
На смысловых вопросах гибрид хуже чистого вектора: 25% против 42%, а MRR 0,10 против 0,29. Причина видна из базовой линии. Словесный канал настолько шумный (длинные материалы забивают топ на любой запрос), что RRF подмешивает этот мусор в общий список и вытесняет верные векторные попадания.
Я попробовал это починить настройками. Вес словесного канала в RRF 1,0 / 0,5 / 0,3 / 0,15 / 0,05 дал MRR на смысловых вопросах 0,10 / 0,16 / 0,16 / 0,20 / 0,20, что всё равно хуже 0,29 у чистого вектора. Глубина пула кандидатов (10, 20, 40) на результат не влияла. Простое дописывание слов в хвост без смешивания давало либо ноль, либо падение recall. Пришлось признать, что на этом корпусе словесный канал в ранжировании только мешает, хотя я ждал другого.
В проде я оставил такую схему: векторная выдача идёт как есть, а в конец к ней дописываются до трёх словесных результатов, которых там не было. На стенде эта добавка не меняет ни recall, ни MRR, зато закрывает случай, которого в наборе нет: редкое точное слово (имя, название), где косинус слабый, а совпадение слов и есть весь ответ.
# The semantic channel ranks; the keyword channel only adds a short tail of # pages the vectors didn't return. Measured on the bench: appending up to 3 # keyword results changes neither recall nor MRR, while mixing them into the # ranking (RRF, any weight from 1.0 down to 0.05) always scored worse than # semantic alone. _FTS_TAIL = 3 def hybrid_search(query: str, k: int = 12) -> list[dict]: vec_hits = embeddings.search(query, k) if not vec_hits: # no key / no cache / API down: old behaviour return [ {"source": h["source"], "page_id": h["page_id"], "snippet": h["snippet"], "channels": "keyword"} for h in search_materials(query, k=k) ] results: list[dict] = [] seen: set[str] = set() for pid, _similarity in vec_hits: row = _page_row(pid) if row is None: # stale vector cache: skip rather than crash continue results.append({"source": row[0], "page_id": pid, "snippet": _head_snippet(row[1]), "channels": "meaning"}) seen.add(pid) for hit in search_materials(query, k=_FTS_TAIL * 3): if len(results) >= k + _FTS_TAIL: break if hit["page_id"] in seen: continue results.append({"source": hit["source"], "page_id": hit["page_id"], "snippet": hit["snippet"], "channels": "keyword"}) seen.add(hit["page_id"]) return results
Каждый результат помечен каналом (meaning или keyword), и эта пометка уходит модели в ответе инструмента вместе с подсказкой: для страниц «по смыслу» сниппет это просто первая строка страницы, поэтому прежде чем судить о ней, страницу надо прочитать. Без подсказки модель верит сниппету, хотя настоящего сниппета у смыслового совпадения нет.
Почему k=12, а не 6
Ещё одна находка стенда касается самого k. Для вектора кривая recall на смысловых вопросах выглядит так: при k=6 это 42%, при k=8 уже 67%, при k=12 это 75%, а дальше рост прекращается. Шести результатов явно мало, так что значение по умолчанию я поднял до 12. Для модели это не проблема, потому что в ответе инструмента она видит лишь короткие сниппеты, а читает только то, что выберет.
Итог на стенде для боевого кода (k=12):
lexical | semantic | |
|---|---|---|
было: слова, k=6 (прод до этого) | 38% / 0,18 | 17% / 0,04 |
слова при том же k=12 | 62% / 0,21 | 42% / 0,06 |
стало: смысл + хвост из слов | 100% / 0,84 | 75% / 0,33 |
Строку «слова при k=12» я добавил, потому что часть улучшения даёт само увеличение k, и сравнивать честнее со второй строкой. Смысловой поиск и при равном k выигрывает с большим запасом.
Порог уверенности, который не получился
Хотелось ещё одной защиты: если сходство страницы с вопросом ниже порога, ничего не выдавать и отправлять к эксперту. Я посмотрел на распределение. Косинус на удачных попаданиях лежит в диапазоне 0,458-0,700 (медиана 0,568), а на вопросах без ответа в корпусе в диапазоне 0,471-0,582 (медиана 0,505). Диапазоны перекрываются, любой порог резал бы живые попадания. Поэтому защита от выдумки осталась на промпте и на правиле в ответе инструмента («если ни одна страница не отвечает на вопрос, считай тему не покрытой и направь к эксперту»). Регресса относительно FTS нет: на вопросах без ответа тот тоже возвращал мусор.
Лимиты провайдера: 3 запроса в минуту
Дальше схема, которая отлично работала на стенде, развалилась на проде.
Бесплатный тариф voyage-4 без привязанной карты даёт 3 запроса в минуту и 10 тысяч токенов в минуту. Первая индексация упала на 429. При таком лимите индексация корпуса идёт около 3,5 часов: у меня индекс из 1050 векторов досчитывался в режиме --slow (пачки примерно по 9 тысяч токенов раз в 60 секунд) 3 часа 57 минут. Кэш при этом сохранялся каждые несколько пачек атомарно, через временный файл и переименование, чтобы прерванная сборка не теряла уже оплаченное. Там же меня поймал мелкий баг: np.savez_compressed дописывает .npz к пути, у которого нет такого расширения, и атомарная замена файла падала. Лечится записью через файловый объект:
tmp = CACHE_PATH.with_name(CACHE_PATH.name + ".tmp") # np.savez_compressed appends ".npz" to a PATH that lacks it, which would # leave the temp file elsewhere and break the rename below. with tmp.open("wb") as fh: np.savez_compressed(fh, model=np.array(MODEL), hashes=np.array(keys), vectors=matrix) tmp.replace(CACHE_PATH) # atomic: a crash mid-write can't corrupt the cache
Ещё я поставил в код предохранитель расхода. У провайдера нет жёсткого лимита трат, «бюджет» у них только присылает письмо, поэтому потолок живёт в коде: одна сборка отказывается отправлять больше MAX_TOKENS_PER_BUILD (5 млн токенов) без явного флага --yes. Корпус целиком около 1,9 млн токенов, нормальная работа проходит, а если потеряется кэш или сломается ключ хеша, сборка просто откажется работать и счёт не придёт.
С индексацией справились, а вот живому боту этот лимит мешал сильнее, чем я думал. Один вопрос пользователя это 2-4 раунда поиска, и каждый раунд делает запрос эмбеддинга для нового запроса модели. Раздели 3 запроса в минуту между несколькими пользователями, и последний раунд регулярно падал в 429, смысловой канал молча деградировал до слов прямо посреди ответа, а каждая упавшая попытка добавляла около 10 секунд ретраев. Потом пришла жалоба «бот опять отвечает не то и долго думает», и два симптома оказались одной причиной. Замер на тяжёлом вопросе: 77 секунд, и только 3 поиска из 11 прошли по смыслу.
Я перевёл эмбеддинги на другого провайдера: qwen/qwen3-embedding-8b через OpenRouter. Выбор провайдера зависит от ключа в окружении, а оба варианта описаны в одном словаре, так что переключение не требует правки кода:
_PROVIDERS = { "openrouter": { "url": "<адрес эмбеддингов OpenRouter>", "model": "qwen/qwen3-embedding-8b", "usd_per_mtok": 0.01, "input_type": False, # OpenAI-shaped API, no input_type field }, "voyage": { "url": "<адрес эмбеддингов Voyage>", "model": "voyage-4", "usd_per_mtok": 0.06, "input_type": True, }, } PROVIDER = "openrouter" if settings.openrouter_api_key else "voyage"
У новой модели обнаружились две особенности, которые я бы не нашёл без стенда.
Первая: Qwen3-Embedding обучена с инструкцией на стороне запроса, а документы идут без префикса. Я добавил инструкцию в начало каждого запроса:
_QUERY_INSTRUCTION = ( "Instruct: Дан вопрос ученика. Найди фрагменты обучающих материалов " "преподавателя, которые на него отвечают\nQuery:" if MODEL.startswith("qwen/qwen3-embedding") else "" ) def _payload(batch: list[str], input_type: str) -> dict: if _SEND_INPUT_TYPE: return {"input": batch, "model": MODEL, "input_type": input_type} if input_type == "query" and _QUERY_INSTRUCTION: batch = [_QUERY_INSTRUCTION + t for t in batch] return {"input": batch, "model": MODEL}
Без инструкции recall на прямых вопросах падал со 100% до 88%. Кэш векторов страниц от этого не меняется (документы без префикса), а вот кэш запросов в стенде обязан учитывать инструкцию: код-ревью обнаружило, что мой ключ кэша стенда её не учитывал, и после правки инструкции стенд молча показывал бы цифры для конфигурации, которой уже нет.
Вторая особенность связана с тем, что длинные материалы выигрывают у коротких числом страниц. В продакшене я увидел топ-12, в котором 6 мест занимал один курс, а материал, который реально отвечал на вопрос, в список не попадал. Модель читает только то, что ей показали, поэтому ей полезнее увидеть несколько разных материалов, чем шесть страниц одного. Я добавил _diversify: в топе не больше двух страниц одного материала. Остальные страницы я не выбрасываю, они уходят в конец и заполняют свободные места, если разных материалов мало:
def _diversify(ranked, k): picked, overflow = [], [] per_material: dict[str, int] = {} for pid, score in ranked: slug = pid.rsplit("#", 1)[0] if per_material.get(slug, 0) < _MAX_PAGES_PER_MATERIAL: per_material[slug] = per_material.get(slug, 0) + 1 picked.append((pid, score)) if len(picked) >= k: return picked else: overflow.append((pid, score)) return (picked + overflow)[:k]
Двойку вместо единицы я тоже подобрал по стенду: настоящий ответ часто лежит на соседних страницах одного курса.
Результаты стенда после переезда (24 вопроса, k=12):
Voyage | Qwen3 + инструкция + | |
|---|---|---|
прямые: recall / MRR | 100% / 0,84 | 100% / 0,91 |
смысловые: recall / MRR | 75% / 0,33 | 83% / 0,40 |
Пользователю цифры стенда безразличны, ему важнее другое: в проде после переезда 13 поисков из 13 прошли по смыслу, ответ приходит за 16-27 секунд (до этого 28-77), а тяжёлый вопрос с тремя поисками укладывается в 43 секунды. Цена миллиона токенов упала с 0,06 до 0,01 доллара, полный пересчёт корпуса вместо 3,5 часов занимает 146 секунд и стоит два цента. Заодно перестала быть актуальной проблема с 429, и я сократил терпение запросов: на OpenRouter при ошибке бот ждёт недолго и откатывается на слова вместо того, чтобы висеть девять секунд.
Я проверил и другую модель, bge-m3: она оказалась заметно хуже (MRR 0,62 и 0,31 на прямых и смысловых вопросах) и ловила 429 у провайдера. Её я отверг.
На смене модели есть грабли с порядком действий: сначала ключ нового провайдера в окружение сервера, и только потом перезапуск. Иначе бот стартует со старым провайдером, не узнаёт собственные векторы (модель входит в ключ кэша) и молча уходит на слова. Бот при этом не падает, но качество поиска тихо ухудшается, и заметишь ты это только по жалобе.
Мелочи, которые поймала приёмка
Ещё две вещи стенд пропустил, их нашли живые вопросы перед сдачей.
Первая: бот однажды отдал пользователю служебную фразу вместо ответа, что-то вроде «Прочитаю страницу такую-то, они выглядят самыми точными». В цикле стоит MAX_TOOL_ROUNDS = 4, и когда бюджет раундов кончается, последний вызов делается с tool_choice: "none". Это запрещает вызывать инструмент, но не сообщает модели, что поиск окончен, и она проговаривает следующий шаг вслух: в логе этот ответ занимал 29 выходных токенов. Я вылечил это напоминанием, которое дописывается в последний ход.
_LAST_TURN_NUDGE = ( "Это последний шаг: искать и читать материалы больше нельзя. Ответь " "пользователю прямо сейчас по тому, что уже прочитал. Если прочитанного " "не хватает для ответа — скажи об этом своим голосом и предложи спросить " "тебя лично. Не описывай, что собираешься сделать дальше." )
После правки тот же вопрос дал ответ на 675 токенов с честной оговоркой. Баг существовал и до векторов, просто раньше ловился реже.
Вторая: вызовы инструментов в цикле оказались синхронными, а поиск ходит в сеть за эмбеддингом. Пока один пользователь ждал ответа, бот замирал для всех остальных. Вызов теперь уходит в отдельный поток: asyncio.to_thread(search.run_tool, name, input).
Что осталось неидеальным
Стенд маленький. Всего 12 смысловых вопросов, и один кейс это 8 процентных пунктов recall. Разница между 75% и 83% это буквально один вопрос, поэтому я смотрю на них как на направление и точными числами не считаю. Расширять набор имеет смысл только живыми вопросами пользователей, а их у этого бота пока мало.
Метрика засчитывает попадание по материалу. Поиск мог найти верный материал, но не ту страницу в нём, и стенд этого не покажет. Модель обычно дочитывает соседние страницы, но гарантии нет.
Смысловой recall на 12 результатах не дотягивает до 100%. Три способа улучшить, которые я не пробовал, потому что не было нужды: нарезка страниц на куски поменьше (в коде есть ручка
CHUNK_CHARS, но смена нарезки означает переиндексацию всего корпуса), более крупная модель эмбеддингов и реранкер, который переупорядочивает первые результаты второй моделью.Стенд не меряет заземление. Он говорит, что нужный материал оказался в выдаче, но не говорит, что модель из него правильно извлекла. Для этого у меня есть отдельная проверка на уточняющих вопросах и ручная приёмка, а полноценной автоматической оценки ответов нет.
Сравнивать цифры стенда до и после чисток корпуса нельзя. Когда по просьбе эксперта часть материалов убрали из индекса (в том числе убрали два огромных практикума, и корпус сократился почти вдвое: до 51 материала и 579 страниц), один кейс стенда остался без ответа в корпусе, и я перевёл его в
negative. Он ушёл из знаменателя: было 10 попаданий из 12, стало 10 из 11, и смысловой recall «вырос» с 83% до 91% без единого улучшения поиска. После этого я записал правило: цифры до и после перевода кейса вnegativeне сравниваются.Порога уверенности нет, защита от выдумки держится на промпте и на инструкции в ответе инструмента. Для бота, который отвечает чужим голосом, это самое уязвимое место, и на нём я бы сделал упор в следующей итерации.
Итог в цифрах
Корпус: 53 материала, 630 страниц по 4500 символов, около 2,25 млн знаков.
Индекс: 1050 векторов по 1024 числа на момент первой сборки, около 4 МБ, перебор в памяти за миллисекунды, векторной базы нет.
Стенд: 24 вопроса (8 прямых, 12 смысловых, 4 без ответа), вопросы написаны до просмотра корпуса.
FTS5 при k=6: recall 38% на прямых вопросах и 17% на смысловых.
Чистый вектор при k=6: 100% и 42%. Гибрид с RRF при k=6: 100% и 25%, то есть хуже вектора на смысловых.
Итоговая схема (смысл ранжирует, три слова в хвост, k=12): 100% и 75% на Voyage, 100% и 83% на Qwen3 с инструкцией и ограничением двух страниц на материал.
Индексация на бесплатном тарифе Voyage: 3 часа 57 минут. На OpenRouter с Qwen3: 146 секунд и два цента.
Ответ на тяжёлый вопрос: 77 секунд до переезда (3 смысловых поиска из 11), 16-27 секунд после (13 из 13).
Цена эмбеддингов: 0,06 доллара за миллион токенов, стало 0,01.
Для себя я вынес одно: рекомендации вроде «гибрид лучше чистого вектора» надо проверять на своих вопросах, пусть в наборе всего два десятка кейсов. Стенд в этом проекте оказался самой полезной частью, он опроверг мою исходную гипотезу, и из-за него я поменял архитектуру.
