Задача: найти «похожие» векторы

У нас есть запрос пользователя — мы превратили его в вектор из 1536 чисел. У нас есть 100 000 документов — каждый тоже вектор из 1536 чисел. Задача: найти 10 документов, «ближайших» к запросу.

Что значит «ближайший»? Вот здесь и расходятся пути. В зависимости от метрики один и тот же набор векторов даёт разное ранжирование. Три метрики — три взгляда на «похожесть».

Cosine Similarity
cos(θ) = A · B / (||A|| × ||B||)
Диапазон: −1 ... +1 (чем больше, тем похожее)
Мера угла между векторами. Не зависит от длины — важно только направление.
Dot Product
A · B = Σ Aᵢ × Bᵢ
Диапазон: −∞ ... +∞ (чем больше, тем похожее)
Скалярное произведение. Зависит от длины векторов. При нормализации = cosine.
Euclidean Distance
d = √Σ (AᵢBᵢ
Диапазон: 0 ... +∞ (чем меньше, тем похожее)
Прямолинейное расстояние между точками. Зависит от масштаба координат.

Cosine similarity: угол важнее расстояния

Представьте два текста: «Python очень быстрый язык» и «Python — очень, очень, очень быстрый язык программирования». Второй текст длиннее, его вектор будет длиннее (большие значения компонент). Но смысл — тот же.

Cosine similarity измеряет угол между векторами — не расстояние между концами. Два вектора, направленные в одну сторону, имеют cosine = 1, даже если один в 10 раз длиннее другого. Именно это нужно для семантического поиска: нам важен смысл, не объём текста.

cos θ = A · B / (||A|| × ||B||) нормируем на произведение длин A · B = A₁B₁ + A₂B₂ + ... + AₙBₙ   (скалярное произведение, числитель)
||A|| = √(A₁² + A₂² + ... + Aₙ²)   (евклидова норма, длина вектора)

Интерпретация значений:

−1.0 .. −0.5
−0.5 .. 0
0 .. 0.5
0.5 .. 0.8
0.8 .. 1.0
−1.0−0.500.50.81.0
≈ −1.0
Противоположный смысл — на практике крайне редко
«горячо» / «холодно»
≈ 0.0
Перпендикулярны — никакой смысловой связи
код / рецепт
0.6–0.8
Связанные темы, один домен
Python / Django
0.85–0.95
Высокое сходство — почти один смысл
вопрос / ответ
> 0.97
Почти идентичный текст или дубликат
дубликаты
Внимание на диапазон: большинство embedding-моделей даёт значения cosine в диапазоне 0.3–0.9 для реальных текстов — не 0–1. Значение 0.75 может уже быть очень высоким для конкретной модели. Порог «релевантно / нерелевантно» нужно калибровать под свой корпус.

Dot product: скалярное произведение

Dot product — это числитель формулы cosine без нормировки. Он измеряет одновременно угол и длины обоих векторов: длинные векторы, направленные в одну сторону, дают большой dot product.

A · B = Σ Aᵢ × Bᵢ = ||A|| × ||B|| × cos θ Если ||A|| = ||B|| = 1 (векторы нормализованы) → dot product = cosine

Dot product удобен, когда важна не только направленность, но и «уверенность» (длина вектора как сигнал интенсивности). Некоторые модели специально обучены так, что более частотные или более «важные» концепции имеют более длинные векторы — и тогда dot product ранжирует их выше.

Когда это опасно: если вы используете dot product с ненормализованными векторами, очень длинные векторы (от длинных текстов) могут доминировать в результатах вне зависимости от смысла. В большинстве RAG-систем это нежелательно. OpenAI, nomic-embed и E5 с normalize_embeddings=True возвращают нормализованные векторы — тогда dot product = cosine.

Euclidean distance: прямая линия

Евклидово расстояние — это привычное «расстояние по прямой» в многомерном пространстве. Формула обобщает теорему Пифагора на N измерений.

d(A, B) = √( Σ (Aᵢ − Bᵢ)² ) Чем меньше d, тем похожее тексты. Размерность: d ∈ [0, +∞)

Ключевое отличие от cosine: евклидово расстояние чувствительно к «масштабу» — длинный вектор, направленный в ту же сторону, будет далеко от короткого вектора в евклидовом смысле, даже если они семантически идентичны.

Ранжирование по cosine
#1
«Установка FastAPI»
короткий чанк, 80 токенов
0.93
#2
«FastAPI — современный фреймворк...»
длинный чанк, 320 токенов
0.91
#3
«Django vs Flask vs FastAPI»
сравнение, 250 токенов
0.84
Ранжирование по euclidean
#1
«FastAPI — современный фреймворк...»
длинный чанк, 320 токенов
0.41
#2
«Django vs Flask vs FastAPI»
сравнение, 250 токенов
0.48
#3
«Установка FastAPI»
короткий чанк, 80 токенов
0.53

В этом примере cosine ставит на первое место короткий чанк с точным ответом, потому что его вектор направлен ближе к запросу. Euclidean же предпочитает длинный чанк — он богаче терминами, его вектор по модулю ближе к длинному запросу. Для RAG первое поведение правильнее: нужен точный ответ, не объём.

Геометрическая интерпретация

100%
колёсико — масштаб  ·  зажать и тянуть — перемещение
Cosine Similarity — угол Направление важнее длины вектора x₁ x₂ O Q (запрос) A θ≈8° B θ≈31° C (короткий) РАНЖИРОВАНИЕ ПО COSINE: ① A cos ≈ 0.99 (угол 8°) ② C cos ≈ 0.99 (угол 9°) ← короткий, но близкий ③ B cos ≈ 0.86 (угол 31°) Euclidean Distance — длина Длина вектора меняет ранжирование x₁ x₂ O Q A B C (короткий) d≈28 d≈83 d≈73 РАНЖИРОВАНИЕ ПО EUCLIDEAN: ① A d ≈ 28 (близко по длине и углу) ② C d ≈ 73 ← ХУЖЕ, хотя угол почти тот же! ③ B d ≈ 83 (далеко) C поднялась с #2 → #3 из-за короткого вектора!

Стрелки слева — это embedding-векторы от начала координат. По cosine векторы A и C почти одинаковы (малый угол с Q), B далеко (большой угол). По euclidean C проигрывает A просто потому, что она короче — конец вектора C физически дальше от конца Q. Хотя направление почти совпадает.

Нормализация L2: ключ ко всему

Нормализация L2 — это масштабирование вектора до единичной длины. После нормализации все три метрики ранжируют одинаково.

[0.8]
[1.6]
[0.4]
...
||v||=2.1
Исходный вектор
произвольная длина
v_norm = v / ||v||
Делим на L2-норму
[0.38]
[0.76]
[0.19]
...
||v||=1.0
Нормализованный
||v|| = 1 (unit vector)
dot product
= cosine
≡ euclidean

Математически: если ||A|| = ||B|| = 1, то:

cos(A, B) = A·B / (1 × 1) = A·B и ||A−B||² = 2 − 2·cos(A, B) При нормализованных векторах евклидово расстояние — монотонное преобразование cosine.
Одинаковое ранжирование: меньше d ↔ больше cosine.

Вывод практический: если ваша embedding-модель возвращает нормализованные векторы — можно использовать dot product вместо cosine. Это одно и то же, но dot product считается быстрее (нет нормировки в знаменателе).

Какие модели нормализуют автоматически:
text-embedding-3-* — да, всегда
sentence-transformers — только при normalize_embeddings=True
BGE-M3 — dense-вектора нормализованы
nomic-embed-text-v1.5 — нормализованы по умолчанию

Как проверить: np.linalg.norm(vec) должна вернуть ~1.0.

Как выбрать метрику

Ситуация                                  Метрика        Почему
──────────────────────────────────────────────────────────────────────
Векторы нормализованы                     dot product    Fastest, = cosine
Векторы НЕ нормализованы, нужен угол      cosine         Нормализует сам
Векторы НЕ нормализованы, важна длина     dot product    Magnitude=confidence
Кластеризация (k-means, DBSCAN)           euclidean      Алгоритмы требуют
Хранение в векторной БД (Chroma, Qdrant)  cosine/IP      По умолчанию в БД
Поиск по нормализованным (FAISS IndexIP)  dot product    Самый быстрый FAISS
        

В подавляющем большинстве RAG-систем используют cosine или dot product с нормализованными векторами — это одно и то же. Euclidean применяют редко: только если алгоритм явно требует (k-means) или если длина вектора несёт смысловую нагрузку (что нетипично).

Реализация с NumPy

import numpy as np
from typing import Literal


# ── Базовые реализации ────────────────────────────────────────────

def cosine_similarity(a: np.ndarray, b: np.ndarray) -> float:
    """Cosine similarity между двумя векторами."""
    norm_a = np.linalg.norm(a)
    norm_b = np.linalg.norm(b)
    if norm_a == 0 or norm_b == 0:
        return 0.0
    return float(np.dot(a, b) / (norm_a * norm_b))


def dot_product(a: np.ndarray, b: np.ndarray) -> float:
    """Скалярное произведение. Для нормализованных = cosine."""
    return float(np.dot(a, b))


def euclidean_distance(a: np.ndarray, b: np.ndarray) -> float:
    """Евклидово расстояние. Меньше = похожее."""
    return float(np.linalg.norm(a - b))


def l2_normalize(v: np.ndarray) -> np.ndarray:
    """Нормализовать вектор до единичной длины."""
    norm = np.linalg.norm(v)
    return v / norm if norm > 0 else v


# ── Пример: три метрики на одних данных ──────────────────────────

# Моделируем: два вектора похожего смысла (одно направление),
# но разной длины (разный объём текста)
vec_query = np.array([0.6, 0.8, 0.1, -0.3])          # нормализован: ||q||=1.0
vec_a     = np.array([0.55, 0.75, 0.08, -0.28])       # похожее направление, norm≈0.97
vec_b     = np.array([1.2, 1.6, 0.2, -0.6])           # ТО ЖЕ направление × 2, norm≈2.0
vec_c     = np.array([-0.5, 0.2, 0.8, 0.3])           # другое направление

print("=== Исходные нормы ===")
for name, v in [("query", vec_query), ("A", vec_a), ("B", vec_b), ("C", vec_c)]:
    print(f"  ||{name}|| = {np.linalg.norm(v):.3f}")

print("\n=== Cosine similarity с query ===")
for name, v in [("A", vec_a), ("B", vec_b), ("C", vec_c)]:
    print(f"  cosine(query, {name}) = {cosine_similarity(vec_query, v):.4f}")

print("\n=== Dot product с query ===")
for name, v in [("A", vec_a), ("B", vec_b), ("C", vec_c)]:
    print(f"  dot(query, {name})    = {dot_product(vec_query, v):.4f}")
# ► B получит в 2× больший dot product чем A (хотя направление одинаковое!)

print("\n=== Euclidean distance от query ===")
for name, v in [("A", vec_a), ("B", vec_b), ("C", vec_c)]:
    print(f"  euclidean(query, {name}) = {euclidean_distance(vec_query, v):.4f}")
# ► B далеко от query евклидово, хотя смысл идентичен!

print("\n=== После L2-нормализации: dot == cosine ===")
qn = l2_normalize(vec_query)
for name, v in [("A", vec_a), ("B", vec_b), ("C", vec_c)]:
    vn = l2_normalize(v)
    cos = cosine_similarity(qn, vn)
    dp  = dot_product(qn, vn)
    print(f"  {name}: cosine={cos:.4f}, dot(norm)={dp:.4f}  → {'≡' if abs(cos-dp) < 1e-6 else '≠'}")
# ► cos = dot для нормализованных; B больше не доминирует

Батчевый поиск: матричное умножение

В реальном RAG нам нужно найти топ-K из миллиона векторов. Наивный цикл — O(N) операций, каждая отдельно. Матричное умножение позволяет посчитать все сходства за одну операцию с GPU-ускорением.

import numpy as np
import time


def batch_cosine_search(
    query_vec: np.ndarray,
    doc_matrix: np.ndarray,
    top_k: int = 10,
    pre_normalized: bool = False,
) -> tuple[np.ndarray, np.ndarray]:
    """
    Найти top-k документов по cosine similarity.

    Args:
        query_vec:       1D вектор запроса, shape (D,)
        doc_matrix:      2D матрица документов, shape (N, D)
        top_k:           Сколько лучших вернуть
        pre_normalized:  True → пропустить нормализацию (быстрее)

    Returns:
        (indices, scores) — индексы и оценки top-k, отсортированные по убыванию
    """
    if not pre_normalized:
        query_vec  = query_vec  / (np.linalg.norm(query_vec) + 1e-8)
        # Нормализуем каждую строку матрицы
        norms      = np.linalg.norm(doc_matrix, axis=1, keepdims=True)
        doc_matrix = doc_matrix / (norms + 1e-8)

    # Матричное умножение: (D,) @ (D, N) = (N,) — все сходства за раз
    scores = doc_matrix @ query_vec         # shape: (N,)

    # argpartition быстрее argsort для нахождения top-k
    if top_k < len(scores):
        top_indices = np.argpartition(scores, -top_k)[-top_k:]
    else:
        top_indices = np.arange(len(scores))

    # Сортируем только top-k
    top_indices = top_indices[np.argsort(scores[top_indices])[::-1]]

    return top_indices, scores[top_indices]


# ── Бенчмарк: наивный цикл vs матричное умножение ──────────────

N = 50_000    # документов
D = 1536      # размерность (text-embedding-3-small)

np.random.seed(42)
docs = np.random.randn(N, D).astype(np.float32)
# Нормализуем заранее (в реальности делается при индексировании)
docs /= np.linalg.norm(docs, axis=1, keepdims=True)

query = np.random.randn(D).astype(np.float32)
query /= np.linalg.norm(query)

# Наивный цикл
t0 = time.time()
naive_scores = [float(np.dot(docs[i], query)) for i in range(N)]
t_naive = time.time() - t0

# Матричное умножение
t0 = time.time()
indices, scores = batch_cosine_search(query, docs, top_k=10, pre_normalized=True)
t_batch = time.time() - t0

print(f"Наивный цикл: {t_naive*1000:.1f} мс")
print(f"Матричное умножение: {t_batch*1000:.1f} мс")
print(f"Ускорение: {t_naive/t_batch:.0f}×")
print(f"\nTop-3 результаты:")
for idx, score in zip(indices[:3], scores[:3]):
    print(f"  doc[{idx:5d}]  cosine = {score:.4f}")

Фильтрация по порогу сходства

В RAG важен не только ранг, но и минимальный порог: если ни один документ не похож достаточно — лучше ответить «не знаю», чем отдать нерелевантный контекст модели.

from dataclasses import dataclass
from typing import Optional
import numpy as np


@dataclass
class SearchResult:
    index:    int
    score:    float
    document: str


def search_with_threshold(
    query_vec: np.ndarray,
    doc_matrix: np.ndarray,
    documents: list[str],
    top_k: int = 5,
    min_score: float = 0.70,
) -> list[SearchResult]:
    """
    Поиск с фильтрацией по минимальному порогу cosine.

    min_score: документы с cosine < min_score отбрасываются.
    Если подходящих нет — возвращает пустой список.
    """
    # Нормализуем
    q_norm  = query_vec / np.linalg.norm(query_vec)
    norms   = np.linalg.norm(doc_matrix, axis=1, keepdims=True)
    d_norm  = doc_matrix / (norms + 1e-8)

    scores = d_norm @ q_norm                          # (N,)
    ranked = np.argsort(scores)[::-1][:top_k]         # индексы top-k

    results = []
    for idx in ranked:
        if scores[idx] < min_score:
            break                                     # отсортированы → дальше хуже
        results.append(SearchResult(
            index=int(idx),
            score=float(scores[idx]),
            document=documents[idx],
        ))

    return results


def calibrate_threshold(
    query_vecs: np.ndarray,      # (Q, D) — несколько тестовых запросов
    doc_matrix: np.ndarray,      # (N, D) — корпус
    relevant_ids: list[list[int]], # relevant_ids[q] = индексы релевантных документов
    percentile: float = 10.0,    # нижний percentile среди релевантных пар
) -> float:
    """
    Калибровка минимального порога на тестовых данных.
    Возвращает cosine similarity ниже которого отрезается нерелевантное.
    """
    q_norm = query_vecs / np.linalg.norm(query_vecs, axis=1, keepdims=True)
    d_norm = doc_matrix / np.linalg.norm(doc_matrix, axis=1, keepdims=True)

    relevant_scores = []
    for q_idx, rel_ids in enumerate(relevant_ids):
        qv = q_norm[q_idx]
        for doc_id in rel_ids:
            score = float(d_norm[doc_id] @ qv)
            relevant_scores.append(score)

    threshold = float(np.percentile(relevant_scores, percentile))
    print(f"Релевантных пар: {len(relevant_scores)}")
    print(f"Cosine p10: {threshold:.3f}, p50: {np.median(relevant_scores):.3f}")
    print(f"Рекомендуемый min_score: {threshold:.2f}")
    return threshold


# Пример: порог 0.70 — стандартная отправная точка,
# но всегда калибруйте на своём корпусе!
# Для технической документации: часто 0.75–0.80
# Для новостей / разнообразного текста: 0.60–0.70

Метрики в векторных базах данных

Векторные БД выбирают метрику при создании коллекции — потом изменить нельзя без переиндексации. Разные БД используют разные названия для одних и тех же метрик.

"""
Метрики в популярных векторных БД.
Выбор делается ОДИН РАЗ при создании коллекции.
"""

# ── ChromaDB ─────────────────────────────────────────────────────
import chromadb

client = chromadb.Client()

# Доступные метрики в Chroma: "cosine" | "l2" | "ip"
# ip = inner product = dot product
# По умолчанию: "l2" (euclidean²), но для embedding-поиска лучше cosine

cosine_collection = client.create_collection(
    name="docs_cosine",
    metadata={"hnsw:space": "cosine"},   # ← задаём здесь
)

ip_collection = client.create_collection(
    name="docs_ip",
    metadata={"hnsw:space": "ip"},       # для нормализованных = cosine, быстрее
)


# ── Qdrant ───────────────────────────────────────────────────────
from qdrant_client import QdrantClient
from qdrant_client.models import Distance, VectorParams

qdrant = QdrantClient(":memory:")

qdrant.create_collection(
    collection_name="docs",
    vectors_config=VectorParams(
        size=1536,
        distance=Distance.COSINE,      # или DOT / EUCLID / MANHATTAN
    ),
)

# Qdrant нормализует векторы при cosine — хранит unit vectors внутри
# и использует dot product для скорости.
# При Distance.DOT — не нормализует, raw dot product.


# ── FAISS (локально, без сервера) ─────────────────────────────────
import faiss
import numpy as np

D = 1536

# IndexFlatIP = inner product (= cosine для нормализованных)
index_ip = faiss.IndexFlatIP(D)

# IndexFlatL2 = euclidean distance
index_l2 = faiss.IndexFlatL2(D)

# HNSW + IP (approx nearest neighbor, production-grade)
index_hnsw = faiss.IndexHNSWFlat(D, 32)   # 32 = M (graph connectivity)
index_hnsw.metric_type = faiss.METRIC_INNER_PRODUCT

# Пример: индексирование нормализованных векторов
N = 10_000
docs = np.random.randn(N, D).astype(np.float32)
faiss.normalize_L2(docs)                   # нормализуем inplace
index_ip.add(docs)

# Поиск: возвращает (scores, indices)
query = np.random.randn(1, D).astype(np.float32)
faiss.normalize_L2(query)
scores, indices = index_ip.search(query, k=10)
# scores: cosine similarity (т.к. оба нормализованы + IP)

Типичные ошибки

Использовать euclidean с ненормализованными векторами
Длинные документы создают длинные векторы. Euclidean расстояние до них больше — они уходят в конец ранжирования, даже если семантически точны. Особенно заметно при mixed chunksize.
✓ Всегда нормализуйте перед euclidean-поиском. Или используйте cosine — он нечувствителен к длине.
Использовать dot product с ненормализованными векторами
Длинные тексты → большие координаты → огромный dot product вне зависимости от смысла. Модели типа multilingual-e5 без normalize_embeddings=True вернут ненормализованные векторы.
✓ Проверяйте: np.linalg.norm(vec) ≈ 1.0. Если нет — нормализуйте или переключитесь на cosine.
Задавать порог «из головы» без калибровки
«0.8 — это высокое сходство» — но для разных моделей и доменов это неверно. text-embedding-3-small на общих текстах даёт cosine 0.65–0.85 для пар «вопрос-ответ». Порог 0.8 отрежет половину релевантного.
✓ Запустите calibrate_threshold() на 20–50 аннотированных парах. Смотрите p10 релевантных — это нижняя граница.
Смешивать метрики при добавлении документов в индекс
Коллекция создана с metric=cosine. При добавлении новых документов вы нормализовали одну партию, но забыли другую. Cosine-метрика БД посчитает cos(normalized_query, raw_doc) — это косинус, но doc не единичный, значит результат неверен.
✓ Нормализацию выполняйте всегда в одном месте — в embed-функции, до передачи в БД. Не полагайтесь на то, что «БД сделает сама».
Menять метрику у существующего FAISS-индекса
Vectors уже добавлены в IndexFlatL2. Вы хотите переключиться на IP — создаёте новый индекс с тем же массивом. Но старые L2-индексы без нормализации + новый IP дадут мусор.
✓ При смене метрики — полная переиндексация. Нормализуйте или не нормализуйте весь корпус заново под новую метрику.

Шпаргалка

ВЫБОР МЕТРИКИ (одна строка):

  Векторы нормализованы?
    Да  → используй dot product (быстрее, идентично cosine)
    Нет → используй cosine (нормирует сам, безопасно)
  Кластеризация/k-means?
    → euclidean (алгоритмы требуют это)
  Всё остальное → cosine

ИНТЕРПРЕТАЦИЯ COSINE (ориентир, зависит от модели):
  > 0.95   Почти идентичный текст
  0.85-0.95 Очень похожий смысл
  0.70-0.85 Похожий, один домен
  0.50-0.70 Слабая связь
  < 0.50   Нерелевантно (обычно)

ФОРМУЛЫ:
  cosine(A,B) = (A·B) / (||A|| * ||B||)   ∈ [−1, 1]
  dot(A,B)    = Σ Aᵢ*Bᵢ                   ∈ (−∞, +∞)
  eucl(A,B)   = √Σ(Aᵢ−Bᵢ)²               ∈ [0, +∞)

  При ||A||=||B||=1: cosine = dot; eucl² = 2 − 2*cosine

ПРОИЗВОДИТЕЛЬНОСТЬ:
  dot product   < cosine (нет деления на нормы)
  FAISS IndexFlatIP << FAISS IndexFlatL2 при нормализованных векторах
  batch (матрица @ вектор) >> цикл в 100-500×

НАЗВАНИЯ В БД:
  ChromaDB:  "cosine" | "l2" | "ip"
  Qdrant:    Distance.COSINE | DOT | EUCLID
  Weaviate:  cosine | dot | l2-squared | hamming
  pgvector:  <#> (ip) | <=> (cosine) | <-> (l2)
        

Практические задания

  1. Убедитесь в эквивалентности. Возьмите любую модель (text-embedding-3-small или sentence-transformers). Эмбеддируйте 5 предложений. Проверьте нормы векторов. Вычислите cosine и dot product между всеми парами. Убедитесь, что они совпадают (если модель нормализует). Затем отмасштабируйте вектора × 2 и повторите — посмотрите, как меняются dot, cosine, euclidean.
  2. Калибровка порога. Создайте корпус из 50 чанков вашей документации. Составьте 10 вопросов с известными ответами (правильными документами). Запустите calibrate_threshold() и найдите p10, p25, p50 косинусного сходства между вопросом и правильным ответом. Какой порог отсечёт меньше 10% релевантного?
  3. Бенчмарк метрик. Используя batch_cosine_search, измерьте время поиска по 100k нормализованных векторов (dim=1536) при top_k=10. Сравните с pre_normalized=False. Затем реализуйте аналог для euclidean и сравните скорости. Насколько нормализация заранее ускоряет поиск?