L3 Открытый урок · Стажёр
L3.6. Эмбеддинги и семантический поиск
Косинусная близость, откуда брать эмбеддинги, минимальный поисковик на NumPy и чего эмбеддинги не умеют: коды, отрицания, длинные тексты.
- Объём
- 5 уроков · 10 часов
- Компетенции
- EMB-01·A, EMB-02·A, RAG-01·F
От ключевых слов к смыслу
Клиент спрашивает: «вода после фильтра пахнет странно». В базе есть статья «Посторонний запах воды: причины и замена картриджа». Общих слов почти нет, поиск по ключевым словам её не найдёт. Эмбеддинги (L2.3) переводят текст в вектор так, что тексты близкие по смыслу оказываются рядом.
Косинусная близость
Мерой близости служит косинус угла между векторами: 1 — направлены одинаково, 0 — не связаны.
import numpy as np
def cosine(a: np.ndarray, b: np.ndarray) -> float:
return float(a @ b / (np.linalg.norm(a) * np.linalg.norm(b)))
a, b = np.array([0.2, 0.9, 0.1]), np.array([0.25, 0.8, 0.0])
print(round(cosine(a, b), 3)) # 0.991 — почти одно и то же направление
Если векторы заранее нормализованы (длина 1), косинус — это просто скалярное произведение, а поиск по всей базе — одно умножение матрицы на вектор.
Откуда брать эмбеддинги
| Вариант | Плюсы | Минусы |
|---|---|---|
| API провайдера эмбеддингов | качество, не нужно железо | данные уходят наружу, плата за токены |
Открытая модель локально (sentence-transformers) |
бесплатно, данные остаются у вас | нужен CPU/GPU, выбор модели под язык |
У Anthropic нет собственного API эмбеддингов: в связке с Claude используют сторонние API или открытые модели. Модель выбирают под язык и домен: модель, обученная на английском, хуже работает с русским. Проверяйте на своих запросах (практикум ниже), а не по рейтингам.
Правило совместимости: запросы и документы кодируются одной и той же моделью. Векторы разных моделей несравнимы, даже если у них одинаковая размерность. Сменили модель — переиндексируйте весь корпус.
Минимальный поисковик
import numpy as np
from sentence_transformers import SentenceTransformer
model = SentenceTransformer("<модель, выбранная под русский язык>")
def build_index(texts: list[str]) -> np.ndarray:
return model.encode(texts, normalize_embeddings=True) # матрица N × d
def search(query: str, texts: list[str], index: np.ndarray, k: int = 5) -> list[tuple[str, float]]:
q = model.encode([query], normalize_embeddings=True)[0]
scores = index @ q # косинус со всеми сразу
top = np.argsort(-scores)[:k]
return [(texts[i], float(scores[i])) for i in top]
Индекс сохраните на диск (np.save) вместе с тем, какой моделью и из какой версии корпуса он построен. Иначе через неделю вы не поймёте, почему поиск «сломался».
Что эмбеддинги делают плохо
- Точные идентификаторы. «Картридж АК-3/2025» и «АК-3/2024» для эмбеддинга почти одно и то же. Коды, артикулы, имена лучше находит лексический поиск (BM25). Гибридный поиск соединяет оба канала — L5.1.
- Отрицание. «Фильтр, который не требует замены картриджа» близок к «фильтр с заменой картриджа».
- Длинные тексты. Вектор документа на 20 страниц — «среднее по больнице». Документы режут на фрагменты (чанки) и ищут по ним: на L3 — простая нарезка по абзацам, на L4.6 — стратегии чанкинга.
Поиск ≠ RAG
Поиск возвращает фрагменты. RAG (retrieval-augmented generation) подаёт найденные фрагменты модели, и она формулирует ответ с цитатами. На L3 вы строите только первую половину (RAG-01·F) и измеряете её отдельно. Это правильный порядок: если поиск не находит нужный фрагмент, генерация не поможет.
Практикум: поиск по корпусу
Над корпусом из L3.5 (нарезка по абзацам, не длиннее ~300 слов): индекс, функция search, CLI python search.py "запрос" -k 5 с выводом doc_id, оценки и начала фрагмента. Сравните две модели эмбеддингов на 10 своих запросах: какая чаще ставит нужный документ на первое место? Найдите и запишите 2 запроса, где поиск ошибается, и объясните почему (идентификатор, отрицание, синоним).
