中文翻译暂不可用,显示俄语原文。

Multi-tenant RAG с изоляцией данных

ТЕХНИЧЕСКОЕ ЗАДАНИЕ: Multi-tenant RAG с изоляцией данных

1. Цель задачи

Спроектировать и реализовать мультиарендную архитектуру для RAG-системы, в которой данные каждого тенанта (арендатора) полностью изолированы. Применяется подход collection per tenant (отдельная коллекция в векторной БД для каждого клиента) и/или фильтрация по полю tenant_id в общей коллекции. Система должна гарантировать, что запрос от одного тенанта не вернёт документы другого даже при ошибках в коде приложения.

Ключевой результат Рабочий прототип Multi-tenant RAG API, прошедший тесты на изоляцию (тенант A не видит данные тенанта B).


2. Исходные данные

Что нужноОткуда взять
Документы для минимум 2 тенантов (5–10 документов на тенант)Создать самостоятельно (например, текстовые файлы с разными темами: «Финансы_тенантА», «Медицина_тенантБ»)
Векторная БД (Qdrant / Weaviate / Chroma)Docker-образ (официальный) или облачная инстанция
Python 3.10+ окружениеЛокальная установка / venv
Каркас RAG-приложенияFastAPI + LangChain (или LlamaIndex)
Эмбеддинг-модельSentence-transformers (all-MiniLM-L6-v2) или OpenAI API

Если нет реального инструмента — симулируем:

  1. Если нет возможности поднять Qdrant в Docker — используем Chroma in-memory (но с потерей некоторых функций фильтрации).
  2. Если нет реальных документов — генерируем 10 синтетических текстов с помощью faker + случайные тематики (финансы, спорт, IT), помечаем каждый tenant_id в метаданных.
  3. Если нет OpenAI API — используем локальную модель через Ollama или отключаем генерацию, оставляем только retrieval.

3. Технологический стек

КомпонентИнструментыНазначение
BackendPython 3.10+ / FastAPIВеб-сервер для обработки запросов
Векторная БДQdrant (Docker)Хранение и поиск эмбеддингов с фильтрацией
Embeddingsentence-transformers/all-MiniLM-L6-v2Генерация векторных представлений
RAG-фреймворкLangChain (или LlamaIndex)Пайплайн retrieval + generation
LLMOpenAI GPT-4 (опционально) или OllamaГенерация ответа на основе извлечённых документов
ТестированиеPytest + httpxUnit и интеграционные тесты изоляции
КонтейнеризацияDocker / Docker ComposeЛокальный запуск Qdrant + приложения

4. Этапы выполнения

Этап 1: Проектирование архитектуры и подготовка окружения (30 минут)

Действия

  1. Выбрать модель изоляции — collection per tenant (рекомендуется для production). Каждый тенант получает отдельную коллекцию с именем tenant_{id}. Если тенантов > 1000 — рассмотреть shared collection с фильтром. Для данного pet-проекта используем collection per tenant.

  2. Создать структуру директорий проекта:

    multi-tenant-rag/
    ├── app/
    │   ├── __init__.py
    │   ├── main.py
    │   ├── api.py
    │   ├── vector_store.py
    │   ├── auth.py (заглушка)
    │   └── models.py
    ├── data/
    │   ├── tenant_a/
    │   └── tenant_b/
    ├── tests/
    │   ├── test_isolation.py
    │   └── test_retrieval.py
    ├── docker-compose.yml
    └── requirements.txt
    
  3. Подготовить Docker Compose для Qdrant и, опционально, для приложения:

    version: '3.8'
    services:
      qdrant:
        image: qdrant/qdrant:v1.12.1
        ports:
          - "6333:6333"
        volumes:
          - ./qdrant_storage:/qdrant/storage
    

Ожидаемый результат этапа Поднятый Qdrant, проект с пустыми модулями, requirements.txt.


Этап 2: Реализация per-tenant коллекций и загрузка данных (1.5 часа)

Действия

  1. Написать модуль vector_store.py:

    • Функция get_client() — инициализация QdrantClient (подключение к localhost:6333).
    • Функция create_tenant_collection(tenant_id: str) — создаёт коллекцию с именем tenant_{tenant_id} с размерностью эмбеддинга (384) и конфигурацией HNSW.
    • Функция upsert_documents(tenant_id: str, documents: List[Document]) — разбивает документы на чанки (Chunk size=512, overlap=50), генерирует эмбеддинги через sentence-transformers, загружает в соответствующую коллекцию с payload, содержащим tenant_id и doc_id.
  2. Реализовать загрузку данных:

    # load_data.py (скрипт)
    from app.vector_store import create_tenant_collection, upsert_documents
    from langchain.text_splitter import RecursiveCharacterTextSplitter
    
    splitter = RecursiveCharacterTextSplitter(chunk_size=512, chunk_overlap=50)
    for tenant in ["tenant_a", "tenant_b"]:
        create_tenant_collection(tenant)
        # читать файлы из data/{tenant}/
        files = glob.glob(f"data/{tenant}/*.txt")
        for filepath in files:
            with open(filepath) as f:
                text = f.read()
            chunks = splitter.split_text(text)
            # создать Document objects
            docs = [Document(page_content=chunk, metadata={"tenant_id": tenant}) for chunk in chunks]
            upsert_documents(tenant, docs)
    
  3. Добавить тестовые данные:

    • data/tenant_a/finance.txt: «Отчёт по доходам за Q1 2025 компании Альфа. Прибыль составила 1.2 млн долларов.»
    • data/tenant_b/medical.txt: «История болезни пациента Петрова И.С. Диагноз: гипертония 2 степени.»
  4. Запустить скрипт загрузки и убедиться, что обе коллекции созданы и содержат векторы.

Ожидаемый результат этапа Скрипт загрузки данных успешно заполнил две коллекции; проверка через Qdrant UI (/dashboard) показывает наличие точек.


Этап 3: Реализация API с изоляцией по tenant_id (1 час)

Действия

  1. Создать модель запроса в models.py:

    from pydantic import BaseModel, Field
    
    class QueryRequest(BaseModel):
        tenant_id: str = Field(..., description="Идентификатор тенанта")
        question: str = Field(..., min_length=1, max_length=1000)
    
  2. Реализовать endpoint /retrieve в api.py:

    • Принимает QueryRequest.
    • Извлекает tenant_id из запроса.
    • Использует коллекцию tenant_{tenant_id} для поиска (через QdrantClient.search).
    • Возвращает top_k документов (например, 3) с их содержимым и метаданными.
    • Важно: никогда не разрешать выбор коллекции из пользовательского ввода без валидации — проверять, что tenant_id существует в предопределённом списке.
  3. Включить аутентификацию (упрощённую) — проверять заголовок X-Tenant-ID, который должен совпадать с tenant_id в теле запроса. Это моделирует реальную проверку токена.

  4. Написать основное приложение main.py:

    from fastapi import FastAPI, Depends, HTTPException, Header
    from app.api import router
    
    app = FastAPI()
    app.include_router(router)
    
  5. Запустить и протестировать вручную:

    curl -X POST http://localhost:8000/retrieve \
      -H "X-Tenant-ID: tenant_a" \
      -H "Content-Type: application/json" \
      -d '{"tenant_id": "tenant_a", "question": "Какая прибыль компании Альфа?"}'
    

    Убедиться, что возвращаются только документы из коллекции tenant_a.

Ожидаемый результат этапа Рабочий endpoint /retrieve, корректно фильтрующий по tenant.


Этап 4: Тестирование изоляции и безопасности (1 час)

Действия

  1. Написать интеграционные тесты в tests/test_isolation.py:

    import pytest
    from httpx import AsyncClient
    from app.main import app
    
    @pytest.mark.asyncio
    async def test_tenant_a_cannot_see_tenant_b_data():
        async with AsyncClient(app=app, base_url="http://test") as client:
            # tenant_a просит данные по медицинской теме, которая есть только у tenant_b
            response = await client.post("/retrieve",
                json={"tenant_id": "tenant_a", "question": "История болезни пациента Петрова"},
                headers={"X-Tenant-ID": "tenant_a"})
            data = response.json()
            # Ни один документ не должен содержать слово "гипертония"
            assert not any("гипертония" in doc["content"] for doc in data["documents"])
    
  2. Написать тест на инъекцию tenant_id:

    • Попытаться передать "tenant_id": "tenant_b'" OR '1'='1" — должно отрабатывать безопасно (только буквы, цифры, подчёркивание).
    • Проверить, что доступ к несуществующей коллекции возвращает 404.
  3. Проверить безопасность на уровне API:

    • Запрос без заголовка X-Tenant-ID — 403.
    • Запрос с несовпадающим tenant_id и заголовком — 403.
  4. Запустить тесты и зафиксировать результаты.

