MAATRIX / Блог / Права доступа в RAG: чтобы сотрудник не увидел лишнее

Права доступа в RAG: чтобы сотрудник не увидел лишнее

MAATRIX

Корпоративная RAG-система хороша ровно настолько, насколько хорошо она умеет говорить «нет». Если вы подняли поиск по всем документам компании и он честно находит нужный ответ — это половина задачи. Вторая половина, которую легко пропустить на этапе пилота и вспомнить только после инцидента: система обязана молчать о том, к чему у конкретного спрашивающего нет доступа. Разберём, откуда берётся эта проблема и какими техническими средствами она закрывается на уровне архитектуры, а не на честном слове модели.

Фундаментальная проблема: один индекс на всех

Типичный путь внедрения RAG: берём документы компании — вики, тикеты, финансовые отчёты, HR-папку, — прогоняем через эмбеддинг-модель, складываем векторы в одну базу (Qdrant, pgvector, Weaviate, не важно) и поднимаем поверх чат-интерфейс. Демо выглядит убедительно, все довольны. Проблема появляется в тот момент, когда индекс общий, а права доступа в исходных системах — нет.

Векторный поиск не разбирается, кому что можно. Он ищет фрагменты, семантически близкие к вопросу, и не более того. Если в индексе лежит фрагмент из ведомости по зарплатам топ-менеджмента, любой сотрудник с доступом к общему чату теоретически может сформулировать вопрос так, чтобы система нашла и использовала именно этот фрагмент — «какие есть примеры компенсаций руководителей в компании» звучит невинно, а по сути это прямой запрос к документу, который в исходной HR-системе закрыт для всех, кроме двух человек. Модель не «взламывает» доступ — доступ никогда и не проверялся, потому что индекс изначально построен как единое плоское пространство без учёта того, кто и что должен видеть.

Это не гипотетический риск. HR-документы (зарплаты, оценки, дисциплинарные дела), финансовая отчётность до публикации, юридическая переписка, персональные данные клиентов, черновики контрактов с условиями по конкретным сделкам — всё это точно не должно попадать в общий контекст без проверки прав. Подробнее о том, как в принципе следует хранить такие данные на сервере компании, — в статье про персональные данные сотрудников на корпоративном сервере: RAG добавляет к этой теме отдельный слой риска — раньше данные лежали просто на диске, теперь у них есть удобный natural-language интерфейс поиска, которым может воспользоваться кто угодно с доступом к чату.

Разграничение прав в RAG — не фича, которую добавляют после пилота, а часть архитектуры индекса, которую закладывают с первого дня: переделывать схему хранения и переиндексировать весь корпус задним числом заметно дороже, чем продумать это сразу.

Метаданные доступа на уровне фрагмента

Рабочий подход — хранить информацию о правах не на уровне документа целиком, а на уровне каждого проиндексированного фрагмента (chunk): права могут отличаться даже внутри одного документа, например у страницы вики с публичным разделом и приватным приложением в конце. При индексации вместе с текстом фрагмента и его вектором в payload/metadata записывается, кто имеет право этот фрагмент увидеть:

{
  "id": "doc_4821_chunk_12",
  "text": "...текст фрагмента...",
  "vector": [0.0123, -0.0456, ...],
  "source_system": "confluence",
  "source_doc_id": "SPACE-HR-142",
  "acl": {
    "allowed_users": ["u_1042"],
    "allowed_groups": ["hr-team", "c-level"],
    "is_public": false
  },
  "indexed_at": "2026-08-14T09:31:00Z"
}

Два решения здесь принципиальны. Первое — использовать группы и роли, а не только конкретных пользователей: список из десяти тысяч сотрудников на каждом фрагменте не масштабируется, а группа hr-team с отдельным маппингом «кто сейчас в группе» синхронизируется отдельно. Второе — поле is_public для явно общедоступного контента (общая документация продукта, публичные FAQ), чтобы не гонять проверку прав там, где она заведомо не нужна, — и это явное архитектурное решение «открыто всем», а не значение по умолчанию.

Для векторных баз с payload-фильтрами (Qdrant, Weaviate, Milvus) эти метаданные складываются прямо в payload точки и участвуют в фильтре поиска. Для pgvector в PostgreSQL — это обычные колонки рядом с векторной, фильтрация делается через WHERE или row-level security (см. ниже). О выборе движка под такую схему — в статье про настройку векторной базы для RAG на сервере: там же разбирается, какие базы удобнее для фильтрации по метаданным, а какие заточены только под чистый ANN-поиск.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Развернуть ИИ на сервере

Фильтрация ДО передачи модели, а не после

