Как скрипт считает косинусную близость

В этом скрипте косинусная близость считается между
вопросом, полученным из имени HTML-файла, и
каждым текстовым чанком, который был извлечён из содержимого этого HTML-файла.

Главная часть расчёта находится в этом фрагменте:

question_emb = encode_texts(model, [question], batch_size=1, show_progress=False)[0]
chunk_texts = [chunk.text for chunk in chunks]
chunk_embs = encode_texts(model, chunk_texts, batch_size=args.batch_size, show_progress=args.progress)

# embeddings нормализованы, поэтому dot product равен cosine similarity.
scores = chunk_embs @ question_emb
scores = np.asarray(scores, dtype=np.float32)

1. Что именно сравнивается

Скрипт не сравнивает HTML-файлы между собой. Для каждого файла он берёт вопрос из имени файла:

question = question_from_filename(path, keep_dashes=args.keep_dashes)

Например, файл с именем:

Как вернуть товар.html

превращается в вопрос:

Как вернуть товар

Если имя файла содержит подчёркивания, например:

Kak_vernut_tovar.html

то подчёркивания заменяются на пробелы:

Kak vernut tovar

После этого HTML-файл читается и очищается:

raw_html = read_text_file(path)
clean_text = html_to_clean_text(raw_html, remove_boilerplate=not args.keep_boilerplate)

Из HTML удаляются теги, скрипты, стили, служебные элементы, формы, кнопки и другие фрагменты,
которые обычно не относятся к основному тексту страницы.

Затем очищенный текст разбивается на чанки:

chunks = make_chunks(clean_text, chunk_size=args.chunk_size, overlap=args.overlap)

По умолчанию используется размер чанка 1800 символов и перекрытие
300 символов:

--chunk-size 1800
--overlap 300

В итоге скрипт сравнивает вопрос из имени файла с каждым отдельным чанком текста:

вопрос из имени файла  <->  чанк 1
вопрос из имени файла  <->  чанк 2
вопрос из имени файла  <->  чанк 3
...

2. Как текст превращается в векторы

Чтобы посчитать смысловую близость, скрипт сначала превращает вопрос и каждый чанк в
embedding — числовой вектор, который описывает смысл текста.

За это отвечает функция:

def encode_texts(model: SentenceTransformer, texts: list[str], batch_size: int, show_progress: bool) -> np.ndarray:
    if not texts:
        return np.empty((0, 0), dtype=np.float32)

    embeddings = model.encode(
        texts,
        batch_size=batch_size,
        normalize_embeddings=True,
        convert_to_numpy=True,
        show_progress_bar=show_progress,
    )
    return np.asarray(embeddings, dtype=np.float32)

Ключевой параметр здесь:

normalize_embeddings=True

По умолчанию в скрипте используется модель:

BAAI/bge-m3

Упрощённо процесс выглядит так:

"Как вернуть товар" 
-> [0.12, -0.03, 0.44, ..., 0.08]

"Возврат товара возможен в течение 14 дней"
-> [0.10, -0.01, 0.40, ..., 0.12]

В реальности такие векторы намного длиннее, но принцип остаётся тем же: каждый текст
превращается в набор чисел, по которому можно сравнивать тексты между собой.

3. Что делает normalize_embeddings=True

Обычная формула косинусной близости выглядит так:

cosine_similarity(A, B) = (A · B) / (||A|| * ||B||)

Здесь:

  • A · B — скалярное произведение двух векторов;
  • ||A|| — длина первого вектора;
  • ||B|| — длина второго вектора.

Скалярное произведение считается так:

A1*B1 + A2*B2 + A3*B3 + ... + An*Bn

Но в скрипте используется параметр:

normalize_embeddings=True

Это значит, что каждый embedding заранее приводится к длине 1:

v_normalized = v / ||v||

После нормализации:

||question_emb|| = 1
||chunk_emb|| = 1

Поэтому формула косинусной близости упрощается:

cosine_similarity(question_emb, chunk_emb)
=
(question_emb · chunk_emb) / (1 * 1)
=
question_emb · chunk_emb

Именно поэтому скрипт может считать косинусную близость через обычное скалярное произведение:

scores = chunk_embs @ question_emb

4. Что делает строка chunk_embs @ question_emb

Эта строка является главным расчётом косинусной близости:

scores = chunk_embs @ question_emb

Допустим, в файле есть один вопрос и четыре чанка.

Вектор вопроса имеет форму:

(D,)

где D — размерность embedding-вектора.

А embeddings чанков имеют форму:

(4, D)

То есть это матрица:

