Перейти к содержанию

Эмбеддинги и договор пространства векторов

Фрагменты библиотеки уже имеют заголовки, текст и положение в документе. Чтобы искать близкие формулировки, нужно представить каждый фрагмент вектором — упорядоченным набором чисел. При этом важны не только сами числа: поиск должен знать, каким способом они получены и с какими векторами их допустимо сравнивать.

В состоянии lesson-03 добавим два адаптера с общим методом embed(texts). Первый построит учебные счётчики слов, второй обратится к локальному Ollama. После подготовки у читателя должен появиться явный выбор режима и проверка формы ответа. Модель, её загрузка и запросы при написании урока не запускались; качество конкретного режима ещё не измерено.

Что означает размерность

Вектор размерности 768 содержит ровно 768 числовых координат. Его нельзя непосредственно сравнить с массивом из 384 элементов: соответствие осей уже другое. Но одинаковая длина тоже не доказывает совместимость. Две модели могут возвращать 768 чисел и при этом помещать смысл в разные направления пространства.

Поэтому вводим space: идентификатор способа построения вектора. В него входят режим, модель или алгоритм, отпечаток модели, размерность и версия подготовки текста. Именно эта строка позже попадёт в SQL-условие. Вопрос должен обрабатываться тем же пространством, что и сравниваемые документы, иначе сортировка расстояний будет выглядеть вычислимой, но потеряет смысл.

Для разных задач одна модель иногда предполагает разные инструкции запросу и документу. Наш простой договор передаёт исходный вопрос и текст фрагмента без специальных префиксов. Это ограничение нужно отдельно оценить для выбранного модельного артефакта. Если добавите префиксы или другую предобработку, измените space, а не подмешивайте новые векторы к прежним.

Адаптер с прозрачным устройством

FixtureEmbedder разбивает текст на слова, переводит их в нижний регистр и распределяет по 768 корзинам. Номер корзины определяется хешем слова; значение координаты увеличивается при повторении. Здесь нет обучения и понимания языка. Это детерминированный учебный механизм для разбора импорта, размерности и поиска, а не замена семантической модели.

import hashlib, json, math, os, re
from urllib.request import Request, urlopen
DIM=768
def validate_vectors(rows,count):
    if len(rows)!=count:raise ValueError('embedding count mismatch')
    for row in rows:
        if len(row)!=DIM or any(isinstance(x,bool) or not isinstance(x,(int,float)) or not math.isfinite(x) for x in row):
            raise ValueError('invalid embedding shape')
        if not any(row):raise ValueError('zero vector is not searchable by cosine')
    return rows
def post_json(path,payload=None):
    data=None if payload is None else json.dumps(payload,allow_nan=False).encode()
    req=Request('http://127.0.0.1:11434'+path,data=data,headers={'Content-Type':'application/json'})
    with urlopen(req,timeout=30) as response:
        raw=response.read(2_000_001)
        if len(raw)>2_000_000:raise ValueError('model response too large')
        return json.loads(raw)
class FixtureEmbedder:
    space='fixture-word-buckets-v1:768'
    def embed(self,texts):
        rows=[]
        for text in texts:
            row=[0.0]*DIM
            for word in re.findall(r'\w+',text.lower()):
                slot=int.from_bytes(hashlib.sha256(word.encode()).digest()[:4],'big')%DIM
                row[slot]+=1
            rows.append(row)
        return validate_vectors(rows,len(texts))
class OllamaEmbedder:
    def __init__(self):
        self.model=os.environ.get('EMBED_MODEL','embeddinggemma')
        self.digest=os.environ['EMBED_DIGEST']
        if not re.fullmatch(r'[a-f0-9]{64}',self.digest):raise ValueError('record exact local model digest')
        self.space=f'ollama:{self.model}:{self.digest}:768:raw-v1'
    def check_model(self):
        models=post_json('/api/tags')['models']
        candidates={self.model,self.model+':latest'}
        matches=[m for m in models if m.get('name') in candidates and m.get('digest')==self.digest]
        if len(matches)!=1:raise ValueError('model digest changed or missing')
    def embed(self,texts):
        self.check_model()
        response=post_json('/api/embed',{'model':self.model,'input':texts,'truncate':False,'dimensions':DIM})
        self.check_model()
        return validate_vectors(response['embeddings'],len(texts))
def get_embedder():
    mode=os.environ.get('EMBED_BACKEND','fixture')
    if mode=='fixture':return FixtureEmbedder()
    if mode=='ollama':return OllamaEmbedder()
    raise ValueError('unknown embedding backend')
def vector_literal(row):
    validate_vectors([row],1)
    return json.dumps(row,allow_nan=False,separators=(',',':'))

