The Jerusalem PostTrump's foreign policy threatens Republicans in midterms as war, tariffs push costs up - analysisCNN Türkİstanbul’dan Muş’a uzaktan ameliyat: Türkiye’de bir ilkPunchNigerians who don’t see progress need ‘Tinubu eyeglasses’ – Remi TinubuInquirer3 fishermen rescued after boat partially sinks off Palawan islandInquirer EntertainmentApl.de.ap says new EP is a ‘shoutout’ to leaders to care for the PhilippinesESPNTransfer rumors, news: Chelsea, Bayern, Barcelona battle for Brighton defenderZDF heuteAktuelle Pressemitteilungen des ZDFColliderChristopher Nolan's Best Crime Thriller Officially Leaves NetflixSouth China Morning PostThousands evacuated and flights grounded as Typhoon Champi hits eastern China01net« Les écrans tactiles pires que l’alcool au volant » : cette étude choc pointe du doigt les véhicules modernesالنهارمعبر الشيب بين العراق وإيران... لماذا أُغلق وما أهميته؟HipertextualCon este simple truco de Windows, olvídate de volver a “perder” el puntero del mouse
The Daily Newsstand · Free, Always
Saturday, October 24, 2026

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

Translate

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

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

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.

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