RAG-бот по материалам эксперта: как я мерил качество поиска и почему гибрид оказался хуже чистого вектора

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

Эксперт попросил бота, который отвечает его голосом и только по его собственным материалам: курсам в 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 + инструкция + _diversify

прямые: 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).

Что осталось неидеальным

  1. Стенд маленький. Всего 12 смысловых вопросов, и один кейс это 8 процентных пунктов recall. Разница между 75% и 83% это буквально один вопрос, поэтому я смотрю на них как на направление и точными числами не считаю. Расширять набор имеет смысл только живыми вопросами пользователей, а их у этого бота пока мало.

  2. Метрика засчитывает попадание по материалу. Поиск мог найти верный материал, но не ту страницу в нём, и стенд этого не покажет. Модель обычно дочитывает соседние страницы, но гарантии нет.

  3. Смысловой recall на 12 результатах не дотягивает до 100%. Три способа улучшить, которые я не пробовал, потому что не было нужды: нарезка страниц на куски поменьше (в коде есть ручка CHUNK_CHARS, но смена нарезки означает переиндексацию всего корпуса), более крупная модель эмбеддингов и реранкер, который переупорядочивает первые результаты второй моделью.

  4. Стенд не меряет заземление. Он говорит, что нужный материал оказался в выдаче, но не говорит, что модель из него правильно извлекла. Для этого у меня есть отдельная проверка на уточняющих вопросах и ручная приёмка, а полноценной автоматической оценки ответов нет.

  5. Сравнивать цифры стенда до и после чисток корпуса нельзя. Когда по просьбе эксперта часть материалов убрали из индекса (в том числе убрали два огромных практикума, и корпус сократился почти вдвое: до 51 материала и 579 страниц), один кейс стенда остался без ответа в корпусе, и я перевёл его в negative. Он ушёл из знаменателя: было 10 попаданий из 12, стало 10 из 11, и смысловой recall «вырос» с 83% до 91% без единого улучшения поиска. После этого я записал правило: цифры до и после перевода кейса в negative не сравниваются.

  6. Порога уверенности нет, защита от выдумки держится на промпте и на инструкции в ответе инструмента. Для бота, который отвечает чужим голосом, это самое уязвимое место, и на нём я бы сделал упор в следующей итерации.

Итог в цифрах

  • Корпус: 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.

Для себя я вынес одно: рекомендации вроде «гибрид лучше чистого вектора» надо проверять на своих вопросах, пусть в наборе всего два десятка кейсов. Стенд в этом проекте оказался самой полезной частью, он опроверг мою исходную гипотезу, и из-за него я поменял архитектуру.

Источник: https://habr.com/ru/articles/1091058/


Интересные статьи

Интересные статьи

Исследователи Microsoft представили новую среду моделирования, предназначенную для тестирования агентов на основе искусственного интеллекта. Они показали, что существующие модели агентов могут быть уя...
Привет! Я Сергей Кляхандлер, senior DS-инженер в команде LLM Авито. В статье рассказываю, как мы разработали ML-модель, которая автоматически генерирует описания для объявлений из категории Одежд...
Представляю вашему вниманию цикл статей по работе с пока еще мало знакомым многим битрикс-разработчикам инструментом оперирования данными с CRM Битрикс24 через абстрактные фабрики. В первой статье мы ...
Часто от программистов PHP можно услышать: «О нет! Только не „Битрикс“!». Многие специалисты не хотят связываться фреймворком, считают его некрасивым и неудобным. Однако вакансий ...
«Битрикс» — кошмар на костылях. Эта популярная характеристика системы среди разработчиков и продвиженцев ныне утратила свою актуальность.