CRUD в векторных БД: чем отличается от SQL

В реляционных базах CRUD — это классика: INSERT строки, SELECT по индексу, UPDATE поля, DELETE по условию. В векторных БД те же четыре операции устроены иначе, потому что каждый объект — это не просто строка с данными, а тройка: идентификатор + вектор + метаданные.

Create
upsert
В vectordb почти всегда upsert — не insert. Идемпотентность критична при переиндексации.
Read
query / search
Не «дай строку по ID», а «найди похожих» — по вектору, ключевым словам или фильтру.
Update
upsert / patch
Обновление вектора = пересчёт embedding. Обновление метаданных — без пересчёта.
Delete
delete / purge
По ID или фильтру. HNSW не удаляет физически — ставит флаг, compaction убирает потом.

Анатомия объекта в vectordb

Перед тем как смотреть на операции, важно понять из чего состоит каждый объект в векторной базе. Независимо от того, Chroma это или pgvector, внутренняя структура одна:

Ключ
ID
UUID или строка.
Уникален в коллекции.
Используется для upsert и delete.
Вектор
Embedding
float32[] размером N.
Результат модели embedding.
Хранится в HNSW/IVF индексе.
Данные
Payload / Properties
Произвольные key-value.
Текст, числа, даты, массивы.
Используются в фильтрах.
Индексы
Indexes
Vector index (HNSW).
Payload index (B-tree / inverted).
FTS index (BM25).
100%
колёсико — масштаб  ·  зажать и тянуть — перемещение
ОБЪЕКТ В VECTORDB Объект #a3f2... ID UUID a3f2c1d8 -e5b6-4a9f -b3c7-... Уникален в коллекции → upsert key → delete key VECTOR float32[N] [0.021, -0.183, 0.074, ...] N = 384..3072 зависит от модели → HNSW index → similarity PAYLOAD metadata title: str source: str date: datetime score: float tags: list → filter index → WHERE clause CRUD ↔ КОМПОНЕНТЫ UPSERT ID+vec+meta QUERY vec+filter UPD META только meta DELETE soft flag METADATA FILTERING STRATEGIES query="RAG tutorial" where: category="AI", date>2024 PRE-FILTER сначала WHERE HNSW поиск по кандидатам быстро, но неточно HNSW поиск top-K × oversampling POST-FILTER отбрасываем лишнее точно, но тратит ресурсы IN-FILTER фильтр внутри HNSW оптимально (Qdrant) Ranked Results top-K по векторной близости фильтр первым вектор первым интегрировано oversampling: запрашиваем K×3, фильтруем до K компенсирует потерю при post-filter

Upsert: почему не insert

В RAG-системах документы переиндексируются постоянно: документ обновился, нужно пересчитать embedding и заменить старый. Если использовать обычный insert — получим дубликат. Если update — нужно сначала проверить существование. Upsert решает оба случая одной атомарной операцией.

Входящий объект Есть ID в базе?
ДА → UPDATE: новый вектор + новые метаданные
НЕТ → INSERT: создать новый объект
Результат: в базе ровно один объект с данным ID — независимо от того, был ли он там до этого.
Когда менять только метаданные. Если документ не изменился, но нужно обновить статус или теги — не пересчитывайте embedding. Используйте patch-операцию: Qdrant set_payload, Weaviate data.update(), pgvector UPDATE ... SET metadata = .... Пересчёт embedding дорог — ~0.0001$ за 1000 токенов, но при миллионах документов это важно.

Upsert в Chroma

import chromadb
from chromadb.utils import embedding_functions

client = chromadb.PersistentClient(path="./chroma_db")
ef = embedding_functions.OpenAIEmbeddingFunction(
    api_key="sk-...",
    model_name="text-embedding-3-small",
)
collection = client.get_or_create_collection("docs", embedding_function=ef)

# ── Upsert одного документа ───────────────────────────────────────────
collection.upsert(
    ids=["doc-001"],
    documents=["Введение в RAG: основные принципы..."],
    metadatas=[{"source": "handbook.pdf", "chapter": 1, "lang": "ru"}],
)

# ── Bulk upsert (батчами по 100 — Chroma не любит большие батчи) ──────
BATCH = 100
for i in range(0, len(docs), BATCH):
    batch = docs[i:i+BATCH]
    collection.upsert(
        ids=[d["id"] for d in batch],
        documents=[d["text"] for d in batch],
        metadatas=[d["meta"] for d in batch],
    )

