Агент из второго модуля умеет вызывать инструменты и помнить диалог, но о вашей компании он не знает ничего: ни регламентов, ни базы знаний поддержки, ни внутренней документации. Передать модели все документы целиком не получится: их слишком много для контекстного окна, да и платить за каждый лишний токен в каждом запросе дорого. Нужен способ быстро найти среди тысяч фрагментов те два-три, которые относятся к вопросу пользователя.
Обычный поиск по словам здесь подводит. Пользователь спрашивает «как вернуть деньги за заказ», а в документе написано «оформление возврата средств». Общих слов почти нет, хотя смысл один. Эмбеддинги решают эту проблему: они превращают текст в набор чисел так, что близкие по смыслу тексты получают близкие числа. После урока вы сможете получать эмбеддинги через API, сравнивать их и написать небольшой семантический поиск.
Что уже умеем
В первом модуле вы научились отправлять запросы через Responses API и считать стоимость по токенам, во втором — собирать агентов на LangChain с инструментами и памятью. Теперь начинаем модуль про внешние данные. Сегодня разберём его фундамент — эмбеддинги, на которых держится весь RAG.
Что вы узнаете в рамках статьи
- что такое эмбеддинг и почему он передаёт смысл текста;
- как получить эмбеддинг через пакет
openaiи черезOpenAIEmbeddingsиз LangChain; - как работает косинусное сходство и как его посчитать;
- как устроен семантический поиск top-k;
- сколько стоят эмбеддинги и сколько места занимают.
Ключевые термины
- Эмбеддинг — вектор, то есть список чисел фиксированной длины, который описывает смысл текста.
- Размерность — длина вектора; у модели
text-embedding-3-smallона равна 1536. - Косинусное сходство — мера близости двух векторов от −1 до 1: чем ближе к 1, тем ближе смысл.
- Семантический поиск — поиск по смыслу, а не по совпадению слов.
- top-k — k самых похожих на запрос фрагментов, которые возвращает поиск.
Что такое эмбеддинг
Модель эмбеддингов — это отдельная модель, которая не генерирует текст, а возвращает вектор. Представьте карту, на которой у каждого текста есть координаты. Тексты о возврате денег окажутся в одном районе карты, тексты о доставке — в соседнем, а рецепты пирогов — далеко от обоих. Только координат не две, как на бумажной карте, а 1536.
Получим эмбеддинг через уже знакомый пакет openai:
from openai import OpenAI
client = OpenAI() # ключ берётся из OPENAI_API_KEY
response = client.embeddings.create(
model="text-embedding-3-small",
input=["Как вернуть деньги за заказ?", "Оформление возврата средств"],
)
vectors = [item.embedding for item in response.data]
print(len(vectors), len(vectors[0]))
print(vectors[0][:4])
print("Токенов:", response.usage.total_tokens)
Что увидим в консоли: 2 1536, затем первые четыре числа вектора, например [0.0123, -0.0457, 0.0089, 0.0311], и число потраченных токенов. В input можно передать сразу список строк: один запрос вместо десяти экономит время. Сами по себе числа ничего не говорят человеку, смысл появляется только при сравнении векторов между собой.
Обратите внимание: эмбеддинги оплачиваются только за входные токены, выходных здесь нет. Для учебных расчётов примем цену text-embedding-3-small равной $0.02 за 1 млн токенов, но в задачах цена всегда будет дана в условии.
Эмбеддинги в LangChain
В LangChain у всех моделей эмбеддингов единый интерфейс. Это удобно: дальше в модуле векторное хранилище будет принимать именно такой объект.
from langchain_openai import OpenAIEmbeddings
embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
query_vector = embeddings.embed_query("Сколько идёт доставка?")
doc_vectors = embeddings.embed_documents([
"Доставка по городу занимает 1–2 дня.",
"Возврат оформляется в личном кабинете.",
])
print(len(query_vector), len(doc_vectors))
Что увидим: 1536 2. Метод embed_query принимает одну строку и возвращает один вектор, embed_documents принимает список и возвращает список векторов. Внутри это тот же вызов API, просто с удобной обёрткой.
Важное правило: запрос и документы нужно превращать в векторы одной и той же моделью. Векторы разных моделей лежат в разных «системах координат», и сравнивать их бессмысленно, даже если длина совпадает.
Косинусное сходство
Как понять, что два вектора близки? Самая популярная мера — косинусное сходство: косинус угла между векторами. Если векторы смотрят в одну сторону, косинус равен 1, если перпендикулярны — 0, если противоположны — −1.
Формула: cos(a, b) = (a · b) / (|a| × |b|), где a · b — скалярное произведение (сумма попарных произведений координат), а |a| — длина вектора (корень из суммы квадратов координат). Посчитаем на чистом Python для коротких векторов:
import math
def cosine_similarity(a: list[float], b: list[float]) -> float:
dot = sum(x * y for x, y in zip(a, b))
norm_a = math.sqrt(sum(x * x for x in a))
norm_b = math.sqrt(sum(y * y for y in b))
return dot / (norm_a * norm_b)
print(cosine_similarity([1, 2, 2], [2, 4, 4]))
print(cosine_similarity([1, 0, 0], [0, 1, 0]))
print(round(cosine_similarity([3, 4, 0], [4, 3, 0]), 2))
Что увидим: 1.0 (векторы сонаправлены, хоть и разной длины), 0.0 (перпендикулярны) и 0.96. Для третьей пары: скалярное произведение 3·4 + 4·3 = 24, длина каждого вектора 5, итог 24 / 25 = 0.96.
Векторы OpenAI уже нормированы: их длина равна 1. Поэтому для них косинусное сходство совпадает с обычным скалярным произведением, и считать нормы не обязательно. На реальных текстах значения обычно лежат между 0.1 и 0.9: даже несвязанные тексты редко дают ровно ноль.
Семантический поиск своими руками
Теперь соберём всё вместе. Заранее считаем эмбеддинги всех фрагментов базы знаний, а при вопросе пользователя — эмбеддинг вопроса. Затем сравниваем вопрос с каждым фрагментом и берём k лучших. Для скорости воспользуемся numpy:
import numpy as np
from langchain_openai import OpenAIEmbeddings
embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
docs = [
"Возврат денег возможен в течение 14 дней после покупки.",
"Доставка по Москве занимает один рабочий день.",
"Оплатить заказ можно картой или через СБП.",
"Чтобы вернуть товар, оформите заявку в личном кабинете.",
]
doc_matrix = np.array(embeddings.embed_documents(docs))
def search(query: str, k: int = 2) -> list[tuple[float, str]]:
q = np.array(embeddings.embed_query(query))
scores = doc_matrix @ q # векторы нормированы: это и есть косинус
top = np.argsort(scores)[::-1][:k] # индексы k лучших по убыванию
return [(round(float(scores[i]), 2), docs[i]) for i in top]
for score, text in search("Как получить назад деньги?"):
print(score, text)
Что увидим примерно такое:
0.62 Возврат денег возможен в течение 14 дней после покупки.
0.51 Чтобы вернуть товар, оформите заявку в личном кабинете.
Слова «получить назад» нигде в базе не встречаются, но поиск нашёл оба фрагмента про возврат. Эмбеддинги документов считаются один раз, а на каждый вопрос тратится всего один короткий запрос к API. Точные числа у вас будут другими, важен порядок результатов.
Такой список в памяти хорошо работает на сотнях фрагментов. Когда их десятки тысяч и они должны сохраняться между запусками программы, нужна векторная база данных — её мы подключим в уроке 3.3.
Частые ошибки
- Сравнивать векторы разных моделей. Запрос, посчитанный одной моделью, нельзя искать среди документов, посчитанных другой. При смене модели пересчитайте эмбеддинги всей базы.
- Считать эмбеддинги документов на каждый вопрос. Это многократно увеличивает стоимость и время. Документы векторизуются один раз, на вопрос — только сам запрос.
- Отправлять тексты по одному. Передавайте список строк в одном вызове: так быстрее и проще уложиться в лимиты запросов.
- Ждать сходства, близкого к 1, для хорошего ответа. Абсолютные значения зависят от модели. Ориентируйтесь на порядок результатов, а не на конкретный порог.
Наиболее частые вопросы
- Можно ли по эмбеддингу восстановить исходный текст? — Точно нет, вектор хранит смысл приблизительно. Поэтому рядом с вектором всегда хранят и сам текст.
- Зачем нужен
OpenAIEmbeddings, если естьclient.embeddings.create? — Результат одинаковый, но объект LangChain можно сразу передать векторному хранилищу и другим компонентам фреймворка. - Понимают ли эмбеддинги русский язык? — Да, современные модели многоязычны. Вопрос на русском может даже найти документ на английском.
- Чем семантический поиск хуже поиска по словам? — Он хуже находит точные коды, артикулы и редкие названия. В продакшене его часто комбинируют с поиском по ключевым словам.
Итоги
Отличная работа! Вы разобрались в идее, на которой держится весь современный поиск по документам: текст превращается в вектор, близкие по смыслу тексты получают близкие векторы, а косинусное сходство позволяет это измерить. Вы получили эмбеддинги двумя способами, посчитали сходство вручную и собрали маленький семантический поиск top-k.
Но реальные документы — это не аккуратные строки, а длинные PDF и текстовые файлы. В следующем уроке вы научитесь загружать их и разбивать на фрагменты такого размера, чтобы поиск находил именно нужный кусок.