Косинусная близость: считаем похожесть руками

Главная формула векторного поиска — и почему берут именно угол, а не расстояние.

Косинусная близость — косинус угла между двумя векторами. От 1 (одно направление, максимально похожи) через 0 (перпендикулярны) до -1 (противоположны).

Формула и интуиция

Косинус близости считается так: скалярное произведение векторов делим на произведение их длин.

cos(a, b) = dot(a, b) / (|a| * |b|)

dot(a, b) = a1*b1 + a2*b2 + ... + an*bn
|a|       = sqrt(a1^2 + a2^2 + ... + an^2)

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

Считаем близость текстов руками

Возьмём игрушечные эмбеддинги по осям [животные, техника, еда] и отранжируем слова по близости к «кошке». Никаких библиотек — только математика.

import math

def cosine(a, b):
    dot = sum(x * y for x, y in zip(a, b))
    na = math.sqrt(sum(x * x for x in a))
    nb = math.sqrt(sum(x * x for x in b))
    return dot / (na * nb)

vectors = {
    "кошка":   [0.9, 0.1, 0.2],
    "собака":  [0.8, 0.1, 0.1],
    "ноутбук": [0.1, 0.9, 0.0],
    "пицца":   [0.1, 0.0, 0.9],
}

query = "кошка"
q = vectors[query]
print("Близость к слову:", query)
ranked = sorted(vectors.items(), key=lambda kv: cosine(q, kv[1]), reverse=True)
for word, vec in ranked:
    print(f"  {word:8} cos = {cosine(q, vec):.3f}")

Вывод:

Близость к слову: кошка
  кошка    cos = 1.000
  собака   cos = 0.995
  пицца    cos = 0.322
  ноутбук  cos = 0.214

«Собака» — почти как «кошка» (обе про животных), а «пицца» и «ноутбук» далеко. Это и есть ядро векторного поиска: чтобы найти релевантное, считаем косинус запроса со всем и берём самые высокие значения.

Косинус и нормализация

Если заранее привести все векторы к длине 1 (нормализовать), косинус превращается в обычное скалярное произведение — считать быстрее. Покажем, что это одно и то же.

import math

def dot(x, y):
    return sum(p * q for p, q in zip(x, y))

def normalize(v):
    n = math.sqrt(dot(v, v)) or 1e-9
    return [x / n for x in v]

def cosine(x, y):
    return dot(x, y) / (math.sqrt(dot(x, x)) * math.sqrt(dot(y, y)))

a, b = [3.0, 4.0], [4.0, 3.0]
an, bn = normalize(a), normalize(b)
print("Косинус исходных:    ", round(cosine(a, b), 4))
print("dot нормализованных: ", round(dot(an, bn), 4))

Вывод:

Косинус исходных:     0.96
dot нормализованных:  0.96

Поэтому многие векторные базы хранят нормализованные векторы и используют dot — результат тот же, а вычислений меньше.

Итог

  • Косинус близости = dot / (|a|*|b|), смотрит на направление, не на длину.
  • 1 — максимально похожи, 0 — не связаны, -1 — противоположны.
  • На нормализованных векторах косинус = скалярное произведение.
Проверьте себя
1. Что измеряет косинусная близость?
AРазницу длин векторов
BКосинус угла (направление) между векторами
CЧисло общих слов в текстах
DОбъём памяти под вектор
2. Какое значение косинуса означает максимальную похожесть?
A0
B-1
C1
D100
3. Что произойдёт с косинусом, если все векторы заранее нормализовать (длина = 1)?
AОн станет всегда равен нулю
BОн превратится в обычное скалярное произведение
CОн перестанет работать
DОн начнёт измерять длину
Поддержать проект