Векторная база выросла в 10 раз за неделю: что пошло не так
Утром коллекция в векторной базе весила разумные несколько сотен мегабайт, а через неделю дисk почти забит, память под индекс не влезает в лимиты сервера, а поиск стал заметно медленнее. Источники документов при этом никуда не делись — их как было условное количество файлов, так и осталось. Если у вас похожая картина с Qdrant, Chroma или любым другим векторным хранилищем под RAG — разбор ниже почти наверняка про ваш случай, и решение не потребует переезда на более мощный сервер.
Содержание
- Первая мысль: "документов стало больше" — и почему это не подтвердилось
- Считаем: сколько точек должно быть и сколько есть на самом деле
- Причина первая: переиндексация запускалась повторно без очистки старых записей
- Причина вторая: чанкинг оказался слишком мелким
- Причина третья: нет дедупликации — каждый прогон создаёт новые записи вместо обновления
- Фикс: детерминированный id чанка, upsert и чистка накопленных дублей
Первая мысль: "документов стало больше" — и почему это не подтвердилось
Первая реакция почти всегда одна и та же: где-то в пайплайн попал лишний источник, кто-то залил архив документов, парсер начал забирать вложения или дубли страниц с сайта. Логично начать именно отсюда — проверить, не выросло ли реально количество исходных файлов.
Проверка простая: посчитать документы на входе (в папке с исходниками, в базе метаданных, в 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 ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →