MAATRIX / Блог / Векторная база выросла в 10 раз за неделю: что пошло не так

Векторная база выросла в 10 раз за неделю: что пошло не так

MAATRIX

Утром коллекция в векторной базе весила разумные несколько сотен мегабайт, а через неделю дисk почти забит, память под индекс не влезает в лимиты сервера, а поиск стал заметно медленнее. Источники документов при этом никуда не делись — их как было условное количество файлов, так и осталось. Если у вас похожая картина с Qdrant, Chroma или любым другим векторным хранилищем под RAG — разбор ниже почти наверняка про ваш случай, и решение не потребует переезда на более мощный сервер.

Первая мысль: "документов стало больше" — и почему это не подтвердилось

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

Проверка простая: посчитать документы на входе (в папке с исходниками, в базе метаданных, в S3-бакете — где у вас лежит сырьё) и сравнить с тем, что было неделю назад.

find /data/docs -type f | wc -l
# или, если файлы лежат в объектном хранилище
aws s3 ls s3://rag-docs/ --recursive | wc -l

В подавляющем большинстве похожих ситуаций число почти не меняется — разница в пределах обычного шума (кто-то добавил десяток файлов, кто-то убрал). А коллекция в векторной базе при этом выросла в разы. Это первый и самый важный сигнал: дело не в объёме исходных данных, а в том, как эти данные превращаются в точки внутри базы. Гипотезу "стало больше документов" можно закрывать и переходить к следующему шагу — считать, что происходит уже внутри самой базы.

Считаем: сколько точек должно быть и сколько есть на самом деле

Дальше нужна вторая цифра — не количество файлов, а количество точек (векторов) в коллекции. Она либо подтвердит проблему количественно, либо покажет, что рост объясним и тревога ложная.

Для Qdrant:

curl -s http://localhost:6333/collections/docs | jq '.result.points_count'

Для Chroma (через Python-клиент):

import chromadb
client = chromadb.HttpClient(host="localhost", port=8000)
collection = client.get_collection("docs")
print(collection.count())

Дальше — прикидка "сколько точек должно быть". Она грубая, но для порядка величины достаточная: берём среднее число чанков на документ (для типового чанкинга это обычно от нескольких до пары десятков чанков на файл среднего размера — точная цифра зависит от вашей настройки размера чанка) и умножаем на число исходных документов. Если реальное количество точек в несколько раз выше этой оценки — рост подтверждён, и дело не в размере данных, а в том, что база хранит существенно больше записей, чем нужно для того же самого корпуса документов.

Хорошая практика — не гадать разово, а завести эту проверку как метрику, к которой вернёмся в разделе про профилактику. Дальше — почему реальных точек больше, чем должно быть.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Развернуть ИИ на сервере

Причина первая: переиндексация запускалась повторно без очистки старых записей

Самая частая причина резкого роста — это не разовая ошибка, а особенность самого пайплайна переиндексации. Если джоб, который парсит документы, режет их на чанки и загружает эмбеддинги, запускается заново (по расписанию, руками после правки промпта, после падения с перезапуском) и при этом не удаляет то, что уже было загружено на предыдущем прогоне — каждый повторный запуск просто добавляет новую порцию векторов поверх старой.

Проверить гипотезу можно, поискав явные дубликаты по содержимому. В Qdrant это делается через scroll с фильтром либо прямым запросом к payload:

from qdrant_client import QdrantClient

client = QdrantClient(url="http://localhost:6333")

points, _ = client.scroll(
    collection_name="docs",
    limit=10000,
    with_payload=True,
    with_vectors=False,
)

from collections import Counter
texts = Counter(p.payload.get("text", "")[:200] for p in points)
duplicates = {t: c for t, c in texts.items() if c > 1}
print(f"Уникальных начал текста с повторами: {len(duplicates)}")

Если один и тот же кусок текста встречается в коллекции по 2-5 раз (а иногда и больше — по числу прогонов пайплайна) — причина найдена. Обычно это совпадает по времени с логами джоба переиндексации: если посмотреть, сколько раз он запускался за последнюю неделю, число прогонов часто прямо коррелирует с кратностью раздутия коллекции.