[
  embedding_чанка_1,
  embedding_чанка_2,
  embedding_чанка_3,
  embedding_чанка_4
]

Когда выполняется операция:

chunk_embs @ question_emb

NumPy считает скалярное произведение каждого чанка с вопросом:

score_1 = chunk_1 · question
score_2 = chunk_2 · question
score_3 = chunk_3 · question
score_4 = chunk_4 · question

В результате получается массив значений:

[0.7123, 0.3841, 0.8295, 0.5210]

Это можно интерпретировать так:

  • чанк 1: cosine similarity = 0.7123;
  • чанк 2: cosine similarity = 0.3841;
  • чанк 3: cosine similarity = 0.8295;
  • чанк 4: cosine similarity = 0.5210.

Чем выше значение, тем ближе чанк к вопросу по смыслу.

5. Пример на простых векторах

Представим, что вопрос и два чанка представлены короткими нормализованными векторами:

question = [0.6, 0.8]
chunk_1  = [0.6, 0.8]
chunk_2  = [0.8, -0.6]

Считаем близость вопроса и первого чанка:

0.6*0.6 + 0.8*0.8 = 0.36 + 0.64 = 1.0

Результат:

cosine = 1.0

Это максимальная близость: векторы направлены одинаково.

Теперь считаем близость вопроса и второго чанка:

0.6*0.8 + 0.8*(-0.6) = 0.48 - 0.48 = 0.0

Результат:

cosine = 0.0

Это значит, что по направлению в embedding-пространстве эти тексты почти не связаны.

В реальном скрипте векторы намного длиннее, но принцип расчёта точно такой же.

6. Почему это не просто совпадение слов

Скрипт не ищет прямое совпадение слов. Он использует embedding-модель, которая сравнивает
тексты по смыслу.

Например, вопрос может быть таким:

Как вернуть товар?

А в чанке может быть написано:

Покупатель может оформить возврат покупки в течение 14 дней.

Прямых совпадений слов может быть немного, но модель может понять, что выражения
«вернуть товар» и «оформить возврат покупки»
близки по смыслу.

Поэтому cosine similarity в этом скрипте показывает не количество одинаковых слов,
а смысловую близость текстов в embedding-пространстве.

7. Как скрипт сортирует чанки по близости

После расчёта значений scores скрипт сортирует чанки:

ranked_indices = np.argsort(scores)[::-1]

Функция np.argsort(scores) сортирует индексы по возрастанию score.

Например, если есть такие значения:

scores = [0.7123, 0.3841, 0.8295, 0.5210]

то np.argsort(scores) вернёт индексы от меньшего значения к большему:

[1, 3, 0, 2]

Потому что:

scores[1] = 0.3841
scores[3] = 0.5210
scores[0] = 0.7123
scores[2] = 0.8295

Затем [::-1] разворачивает порядок:

[2, 0, 3, 1]

Теперь чанки идут от самого релевантного к самому слабому.

Далее скрипт присваивает каждому чанку ранг:

ranks = np.empty(len(scores), dtype=np.int32)
for rank, idx in enumerate(ranked_indices, start=1):
    ranks[idx] = rank

Чанк с самым высоким score получает:

rank_by_question = 1

Следующий по близости получает:

rank_by_question = 2

И так далее.

8. Как score записывается в Excel

Для каждого чанка скрипт создаёт строку отчёта:

score = float(scores[idx])
rank = int(ranks[idx])
is_relevant = score >= args.relevant_threshold
is_top_k = rank <= args.top_k
is_important = is_relevant or is_top_k

В Excel записываются, среди прочего, такие поля:

"score_cosine": round(score, 6),
"score_percent_approx": round(max(min(score, 1.0), 0.0) * 100, 2),
"relevance_label": relevance_label(score),
"is_relevant_by_threshold": "ДА" if is_relevant else "нет",
"is_top_k": "ДА" if is_top_k else "нет",
"is_important_for_report": "ДА" if is_important else "нет",

score_cosine

score_cosine — это настоящее значение косинусной близости, округлённое до
шести знаков:

0.712345

score_percent_approx

score_percent_approx — это не вероятность. Это просто удобное отображение
значения score в процентах:

round(max(min(score, 1.0), 0.0) * 100, 2)

Например:

0.712345 -> 71.23

Если score отрицательный, он будет показан как 0%. Если score чуть больше 1 из-за
численной погрешности, он будет ограничен 100%.

Важно: score_percent_approx — это приблизительная шкала для удобства,
а не вероятность релевантности.

9. Как определяется релевантность

Порог релевантности задаётся параметром:

--relevant-threshold