Центральный принцип: фильтрация по правам должна происходить на этапе поиска, до того как найденные фрагменты попадут в промпт модели как контекст. Модель физически не должна получить в контексте текст, который пользователю не положено видеть — если он туда попал, вы полагаетесь на то, что модель «сама не покажет» это в ответе, а это ненадёжная граница. Модель можно попросить процитировать контекст, переформулировать, перевести — системный промпт вида «не показывай конфиденциальное» обходится обычным prompt injection или настойчивым перефразированием вопроса.

Правильный поток запроса: пользователь отправляет вопрос через аутентифицированную сессию (система знает, кто спрашивает, по токену, а не по его словам) → приложение резолвит текущие права пользователя (user_id, список групп/ролей прямо сейчас) → векторный поиск выполняется с фильтром по этим правам, не «найди похожее, а потом отфильтруй», а «найди похожее среди того, что удовлетворяет условию доступа» → в контекст модели попадают только уже отфильтрованные фрагменты.

Пример фильтра для Qdrant, где user_groups — группы текущего пользователя:

from qdrant_client.models import Filter, FieldCondition, MatchAny, MatchValue

results = client.search(
    collection_name="company_docs",
    query_vector=query_embedding,
    query_filter=Filter(
        should=[
            FieldCondition(key="acl.is_public", match=MatchValue(value=True)),
            FieldCondition(key="acl.allowed_groups", match=MatchAny(any=user_groups)),
            FieldCondition(key="acl.allowed_users", match=MatchAny(any=[user_id])),
        ]
    ),
    limit=8,
)

should работает как логическое ИЛИ: фрагмент проходит фильтр, если он публичный, или пользователь состоит в разрешённой группе, или указан персонально. Фильтр применяется движком до вычисления релевантности — фрагменты, которые всё равно пришлось бы выбросить, физически не появляются в наборе кандидатов.

Для pgvector тот же принцип реализуется через row-level security (RLS) — ограничение видимых строк на уровне СУБД, а не логики приложения:

ALTER TABLE doc_chunks ENABLE ROW LEVEL SECURITY;

CREATE POLICY chunk_access_policy ON doc_chunks
  USING (
    is_public = true
    OR current_setting('app.current_user_groups')::text[] && allowed_groups
    OR current_setting('app.current_user_id') = ANY(allowed_users)
  );

Плюс RLS — защита работает, даже если разработчик забыл добавить WHERE в конкретном запросе: СУБД сама не отдаст лишние строки при любом SQL-запросе от этой сессии. Это одна из немногих ситуаций, где стоит переложить критичную для безопасности логику с приложения на базу данных.

Синхронизация прав с исходной системой

Права в исходных системах меняются постоянно — повышение и новая группа, увольнение и удаление из всех групп, закрытие проекта и отзыв доступа к его тикетам. Если индекс RAG хранит снимок прав на момент последней индексации и не обновляет его, возникает окно, в котором уволенный неделю назад сотрудник (пока его сессия не отозвана на уровне аутентификации) может получать через ИИ-поиск ответы по документам, к которым в исходной системе доступа уже нет.

ПодходКак работаетЗадержкаКогда достаточно
Полная переиндексация по расписаниюCron раз в N часов пересобирает права всех документовЧасы-суткиНебольшой корпус, права меняются редко
Инкрементальная синхронизация правЛёгкий процесс раз в 5-15 минут опрашивает только изменения правМинутыСредний/крупный корпус
Webhook-синхронизацияИсточник шлёт событие при изменении прав, ACL обновляется сразуСекундыКритичные данные
Проверка прав в момент запросаACL в индексе — грубый предфильтр, финальная сверка — живой запрос к источнику или его кэшуСекундыВысокая цена ошибки

Последний вариант — двухуровневая проверка — на практике самый надёжный: индекс хранит ACL для быстрой предфильтрации на этапе поиска (не тащить в кандидаты заведомо недоступные тысячи фрагментов), а по 5-10 документам, которые реально попадут в ответ, делается финальная сверка с источником или его кэшем с коротким TTL. Дороже по числу запросов, зато закрывает окно между «права изменились» и «индекс это заметил».

Деактивация учётной записи целиком должна рвать доступ к ИИ-поиску мгновенно на уровне аутентификации (отзыв SSO/OIDC-сессии), независимо от расписания синхронизации прав на отдельные документы — это первый и самый грубый рубеж защиты, и он не должен зависеть от цикла переиндексации.

Group-based ACL и коннекторы к реальным источникам

На небольшом корпусе (сотни документов, десятки сотрудников) персональные ACL работают и понятны в логах. С ростом компании они становятся неподъёмными: при переводе сотрудника в другой отдел нужно проставить его в тысячи фрагментов документов этого отдела вместо одной записи группы. Group-based ACL масштабируется лучше — изменение происходит в одном месте, членстве в группе, а не в метаданных документов, — но требует надёжного источника правды о группах, обычно того же каталога, что используется для SSO (Active Directory, Okta, Google Workspace). Практичнее всего гибрид: базовый доступ через группы (отдел, роль, проект) плюс точечные персональные исключения там, где это реально нужно.

