ПараметрЗначение
BloomL3–L4 (Применение → Анализ)
SFIAУровень 2–3
DreyfusAdvanced Beginner → Competent
АртефактСкрипт сравнения моделей + benchmark
ПроверкаТри модели, одна фраза – сравниваем score

TL;DR

all-MiniLM и nomic-embed-text плохо различают русский текст: борщ и nginx получают одинаковый score. mxbai-embed-large – единственная приемлемая из трёх протестированных, но требует правильной настройки порога.


Проблема: мусор на входе – мусор на выходе

В прошлом посте мы запустили Qdrant и сделали семантический поиск. Но использовали случайные вектора (random.uniform). В реальном pipeline вектора создаёт embedding-модель – и от неё зависит всё.

Плохая модель превращает “настройка reverse proxy” и “проксирование запросов через nginx” в далёкие точки. Хорошая – в соседние. Если модель не понимает русский текст, ваш RAG будет находить ерунду, даже если Qdrant работает идеально. Garbage in – garbage out. Только тут garbage не в данных, а в модели.


Как работает embedding

Embedding-модель – это нейросеть, обученная на миллионах пар текстов. На входе – строка. На выходе – массив чисел фиксированной длины (вектор).

# Отправляем текст в Ollama
curl -s http://localhost:11434/api/embed \
  -d '{"model":"all-minilm","input":"Docker контейнер"}' \
  | python3 -c "
import sys, json
emb = json.load(sys.stdin)['embeddings'][0]
print(f'Размерность: {len(emb)}')
print(f'Первые 5: {[round(x,4) for x in emb[:5]]}')
"
# Размерность: 384
# Первые 5: [-0.0312, 0.0891, -0.0456, 0.1234, -0.0678]

Два правила, которые нельзя нарушать:

  1. Детерминированность: один и тот же текст всегда даёт один и тот же вектор
  2. Одна модель на pipeline: индексировали через mxbai-embed-large – ищите через неё же. Вектора разных моделей несовместимы (разная размерность, разное пространство смыслов)

Нарушение второго правила – типичная причина “RAG ничего не находит”. Переехали на новую модель – переиндексируйте всю базу.


Три модели: сравнение на практике

Все три доступны через Ollama. Скачиваем:

ollama pull all-minilm        # 23 MB, 384d
ollama pull nomic-embed-text  # 274 MB, 768d
ollama pull mxbai-embed-large # 670 MB, 1024d

Характеристики

МодельРазмерностьРазмерКонтекстРусскийСкорость
all-MiniLM38423 MB256 tokensПлохоОчень быстрая
nomic-embed-text768274 MB8192 tokensПлохо (не отличает релевантное от нерелевантного)Быстрая
mxbai-embed-large1024670 MB512 tokensПриемлемо (при правильном пороге)Средняя

Эксперимент: одна фраза, три модели

Проверим, как модели понимают семантическую близость русского текста. Две пары фраз с одинаковым смыслом, но разными словами, и одна контрольная – заведомо нерелевантная (“рецепт борща”), чтобы проверить, отличает ли модель полезное от мусора:

#!/usr/bin/env python3
# compare-embeddings.py – сравниваем три модели
import requests
import numpy as np

OLLAMA = "http://localhost:11434"

def get_embedding(model, text):
    resp = requests.post(f"{OLLAMA}/api/embed",
                         json={"model": model, "input": text})
    return np.array(resp.json()["embeddings"][0])

def cosine_sim(a, b):
    return np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b))

queries = [
    ("настройка reverse proxy", "проксирование запросов через nginx"),
    ("контейнер упал с OOM", "процесс убит из-за нехватки памяти"),
    ("настройка reverse proxy", "рецепт борща"),
]

for model in ["all-minilm", "nomic-embed-text", "mxbai-embed-large"]:
    print(f"\n=== {model} ===")
    for q1, q2 in queries:
        e1 = get_embedding(model, q1)
        e2 = get_embedding(model, q2)
        score = cosine_sim(e1, e2)
        print(f"  {score:.3f}  '{q1}' ↔ '{q2}'")

Результаты (реальные замеры на нашем сервере, Ollama):

=== all-minilm ===
  0.24  'настройка reverse proxy' ↔ 'проксирование запросов через nginx'
  0.55  'контейнер упал с OOM' ↔ 'процесс убит из-за нехватки памяти'
  0.15  'настройка reverse proxy' ↔ 'рецепт борща'

=== nomic-embed-text ===
  0.45  'настройка reverse proxy' ↔ 'проксирование запросов через nginx'
  0.66  'контейнер упал с OOM' ↔ 'процесс убит из-за нехватки памяти'
  0.46  'настройка reverse proxy' ↔ 'рецепт борща'

=== mxbai-embed-large ===
  0.72  'настройка reverse proxy' ↔ 'проксирование запросов через nginx'
  0.75  'контейнер упал с OOM' ↔ 'процесс убит из-за нехватки памяти'
  0.50  'настройка reverse proxy' ↔ 'рецепт борща'

Что видно по цифрам:

  • all-MiniLM: score 0.24 для семантически идентичных фраз – провал. На разумном пороге (0.5+) RAG ничего не найдёт. Единственный плюс: борщ (0.15) хотя бы далеко от proxy.
  • nomic-embed-text: proxy 0.45, но борщ тоже 0.46. Модель не отличает nginx от кулинарии на русском тексте. Это хуже, чем бесполезно – это опасно.
  • mxbai-embed-large: proxy 0.72 – уже рабочий score. Но борщ 0.50 – всё ещё высоковато. На пороге 0.6 борщ проскочит. На пороге 0.7 – нет. Настройка порога критична.

Главный вывод: ни одна из моделей не даёт на русском тексте score выше 0.8 для семантически идентичных фраз. На английском all-MiniLM выдаёт 0.68 для “Docker container” / “containerization” – тоже не блестяще, но мусор получает заметно более низкий score. На русском tokenizer разбивает слово на 8-11 частей (почти по буквам), и модель теряет контекст – это не “цена мультиязычности”, а следствие того, что русского текста в обучающих данных было мало.

Почему на практике это работает лучше, чем в тесте. Тест выше – worst case: чисто русские фразы без единого английского слова. Реальный DevOps-контент выглядит иначе: “настройка reverse proxy в nginx”, “деплой через docker compose”, “kubectl apply -f deployment.yaml”. Английские технические термины токенизируются нормально (1 токен) и несут основной смысловой сигнал. Русские слова вокруг них – связующая ткань, менее важная для поиска. Наш продакшен pipeline (206K векторов) работает на таком смешанном контенте без проблем. Но если индексировать чисто русскую документацию без технических терминов – score будут такими же низкими, как в тесте.


Подводные камни с русским текстом

1. Токенизация: русское слово = 8-11 токенов

Embedding-модели используют tokenizer, обученный преимущественно на английском тексте. Одно английское слово – обычно 1 токен. Русское слово раскладывается почти по буквам:

English: "container"     → 1 token  → ["container"]
Русский: "контейнер"     → 9 tokens → ["к", "о", "н", "т", "е", "и", "н", "е", "р"]
Русский: "проксирование" → 11 tokens

Проверено на реальных tokenizer’ах all-MiniLM, nomic-embed-text и mxbai-embed-large – результат одинаковый. Одно русское слово = 8-11 токенов.

Последствие: русский текст длиной 800 символов может содержать 600-800 токенов. Модель с контекстом 256 токенов (all-MiniLM) обрежет его молча, потеряв большую часть. Модель с контекстом 512 (mxbai-embed-large) – тоже может не уместить.

2. Truncation: Ollama truncate=true не работает

Документация Ollama обещает параметр truncate: true для автоматической обрезки. На практике поведение нестабильно: в одних версиях модель молча обрезает текст (теряя конец), в других – возвращает ошибку. Полагаться на это нельзя.

Решение – обрезать самостоятельно до отправки:

def safe_embed(model, text, max_chars=800):
    """Progressive truncation: 800 → 600 → 400 при ошибке"""
    for limit in [max_chars, 600, 400]:
        chunk = text[:limit]
        try:
            resp = requests.post(f"{OLLAMA}/api/embed",
                                 json={"model": model, "input": chunk},
                                 timeout=30)
            if resp.status_code == 200:
                return resp.json()["embeddings"][0]
        except Exception:
            continue
    return None  # Не удалось получить embedding

Это реальный код из нашего продакшен pipeline. Progressive truncation: сначала пробуем 800 символов, если модель не справляется – 600, потом 400.

3. Batch-обработка ломается

Ollama поддерживает batch embedding – отправить несколько текстов за один запрос. На коротких фразах работает даже с русским. Но на длинных текстах (300+ символов, реальные чанки документации) – ломается: модель молча возвращает пустой массив или ошибку. Воспроизводимость зависит от версии Ollama и модели.

# ТАК НЕ НАДО (с русским текстом):
resp = requests.post(f"{OLLAMA}/api/embed",
    json={"model": "mxbai-embed-large",
          "input": [text1, text2, text3]})  # batch – ненадёжно

# ТАК НАДЁЖНО:
for text in [text1, text2, text3]:
    resp = requests.post(f"{OLLAMA}/api/embed",
        json={"model": "mxbai-embed-large",
              "input": text})  # по одному

Да, это медленнее. Но на первой итерации pipeline (all-MiniLM, 16K чанков) индексация поштучно занимала ~20 минут. На текущем объёме (206K, mxbai-embed-large) полная переиндексация дольше, но в штатном режиме systemd timer переиндексирует только изменённые файлы – и это незаметно.

4. Санитизация текста

Перед embedding текст нужно очистить:

def sanitize_for_embedding(text):
    """Убираем мусор, который ломает embedding"""
    import re
    text = text.replace('\ufffd', '')           # replacement character
    text = re.sub(r'[\x00-\x08\x0b\x0c\x0e-\x1f]', '', text)  # control chars
    text = re.sub(r'(.)\1{20,}', r'\1\1\1', text)  # aaaa...aaa → aaa
    text = text.strip()
    if len(text) < 20:       # quality gate
        return None
    return text

Без этого \ufffd (Unicode replacement character) и длинные повторяющиеся последовательности (=====...===== из markdown) генерируют мусорные вектора, которые “притягивают” нерелевантные результаты.


Ollama vs API

КритерийOllama (self-hosted)OpenAI API (text-embedding-3-small)
СтоимостьБесплатно (ваше железо)$0.02 / 1M tokens
ПриватностьДанные не покидают серверДанные уходят в OpenAI
СкоростьЗависит от GPU/CPUСтабильно быстро
Качество на русскомmxbai-embed-large – приемлемо (score ~0.72 для похожих фраз)text-embedding-3-large – по отзывам лучше (не тестировали)
OfflineДаНет
ЗависимостьНетAPI key, rate limits, downtime

Мы используем Ollama + mxbai-embed-large. Данные остаются на сервере, нет зависимости от внешнего API, нет счетов за токены. Качество на русском достаточное для RAG – при правильном пороге.

OpenAI API оправдан, если: нет GPU, нужно максимальное качество на русском, или объём данных маленький (стоимость копейки).


Мини-тест

1. RAG находил фрагменты, вы сменили модель эмбеддинга. Теперь ничего не находит. Почему?

Ответ

Вектора старой и новой модели несовместимы – разная размерность и/или разное пространство смыслов. Нужно переиндексировать всю базу Qdrant новой моделью. Старые вектора бесполезны.

2. Русский текст 800 символов, модель all-MiniLM (контекст 256 токенов). Что произойдёт?

Ответ

800 символов русского текста ≈ 600-800 токенов (одно русское слово = 8-11 токенов – побуквенное разбиение). Модель с контекстом 256 (all-MiniLM) потеряет большую часть текста. Даже mxbai-embed-large с контекстом 512 не уместит всё. Решение: обрезать текст перед отправкой (safe_embed) или уменьшить размер чанка.

3. Score между двумя явно похожими фразами = 0.4. Это нормально?

Ответ

Зависит от модели и языка. На английском – нет, ожидается 0.7+. На русском – score 0.4 может быть лучшим результатом для all-MiniLM. Проблема в том, что нерелевантный текст (например, “рецепт борща”) тоже может получить 0.4 на некоторых моделях (nomic-embed-text). Если разница score между релевантным и нерелевантным меньше 0.15 – модель непригодна для вашего языка. Для русского mxbai-embed-large даёт разницу ~0.22 (proxy 0.72, борщ 0.50), что уже рабочий вариант.

4. Зачем sanitize_for_embedding() перед отправкой текста?

Ответ

Мусорные символы (\ufffd, control chars) и длинные повторяющиеся последовательности (===…===) генерируют шумные вектора, которые “притягивают” нерелевантные результаты при поиске. Порог качества (<20 символов) отсекает слишком короткие фрагменты, которые не несут достаточного смысла для embedding.


Артефакт: скрипт сравнения моделей

#!/usr/bin/env python3
"""
compare-embeddings.py – benchmark embedding-моделей для вашего RAG
Запуск: python3 compare-embeddings.py

Требования:
  pip install requests numpy
  ollama pull all-minilm nomic-embed-text mxbai-embed-large
"""
import requests
import numpy as np
import time

OLLAMA = "http://localhost:11434"

MODELS = ["all-minilm", "nomic-embed-text", "mxbai-embed-large"]

# Пары: (текст1, текст2, ожидание)
# similar = должны быть близки, different = должны быть далеки
PAIRS = [
    ("настройка reverse proxy", "проксирование запросов через nginx", "similar"),
    ("контейнер упал с OOM", "процесс убит из-за нехватки памяти", "similar"),
    ("Kubernetes pod в CrashLoopBackOff", "контейнер перезапускается циклически", "similar"),
    ("ansible playbook для деплоя", "автоматизация развёртывания через YAML", "similar"),
    ("настройка reverse proxy", "рецепт борща", "different"),
    ("мониторинг CPU", "история Древнего Рима", "different"),
]

def get_embedding(model, text):
    resp = requests.post(f"{OLLAMA}/api/embed",
                         json={"model": model, "input": text},
                         timeout=60)
    resp.raise_for_status()
    return np.array(resp.json()["embeddings"][0])

def cosine_sim(a, b):
    return float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b)))

print("=" * 70)
for model in MODELS:
    print(f"\n{'=' * 70}")
    print(f"  MODEL: {model}")
    print(f"{'=' * 70}")

    start = time.time()
    scores_sim, scores_diff = [], []

    for q1, q2, expect in PAIRS:
        e1 = get_embedding(model, q1)
        e2 = get_embedding(model, q2)
        score = cosine_sim(e1, e2)

        marker = "OK" if (expect == "similar" and score > 0.6) or \
                         (expect == "different" and score < 0.3) else "!!"
        print(f"  [{marker}] {score:.3f}  '{q1}' ↔ '{q2}'")

        if expect == "similar":
            scores_sim.append(score)
        else:
            scores_diff.append(score)

    elapsed = time.time() - start
    print(f"\n  Avg similar: {np.mean(scores_sim):.3f}")
    print(f"  Avg different: {np.mean(scores_diff):.3f}")
    print(f"  Separation: {np.mean(scores_sim) - np.mean(scores_diff):.3f}")
    print(f"  Time: {elapsed:.1f}s ({len(PAIRS)} pairs)")
    print(f"  Dimensions: {len(get_embedding(model, 'test'))}")

Запуск:

pip install requests numpy
python3 compare-embeddings.py

Чем больше Separation (разница между avg similar и avg different) – тем лучше модель отличает релевантное от нерелевантного.


Продакшен-параметры

В RAG Pipeline 1/N мы показывали параметры учебного pipeline (all-MiniLM, 384d, 16K чанков курса). С тех пор pipeline вырос: переехали на mxbai-embed-large, объём данных увеличился на порядок. Вот актуальные параметры (206,000+ векторов):

ПараметрЗначениеПочему
Модельmxbai-embed-largeЛучшее качество на русском из Ollama
Размерность1024Определяется моделью
Контекст модели512 tokensОграничение mxbai
CHUNK_SIZE800 charsС progressive truncation не превышает 512 tokens
CHUNK_OVERLAP150 charsКонтекст на границах чанков
TruncationProgressive: 800→600→400Fallback при “context length exceeded”
BatchПоштучно (не batch)Batch ломается на длинном русском
Sanitizestrip \ufffd, control chars, dedupЧистые вектора = чистый поиск
Quality gatemin 20 charsСлишком короткие фрагменты → шум
Объём206,000+ векторовТехническая документация + рабочие заметки
Syncsystemd timer, 10 minRe-index только изменённые

Что дальше

Модель выбрана, вектора генерируются. Но качество RAG зависит не только от модели – оно зависит от того, как вы нарезаете текст на куски:

  • RAG Pipeline 3/N – Chunking – размер чанка, overlap, split по границам функций, metadata enrichment. Почему 800 символов, а не 500 или 1200.

Telegram: @DevITWay Сайт: devopsway.ru