По умолчанию он равен:

--relevant-threshold 0.55

В коде это выглядит так:

is_relevant = score >= args.relevant_threshold

То есть если score равен или выше 0.55, чанк считается релевантным.

Примеры:

  • score = 0.71 — релевантный;
  • score = 0.56 — релевантный;
  • score = 0.54 — не релевантный по порогу.

При этом чанк ниже порога всё равно может быть отмечен как важный, если он входит в top-k.

10. Что такое top-k

Параметр --top-k по умолчанию равен:

5

Скрипт берёт пять чанков с самым высоким значением cosine similarity:

top_indices = ranked_indices[: args.top_k]

Затем он помечает такие чанки:

is_top_k = rank <= args.top_k

После этого определяется, является ли чанк важным для отчёта:

is_important = is_relevant or is_top_k

То есть чанк считается важным, если выполняется хотя бы одно из условий:

  • его score выше или равен порогу релевантности;
  • он входит в top-k лучших чанков.

Благодаря этому в отчёт попадут все чанки выше порога, а также лучшие чанки даже в том случае,
если они не дотянули до заданного порога.

11. Как присваиваются текстовые метки релевантности

В скрипте есть список меток:

RELEVANCE_LABELS = [
    (0.75, "очень релевантно"),
    (0.60, "релевантно"),
    (0.45, "возможно релевантно"),
    (0.30, "слабая связь"),
    (-1.00, "почти нет связи"),
]

Метка выбирается функцией:

def relevance_label(score: float) -> str:
    for threshold, label in RELEVANCE_LABELS:
        if score >= threshold:
            return label
    return "почти нет связи"

Проверка идёт сверху вниз:

  • score >= 0.75 — очень релевантно;
  • score >= 0.60 — релевантно;
  • score >= 0.45 — возможно релевантно;
  • score >= 0.30 — слабая связь;
  • score < 0.30 — почти нет связи.

Примеры:

  • 0.82 — очень релевантно;
  • 0.67 — релевантно;
  • 0.51 — возможно релевантно;
  • 0.34 — слабая связь;
  • 0.12 — почти нет связи.

12. Какие показатели считаются в summary

После расчёта score для всех чанков скрипт считает сводные показатели по файлу:

"max_score": round(float(np.max(scores)), 6),
"avg_top_k_score": round(float(np.mean(top_scores)), 6) if top_scores else None,
"relevant_chunks_count": relevant_count,
"relevant_share_percent": round((relevant_count / len(chunks)) * 100, 2),
"top_chunk_ids": ", ".join(map(str, top_chunk_ids)),

max_score

max_score — максимальная косинусная близость среди всех чанков файла.

Например:

0.8295

Это значит, что лучший чанк файла имеет близость 0.8295 к вопросу.

avg_top_k_score

avg_top_k_score — средний score по top-k чанкам.
Если top_k = 5, берутся пять лучших чанков и считается среднее значение.

relevant_chunks_count

relevant_chunks_count — количество чанков, у которых score выше или равен
порогу релевантности:

score >= relevant_threshold

relevant_share_percent

relevant_share_percent — доля релевантных чанков от общего количества чанков.

Например, если всего 20 чанков, а релевантных 4:

4 / 20 * 100 = 20%

top_chunk_ids

top_chunk_ids — ID лучших чанков по значению cosine similarity.

13. Общий алгоритм работы

Весь процесс можно описать так:

  1. Скрипт берёт HTML-файл.
  2. Получает вопрос из имени файла.
  3. Очищает HTML от тегов и лишних элементов.
  4. Разбивает очищенный текст на чанки.
  5. Превращает вопрос в embedding-вектор.
  6. Превращает каждый чанк в embedding-вектор.
  7. Нормализует все embedding-векторы до длины 1.
  8. Считает косинусную близость каждого чанка с вопросом.
  9. Сортирует чанки по score от большего к меньшему.
  10. Присваивает каждому чанку ранг.
  11. Помечает релевантные чанки по порогу.
  12. Помечает top-k лучших чанков.
  13. Сохраняет результаты в Excel.

14. Главная формула

Математически скрипт считает:

score_i = cosine(question, chunk_i)

Полная формула выглядит так:

score_i = (question_emb · chunk_emb_i) / (||question_emb|| * ||chunk_emb_i||)

Но так как embeddings уже нормализованы:

||question_emb|| = 1
||chunk_emb_i|| = 1

формула упрощается:

score_i = question_emb · chunk_emb_i

Именно это делает строка:

scores = chunk_embs @ question_emb

Оставьте комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *

Прокрутить вверх