Причина вторая: чанкинг оказался слишком мелким

Вторая частая причина — независимо от дублей — это размер чанка. Если документы режутся на очень маленькие куски (условно говоря, по одному-два предложения вместо смыслового абзаца), на один и тот же объём текста получается заметно больше точек, каждая со своим вектором и оверхедом на метаданные, индекс и служебные поля.

Мелкий чанкинг иногда выбирают осознанно — под задачи, где нужна очень точная локализация ответа. Но у него есть цена: не только рост числа точек, но и потеря контекста внутри каждого чанка (ретривер начинает выдавать обрывки без окружающего смысла), и рост нагрузки на реранкер, если он используется — про выбор модели эмбеддингов и связанные компромиссы подробнее разбирали в статье про выбор embedding-модели для RAG.

Если строгой необходимости в очень мелком дроблении нет, разумный компромисс — укрупнить чанк с перекрытием (overlap), чтобы не терять связность на границах:

from langchain_text_splitters import RecursiveCharacterTextSplitter

splitter = RecursiveCharacterTextSplitter(
    chunk_size=800,      # было, например, 200 — заметно мельче
    chunk_overlap=100,
)
chunks = splitter.split_text(document_text)

Здесь важно не менять размер чанка бездумно "в сторону побольше" — оптимальный размер зависит от типа документов и от того, какие вопросы читатель будет задавать. Но если текущий размер выбирался наугад, а не осознанно, пересмотреть его стоит — это один из немногих параметров, который одновременно влияет и на объём базы, и на качество ответов.

Причина третья: нет дедупликации — каждый прогон создаёт новые записи вместо обновления

Это корневая причина, из-за которой первая (повторные прогоны без очистки) вообще становится проблемой. Если бы у коллекции была настроена дедупликация по стабильному идентификатору чанка, повторный прогон пайплайна просто обновил бы существующие записи — и рост объёма был бы невозможен в принципе, сколько раз джоб ни запускай.

Технически проблема почти всегда в том, что при загрузке в векторную базу id точки генерируется случайно (автоинкремент, uuid4 без привязки к содержимому) — либо не передаётся вовсе, и база сама присваивает новый id каждой вставке:

# Так делать не стоит для продакшн-пайплайна переиндексации:
client.upsert(
    collection_name="docs",
    points=[
        {
            "id": str(uuid.uuid4()),  # новый случайный id при каждом прогоне
            "vector": embedding,
            "payload": {"text": chunk_text, "source": file_path},
        }
    ],
)

При таком подходе слово "upsert" в названии метода не спасает — insert-or-update работает по id, а id каждый раз новый, поэтому реального update никогда не происходит, только insert.

Фикс: детерминированный id чанка, upsert и чистка накопленных дублей

Решение прямое: id точки должен детерминированно зависеть от содержимого и позиции чанка, а не генерироваться случайно. Тогда повторный прогон с тем же документом посчитает тот же самый id — и upsert обновит существующую запись вместо создания новой. Проще всего — хеш от пути файла и номера чанка (можно добавить хеш содержимого чанка, если файлы могут переименовываться, а чанки — оставаться теми же):

import hashlib

def make_chunk_id(file_path: str, chunk_index: int) -> str:
    raw = f"{file_path}::{chunk_index}"
    return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:32]

points = [
    {
        "id": make_chunk_id(file_path, i),
        "vector": embedding,
        "payload": {"text": chunk_text, "source": file_path, "chunk_index": i},
    }
    for i, (chunk_text, embedding) in enumerate(chunks_with_embeddings)
]

client.upsert(collection_name="docs", points=points)

Для Chroma принцип тот же — id передаётся явно и должен быть стабильным между прогонами:

collection.upsert(
    ids=[make_chunk_id(file_path, i) for i in range(len(chunks))],
    embeddings=embeddings,
    documents=texts,
    metadatas=metadatas,
)

