Задача: найти «похожие» векторы
У нас есть запрос пользователя — мы превратили его в вектор из 1536 чисел. У нас есть 100 000 документов — каждый тоже вектор из 1536 чисел. Задача: найти 10 документов, «ближайших» к запросу.
Что значит «ближайший»? Вот здесь и расходятся пути. В зависимости от метрики один и тот же набор векторов даёт разное ранжирование. Три метрики — три взгляда на «похожесть».
Cosine similarity: угол важнее расстояния
Представьте два текста: «Python очень быстрый язык» и «Python — очень, очень, очень быстрый язык программирования». Второй текст длиннее, его вектор будет длиннее (большие значения компонент). Но смысл — тот же.
Cosine similarity измеряет угол между векторами — не расстояние между концами. Два вектора, направленные в одну сторону, имеют cosine = 1, даже если один в 10 раз длиннее другого. Именно это нужно для семантического поиска: нам важен смысл, не объём текста.
||A|| = √(A₁² + A₂² + ... + Aₙ²) (евклидова норма, длина вектора)
Интерпретация значений:
Dot product: скалярное произведение
Dot product — это числитель формулы cosine без нормировки. Он измеряет одновременно угол и длины обоих векторов: длинные векторы, направленные в одну сторону, дают большой dot product.
Dot product удобен, когда важна не только направленность, но и «уверенность» (длина вектора как сигнал интенсивности). Некоторые модели специально обучены так, что более частотные или более «важные» концепции имеют более длинные векторы — и тогда dot product ранжирует их выше.
normalize_embeddings=True
возвращают нормализованные векторы — тогда dot product = cosine.
Euclidean distance: прямая линия
Евклидово расстояние — это привычное «расстояние по прямой» в многомерном пространстве. Формула обобщает теорему Пифагора на N измерений.
Ключевое отличие от cosine: евклидово расстояние чувствительно к «масштабу» — длинный вектор, направленный в ту же сторону, будет далеко от короткого вектора в евклидовом смысле, даже если они семантически идентичны.
короткий чанк, 80 токенов
длинный чанк, 320 токенов
сравнение, 250 токенов
длинный чанк, 320 токенов
сравнение, 250 токенов
короткий чанк, 80 токенов
В этом примере cosine ставит на первое место короткий чанк с точным ответом, потому что его вектор направлен ближе к запросу. Euclidean же предпочитает длинный чанк — он богаче терминами, его вектор по модулю ближе к длинному запросу. Для RAG первое поведение правильнее: нужен точный ответ, не объём.
Геометрическая интерпретация
Стрелки слева — это embedding-векторы от начала координат. По cosine векторы A и C почти одинаковы (малый угол с Q), B далеко (большой угол). По euclidean C проигрывает A просто потому, что она короче — конец вектора C физически дальше от конца Q. Хотя направление почти совпадает.
Нормализация L2: ключ ко всему
Нормализация L2 — это масштабирование вектора до единичной длины. После нормализации все три метрики ранжируют одинаково.
[1.6]
[0.4]
...
||v||=2.1
произвольная длина
[0.76]
[0.19]
...
||v||=1.0
||v|| = 1 (unit vector)
= cosine
≡ euclidean
Математически: если ||A|| = ||B|| = 1, то:
Одинаковое ранжирование: меньше d ↔ больше cosine.
Вывод практический: если ваша embedding-модель возвращает нормализованные векторы — можно использовать dot product вместо cosine. Это одно и то же, но dot product считается быстрее (нет нормировки в знаменателе).
text-embedding-3-* — да, всегдаsentence-transformers — только при normalize_embeddings=TrueBGE-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)
Типичные ошибки
normalize_embeddings=True вернут ненормализованные векторы.
Шпаргалка
ВЫБОР МЕТРИКИ (одна строка):
Векторы нормализованы?
Да → используй 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)
Практические задания
- Убедитесь в эквивалентности. Возьмите любую модель (text-embedding-3-small или sentence-transformers). Эмбеддируйте 5 предложений. Проверьте нормы векторов. Вычислите cosine и dot product между всеми парами. Убедитесь, что они совпадают (если модель нормализует). Затем отмасштабируйте вектора × 2 и повторите — посмотрите, как меняются dot, cosine, euclidean.
-
Калибровка порога. Создайте корпус из 50 чанков
вашей документации. Составьте 10 вопросов с известными ответами
(правильными документами). Запустите
calibrate_threshold()и найдите p10, p25, p50 косинусного сходства между вопросом и правильным ответом. Какой порог отсечёт меньше 10% релевантного? -
Бенчмарк метрик. Используя
batch_cosine_search, измерьте время поиска по 100k нормализованных векторов (dim=1536) при top_k=10. Сравните сpre_normalized=False. Затем реализуйте аналог для euclidean и сравните скорости. Насколько нормализация заранее ускоряет поиск?