В этом скрипте косинусная близость считается между
вопросом, полученным из имени 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. Общий алгоритм работы
Весь процесс можно описать так:
- Скрипт берёт HTML-файл.
- Получает вопрос из имени файла.
- Очищает HTML от тегов и лишних элементов.
- Разбивает очищенный текст на чанки.
- Превращает вопрос в embedding-вектор.
- Превращает каждый чанк в embedding-вектор.
- Нормализует все embedding-векторы до длины 1.
- Считает косинусную близость каждого чанка с вопросом.
- Сортирует чанки по score от большего к меньшему.
- Присваивает каждому чанку ранг.
- Помечает релевантные чанки по порогу.
- Помечает top-k лучших чанков.
- Сохраняет результаты в 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