# ── Обновить только метаданные (без пересчёта embedding) ──────────────
collection.update(
    ids=["doc-001"],
    metadatas=[{"source": "handbook.pdf", "chapter": 1, "lang": "ru", "status": "reviewed"}],
)

# ── Получить объект по ID ─────────────────────────────────────────────
result = collection.get(ids=["doc-001"], include=["documents", "metadatas"])
print(result["metadatas"][0])

Upsert в Qdrant

from qdrant_client import QdrantClient
from qdrant_client.models import PointStruct, PointIdsList, SetPayload
import uuid

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

# ── Upsert (upsert = insert or replace) ──────────────────────────────
client.upsert(
    collection_name="docs",
    points=[
        PointStruct(
            id=str(uuid.uuid4()),          # UUID или int
            vector=[0.1, 0.2, ...],        # embedding
            payload={
                "text": "Введение в RAG...",
                "source": "handbook.pdf",
                "chapter": 1,
                "lang": "ru",
            },
        )
    ],
)

# ── Batch upsert ───────────────────────────────────────────────────────
points = [
    PointStruct(id=d["id"], vector=d["embedding"], payload=d["meta"])
    for d in docs
]
# upload_points автоматически бьёт на батчи по 64
client.upload_points(
    collection_name="docs",
    points=points,
    batch_size=64,
    parallel=4,     # параллельных воркеров
)

# ── Обновить только payload (без пересчёта вектора) ───────────────────
client.set_payload(
    collection_name="docs",
    payload={"status": "reviewed", "reviewer": "ivan"},
    points=["doc-uuid-001"],  # или Filter(...)
)

# ── Удалить конкретные ключи из payload ───────────────────────────────
client.delete_payload(
    collection_name="docs",
    keys=["reviewer"],
    points=["doc-uuid-001"],
)

# ── Получить объекты по ID ────────────────────────────────────────────
results = client.retrieve(
    collection_name="docs",
    ids=["doc-uuid-001"],
    with_payload=True,
    with_vectors=False,  # вектор обычно не нужен
)

Upsert в Weaviate

import weaviate
import weaviate.classes as wvc
from weaviate.util import generate_uuid4

with weaviate.connect_to_local() as client:
    articles = client.collections.use("Articles")

    # ── Insert (= upsert с явным UUID) ────────────────────────────────
    # Если UUID уже существует — Weaviate выдаст ошибку, используйте replace
    uuid = generate_uuid4()
    articles.data.insert(
        properties={"title": "RAG Guide", "content": "...", "lang": "ru"},
        uuid=uuid,
    )

    # ── Replace (полная замена объекта по UUID) ────────────────────────
    articles.data.replace(
        uuid=uuid,
        properties={"title": "RAG Guide v2", "content": "Новый контент", "lang": "ru"},
    )

    # ── Partial update (обновить только указанные поля) ───────────────
    articles.data.update(
        uuid=uuid,
        properties={"lang": "en"},   # остальные поля не меняются
    )

    # ── Bulk upsert через insert_many ─────────────────────────────────
    objects = [
        wvc.data.DataObject(
            properties={"title": d["title"], "content": d["text"]},
            uuid=d.get("uuid") or generate_uuid4(),
        )
        for d in docs
    ]
    result = articles.data.insert_many(objects)
    if result.has_errors:
        for err in result.errors.values():
            print(f"Error: {err.message}")

    # ── Паттерн настоящего upsert (проверить → вставить/заменить) ─────
    def upsert_article(client, collection_name: str, doc: dict, doc_uuid: str):
        col = client.collections.use(collection_name)
        try:
            col.data.replace(uuid=doc_uuid, properties=doc)
        except weaviate.exceptions.UnexpectedStatusCodeError:
            col.data.insert(properties=doc, uuid=doc_uuid)

Upsert в pgvector

-- ── Схема таблицы ─────────────────────────────────────────────────────
CREATE TABLE chunks (
    id         TEXT PRIMARY KEY,
    content    TEXT NOT NULL,
    embedding  vector(1536),
    source     TEXT,
    chapter    INT,
    lang       TEXT DEFAULT 'ru',
    updated_at TIMESTAMPTZ DEFAULT now()
);

-- ── Upsert: INSERT ... ON CONFLICT DO UPDATE ───────────────────────────
INSERT INTO chunks (id, content, embedding, source, chapter, lang)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (id) DO UPDATE SET
    content    = EXCLUDED.content,
    embedding  = EXCLUDED.embedding,
    source     = EXCLUDED.source,
    chapter    = EXCLUDED.chapter,
    updated_at = now();

-- ── Обновить только метаданные (без пересчёта embedding) ──────────────
UPDATE chunks
SET lang = 'en', updated_at = now()
WHERE id = $1;

-- ── Batch upsert через COPY + upsert ──────────────────────────────────
-- Эффективнее для больших объёмов:
CREATE TEMP TABLE chunks_staging (LIKE chunks INCLUDING ALL);
COPY chunks_staging FROM STDIN WITH (FORMAT CSV);
INSERT INTO chunks
    SELECT * FROM chunks_staging
ON CONFLICT (id) DO UPDATE SET
    content = EXCLUDED.content,
    embedding = EXCLUDED.embedding,
    updated_at = now();
DROP TABLE chunks_staging;
import asyncpg
import numpy as np

async def upsert_chunks(pool: asyncpg.Pool, chunks: list[dict]):
    """Bulk upsert чанков с embeddingами."""
    async with pool.acquire() as conn:
        await conn.executemany(
            """
            INSERT INTO chunks (id, content, embedding, source, lang)
            VALUES ($1, $2, $3::vector, $4, $5)
            ON CONFLICT (id) DO UPDATE SET
                content   = EXCLUDED.content,
                embedding = EXCLUDED.embedding,
                source    = EXCLUDED.source,
                updated_at = now()
            """,
            [
                (c["id"], c["content"], str(c["embedding"].tolist()), c["source"], c.get("lang", "ru"))
                for c in chunks
            ],
        )

Query: режимы поиска

В реляционных базах есть один вид чтения — SELECT по условию. В векторных БД четыре режима запроса, каждый под свою задачу:

Режим 1
Vector search
Семантическое сходство. Вопрос → embedding → ближайшие векторы.
Режим 2
Keyword search
BM25 по инвертированному индексу. Точные слова, коды, артикулы.
Режим 3
Hybrid search
Vector + BM25 через RRF или RelativeScoreFusion. Лучшее для RAG.
Режим 4
Filter-only
Только структурированный WHERE. Без семантики — для пагинации, аудита.
Режим запроса
Chroma
Qdrant
Weaviate
pgvector
Vector search
query(query_texts)
query_points(query=vec)
query.near_text()
ORDER BY emb <=> $1
Keyword / BM25
Нет
Через sparse vec
query.bm25()
ts_rank + GIN FTS
Hybrid search
Нет
Prefetch + fusion
query.hybrid(alpha)
CTE + RRF вручную
Filter-only
get(where=...)
scroll(filter=...)
query.fetch_objects()
SELECT WHERE
Порог близости
distance threshold
score_threshold
distance / certainty
WHERE dist < $threshold

Примеры запросов

# ── Chroma ────────────────────────────────────────────────────────────
results = collection.query(
    query_texts=["как работает RAG"],
    n_results=10,
    where={"lang": {"$eq": "ru"}},                    # metadata filter
    where_document={"$contains": "retrieval"},         # filter по тексту документа
    include=["documents", "metadatas", "distances"],
)
# Порог: results["distances"][0][i] < 0.5

# ── Qdrant ────────────────────────────────────────────────────────────
from qdrant_client.models import Filter, FieldCondition, MatchValue, Range

results = client.query_points(
    collection_name="docs",
    query=query_vector,               # готовый embedding
    query_filter=Filter(
        must=[
            FieldCondition(key="lang", match=MatchValue(value="ru")),
            FieldCondition(key="chapter", range=Range(gte=1, lte=5)),
        ]
    ),
    score_threshold=0.7,              # отсечь нерелевантное
    limit=10,
    with_payload=True,
)

# ── Weaviate ──────────────────────────────────────────────────────────
from weaviate.classes.query import Filter, MetadataQuery

with weaviate.connect_to_local() as client:
    results = client.collections.use("Articles").query.hybrid(
        query="как работает RAG",
        alpha=0.75,
        where=Filter.by_property("lang").equal("ru") &
              Filter.by_property("chapter").greater_or_equal(1),
        limit=10,
        return_metadata=MetadataQuery(score=True, distance=True),
    )

# ── pgvector ──────────────────────────────────────────────────────────
rows = await conn.fetch(
    """
    SELECT id, content, source,
           embedding <=> $1 AS distance
    FROM chunks
    WHERE lang = $2
      AND chapter BETWEEN $3 AND $4
      AND embedding <=> $1 < $5        -- порог близости
    ORDER BY distance
    LIMIT $6
    """,
    str(query_vector.tolist()), "ru", 1, 5, 0.5, 10,
)

Metadata filtering: pre-filter vs post-filter

Самая нетривиальная тема в работе с векторными БД — как фильтрация по метаданным взаимодействует с векторным поиском. Наивная реализация даёт или медленные, или неточные результаты. Разберём три стратегии.

Pre-filter
Сначала выбираем объекты, удовлетворяющие фильтру, потом ищем ближайшие векторы среди них. HNSW работает с ограниченным графом.
+ Быстро при высокой selectivity (мало кандидатов)
+ Результат строго соответствует фильтру
− Если selectivity низкая — теряем recall
− HNSW может не найти оптимальный путь в разреженном графе
Chroma: все запросы с where= работают как pre-filter
Post-filter
Сначала HNSW находит top-K×oversampling похожих векторов без фильтра, потом отфильтровываем лишнее. Работает с полным графом.
+ Высокий recall — HNSW работает нормально
+ Можно компенсировать oversampling
− При строгом фильтре может вернуть меньше limit
− Тратим compute на нерелевантные объекты
pgvector с маленьким LIMIT требует LIMIT×oversampling
In-filter (Qdrant)
Фильтр интегрирован в обход HNSW графа. При каждом шаге по графу проверяем условие фильтра. Если кандидатов мало — автоматически переключается на brute-force по отфильтрованным объектам.
+ Лучший из обоих миров
+ Автоматический выбор стратегии
+ Стабильный recall при любой selectivity
Qdrant: параметр full_scan_threshold настраивает порог

Oversampling: решение для post-filter

Если фильтр отсекает 80% результатов, при limit=10 нужно запросить минимум 50, чтобы получить 10 валидных. Правило: запрашивайте limit × (1 / selectivity), но не менее limit × 3 в качестве безопасного минимума.

def search_with_filter(
    query_vec: list[float],
    metadata_filter: dict,
    limit: int = 10,
    oversampling: int = 5,         # запрашиваем в 5 раз больше
) -> list[dict]:
    """
    Реализует post-filter с oversampling для pgvector.
    Если фильтры очень строгие — увеличьте oversampling до 10-20.
    """
    candidates = limit * oversampling  # запрашиваем с запасом

    rows = conn.execute(
        """
        SELECT id, content, source,
               embedding <=> %s AS distance
        FROM chunks
        ORDER BY distance           -- сначала ищем без фильтра
        LIMIT %s                    -- берём с запасом
        """,
        (str(query_vec), candidates),
    ).fetchall()

    # Post-filter в Python
    filtered = [
        r for r in rows
        if all(r[k] == v for k, v in metadata_filter.items())
    ]
    return filtered[:limit]


# ── Qdrant: настройка oversampling ────────────────────────────────────
from qdrant_client.models import SearchParams

results = client.query_points(
    collection_name="docs",
    query=query_vector,
    query_filter=Filter(must=[FieldCondition(key="lang", match=MatchValue(value="ru"))]),
    search_params=SearchParams(
        hnsw_ef=128,              # точность HNSW traversal
        exact=False,              # False = HNSW, True = brute-force (100% recall)
    ),
    limit=10,
    # Qdrant сам обрабатывает фильтр in-search — oversampling не нужен
)

Типы индексов метаданных

Эффективность фильтра зависит от того, какой индекс создан под поле. Без индекса фильтр требует полного сканирования — O(N).

Hash / Keyword
Для: exact match текстовых полей
O(1) поиск. Работает только для точного совпадения (=, !=, in). Не поддерживает LIKE или сортировку. Qdrant: keyword, Chroma: by default.
B-tree / Range
Для: числа, даты, сортировка
O(log N) поиск. Поддерживает >, <, >=, <=, BETWEEN, ORDER BY. pgvector: обычный индекс PostgreSQL. Qdrant: integer/float/datetime.
Inverted / FTS
Для: полнотекстовый поиск
Токенизация + обратный индекс. Поддерживает CONTAINS, BM25, релевантность по частоте. Weaviate: indexSearchable. pgvector: GIN + tsvector.
Bitmap
Для: булевы и enum поля с низкой кардинальностью
Битовый массив для каждого значения. Очень быстрые AND/OR/NOT. Эффективен когда значений мало (статус: 3 варианта). Qdrant: bool.
Geo / Spatial
Для: координаты, геозоны
R-tree или аналог. Поддерживает radius search, bounding box. Qdrant: geo_bounding_box / geo_radius. pgvector: PostGIS.
No index
Для: редкие фильтры, payload-only поля
Линейное сканирование O(N). Допустимо для коллекций <10K или полей, по которым никогда не фильтруют. Экономит RAM и disk.
# ── Qdrant: создать индексы на payload ───────────────────────────────
from qdrant_client.models import PayloadSchemaType

client.create_payload_index(
    collection_name="docs",
    field_name="lang",
    field_schema=PayloadSchemaType.KEYWORD,   # hash-индекс для exact match
)
client.create_payload_index(
    collection_name="docs",
    field_name="chapter",
    field_schema=PayloadSchemaType.INTEGER,   # range-индекс для числовых сравнений
)
client.create_payload_index(
    collection_name="docs",
    field_name="published_at",
    field_schema=PayloadSchemaType.DATETIME,  # для сравнений дат
)

# ── pgvector: стандартные PostgreSQL индексы ──────────────────────────
# Запустить один раз после создания таблицы
await conn.execute("CREATE INDEX idx_chunks_lang    ON chunks(lang)")
await conn.execute("CREATE INDEX idx_chunks_chapter ON chunks(chapter)")
await conn.execute("CREATE INDEX idx_chunks_date    ON chunks(published_at)")
# Для полнотекстового поиска
await conn.execute("""
    CREATE INDEX idx_chunks_fts ON chunks
    USING GIN (to_tsvector('russian', content))
""")

# ── Weaviate: настройка при создании схемы ────────────────────────────
from weaviate.classes.config import Property, DataType
Property(
    name="lang",
    data_type=DataType.TEXT,
    index_filterable=True,    # hash-индекс для фильтров
    index_searchable=False,   # не нужен BM25 для lang
),
Property(
    name="chapter",
    data_type=DataType.INT,
    index_filterable=True,
    index_range_filters=True, # дополнительный B-tree для range запросов
),

Delete: удаление объектов

В HNSW-индексе физическое удаление невозможно без перестройки графа. Поэтому все векторные БД реализуют soft delete: объект помечается удалённым, из результатов поиска исчезает немедленно, но физически удаляется при следующей компакции (фоновый процесс). Для пользователя это незаметно — но важно для планирования дискового пространства.

Операция
Chroma
Qdrant
Weaviate
pgvector
По ID
collection.delete(ids=[...])
client.delete(points=[...])
col.data.delete_by_id(uuid)
DELETE WHERE id=$1
По фильтру
collection.delete(where=...)
client.delete(filter=...)
col.data.delete_many(where=...)
DELETE WHERE условие
Очистить всё
client.delete_collection()
client.delete_collection()
client.collections.delete()
TRUNCATE chunks
Физическое удаление
авто при сборке
optimize_collection()
фоновая компакция
VACUUM ANALYZE
# ── Chroma ────────────────────────────────────────────────────────────
# По ID
collection.delete(ids=["doc-001", "doc-002"])

# По фильтру (все документы из источника)
collection.delete(where={"source": {"$eq": "old_handbook.pdf"}})

# По тексту документа
collection.delete(where_document={"$contains": "устаревшая информация"})


# ── Qdrant ────────────────────────────────────────────────────────────
from qdrant_client.models import PointIdsList, FilterSelector, Filter, FieldCondition, MatchValue

# По списку ID
client.delete(
    collection_name="docs",
    points_selector=PointIdsList(points=["uuid-001", "uuid-002"]),
)

# По фильтру — удалить все из старого источника
client.delete(
    collection_name="docs",
    points_selector=FilterSelector(
        filter=Filter(
            must=[FieldCondition(key="source", match=MatchValue(value="old_handbook.pdf"))]
        )
    ),
)

# Принудительная оптимизация после массового удаления
client.update_collection(
    collection_name="docs",
    optimizer_config={"deleted_threshold": 0.2},   # оптимизировать при 20% удалённых
)


# ── Weaviate ──────────────────────────────────────────────────────────
from weaviate.classes.query import Filter

with weaviate.connect_to_local() as client:
    articles = client.collections.use("Articles")

    # По ID
    articles.data.delete_by_id("object-uuid-here")

    # По фильтру
    result = articles.data.delete_many(
        where=Filter.by_property("source").equal("old_handbook.pdf"),
        dry_run=True,    # сначала посмотреть сколько будет удалено
    )
    print(f"Будет удалено: {result.matches} объектов")

    result = articles.data.delete_many(
        where=Filter.by_property("source").equal("old_handbook.pdf"),
        # dry_run=False по умолчанию — реальное удаление
    )


# ── pgvector ──────────────────────────────────────────────────────────
# По ID
await conn.execute("DELETE FROM chunks WHERE id = $1", "doc-001")

# По фильтру (с RETURNING чтобы узнать сколько удалено)
deleted = await conn.fetch(
    "DELETE FROM chunks WHERE source = $1 RETURNING id",
    "old_handbook.pdf"
)
print(f"Удалено: {len(deleted)} чанков")

# Очистить Dead tuples после массового удаления
await conn.execute("VACUUM ANALYZE chunks")

Soft delete паттерн для RAG

Иногда нужно «скрыть» документ из поиска, не удаляя физически. Например, при переиндексации или аудите. Паттерн — поле is_deleted в метаданных + фильтр во всех запросах:

from datetime import datetime, UTC
from functools import wraps

# ── Паттерн: soft delete через метаданные ─────────────────────────────
# При "удалении" — обновляем payload, не трогаем вектор
def soft_delete_qdrant(client, collection_name: str, doc_ids: list[str]):
    """Помечает объекты как удалённые. Физически не трогает вектор."""
    client.set_payload(
        collection_name=collection_name,
        payload={
            "is_deleted": True,
            "deleted_at": datetime.now(UTC).isoformat(),
        },
        points=doc_ids,
    )

# ── Запросы всегда исключают deleted ─────────────────────────────────
def active_filter(base_filter=None):
    """Добавляет фильтр is_deleted=false к любому запросу."""
    from qdrant_client.models import Filter, FieldCondition, MatchValue
    not_deleted = FieldCondition(
        key="is_deleted",
        match=MatchValue(value=False),
    )
    if base_filter is None:
        return Filter(must=[not_deleted])
    return Filter(
        must=[not_deleted, *base_filter.must],
        should=base_filter.should,
        must_not=base_filter.must_not,
    )

# Использование:
results = client.query_points(
    collection_name="docs",
    query=query_vector,
    query_filter=active_filter(
        Filter(must=[FieldCondition(key="lang", match=MatchValue(value="ru"))])
    ),
    limit=10,
)

# ── Периодическая физическая очистка (раз в сутки/неделю) ─────────────
def purge_deleted(client, collection_name: str):
    """Физически удаляет soft-deleted объекты."""
    from qdrant_client.models import FilterSelector, Filter, FieldCondition, MatchValue
    client.delete(
        collection_name=collection_name,
        points_selector=FilterSelector(
            filter=Filter(must=[
                FieldCondition(key="is_deleted", match=MatchValue(value=True))
            ])
        ),
    )

Единый интерфейс поверх разных баз

Когда проект начинается с Chroma (для прототипа) и потом мигрирует на Qdrant или pgvector — боль от смены API огромна, если нет абстракции. Простой интерфейс с тремя методами сохраняет гибкость:

from abc import ABC, abstractmethod
from dataclasses import dataclass

@dataclass
class SearchResult:
    id: str
    text: str
    metadata: dict
    score: float

class VectorStore(ABC):
    """Абстракция над векторной базой данных."""

    @abstractmethod
    def upsert(self, documents: list[dict]) -> None:
        """
        documents: [{"id": str, "text": str, "embedding": list[float], "metadata": dict}]
        Каждый документ upsert'ится по его id.
        """
        ...

    @abstractmethod
    def search(
        self,
        query_embedding: list[float],
        filters: dict | None = None,
        limit: int = 10,
        score_threshold: float | None = None,
    ) -> list[SearchResult]:
        """Векторный поиск с опциональным metadata-фильтром."""
        ...

    @abstractmethod
    def delete(self, ids: list[str]) -> None:
        """Удалить объекты по ID."""
        ...

    def delete_by_filter(self, filters: dict) -> int:
        """Удалить по фильтру. Возвращает количество удалённых. Опционально."""
        raise NotImplementedError


# ── Реализация для Qdrant ─────────────────────────────────────────────
class QdrantStore(VectorStore):
    def __init__(self, url: str, collection: str):
        from qdrant_client import QdrantClient
        self.client = QdrantClient(url=url)
        self.collection = collection

    def upsert(self, documents: list[dict]) -> None:
        from qdrant_client.models import PointStruct
        self.client.upsert(
            collection_name=self.collection,
            points=[
                PointStruct(
                    id=d["id"],
                    vector=d["embedding"],
                    payload={"text": d["text"], **d.get("metadata", {})},
                )
                for d in documents
            ],
        )

    def search(self, query_embedding, filters=None, limit=10, score_threshold=None):
        from qdrant_client.models import Filter, FieldCondition, MatchValue
        q_filter = None
        if filters:
            q_filter = Filter(must=[
                FieldCondition(key=k, match=MatchValue(value=v))
                for k, v in filters.items()
            ])
        results = self.client.query_points(
            collection_name=self.collection,
            query=query_embedding,
            query_filter=q_filter,
            score_threshold=score_threshold,
            limit=limit,
            with_payload=True,
        )
        return [
            SearchResult(
                id=str(r.id),
                text=r.payload.get("text", ""),
                metadata={k: v for k, v in r.payload.items() if k != "text"},
                score=r.score,
            )
            for r in results.points
        ]

    def delete(self, ids: list[str]) -> None:
        from qdrant_client.models import PointIdsList
        self.client.delete(
            collection_name=self.collection,
            points_selector=PointIdsList(points=ids),
        )


# ── Использование: можно менять бэкенд без изменения кода ─────────────
def build_index(store: VectorStore, chunks: list[dict]):
    """Работает с любой реализацией VectorStore."""
    store.upsert(chunks)

def retrieve(store: VectorStore, query_emb: list[float], lang: str) -> list[SearchResult]:
    return store.search(query_emb, filters={"lang": lang}, limit=10, score_threshold=0.7)

# Dev:
store = QdrantStore("http://localhost:6333", "docs")
# Prod — просто меняем:
# store = PGVectorStore(dsn="postgresql://...", table="chunks")

Типичные ошибки

Ошибка 1: insert вместо upsert при переиндексации. Запускаете индексирование повторно — получаете дубликаты. Всегда используйте upsert с детерминированным ID (хэш от URL + chunk_index), а не случайный UUID. doc_id = hashlib.md5(f"{url}::{chunk_index}".encode()).hexdigest()
Ошибка 2: Не создавать индексы на поля фильтрации. Фильтр по lang без индекса = полное сканирование всей коллекции перед каждым запросом. Создавайте payload-индексы сразу после создания коллекции, не после заполнения.
Ошибка 3: Рассчитывать на limit при post-filter. limit=10 с post-filter и строгим фильтром может вернуть 2-3 результата. Всегда добавляйте oversampling: candidates = limit * oversampling. Qdrant с in-filter избавляет от этой проблемы.
Ошибка 4: Обновлять вектор при смене метаданных. Если изменился только статус документа — не пересчитывайте embedding. Используйте patch-операции: set_payload (Qdrant), data.update() (Weaviate), UPDATE SET meta=... (pgvector). Пересчёт embedding — дорогая операция.
Ошибка 5: Не чистить soft-deleted объекты. При частом soft delete коллекция накапливает мёртвые объекты. HNSW может ухудшиться: больше узлов в графе, медленнее traversal. Планируйте регулярную очистку: VACUUM ANALYZE (pgvector), optimize_collection() (Qdrant).
Ошибка 6: Singleton запросы вместо batch при поиске. Если нужно обработать 100 вопросов — не запускайте 100 отдельных запросов. Батчируйте embedding-запросы (один вызов API = 100 эмбеддингов), потом запускайте векторный поиск параллельно через asyncio.gather().

Шпаргалка

# ── Объект vectordb = ID + Vector + Payload ───────────────────────────

# ── Детерминированный ID (для idempotent upsert) ─────────────────────
import hashlib
doc_id = hashlib.md5(f"{source_url}::{chunk_index}".encode()).hexdigest()

# ── Upsert ────────────────────────────────────────────────────────────
# Chroma:   collection.upsert(ids, documents, metadatas)
# Qdrant:   client.upsert(collection_name, points=[PointStruct(...)])
#           client.upload_points(..., batch_size=64, parallel=4)
# Weaviate: collection.data.insert_many([DataObject(...)])
# pgvector: INSERT ... ON CONFLICT (id) DO UPDATE SET ...

# ── Обновить только метаданные (без пересчёта вектора) ───────────────
# Qdrant:   client.set_payload(payload={...}, points=[id])
# Weaviate: collection.data.update(uuid, properties={...})
# pgvector: UPDATE chunks SET status=$1 WHERE id=$2

# ── Query ─────────────────────────────────────────────────────────────
# Chroma:   collection.query(query_texts, where={...}, n_results)
# Qdrant:   client.query_points(query=vec, query_filter=Filter(...), score_threshold)
# Weaviate: collection.query.hybrid(query, alpha, where, limit)
# pgvector: SELECT ... WHERE ... ORDER BY emb<=>$1 LIMIT $2

# ── Metadata filtering стратегии ──────────────────────────────────────
# Pre-filter (Chroma):  WHERE применяется ДО HNSW поиска
#   + быстро, - ухудшает recall на разреженных фильтрах
# Post-filter (pgvector): HNSW ищет topK×oversampling, потом WHERE
#   + хороший recall, - нужен oversampling (×3-10)
# In-filter (Qdrant):  фильтр интегрирован в обход графа
#   + оптимально, автоматический выбор стратегии

# ── Indексы на payload (создать ДО заполнения!) ───────────────────────
# Qdrant:   client.create_payload_index(field_name, field_schema=KEYWORD/INTEGER/...)
# pgvector: CREATE INDEX ON chunks(lang); CREATE INDEX ON chunks(chapter)
# Weaviate: Property(index_filterable=True, index_range_filters=True)

# ── Delete ────────────────────────────────────────────────────────────
# Chroma:   collection.delete(ids=[...]) / collection.delete(where={...})
# Qdrant:   client.delete(points_selector=PointIdsList/FilterSelector)
# Weaviate: collection.data.delete_by_id(uuid) / .delete_many(where=...)
# pgvector: DELETE FROM chunks WHERE id=$1 / WHERE условие

# ── После массового удаления ──────────────────────────────────────────
# pgvector: VACUUM ANALYZE chunks
# Qdrant:   optimizer_config={"deleted_threshold": 0.2}
# Weaviate: фоновая компакция автоматическая

# ── Soft delete паттерн ───────────────────────────────────────────────
# set_payload({"is_deleted": True})
# Все запросы: must=[FieldCondition(key="is_deleted", match=False)]
# Периодически: purge физически удалённые объекты

Практические задания

  1. Idempotent indexer. Напишите функцию index_documents(docs, store), которая принимает список документов с source_url и chunk_index, генерирует детерминированный ID через MD5, вычисляет embedding через OpenAI API батчами по 100, и делает upsert в Qdrant. Запустите дважды — убедитесь, что количество объектов в коллекции не изменилось.
  2. Filtered search с oversampling. Создайте коллекцию в pgvector с 10000 чанков, где 20% имеют lang='en', остальные lang='ru'. Реализуйте search(query, lang, limit=10) двумя способами: без oversampling и с oversampling=5. Измерьте, сколько результатов возвращает каждый при строгом фильтре по языку.
  3. Абстракция с тестами. Реализуйте интерфейс VectorStore для Chroma (для тестов) и Qdrant (для production). Напишите одинаковые параметризованные тесты через pytest, которые проверяют upsert/search/delete для обеих реализаций. Убедитесь, что поведение одинаковое.