Qdrant score_threshold: как выбрать порог cosine и не потерять ответы
В приложении с Qdrant осталось правило: передавать в контекст только фрагменты с cosine score не ниже 0.8. После смены embedding-модели бот начал чаще сообщать, что ничего не нашёл. При этом нужные документы по-прежнему стоят первыми, если убрать порог. Меняется распределение оценок, а старое число продолжает отрезать полезную выдачу.
Косинусная близость описывает геометрию векторов выбранной модели. Она не задаёт вероятность того, что текст отвечает на вопрос. Две модели могут одинаково упорядочить документы, но дать разный разрыв между релевантными и нерелевантными кандидатами. Поэтому значение порога приходится выбирать на размеченных примерах именно вашего поискового контура.
Сначала восстановите выдачу до отсечения
Возьмите вопросы, на которых после обновления появились пустые ответы. Выполните поиск с прежними фильтрами и лимитом, временно убрав score_threshold. Сохраните ID точек, тексты и оценки первой десятки. Проверьте вручную, есть ли среди них фрагменты, достаточные для ответа. Если нужного текста нет даже в расширенном списке, изменение порога само по себе проблему не решит.
Затем соберите похожие, но неподходящие документы. Для вопроса о возврате оплаты это могут быть правила отмены заказа, возврата товара и корректировки счёта. Случайный текст из другой предметной области слишком легко отделить; такой набор создаст удобный порог, который пропустит сложные ошибки в реальных вопросах. Примеры должны отражать кандидатов, которые ваш поиск действительно возвращает.
Посчитайте цену каждого порога
Ниже небольшой расчёт для уже размеченных пар «вопрос — кандидат». Метка 1 означает, что фрагмент полезен для ответа, 0 — что он не подходит. Числа выбраны для воспроизводимого примера; в рабочем прогоне их заменяют сохранёнными оценками Qdrant. Предполагается коллекция с метрикой Cosine, где больший score означает большую близость.
# Каждая строка: (cosine score, ручная релевантность кандидата)
validation = [(0.91, 1), (0.84, 1), (0.79, 0), (0.75, 1),
(0.71, 0), (0.66, 1), (0.63, 0), (0.52, 0)]
def measure(rows, threshold):
tp = sum(score >= threshold and relevant == 1
for score, relevant in rows)
fp = sum(score >= threshold and relevant == 0
for score, relevant in rows)
fn = sum(score < threshold and relevant == 1
for score, relevant in rows)
precision = tp / (tp + fp) if tp + fp else None
recall = tp / (tp + fn) if tp + fn else None
return tp, fp, fn, precision, recall
for threshold in (0.60, 0.70, 0.80):
tp, fp, fn, precision, recall = measure(validation, threshold)
print(f"t={threshold:.2f}: TP={tp} FP={fp} FN={fn} "
f"precision={precision:.2f} recall={recall:.2f}")
assert measure(validation, 0.80) == (2, 0, 2, 1.0, 0.5)
assert measure([(0.80, 1)], 0.80) == (1, 0, 0, 1.0, 1.0)
assert measure(validation, 1.0)[3] is None
При пороге 0.60 получаем четыре полезных и три лишних кандидата: precision равен 0.57, recall — 1.00. Порог 0.70 оставляет три полезных и два лишних: 0.60 и 0.75. На 0.80 остаются два полезных без лишних, но половина нужных фрагментов теряется: 1.00 и 0.50. Отсутствие ошибок среди пропущенных в контекст текстов достигнуто ценой пропусков.
Precision здесь — доля релевантных пар среди принятых. Recall — доля принятых среди всех релевантных пар этого набора. Если ни один кандидат не прошёл, precision не определён; код возвращает None. Отчёт должен показывать такую ситуацию отдельно. Иначе высокий порог и полностью пустая выдача могут выглядеть как успешная очистка контекста.
Не потеряйте вопросы за средними по парам
В примере все пары имеют одинаковый вес. В продукте один вопрос может дать десять кандидатов, другой — два. Поэтому рядом с метриками пар посчитайте долю вопросов, у которых после порога осталось хотя бы одно достаточное основание для ответа. Для составных вопросов отдельно проверяют полный набор необходимых оснований. Эта проверка показывает, сколько пользователей действительно смогут получить содержательный ответ.
Разделите данные на часть для подбора и отдельную часть для итоговой проверки. Перефразы одного исходного вопроса держите в одной группе при разделении. Иначе настройка под конкретную формулировку попадёт сразу в обе части. Полезно сохранять отдельный срез вопросов без ответа в корпусе: на нём видно, как часто порог пропускает правдоподобный, но посторонний текст.
Где закрепить выбранное значение
В обычном запросе Qdrant поле score_threshold задаётся рядом с query и limit. Для Cosine отбираются достаточно высокие оценки. При другой метрике проверьте направление сравнения; для расстояний интуиция «больше значит лучше» может быть неверной. Если после dense-поиска идут RRF или reranker, порог их итоговых оценок требует собственного подбора.
Храните выбранный порог вместе с именем и версией embedding-модели, метрикой коллекции, способом подготовки текста и версией набора вопросов. После изменения любого из этих компонентов повторяйте проверку. При нескольких языках или типах документов сначала посмотрите отдельные распределения. Единый порог удобнее сопровождать, но его пригодность должна подтверждаться на каждом важном срезе.
Когда текст проходит фильтр, генератору всё равно нужно корректно использовать его смысл. Об этом — статья о цитатах, которые не подтверждают ответ. Порог отвечает за отбор кандидатов; оценку готового ответа ведите следующим этапом.
В платном курсе Vector DB & RAG Developer вы выберете метрику близости, соберёте поиск с фильтрами и отработаете измерение его качества. Отдельный раздел посвящён порогу «не знаю» и правилам отказа. Откройте программу и пробный урок по Qdrant, чтобы перейти от готовых оценок к поиску по своим документам.
