Что ломается без фильтров

Мультитенантная платформа: три компании используют одну RAG-систему. Каждая загрузила свои документы. Пользователь из компании А спрашивает про внутреннюю политику отпусков. Семантически похожие документы есть у всех трёх компаний — и векторный поиск вернёт их все вперемешку. Компания А получит в ответе политику компании Б. Это не просто неправильно — это нарушение конфиденциальности.

Вот четыре типичные ситуации, где семантика недостаточна:

Сценарий Что нужно Что происходит без фильтра
Мультитенантная система Только документы текущего клиента Документы других клиентов в ответе
Актуальность данных Документы не старше 6 месяцев Устаревшие версии регламентов, старые API
Локализация Только документы на языке пользователя Релевантный по смыслу, но нечитаемый контент
Контроль доступа (RBAC) Только документы по роли пользователя Секретные HR-данные в ответе на обычный вопрос

Решение — хранить вместе с вектором структурированные атрибуты документа (метаданные) и применять точные условия до или во время векторного поиска.

Что такое метаданные в контексте RAG

Метаданные — это структурированные поля, прикреплённые к каждому чанку документа. Они не участвуют в вычислении эмбеддинга и не влияют на семантическое сходство — они живут отдельно в payload (в терминологии Qdrant) или properties (Weaviate).

Пример payload корпоративного документа
tenant_id string
Идентификатор клиента в мультитенантной системе
→ фильтр: tenant_id == "acme-corp"
source_url string
URL или путь исходного документа
→ фильтр: source_url starts_with "docs.example.com"
created_at datetime
Дата создания/обновления документа (Unix timestamp)
→ фильтр: created_at >= 2024-01-01
language string
Язык документа (ISO 639-1: "ru", "en", "de")
→ фильтр: language == "ru"
access_level integer
Уровень доступа: 0 = публично, 1 = сотрудник, 2 = менеджер, 3 = C-level
→ фильтр: access_level <= user.role_level
doc_type string
"policy", "faq", "technical", "legal", "announcement"
→ фильтр: doc_type IN ["policy", "faq"]
tags string[]
Теги документа: ["hr", "onboarding", "benefits"]
→ фильтр: tags CONTAINS ANY ["hr", "benefits"]
is_verified boolean
Прошёл ли документ проверку контент-командой
→ фильтр: is_verified == true

Типы полей и применимые операции

string
== !=
IN / NOT IN
starts_with
contains
match (regex)
lang == "ru"
type IN ["faq","doc"]
integer / float
== != < >
<= >=
BETWEEN
IN / NOT IN
score >= 0.8
year BETWEEN 2023 2025
boolean
== true
== false
IS NULL
IS NOT NULL
is_verified == true
deleted IS NULL
array
CONTAINS
CONTAINS ANY
CONTAINS ALL
is_empty
tags CONTAINS "hr"
ids CONTAINS ANY [1,2,3]

Pre-filter vs Post-filter: в чём разница

Есть три подхода к применению фильтра относительно ANN-поиска. Выбор между ними влияет и на качество результатов, и на производительность.

✓ Pre-filtering (рекомендуется)
1
Применяем фильтр к индексу метаданных → получаем candidate set из M документов (M < N)
2
ANN-поиск работает только внутри candidate set, игнорируя остальные N−M документов
3
Возвращаем top-k из M кандидатов. Все результаты гарантированно соответствуют фильтру
Гарантирует корректность результатов. Быстрее при селективном фильтре (M ≪ N). Требует payload index.
✗ Post-filtering (проблемный)
1
ANN-поиск по всему индексу (N документов) → top-K результатов по семантике
2
Применяем фильтр к top-K → отбрасываем несоответствующие
3
Получаем меньше K результатов, а иногда ноль — если все K не прошли фильтр
Нарушает recall: релевантные документы, не попавшие в top-K, теряются навсегда. «Дырявый» результат.

Представьте: N=100 000 документов, из них 500 принадлежат нужному клиенту. При post-filter с K=10 мы сначала ищем top-10 по всем 100 000 — среди них может не оказаться ни одного документа нужного клиента. Ответ: пустой. При pre-filter мы сначала изолируем 500 документов, затем ищем top-10 среди них — результат гарантированно есть.

Inline filtering — третий вариант, реализованный в некоторых HNSW-движках. Фильтр применяется прямо во время обхода графа: при переходе к соседнему узлу проверяем, проходит ли он фильтр, и если нет — пропускаем без остановки поиска. Это компромисс: не требует полного построения candidate set заранее, но может пропустить релевантные документы при высокоселективном фильтре. Qdrant использует именно этот подход как основной.

Как фильтрация работает внутри векторной БД

Чтобы фильтрация была быстрой, недостаточно просто хранить метаданные рядом с вектором. Нужны специализированные структуры данных — отдельные от ANN-индекса.

100%
колёсико — масштаб  ·  зажать и тянуть — перемещение
Запрос + условие фильтра query="политика отпусков" tenant="acme" lang="ru" Payload Index B-tree · инвертированный · bitmap tenant_id: hashmap → doc_ids language: hashmap → doc_ids Candidate Set (битовая маска) M из N документов прошли фильтр 500 из 100,000 → экономия 99.5% Embedding Model query → dense vector[1536] [0.12, −0.87, 0.43, …] ограничиваем поиск до M кандидатов ANN Vector Search (HNSW) поиск ближайших только в M кандидатах игнорирует остальные 99,500 doc Top-K результатов гарантированно соответствуют фильтру Отброшено фильтром 99,500 документов — ANN их не видит

Payload Index: индексы на метаданных

Чтобы фильтрация работала за миллисекунды (а не за секунды линейного скана), нужны отдельные индексы на полях метаданных. Каждый тип данных требует своей структуры.

Hashmap / Inverted index
Строки, enum-поля
tenant_id → {acme: [1,5,12,...], beta: [2,7,...]}

Быстрый поиск точного совпадения. Поле language, doc_type, tenant_id — именно сюда.
B-tree / B+-tree
Числа, даты, диапазоны
Сортированное дерево — поддерживает <, >, BETWEEN за O(log n).

Поля created_at, score, access_level — сюда.
Bitmap index
Булевы поля, AND/OR комбинации
Для каждого значения поля — битовый массив размером N. AND двух фильтров = битовое AND за O(N/64).

Идеален для is_verified, is_deleted.
Без явного создания индекса фильтрация работает как полный скан — O(N) операций для каждого запроса. В Qdrant нужно явно вызвать create_payload_index(). В Weaviate индексы создаются автоматически при объявлении схемы коллекции.

Синтаксис условий: AND, OR, NOT, вложенность

Реальные задачи требуют сложных условий. Все векторные БД поддерживают логические операторы для объединения нескольких условий.

Простые условия:
──────────────────────────────────────────────────────────────────────
tenant_id == "acme-corp"
created_at >= 1704067200          # 2024-01-01 Unix timestamp
language IN ["ru", "en"]
is_verified == true
access_level <= 2
tags CONTAINS "hr"

Составные условия (AND / OR / NOT):
──────────────────────────────────────────────────────────────────────
# Оба условия выполнены
tenant_id == "acme" AND language == "ru"

# Хотя бы одно выполнено
doc_type == "faq" OR doc_type == "policy"

# Исключение
NOT is_deleted == true

Вложенные условия:
──────────────────────────────────────────────────────────────────────
(tenant_id == "acme") AND
(language == "ru" OR language == "en") AND
(created_at >= 1704067200) AND
(access_level <= user_level) AND
NOT (doc_type == "draft")

Ручная реализация на Python

Разберём механику до подключения векторной БД. Чистая Python-реализация покажет, как payload index устроен в памяти.

python — payload store + фильтрация
from dataclasses import dataclass, field
from datetime import datetime
from typing import Any
import numpy as np
from sentence_transformers import SentenceTransformer


@dataclass
class Document:
    id: int
    text: str
    vector: np.ndarray
    # Metadata (payload)
    tenant_id: str
    language: str
    doc_type: str
    created_at: datetime
    access_level: int
    tags: list[str]
    is_verified: bool


class SimpleVectorStore:
    """Минималистичное хранилище с поддержкой metadata filtering."""

    def __init__(self):
        self.docs: dict[int, Document] = {}
        # Payload индексы для быстрой фильтрации
        self._idx_tenant:   dict[str, set[int]] = {}    # hashmap
        self._idx_language: dict[str, set[int]] = {}    # hashmap
        self._idx_doc_type: dict[str, set[int]] = {}    # hashmap
        # Числовые/дата поля — просто храним для range-фильтров (в prod: B-tree)
        # Сортированный список для range-запросов
        self._idx_created_at: list[tuple[datetime, int]] = []  # sorted by datetime

    def add(self, doc: Document) -> None:
        self.docs[doc.id] = doc
        # Обновляем инвертированные индексы
        self._idx_tenant.setdefault(doc.tenant_id, set()).add(doc.id)
        self._idx_language.setdefault(doc.language, set()).add(doc.id)
        self._idx_doc_type.setdefault(doc.doc_type, set()).add(doc.id)
        self._idx_created_at.append((doc.created_at, doc.id))
        self._idx_created_at.sort(key=lambda x: x[0])

    def _filter_to_ids(
        self,
        tenant_id: str | None = None,
        language: str | None = None,
        doc_types: list[str] | None = None,
        created_after: datetime | None = None,
        max_access_level: int | None = None,
        require_verified: bool | None = None,
        tags: list[str] | None = None,
    ) -> set[int] | None:
        """
        Возвращает set[id] документов, удовлетворяющих всем условиям.
        None означает «без фильтра» (все документы).
        """
        candidate_sets: list[set[int]] = []

        if tenant_id is not None:
            candidate_sets.append(self._idx_tenant.get(tenant_id, set()))

        if language is not None:
            candidate_sets.append(self._idx_language.get(language, set()))

        if doc_types is not None:
            # OR по списку типов, затем пересечение с остальными
            union: set[int] = set()
            for dt in doc_types:
                union |= self._idx_doc_type.get(dt, set())
            candidate_sets.append(union)

        # Пересечение всех conditions (AND)
        if not candidate_sets:
            base_ids = set(self.docs.keys())
        else:
            base_ids = candidate_sets[0]
            for s in candidate_sets[1:]:
                base_ids = base_ids & s

        # Диапазонные фильтры — применяем поверх base_ids
        if created_after is not None:
            base_ids = {
                doc_id for doc_id in base_ids
                if self.docs[doc_id].created_at >= created_after
            }

        if max_access_level is not None:
            base_ids = {
                doc_id for doc_id in base_ids
                if self.docs[doc_id].access_level <= max_access_level
            }

        if require_verified is not None:
            base_ids = {
                doc_id for doc_id in base_ids
                if self.docs[doc_id].is_verified == require_verified
            }

        if tags is not None:
            base_ids = {
                doc_id for doc_id in base_ids
                if any(t in self.docs[doc_id].tags for t in tags)
            }

        return base_ids

    def search(
        self,
        query_vector: np.ndarray,
        top_k: int = 5,
        **filter_kwargs,
    ) -> list[tuple[Document, float]]:
        """Pre-filtering: сначала фильтр, затем ANN по кандидатам."""
        candidate_ids = self._filter_to_ids(**filter_kwargs)

        # Векторный поиск только по кандидатам
        results = []
        for doc_id in candidate_ids:
            doc = self.docs[doc_id]
            score = float(query_vector @ doc.vector)  # cosine (при норм. векторах)
            results.append((doc, score))

        results.sort(key=lambda x: x[1], reverse=True)
        return results[:top_k]


# ── Пример использования ────────────────────────────────────────────────
model = SentenceTransformer("all-MiniLM-L6-v2")
store = SimpleVectorStore()

documents_data = [
    ("Политика отпусков компании ACME 2024", "acme", "ru", "policy", 1, ["hr","benefits"], True),
    ("ACME Vacation Policy 2024",             "acme", "en", "policy", 1, ["hr","benefits"], True),
    ("Политика отпусков компании Beta 2024",  "beta", "ru", "policy", 1, ["hr"],           True),
    ("ACME техническая документация API",     "acme", "ru", "technical", 0, ["api","dev"], True),
    ("ACME черновик новой политики",          "acme", "ru", "draft",  1, ["hr"],           False),
    ("ACME компенсации и льготы C-level",     "acme", "ru", "policy", 3, ["hr","comp"],    True),
]

for i, (text, tenant, lang, doc_type, access, tags, verified) in enumerate(documents_data):
    vec = model.encode(text, normalize_embeddings=True)
    store.add(Document(
        id=i, text=text, vector=vec,
        tenant_id=tenant, language=lang, doc_type=doc_type,
        created_at=datetime(2024, 1, 1),
        access_level=access, tags=tags, is_verified=verified,
    ))

# Поиск для обычного сотрудника ACME (access_level=1)
query_vec = model.encode("политика отпусков", normalize_embeddings=True)
results = store.search(
    query_vec,
    top_k=3,
    tenant_id="acme",           # только документы ACME
    language="ru",              # только русский язык
    doc_types=["policy","faq"], # только политики и FAQ
    max_access_level=1,         # уровень доступа ≤ 1
    require_verified=True,      # только проверенные
)

for doc, score in results:
    print(f"[{score:.3f}] {doc.text}")
output
[0.891] Политика отпусков компании ACME 2024
# ACME Vacation Policy (english) — отфильтрован по language="ru"
# Beta policy — отфильтрован по tenant_id="acme"
# draft — отфильтрован по require_verified=True
# C-level — отфильтрован по max_access_level=1

Qdrant: payload index и filters

Qdrant — самая гибкая из популярных векторных БД по части фильтрации. Поддерживает произвольно вложенные условия через объекты Filter, FieldCondition, MatchValue и операторы must / should / must_not (аналогия с ES-синтаксисом).

python — Qdrant: создание payload index + поиск с фильтром
from qdrant_client import QdrantClient
from qdrant_client.models import (
    Distance, VectorParams, PointStruct,
    Filter, FieldCondition, MatchValue, MatchAny, Range,
    PayloadSchemaType,
)
from datetime import datetime

client = QdrantClient(":memory:")

COLLECTION = "kb"
client.create_collection(
    collection_name=COLLECTION,
    vectors_config=VectorParams(size=384, distance=Distance.COSINE),
)

# ── Создаём payload index на нужных полях ──────────────────────────────
# БЕЗ этого фильтрация = полный скан (O(N)), медленно при большом N

client.create_payload_index(COLLECTION, "tenant_id",    PayloadSchemaType.KEYWORD)
client.create_payload_index(COLLECTION, "language",     PayloadSchemaType.KEYWORD)
client.create_payload_index(COLLECTION, "doc_type",     PayloadSchemaType.KEYWORD)
client.create_payload_index(COLLECTION, "access_level", PayloadSchemaType.INTEGER)
client.create_payload_index(COLLECTION, "created_at",   PayloadSchemaType.FLOAT)
client.create_payload_index(COLLECTION, "is_verified",  PayloadSchemaType.BOOL)
client.create_payload_index(COLLECTION, "tags",         PayloadSchemaType.KEYWORD)

# ── Загрузка точек ─────────────────────────────────────────────────────
from sentence_transformers import SentenceTransformer
model = SentenceTransformer("all-MiniLM-L6-v2")

docs = [
    {"id": 1, "text": "Политика отпусков ACME 2024",       "tenant": "acme", "lang": "ru", "type": "policy",    "access": 1, "tags": ["hr","benefits"], "verified": True,  "ts": 1704067200},
    {"id": 2, "text": "ACME Vacation Policy 2024",          "tenant": "acme", "lang": "en", "type": "policy",    "access": 1, "tags": ["hr","benefits"], "verified": True,  "ts": 1704067200},
    {"id": 3, "text": "Политика Beta Corporation",          "tenant": "beta", "lang": "ru", "type": "policy",    "access": 1, "tags": ["hr"],            "verified": True,  "ts": 1704067200},
    {"id": 4, "text": "ACME API техническая документация",  "tenant": "acme", "lang": "ru", "type": "technical", "access": 0, "tags": ["api","dev"],     "verified": True,  "ts": 1704067200},
    {"id": 5, "text": "ACME черновик политики (draft)",     "tenant": "acme", "lang": "ru", "type": "draft",     "access": 1, "tags": ["hr"],            "verified": False, "ts": 1704067200},
    {"id": 6, "text": "ACME компенсации топ-менеджмента",  "tenant": "acme", "lang": "ru", "type": "policy",    "access": 3, "tags": ["hr","comp"],     "verified": True,  "ts": 1704067200},
]

points = [
    PointStruct(
        id=d["id"],
        vector=model.encode(d["text"], normalize_embeddings=True).tolist(),
        payload={
            "text":         d["text"],
            "tenant_id":    d["tenant"],
            "language":     d["lang"],
            "doc_type":     d["type"],
            "access_level": d["access"],
            "tags":         d["tags"],
            "is_verified":  d["verified"],
            "created_at":   d["ts"],
        },
    )
    for d in docs
]
client.upsert(collection_name=COLLECTION, points=points)

# ── Поиск с фильтром ───────────────────────────────────────────────────
query_vector = model.encode("политика отпусков", normalize_embeddings=True).tolist()

results = client.search(
    collection_name=COLLECTION,
    query_vector=query_vector,
    limit=5,
    query_filter=Filter(
        must=[
            FieldCondition(key="tenant_id",   match=MatchValue(value="acme")),
            FieldCondition(key="language",    match=MatchValue(value="ru")),
            FieldCondition(key="is_verified", match=MatchValue(value=True)),
            FieldCondition(key="access_level",range=Range(lte=1)),
        ],
        must_not=[
            FieldCondition(key="doc_type", match=MatchValue(value="draft")),
        ],
        should=[
            # Повышаем приоритет документов с тегами hr или benefits
            # (should ≠ обязательное условие; ни одно из should может не выполняться)
            FieldCondition(key="tags", match=MatchAny(any=["hr", "benefits"])),
        ],
    ),
)

for r in results:
    print(f"id={r.id}  score={r.score:.3f}  {r.payload['text']}")
python — Qdrant: диапазоны и вложенные фильтры
from qdrant_client.models import DatetimeRange
from datetime import datetime, timezone

# ── Range по дате: документы за последний год ──────────────────────────
cutoff = datetime(2024, 1, 1, tzinfo=timezone.utc).timestamp()

results = client.search(
    collection_name=COLLECTION,
    query_vector=query_vector,
    limit=5,
    query_filter=Filter(
        must=[
            FieldCondition(key="tenant_id", match=MatchValue(value="acme")),
            FieldCondition(
                key="created_at",
                range=Range(gte=cutoff),   # created_at >= 2024-01-01
            ),
        ]
    ),
)

# ── Nested AND/OR: (type==policy OR type==faq) AND (lang==ru OR lang==en) ──
from qdrant_client.models import Filter as QFilter

results = client.search(
    collection_name=COLLECTION,
    query_vector=query_vector,
    limit=5,
    query_filter=QFilter(
        must=[
            FieldCondition(key="tenant_id", match=MatchValue(value="acme")),
            # Вложенный should работает как OR внутри must (AND):
            QFilter(
                should=[
                    FieldCondition(key="doc_type", match=MatchValue(value="policy")),
                    FieldCondition(key="doc_type", match=MatchValue(value="faq")),
                ]
            ),
        ]
    ),
)

# ── Scroll (получить все документы клиента без векторного поиска) ──────
scroll_result, next_cursor = client.scroll(
    collection_name=COLLECTION,
    scroll_filter=Filter(
        must=[FieldCondition(key="tenant_id", match=MatchValue(value="acme"))]
    ),
    limit=100,
    with_payload=True,
    with_vectors=False,
)
print(f"Всего документов ACME: {len(scroll_result)}")

Weaviate: where-фильтры

Weaviate использует GraphQL-подобный синтаксис с объектом where. В Python SDK v4 это делается через Filter.by_property().

python — Weaviate: векторный поиск с фильтром
import weaviate
import weaviate.classes as wvc
from weaviate.classes.query import Filter, MetadataQuery

client = weaviate.connect_to_local()

kb = client.collections.get("KnowledgeBase")

# ── Простой фильтр ─────────────────────────────────────────────────────
results = kb.query.near_text(
    query="политика отпусков",
    limit=5,
    filters=Filter.by_property("tenant_id").equal("acme"),
)

# ── Составной AND-фильтр ───────────────────────────────────────────────
results = kb.query.near_text(
    query="политика отпусков",
    limit=5,
    filters=(
        Filter.by_property("tenant_id").equal("acme")
        & Filter.by_property("language").equal("ru")
        & Filter.by_property("is_verified").equal(True)
        & Filter.by_property("access_level").less_or_equal(1)
    ),
    return_metadata=MetadataQuery(distance=True),
)

for obj in results.objects:
    print(f"distance={obj.metadata.distance:.3f}  {obj.properties['text']}")

# ── OR-фильтр ──────────────────────────────────────────────────────────
results = kb.query.near_text(
    query="техническая документация",
    limit=5,
    filters=(
        Filter.by_property("tenant_id").equal("acme")
        & (
            Filter.by_property("doc_type").equal("technical")
            | Filter.by_property("doc_type").equal("faq")
        )
    ),
)

# ── Range по числовому полю ────────────────────────────────────────────
results = kb.query.near_text(
    query="новости компании",
    limit=5,
    filters=(
        Filter.by_property("tenant_id").equal("acme")
        & Filter.by_property("created_at").greater_or_equal(1704067200)  # 2024-01-01
    ),
)

# ── Фильтр по массиву tags (contains any) ─────────────────────────────
results = kb.query.near_text(
    query="льготы сотрудников",
    limit=5,
    filters=(
        Filter.by_property("tenant_id").equal("acme")
        & Filter.by_property("tags").contains_any(["hr", "benefits"])
    ),
)

client.close()

Chroma: where-словарь

Chroma использует словарь where с оператором в качестве ключа. Синтаксис проще, но менее выразителен — вложенные условия задаются через $and / $or.

python — Chroma: фильтрация с where
import chromadb
from sentence_transformers import SentenceTransformer

client = chromadb.Client()
model = SentenceTransformer("all-MiniLM-L6-v2")

collection = client.create_collection("kb")

# Добавляем документы с метаданными
texts = [
    "Политика отпусков ACME 2024",
    "ACME API документация",
    "Политика Beta Corporation",
    "ACME черновик политики",
]
metas = [
    {"tenant_id": "acme", "language": "ru", "doc_type": "policy",    "access_level": 1, "is_verified": True},
    {"tenant_id": "acme", "language": "ru", "doc_type": "technical", "access_level": 0, "is_verified": True},
    {"tenant_id": "beta", "language": "ru", "doc_type": "policy",    "access_level": 1, "is_verified": True},
    {"tenant_id": "acme", "language": "ru", "doc_type": "draft",     "access_level": 1, "is_verified": False},
]

collection.add(
    documents=texts,
    embeddings=model.encode(texts, normalize_embeddings=True).tolist(),
    metadatas=metas,
    ids=[str(i) for i in range(len(texts))],
)

query_vec = model.encode(["политика отпусков"], normalize_embeddings=True).tolist()

# ── Простой равенство ──────────────────────────────────────────────────
results = collection.query(
    query_embeddings=query_vec,
    n_results=3,
    where={"tenant_id": "acme"},   # сокращение для {"tenant_id": {"$eq": "acme"}}
)

# ── AND через $and ─────────────────────────────────────────────────────
results = collection.query(
    query_embeddings=query_vec,
    n_results=3,
    where={
        "$and": [
            {"tenant_id":   {"$eq": "acme"}},
            {"language":    {"$eq": "ru"}},
            {"is_verified": {"$eq": True}},
            {"access_level":{"$lte": 1}},
            {"doc_type":    {"$ne": "draft"}},
        ]
    },
)

# ── OR через $or ───────────────────────────────────────────────────────
results = collection.query(
    query_embeddings=query_vec,
    n_results=3,
    where={
        "$and": [
            {"tenant_id": {"$eq": "acme"}},
            {"$or": [
                {"doc_type": {"$eq": "policy"}},
                {"doc_type": {"$eq": "faq"}},
            ]},
        ]
    },
)

# Операторы: $eq, $ne, $gt, $gte, $lt, $lte, $in, $nin
results = collection.query(
    query_embeddings=query_vec,
    n_results=3,
    where={"doc_type": {"$in": ["policy", "faq", "technical"]}},
)

for i, doc in enumerate(results["documents"][0]):
    meta = results["metadatas"][0][i]
    dist = results["distances"][0][i]
    print(f"[{dist:.3f}] {doc[:60]}  ({meta['doc_type']})")
Ограничение Chroma: массивы в метаданных не поддерживаются напрямую — нельзя сделать tags CONTAINS "hr". Обходной путь: хранить теги как строку с разделителем ("hr,benefits,onboarding") и использовать $contains. Для сложных array-фильтров лучше выбирать Qdrant или Weaviate.

Паттерн: контроль доступа через метаданные

Metadata filtering — основной инструмент реализации разграничения доступа в RAG-системах. Это надёжнее, чем постпроцессинг ответа LLM, потому что ограниченные документы физически не попадают в контекст.

RBAC: ролевой доступ

Пользователь: аналитик (level=1)
Публичные документы (level=0)
Документы для сотрудников (level=1)
Документы для менеджеров (level=2)
C-level документы (level=3)
Пользователь: директор (level=3)
Публичные документы (level=0)
Документы для сотрудников (level=1)
Документы для менеджеров (level=2)
C-level документы (level=3)
python — RBAC + мультитенант в одном фильтре
from dataclasses import dataclass

@dataclass
class UserContext:
    user_id: str
    tenant_id: str
    role_level: int          # 0=guest, 1=employee, 2=manager, 3=admin
    allowed_languages: list[str]
    allowed_doc_types: list[str] | None = None  # None = все типы


def build_qdrant_filter(ctx: UserContext) -> Filter:
    """Строим фильтр на основе контекста пользователя."""
    must_conditions = [
        # Мультитенантная изоляция — первым делом
        FieldCondition(key="tenant_id",    match=MatchValue(value=ctx.tenant_id)),
        # Только проверенные документы
        FieldCondition(key="is_verified",  match=MatchValue(value=True)),
        # RBAC: access_level <= роль пользователя
        FieldCondition(key="access_level", range=Range(lte=ctx.role_level)),
    ]

    # Языковой фильтр
    if len(ctx.allowed_languages) == 1:
        must_conditions.append(
            FieldCondition(key="language", match=MatchValue(value=ctx.allowed_languages[0]))
        )
    else:
        must_conditions.append(
            FieldCondition(key="language", match=MatchAny(any=ctx.allowed_languages))
        )

    # Фильтр по типу документа (если задан)
    if ctx.allowed_doc_types:
        must_conditions.append(
            FieldCondition(key="doc_type", match=MatchAny(any=ctx.allowed_doc_types))
        )

    return Filter(
        must=must_conditions,
        must_not=[
            # Никогда не показываем черновики
            FieldCondition(key="doc_type", match=MatchValue(value="draft")),
        ],
    )


def rag_search(query: str, user_ctx: UserContext, top_k: int = 5):
    """RAG-поиск с автоматическим применением ACL-фильтра."""
    query_vector = model.encode(query, normalize_embeddings=True).tolist()
    filter_ = build_qdrant_filter(user_ctx)

    results = client.search(
        collection_name=COLLECTION,
        query_vector=query_vector,
        limit=top_k,
        query_filter=filter_,
    )
    return results


# ── Использование ──────────────────────────────────────────────────────
employee_ctx = UserContext(
    user_id="ivan@acme.com",
    tenant_id="acme",
    role_level=1,
    allowed_languages=["ru", "en"],
)

manager_ctx = UserContext(
    user_id="anna@acme.com",
    tenant_id="acme",
    role_level=2,
    allowed_languages=["ru"],
    allowed_doc_types=["policy", "faq", "technical", "legal"],
)

# Сотрудник не увидит C-level документы
employee_results = rag_search("компенсации топ-менеджмента", employee_ctx)
# Менеджер увидит
manager_results  = rag_search("компенсации топ-менеджмента", manager_ctx)
Безопасность по умолчанию. Фильтр контроля доступа должен быть обязательным, не опциональным. Паттерн: запрос в RAG-систему без явного UserContext невозможен. Не разрешайте передавать сырой filter=None из клиентского кода — это должно быть ошибкой компиляции или исключением на уровне API.

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

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

Поле Тип индекса Когда индексировать Когда НЕ индексировать
tenant_id KEYWORD (hashmap) Всегда в мультитенантной системе Single-tenant: нет смысла
language KEYWORD Мультиязычный корпус Только один язык
doc_type KEYWORD Несколько типов, пользователь выбирает Один тип во всей коллекции
access_level INTEGER Всегда при наличии ролей Открытый публичный корпус
created_at FLOAT/DATETIME Нужна актуальность, time-range запросы Статичный архив без временного измерения
tags KEYWORD (array) Тематическая навигация, фасетный поиск Теги генерируются случайно, не используются
source_url — (нет индекса) Редко нужна точная фильтрация Храним в payload для display, не для фильтра
full_text_backup — (нет индекса) Никогда Дублирование текста — только в BM25-индексе
Cardinality имеет значение. Hashmap-индекс на поле с высокой кардинальностью (UUID, user_id, source_url) работает не хуже по скорости, но занимает значительно больше памяти. Для полей с тысячами уникальных значений оцените: нужна ли вам фильтрация именно по этому полю в run-time, или достаточно хранения для отображения?

Шпаргалка

КонцепцияКлючевое
Pre-filtering Фильтр → candidate set → ANN по кандидатам. Гарантирует корректность. Требует payload index.
Post-filtering ANN по всему индексу → фильтр. Теряет релевантные документы. Не используйте.
Payload Index (Qdrant) create_payload_index(col, "field", KEYWORD/INTEGER/FLOAT/BOOL). Без него — полный скан.
Qdrant must/should/must_not must=AND, should=OR (хотя бы одно), must_not=NOT.
Weaviate Filter Filter.by_property("f").equal(v). Операторы: & (AND), | (OR).
Chroma where Словарь с операторами $eq/$ne/$gt/$gte/$lt/$lte/$in/$nin. $and/$or для логики.
RBAC-паттерн access_level <= user.role_level. Всегда обязательный фильтр, не опциональный.
Мультитенант tenant_id == user.tenant — первым условием в must. Нет исключений.
Когда индексировать Только поля, по которым будете фильтровать. Остальные — просто в payload для display.
Array-фильтры Qdrant и Weaviate: нативная поддержка. Chroma: костыль через строку с разделителем.

Практика

  1. Мультитенантная изоляция. Создайте коллекцию Qdrant с 50+ документами от трёх «клиентов». Реализуйте функцию search(query, tenant_id, user_role), которая гарантированно возвращает только документы текущего клиента с подходящим уровнем доступа. Напишите тест, убеждающийся что документы других клиентов никогда не попадают в результаты.
  2. Фасетный поиск. Загрузите 30–40 статей с метаданными: теги, язык, дата, тип. Реализуйте функцию faceted_search(query, filters: dict), которая принимает произвольный набор фильтров из словаря и динамически строит Qdrant-фильтр. Поддержите поля: language, tags, date_range, doc_types.
  3. Сравнение pre- vs post-filter. Создайте датасет из 10 000 документов, где только 100 принадлежат нужному тенанту. Замерьте через time.perf_counter(): (а) поиск с pre-filter (payload index), (б) поиск по всем без фильтра + post-filter в Python, (в) поиск без индекса (удалите payload index) + pre-filter. Сравните скорость и полноту результатов.