Такой режим позволяет исследовать очевидные случаи: одинаковые слова усиливают совпадение, разные формы слова могут разойтись, случайная коллизия хеша может объединить несвязанные элементы. Эти особенности следует ожидать из устройства адаптера. Высокое место в выдаче не становится свидетельством хорошего понимания вопроса.

Метод возвращает один вектор на один входной текст. Порядок сохраняется, поэтому vectors[i] относится к texts[i]. Если адаптер вернул меньше элементов, импорт должен остановиться. Продолжить zip() без проверки опасно: Python закончит обход по самому короткому списку и может незаметно оставить часть фрагментов без векторов.

Проверка числовых значений

validate_vectors() проверяет длину результата, размерность каждого элемента и конечность координат. Значения NaN и бесконечность исключаются, потому что они нарушают обычные предположения при расстояниях и хранении. Логическое значение также запрещено, хотя в Python bool является разновидностью целого числа. Ответ API с true вместо координаты следует считать испорченным.

Отдельно запрещаем нулевой вектор. Для косинусного расстояния нужен ненулевой масштаб, иначе направление не определено. Такая ошибка может возникнуть, например, при пустом тексте в учебном адаптере. Не заменяем его случайным числом ради успешной вставки: это внесло бы недостоверные данные в индекс.

Эта проверка подтверждает форму представления, но не его качество. Любой произвольный конечный массив подходящей длины пройдёт структурную проверку. Способность находить правильные источники исследуется отдельным набором вопросов, а не проверкой длины списка.

Запрос к локальной модели

Режим ollama использует /api/embed. В поле input передаётся массив текстов, в dimensions — выбранная размерность. Значение truncate: false требует отказа при превышении модельного контекста вместо незаметного обрезания входа. Обрезанный текст и полный текст не должны получать один и тот же договор импорта без предупреждения.

Адрес модели фиксирован на 127.0.0.1:11434; браузер не выбирает произвольный URL. Задан таймаут, а размер прочитанного ответа ограничен. Это не полноценный промышленный клиент с очередями и общим бюджетом времени, но такие границы позволяют отличить отказ внешнего компонента от нормального результата поиска.

Параметр EMBED_DIGEST нужно получить из своей локальной установки и сохранить вне исходников с секретами. Проверка /api/tags до и после запроса обнаруживает обычную смену модельного артефакта. Она не является атомарной блокировкой модели: параллельную замену на короткое время такой протокол не гарантирует обнаружить. Во время подготовки корпуса необходимо отдельно запретить изменения модельных файлов.

Название embeddinggemma используется как исходный выбор, а не как вечная ссылка на неизменный артефакт. Тег может обновиться. Именно поэтому фиксируем фактический digest и не называем установку воспроизводимой только по команде загрузки модели. Версию самого Ollama также записывают при самостоятельной подготовке среды.

Одна модель для корпуса и вопроса

Поменяйте EMBED_BACKEND с fixture на ollama только как явное решение для всей лаборатории. После этого весь корпус нужно импортировать в новое пространство. Наличие прежних записей с такой же длиной вектора не делает их пригодными для нового запроса. Дальнейший код специально отфильтрует их по space.

Для диагностики начните с короткого списка из двух известных фрагментов. Ожидаемая форма результата — два массива по 768 чисел, а не конкретные опубликованные здесь координаты. Авторских модельных замеров нет. Сначала убедитесь в форме и происхождении результата, затем исследуйте порядок найденных документов.

Записывать нужно не только имя модели, но и состав входного текста. В импорт передаём заголовок раздела вместе с абзацем, тогда как вопрос остаётся самостоятельной строкой. Если позднее добавите название курса или специальный префикс, изменится договор получения векторов. Такой переход требует новой версии пространства и полного переимпорта соответствующих документов. Само поле dimensions не описывает все эти преобразования. Не смешивайте старые и новые векторы после «небольшого» изменения подготовки текста, даже если локальная модель сохранила прежний digest. В записи опыта полезно хранить версию адаптера, формат входа и решение о нормализации, чтобы следующий редактор мог восстановить тот же способ сравнения.

В следующем уроке создадим таблицы PostgreSQL. Размерность станет частью схемы, а space и ревизия документа позволят хранить согласованные состояния корпуса. Это разделит ошибки модели, ошибки импорта и ошибки запроса ещё до подготовки текстового ответа.

Исходники и документация

Оглавление серии · Все состояния проекта. Для этого урока откройте папку lesson-03.

pgvector, транзакции Psycopg, Ollama Embed, структурированные ответы.

Примеры подготовлены как исходники. Их выполнение, замеры, тесты и публикация при написании не проводились. Описанные результаты ожидаемые.