Есть нюанс: если документ отредактировали и число чанков в нём изменилось (было 10, стало 8), два "лишних" id из старой версии останутся в базе как мусор — детерминированный id решает проблему повторных прогонов одного и того же состояния документа, но не проблему устаревших чанков от удалённых или сильно изменённых файлов. Для этого в пайплайне переиндексации нужен отдельный шаг: перед загрузкой новой версии документа удалять все точки с этим source по фильтру, и только потом заливать актуальный набор чанков:

from qdrant_client.models import Filter, FieldCondition, MatchValue

client.delete(
    collection_name="docs",
    points_selector=Filter(
        must=[FieldCondition(key="source", match=MatchValue(value=file_path))]
    ),
)

Уже накопленные дубли из старых прогонов детерминированный id не уберёт сам по себе — их нужно вычистить один раз вручную. Самый надёжный способ для не самой большой коллекции — пересобрать её с нуля: создать новую коллекцию, прогнать переиндексацию уже с исправленным пайплайном (детерминированные id гарантируют, что дублей больше не появится), проверить, что число точек соответствует ожиданию из раздела про диагностику, и только после этого переключить продакшн на новую коллекцию, удалив старую. Если данных много и пересборка с нуля дорога по времени или по счёту за эмбеддинги — можно чистить дубли адресно, найдя их через тот же scroll с группировкой по содержимому, что использовался при диагностике, и удалив все id кроме одного из каждой группы. Если база разворачивается впервые или коллекцию проще пересобрать на новом сервере, порядок первичной настройки описан в статье как поднять векторную базу для RAG на сервере.

Отдельно стоит сразу настроить профилактику, чтобы не искать проблему постфактум в следующий раз: заведите простую метрику — отношение числа точек в коллекции к числу исходных документов (или к суммарному объёму текста в исходниках) — и алерт, если это отношение резко скакнуло по сравнению со своим обычным значением за последнюю неделю. Это дешевле любого ручного разбора постфактум и ловит проблему в день появления, а не через неделю, когда диск уже забит. Полезно также время от времени смотреть на использование памяти под индекс — расчёт по объёму векторов и параметрам HNSW разобран в статье сколько RAM нужно для Qdrant, а частые ошибки эксплуатации Qdrant на сервере — в отдельном разборе про частые ошибки Qdrant на сервере.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Развернуть ИИ на сервере

Нужны сами нейросети для контента?

Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.

Частые вопросы

Можно ли просто увеличить сервер вместо того, чтобы разбираться с дублями?

Можно, но это лечение симптома, а не причины. Без детерминированных id и удаления старых точек перед переиндексацией коллекция продолжит расти с каждым прогоном пайплайна, и через какое-то время закончится уже и увеличенный ресурс.

Как узнать, сколько прогонов переиндексации привело к текущему раздутию?

Косвенно — по логам джоба переиндексации (сколько раз он запускался за нужный период) и по кратности превышения реального числа точек над расчётным. Прямого счётчика "версия точки" в базе обычно нет, если он не был предусмотрен заранее в payload.

Обязательно ли использовать хеш именно от пути файла и номера чанка?

Нет, это просто удобный и стабильный вариант. Подходит любая детерминированная схема — например, хеш от содержимого самого чанка (устойчиво к переименованию файла, но чувствительно к любой правке текста) или составной id вида source_id:chunk_index, если source_id у вас уже есть в системе документооборота.

Если пересобирать коллекцию с нуля, не потеряются ли данные, пока идёт переиндексация?

Нет, если делать это через отдельную новую коллекцию и переключать на неё продакшн только после проверки — старая коллекция продолжает обслуживать запросы, пока новая наполняется и проверяется.

Мелкий чанкинг — это всегда плохо?

Нет, для некоторых задач (точечный поиск по коротким фактам, юридические документы с построчной точностью) мелкий чанкинг оправдан. Проблема не в самом выборе, а в том, когда он сделан без учёта побочного эффекта на объём базы и без осознанного компромисса с качеством контекста.

Обсудить статью, задать вопрос или начать новую тему

Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →