Что ломается без фильтров
Мультитенантная платформа: три компании используют одну RAG-систему. Каждая загрузила свои документы. Пользователь из компании А спрашивает про внутреннюю политику отпусков. Семантически похожие документы есть у всех трёх компаний — и векторный поиск вернёт их все вперемешку. Компания А получит в ответе политику компании Б. Это не просто неправильно — это нарушение конфиденциальности.
Вот четыре типичные ситуации, где семантика недостаточна:
| Сценарий | Что нужно | Что происходит без фильтра |
|---|---|---|
| Мультитенантная система | Только документы текущего клиента | Документы других клиентов в ответе |
| Актуальность данных | Документы не старше 6 месяцев | Устаревшие версии регламентов, старые API |
| Локализация | Только документы на языке пользователя | Релевантный по смыслу, но нечитаемый контент |
| Контроль доступа (RBAC) | Только документы по роли пользователя | Секретные HR-данные в ответе на обычный вопрос |
Решение — хранить вместе с вектором структурированные атрибуты документа (метаданные) и применять точные условия до или во время векторного поиска.
Что такое метаданные в контексте RAG
Метаданные — это структурированные поля, прикреплённые к каждому чанку документа. Они не участвуют в вычислении эмбеддинга и не влияют на семантическое сходство — они живут отдельно в payload (в терминологии Qdrant) или properties (Weaviate).
Типы полей и применимые операции
IN / NOT IN
starts_with
contains
match (regex)
lang == "ru"
type IN ["faq","doc"]
<= >=
BETWEEN
IN / NOT IN
score >= 0.8
year BETWEEN 2023 2025
== false
IS NULL
IS NOT NULL
is_verified == true
deleted IS NULL
CONTAINS ANY
CONTAINS ALL
is_empty
tags CONTAINS "hr"
ids CONTAINS ANY [1,2,3]
Pre-filter vs Post-filter: в чём разница
Есть три подхода к применению фильтра относительно ANN-поиска. Выбор между ними влияет и на качество результатов, и на производительность.
Представьте: N=100 000 документов, из них 500 принадлежат нужному клиенту. При post-filter с K=10 мы сначала ищем top-10 по всем 100 000 — среди них может не оказаться ни одного документа нужного клиента. Ответ: пустой. При pre-filter мы сначала изолируем 500 документов, затем ищем top-10 среди них — результат гарантированно есть.
Как фильтрация работает внутри векторной БД
Чтобы фильтрация была быстрой, недостаточно просто хранить метаданные рядом с вектором. Нужны специализированные структуры данных — отдельные от ANN-индекса.
Payload Index: индексы на метаданных
Чтобы фильтрация работала за миллисекунды (а не за секунды линейного скана), нужны отдельные индексы на полях метаданных. Каждый тип данных требует своей структуры.
tenant_id → {acme: [1,5,12,...], beta: [2,7,...]}Быстрый поиск точного совпадения. Поле language, doc_type, tenant_id — именно сюда.
<, >,
BETWEEN за O(log n).Поля created_at, score, access_level — сюда.
Идеален для is_verified, is_deleted.
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 устроен в памяти.
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}")
[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-синтаксисом).
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']}")
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().
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.
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']})")
tags CONTAINS "hr". Обходной путь: хранить теги
как строку с разделителем ("hr,benefits,onboarding") и использовать
$contains. Для сложных array-фильтров лучше выбирать Qdrant или Weaviate.
Паттерн: контроль доступа через метаданные
Metadata filtering — основной инструмент реализации разграничения доступа в RAG-системах. Это надёжнее, чем постпроцессинг ответа LLM, потому что ограниченные документы физически не попадают в контекст.
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)
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-индексе |
Шпаргалка
| Концепция | Ключевое |
|---|---|
| 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: костыль через строку с разделителем. |
Практика
-
Мультитенантная изоляция.
Создайте коллекцию Qdrant с 50+ документами от трёх «клиентов».
Реализуйте функцию
search(query, tenant_id, user_role), которая гарантированно возвращает только документы текущего клиента с подходящим уровнем доступа. Напишите тест, убеждающийся что документы других клиентов никогда не попадают в результаты. -
Фасетный поиск.
Загрузите 30–40 статей с метаданными: теги, язык, дата, тип.
Реализуйте функцию
faceted_search(query, filters: dict), которая принимает произвольный набор фильтров из словаря и динамически строит Qdrant-фильтр. Поддержите поля:language,tags,date_range,doc_types. -
Сравнение pre- vs post-filter.
Создайте датасет из 10 000 документов, где только 100 принадлежат
нужному тенанту. Замерьте через
time.perf_counter(): (а) поиск с pre-filter (payload index), (б) поиск по всем без фильтра + post-filter в Python, (в) поиск без индекса (удалите payload index) + pre-filter. Сравните скорость и полноту результатов.