Ожидаемый результат этапа Все тесты проходят, изоляция подтверждена.


5. Критерии приемки (Definition of Done)

  • Код размещён в git-репозитории с Readme и инструкцией по запуску.
  • Приложение запускается одной командой (docker-compose up).
  • Существуют две коллекции (или одна с фильтрами) с разными данными для двух тенантов.
  • Endpoint /retrieve возвращает только документы запрашиваемого тенанта.
  • Написаны и проходят минимум 3 теста на изоляцию (разные тенанты, инъекция, несовпадение заголовка).
  • При попытке запроса с несуществующим tenant_id возвращается HTTP 404.
  • Документация API сгенерирована (FastAPI auto-docs).
  • Нет хардкода секретов (API-ключи вынесены в .env).

6. Ожидаемый результат

Основной артефакт Репозиторий с проектом, содержащий:

  • app/ — исходный код FastAPI-приложения.
  • data/ — примеры документов для двух тенантов.
  • tests/ — автоматические тесты изоляции.
  • docker-compose.yml — конфигурация для запуска Qdrant.
  • requirements.txt — зависимости.
  • README.md — инструкция по запуску и демонстрация.

Дополнительные артефакты (опционально):

  • Скрипт для генерации синтетических документов.
  • Grafana-дашборд для мониторинга запросов (число запросов на тенант, задержки).
  • Пример Postman-коллекции для ручного тестирования.

7. Возможные сложности и их решение

СложностьРешение
Случайное смешивание tenant_id при batch-загрузкеВ payload каждого вектора всегда записывать tenant_id; перед upsert проверять соответствие названия коллекции и tenant_id.
Забыли создать коллекцию для нового тенантаРеализовать lazy-создание: при первом запросе на retrieve, если коллекция не существует, возвращать 404 (а не создавать автоматически — это может быть использовано для DoS).
Некорректные фильтры в Qdrant (например, опечатка в имени поля)В тестах явно проверять, что фильтр применяется: запрос с точным must фильтром и сравнение результата с запросом без фильтра.
Передача tenant_id через URL (например, /tenant_a/retrieve) — риск path traversalИспользовать только заголовок и тело запроса, валидировать tenant_id regex: ^[a-zA-Z0-9_]+$.
Асинхронные race conditions при одновременной загрузке данных разными тенантамиИспользовать синхронные блокировки на уровне приложения или очередь задач (celery). Для пет-проекта достаточно одного скрипта загрузки.

8. Бюджет времени (оценка)

ЭтапВремя
Этап 1: Архитектура и окружение30 мин
Этап 2: Коллекции и загрузка данных1 час 30 мин
Этап 3: API с изоляцией1 час
Этап 4: Тестирование1 час
Итого4 часа

Примечание Для первого раза время может увеличиться до 6–8 часов из-за отладки.


9. Связанные вопросы из базы знаний

ВопросТемаНомер
Как реализовать multi-tenant RAG?Архитектура RAG45
Векторные БД: Qdrant vs Chroma vs WeaviateВыбор инструмента112
Фильтрация в Qdrant (filtered search)Query API201
Безопасность tenant-изоляции в приложениях ИИSecurity378
LangChain: работа с несколькими векторными сторамиLangChain89
Тестирование RAG-системQA567
Инъекция в tenant_id (OWASP)Security450
Разбивка документов на чанки (chunking strategies)Preprocessing33
Мониторинг RAG: метрики для multi-tenancyObservability721
Использование эмбеддингов с метаданнымиEmbedding155

10. Чек-лист самопроверки

  • Я спроектировал архитектуру с учётом изоляции данных (collection per tenant или shared + filter) и задокументировал выбор.
  • Все эндпоинты API проверяют tenant_id из заголовка и не доверяют только телу запроса.
  • Я написал тесты, которые явно проверяют, что тенант A не получает данные тенанта B, даже если вопрос явно относится к теме B.
  • Я протестировал граничные случаи: несуществующий tenant_id, специальные символы в tenant_id, отсутствие заголовка.
  • В README описан процесс запуска и ссылка на тесты; код можно запустить и проверить изоляцию.