Готовый пример инструмента, который реализует такую модель на практике — permission sync с коннекторами к Confluence, Slack, Google Drive и другим системам, — разобран в статье про Onyx (Danswer): там видно, как self-hosted платформа синхронизирует права на уровне конкретных коннекторов, и какие из них поддерживают permission sync полноценно, а какие нет. Если вы собираете RAG самописно поверх одного источника, без множества внешних коннекторов, принципы этой статьи применимы напрямую — разница лишь в том, что права синхронизировать нужно с одной системой, а не с десятком; общая механика конвейера поиска и генерации ответа разобрана в статье про то, что происходит внутри RAG между вопросом и ответом.

Тестирование: негативные сценарии обязательны

Частая ошибка внедрения — тестировать RAG только на позитивных сценариях: «сотрудник спросил про отпускную политику, получил правильный ответ, поехали в продакшен». Это проверяет, что система находит нужное, но ничего не говорит о том, что она не находит ненужное. Разграничение прав нужно тестировать зеркально: не «работает ли поиск», а «отказывает ли поиск там, где должен».

Чек-лист перед развёртыванием на реальных чувствительных данных:

  • Заведите тестового пользователя с урезанными правами (без доступа к HR-папке, финансовым отчётам, приватным каналам конкретных проектов).
  • Составьте 10-20 вопросов, ответы на которые лежат именно в недоступных этому пользователю документах, и проверьте, что система либо не находит ответ, либо честно отвечает «не нашёл», но не выдаёт содержимое закрытого документа прямо или перефразированно.
  • Проверьте не только прямые вопросы («покажи зарплату сотрудника X»), но и обходные формулировки («какие в компании вилки компенсаций для позиции Y») — семантически близкие, но не идентичные запросы — самый частый способ случайно вытащить закрытый контент через векторный поиск.
  • Отзовите у тестового пользователя доступ к одной из групп в исходной системе и, дождавшись цикла синхронизации, повторите вопросы — убедитесь, что доступ в ИИ-поиске закрылся вместе с правами в источнике, а не работает по устаревшему снимку.
  • Деактивируйте тестовую учётную запись целиком и убедитесь, что сессия отзывается немедленно, а не при следующем логине.
  • Прогоняйте тот же набор негативных сценариев после каждого значимого изменения схемы индексации или добавления нового источника — регресс в правах возникает именно на стыке изменений.

Такое тестирование должно пройти на реальной, пусть ограниченной, копии чувствительных данных до открытия системы для всей компании — «вроде работает» на позитивных сценариях не говорит ничего о том, работает ли разграничение прав, это два ортогональных свойства системы. Встраивать ACL в уже работающий конвейер постфактум заметно дороже, чем закладывать с нуля: придётся переиндексировать корпус с новой схемой метаданных и восстанавливать информацию о правах, которая на момент первой индексации могла вообще не сохраняться.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Развернуть ИИ на сервере

Нужны сами нейросети для контента?

Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.

Частые вопросы

Можно ли обойтись без изменения схемы индекса и просто фильтровать ответ модели после генерации?

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

Что если исходная система вообще не отдаёт информацию о правах доступа через API?

Тогда либо документы этого источника индексируются как общедоступные осознанно, явным решением, либо источник не подключается к общему поиску, пока не появится способ получать права — индексировать «на всякий случай» с чувствительными данными не стоит.

Насколько дороже по производительности фильтрация по правам на каждый запрос?

Пре-фильтр по метаданным на уровне поиска (payload-фильтр в Qdrant, RLS в Postgres) почти не замедляет запрос; заметные накладные расходы появляются только при живой сверке с исходной системой на каждый документ — здесь помогает кэш прав с коротким TTL вместо запроса к внешнему API на каждый вопрос.

Нужно ли по-разному настраивать права для векторного и полнотекстового поиска в гибридной системе?

Да, фильтр по правам должен применяться на обоих путях поиска одинаково — легко забыть про keyword-индекс, сосредоточившись на векторной базе, и обходной канал утечки остаётся именно там.

Как быть с документами, где права зависят от конкретного клиента или проекта, а не только от отдела?

Нужно дополнительное измерение в ACL — тег проекта или клиента, привязанный и к фрагменту документа, и к списку сотрудников, имеющих отношение к нему прямо сейчас; принцип тот же — фильтровать по актуальным данным о принадлежности, а не по статичному списку при индексации.

Обсудить статью, задать вопрос или начать новую тему

Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →