$ sudo teach IT

Агент из второго модуля умеет вызывать инструменты и помнить диалог, но о вашей компании он не знает ничего: ни регламентов, ни базы знаний поддержки, ни внутренней документации. Передать модели все документы целиком не получится: их слишком много для контекстного окна, да и платить за каждый лишний токен в каждом запросе дорого. Нужен способ быстро найти среди тысяч фрагментов те два-три, которые относятся к вопросу пользователя.

Обычный поиск по словам здесь подводит. Пользователь спрашивает «как вернуть деньги за заказ», а в документе написано «оформление возврата средств». Общих слов почти нет, хотя смысл один. Эмбеддинги решают эту проблему: они превращают текст в набор чисел так, что близкие по смыслу тексты получают близкие числа. После урока вы сможете получать эмбеддинги через 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 и текстовые файлы. В следующем уроке вы научитесь загружать их и разбивать на фрагменты такого размера, чтобы поиск находил именно нужный кусок.

На следующий урок