RAG-агент по корпоративной базе знаний: архитектура на n8n + Qdrant

Зачем строить RAG-агента поверх корпоративной базы знаний

Расскажу честно, откуда растёт задача. В корпоративной среде знания нередко живут в нескольких местах одновременно: регламент в Confluence, актуальная версия того же регламента — в Google Drive, обсуждение исключений из него — в тикетах Jira, а настоящая «живая» версия — в голове Андрея, который ведёт этот процесс уже три года. Поиск по ключевым словам здесь не спасает. Он находит документ 2021 года с нужным заголовком, но не говорит, что в марте 2025-го его переписали и теперь действует другой порядок.

Именно здесь RAG решает конкретную инженерную проблему, а не абстрактную «работу с данными». Retrieval-Augmented Generation — это когда LLM отвечает не из своих весов, обученных на данных прошлого, а из фрагментов документов, которые ты сам контролируешь. Ответ приходит со ссылкой на источник: конкретный заголовок, раздел, дата обновления. Галлюцинации не исчезают полностью, но они становятся проверяемыми — модель либо опирается на чанк из базы, либо честно говорит, что не нашла. И главное: когда документ обновляется, ты переиндексируешь его в векторное хранилище — и агент уже знает новую версию. Дообучать LLM не нужно.

Почему в 2026 году связка n8n + Qdrant выглядит разумным выбором именно для корпоративного контура? Во-первых, оба инструмента разворачиваются self-hosted — данные не покидают периметр, что критично для чувствительных внутренних документов. Во-вторых, n8n сейчас имеет нативные ноды и для Qdrant, и для AI Agent — это означает, что пайплайн «получить документ → нарезать → эмбеддить → положить в Qdrant» и пайплайн «принять вопрос → найти релевантные чанки → сформировать ответ» строятся визуально, без написания инфраструктурного кода с нуля. Low-code здесь не про упрощение — это про скорость итерации: поменял промпт, поменял стратегию разбивки на чанки, переподключил источник.

Сценарии, где это работает в первую очередь:

Саппорт. Агент ищет ответ по базе прецедентов и регламентов, даёт оператору готовый черновик с источником. Время на обработку тикета падает, качество ответов выравнивается.

Внутренние регламенты и политики. HR или юрист перестаёт отвечать на вопрос «а как у нас устроен отпуск по уходу» двадцать раз в месяц. Агент автоматизации HR-процессов отвечает сам, со ссылкой на актуальный документ.

Онбординг. Новый сотрудник получает ответы на стандартные вопросы в первые две недели без необходимости дёргать коллег. База знаний при этом накапливает реальные вопросы онбординга — и со временем становится точнее.

Q&A по продуктовой документации. Команда разработки или продаж спрашивает про поведение конкретной фичи — агент поднимает нужный раздел из спецификации или changelog.

Общая логика одна: там, где человек тратит время на поиск информации, которая уже где-то существует, RAG-агент это время возвращает.

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

Архитектура решения: компоненты и потоки данных

Система строится вокруг двух независимых пайплайнов, которые намеренно не знают друг о друге. Первый — ingestion — отвечает за то, чтобы знания попали в индекс. Второй — retrieval — отвечает за то, чтобы агент нашёл нужное в момент вопроса. Разделение принципиальное: сбой индексации не роняет чат, а деградация ретривала не блокирует загрузку новых документов.

Ingestion pipeline. Источники — Confluence, Notion, Google Drive, корпоративные S3-бакеты — триггерят n8n workflow через вебхуки или по расписанию. Workflow чистит текст, режет на чанки, вызывает embeddings API (в нашем случае text-embedding-3-large), и складывает векторы в Qdrant. Исходник при этом уходит в S3-совместимое хранилище, метаданные чанка — в Postgres: source URL, timestamp, version hash, tenant ID. Redis кэширует эмбеддинги для документов, которые не изменились — при реиндексации большого корпуса это экономит несколько сотен долларов в месяц на API-колах.

Retrieval pipeline. Пользователь пишет в Slack или Teams → сообщение попадает в n8n AI Agent workflow → агент формирует запрос к Qdrant retriever → получает топ-K релевантных чанков → передаёт контекст в LLM вместе с системным промптом → возвращает ответ в чат-канал. Между Qdrant и LLM стоит простой reranker — без него precision на длинных корпусах заметно падает.

Мультитенантность без боли. Распространённая ошибка — создавать отдельную коллекцию Qdrant на каждый отдел. При двадцати департаментах это двадцать коллекций, двадцать наборов индексных параметров, двадцать точек мониторинга. Вместо этого я использую payload-партиционирование в одной коллекции: каждый вектор несёт поле tenant_id, фильтр по нему добавляется к каждому поиску автоматически на уровне retriever-ноды в n8n. Qdrant поддерживает эффективную работу с такими фильтрами через payload индексы — при правильной настройке latency практически не растёт.

Observability. Без трейсинга RAG-система — чёрный ящик: непонятно, почему агент дал плохой ответ — плохой ретривал или плохая генерация. Каждый запрос получает trace_id, который прокидывается через весь путь: от n8n workflow через Qdrant-запрос до LLM-колла. Langfuse собирает эти трейсы и показывает метрики качества ретривала — mean reciprocal rank, доля ответов с пустым контекстом, latency по перцентилям. OpenTelemetry экспортирует инфраструктурные метрики в Grafana. В апреле 2026-го это уже не опциональная надстройка — без observability невозможно объяснить бизнесу, почему система иногда галлюцинирует, и тем более невозможно это починить.

Архитектурная схема RAG-агента с двумя пайплайнами: ingestion и retrieval Классическая RAG-архитектура разделяет процесс на два независимых пайплайна — загрузку и индексацию данных и их последующий поиск по запросу.

Подготовка данных: чанкинг, overlap и контекстуализация

Перед тем как что-то отправлять в векторное хранилище, я обычно трачу больше времени на нарезку и обогащение, чем на сам пайплайн ретривала. Качество чанков определяет потолок качества всей системы — никакой ре-ранкер не вытащит ответ из бессмысленно порезанного абзаца.

Базовый чанкинг

Дефолт, с которого начинаю почти всегда — RecursiveCharacterTextSplitter. Он режет по иерархии разделителей (параграфы → предложения → пробелы), что для большинства документов даёт читаемые куски без обрыва на середине мысли. Параметры, которые работают в большинстве кейсов:

  • размер чанка 500–1000 токенов (не символов — считайте именно токенайзером той модели эмбеддингов, которую будете использовать),
  • overlap 10–20% — этого достаточно, чтобы не терять фразы на границе, и при этом не раздувать индекс вдвое.

Меньше 500 — теряется контекст, ретривал начинает возвращать обрывки. Больше 1000 — эмбеддинг «размазывается», точность семантического поиска падает, плюс упирается в лимиты cross-encoder ре-ранкеров.

Когда дефолта мало

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

Таблицы — отдельная боль. Их нельзя резать построчно вместе с окружающим текстом: теряются заголовки колонок. Я храню таблицу как один чанк (если влезает) или сериализую построчно с обязательным дублированием шапки в каждой строке. Для широких таблиц — markdown или JSON, эмбеддингам это нормально заходит.

Код требует своей стратегии: бить по AST (функциям, классам), а не по символам. Иначе получите половину функции в одном чанке и её сигнатуру в другом — с точки зрения поиска это мусор.

Contextual Retrieval

Самое сильное улучшение, которое я внедрял за последний год — подход Anthropic с контекстуализацией чанков. Идея простая: перед эмбеддингом прогоняешь каждый чанк через дешёвую LLM с промптом «опиши в одном-двух предложениях, о чём этот фрагмент в контексте всего документа». Это короткое описание дописываешь в начало чанка — и уже эту склейку эмбеддишь и индексируешь.

На практике прирост точности ретривала заметен на документах с HR-тематикой и техдоках. Стоимость индексации растёт, но с prompt caching это копейки: документ грузится в кэш один раз, а контекст для каждого чанка генерируется почти бесплатно.

// Пример чанка с контекстом перед embedding
{
  "text": "[Контекст: документ о политике отпусков, раздел 'Перенос дней'] Сотрудник может перенести до 7 дней на следующий год при согласовании с руководителем...",
  "metadata": {
    "doc_id": "hr-policy-2026",
    "section": "vacation-transfer",
    "updated_at": "2026-03-15",
    "acl": ["all-employees"]
  }
}

Обратите внимание: контекст идёт в поле text (то, что эмбеддится), а не в метаданные. Это принципиально — мы хотим, чтобы вектор «знал», к какому документу и разделу относится фрагмент.

Метаданные и фильтрация

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

  • doc_id, section — для трассировки и дедупа,
  • updated_at — для буста свежих документов и фильтрации устаревших,
  • author, department, doc_type — для фасетной фильтрации,
  • acl — список ролей или групп, имеющих доступ. Без этого поля в корпоративном RAG жить нельзя: иначе бухгалтерия найдёт черновики совета директоров.

Фильтрация по метаданным делается до векторного поиска (pre-filter) — это и быстрее, и безопаснее, чем фильтровать топ-K постфактум.

Гигиена данных

Перед индексацией прогоняю всё через два обязательных шага:

  1. Дедупликация по хэшу содержимого (sha256 от нормализованного текста). В корпоративных хранилищах один и тот же регламент часто лежит в трёх местах в разных версиях — без дедупа ретривал будет возвращать один и тот же ответ трижды и забивать контекстное окно.
  2. Нормализация: даты к ISO 8601, телефоны к E.164, нижний регистр для ключей метаданных, прибивание невидимых символов (NBSP, zero-width). На эмбеддингах это влияет слабо, но критично для гибридного поиска с BM25 и для точных фильтров.

Один раз потратив день на нормализатор, экономишь недели на разборках «почему поиск не находит документ, который точно есть».

Визуализация чанкинга документа: разбивка на фрагменты с overlap и добавлением контекста Правильный чанкинг с перекрытием фрагментов и контекстуализацией каждого чанка критически влияет на точность последующего семантического поиска.

Настройка коллекции Qdrant: vectors, payload и индексы

Перед тем как лить данные, я всегда фиксирую три вещи: размерность, метрику и стратегию multitenancy. Менять их потом — значит пересоздавать коллекцию.

Размерность под модель эмбеддингов. Тут жёсткая привязка: 1536 для text-embedding-3-small, 1024 для bge-m3, 3072 для text-embedding-3-large. Если планируете Matryoshka-усечение у OpenAI — заранее решите, на какой размерности будете жить, и пишите её в size. Метрика для текста — почти всегда Cosine; Dot имеет смысл, только если вы сами гарантируете нормализацию.

HNSW и multitenancy. Дефолтные m=16, ef_construct=128 — рабочий компромисс по скорости индексации и recall. Но если в одной коллекции живут десятки/сотни тенантов (group_id, workspace_id и т.п.), глобальный HNSW-граф вреден: запросы одного тенанта тащат за собой связи через всю коллекцию. Решение — отключить глобальный граф (m=0) и строить HNSW локально внутри payload-партиции через payload_m. На запросе достаточно фильтра по group_id, и Qdrant пойдёт по локальному подграфу.

Payload-индексы. Без них фильтры превращаются в полный скан. Минимальный набор для базы знаний:

  • group_idkeyword, обязательно с is_tenant: true при multitenancy;
  • updated_atdatetime, для инкрементальных пересборок и временных срезов;
  • aclkeyword (массив ролей/юзеров), для пост-фильтрации доступа;
  • doc_typekeyword, чтобы можно было ограничивать поиск по типу источника.

Гибридный поиск. Один dense-вектор на топиках с редкой терминологией (артикулы, коды ошибок, юридические формулировки) проигрывает. Я держу named vectors: dense для семантики и sparse_vectors.bm25 для лексики, а на стороне приложения склеиваю через RRF или Reciprocal Fusion из встроенного query_points с prefetch.

Квантизация. До ~1М векторов можно жить без неё. Дальше включаю scalar int8 с always_ram: true — это сжатие в 4 раза при минимальной потере recall и оригиналы остаются на диске для re-scoring. Для 10М+ и жёстких бюджетов по RAM — binary (32×), но только если эмбеддинги к этому пригодны (OpenAI и Cohere — да, мелкие open-source модели часто проседают, проверяйте на своём eval-сете).

PUT /collections/kb_main
{
  "vectors": {
    "dense": { "size": 1024, "distance": "Cosine" }
  },
  "sparse_vectors": { "bm25": {} },
  "hnsw_config": { "m": 0, "payload_m": 16 },
  "quantization_config": {
    "scalar": { "type": "int8", "always_ram": true }
  }
}

После создания коллекции отдельными запросами накатываю payload-индексы:

PUT /collections/kb_main/index
{ "field_name": "group_id", "field_schema": { "type": "keyword", "is_tenant": true } }

PUT /collections/kb_main/index
{ "field_name": "updated_at", "field_schema": "datetime" }

PUT /collections/kb_main/index
{ "field_name": "acl",      "field_schema": "keyword" }

PUT /collections/kb_main/index
{ "field_name": "doc_type", "field_schema": "keyword" }

Что я проверяю сразу после: /collections/kb_main показывает payload_schema со всеми четырьмя полями, config.hnsw_config.m = 0, и при первом query_points с фильтром по group_id латентность не растёт линейно от общего объёма коллекции. Если растёт — значит is_tenant не подхватился, и надо пересоздавать индекс по полю.

Ingestion-воркфлоу в n8n: от источника до векторов

Я строю ingestion как несколько мелких воркфлоу вместо одного монстра. Так проще дебажить, и Error Trigger ловит сбои гранулярно.

Триггеры

Три точки входа на каждый источник:

  • Schedule — раз в час по Confluence и Notion. Этого хватает: документация редко обновляется чаще, а API-лимиты у Notion жёсткие, особенно после изменений политики этой весной.
  • Webhook — для событий page.updated и page.deleted. Confluence шлёт через автоматизации, Notion через сторонний релей (нативных вебхуков по-прежнему нет). Это даёт near-real-time на критичных пространствах.
  • Manual — кнопка для бэкфилла и переиндексации после смены модели эмбеддингов. Принимает параметры space_id, since, force_recompute.

Основная цепочка нод

Source Connector → Cleaner → Splitter → Contextualizer → Embeddings → Qdrant Upsert
  1. Source Connector. HTTP Request с пагинацией через Loop Over Items. Тяну только то, у чего updated_at > last_run_at (хранится в Postgres-таблице ingestion_state).
  2. HTML/Markdown Cleaner. Code-нода: вырезаю навигацию, футеры Confluence, разворачиваю таблицы в Markdown, нормализую заголовки. Сохраняю исходный URL и breadcrumbs в метаданных.
  3. Text Splitter. Recursive по структуре заголовков, окно 800 токенов, overlap 120. Split по смысловым границам, а не по символам — экономит контекст на этапе ретривала.
  4. Contextualizer. LLM-нода генерирует короткий контекст-хедер для каждого чанка: «о чём документ, где этот фрагмент находится». Приклеиваю к чанку перед эмбеддингом — техника contextual retrieval, заметно улучшает recall на практике.
  5. Embeddings. Батч по 96 чанков за вызов.
  6. Qdrant Upsert. См. ниже.

Идемпотентность

point_id = sha1(doc_id + ":" + chunk_index) — детерминированный, влезает в формат UUID после преобразования. Всегда upsert, никогда insert. Это значит, что повторный прогон того же документа не плодит дубликатов и не требует предварительной очистки.

Инкрементальное обновление

Логика на каждый документ:

  1. Считаю новые чанки и их point_id.
  2. Делаю scroll по Qdrant с фильтром doc_id == X, собираю существующие point_id.
  3. Upsert нового набора.
  4. Удаляю разницу: delete по фильтру doc_id == X AND point_id NOT IN (новый_набор).

Если updated_at не изменился относительно ingestion_state — пропускаю документ целиком, даже не дёргая эмбеддинги. На прогоне в 40k страниц это срезает время с часов до минут.

Обработка ошибок

Отдельный воркфлоу с Error Trigger, привязанный ко всем ingestion-флоу. Внутри:

  • Классификатор ошибки: rate_limit, auth, parse, embedding_api, qdrant.
  • Ретраи с экспоненциальной задержкой. После нескольких попыток — dead-letter.
  • Dead-letter в Postgres: таблица ingestion_dlq с payload, ошибкой, traceback и retry_after. Отдельный Schedule-воркфлоу раз в шесть часов перепрогоняет записи, у которых retry_after < now().
  • Алёрт в Slack при росте DLQ сверх настроенного порога — иначе шум.

Батчинг

Qdrant upsert принимает 64–256 точек за раз. Меньше — упирается в RTT, больше — растёт p99 на стороне Qdrant и риск таймаута gRPC. Я держу 128 как дефолт, для крупных бэкфиллов поднимаю до 256 и переключаюсь на gRPC вместо HTTP — на миллионе точек разница ощутимая, часы против десятков минут.

Один нюанс n8n: Split In Batches по умолчанию держит весь массив в памяти. На больших источниках разбиваю на под-воркфлоу через Execute Workflow с передачей курсора — иначе воркер ловит OOM где-то на 200k items.

Воркфлоу ingestion в n8n: путь документа от источника до сохранения в векторном хранилище Автоматизированный ingestion-пайплайн в n8n обрабатывает документ за несколько узлов — от загрузки и чанкинга до эмбеддинга и записи в Qdrant.

AI Agent в n8n: tools, retriever и промпт

Ядром всей оркестрации у меня выступает нода AI Agent в режиме Tools Agent — она единственная держит цикл «think → call tool → observe» и решает, лезть ли в базу знаний или сразу отвечать. К ней я подключаю четыре инструмента, и каждый делает ровно одно:

  • kb_search — Qdrant Vector Store как retriever tool. Сюда уходят все фактологические вопросы о продукте, документации, регламентах.
  • ticket_lookup — HTTP Request Tool в нашу тикет-систему по customer_id и статусу. Полезно, когда пользователь спрашивает «а что с моим обращением».
  • calendar_lookup — Google Calendar tool, читает свободные слоты CSM. Используется только если агент уже идентифицировал намерение записаться.
  • escalate_to_human — Slack-нода, постит в канал поддержки тред с историей диалога и причиной эскалации. Это терминальный tool: после его вызова агент завершает ответ фразой ожидания.

Архитектура инструментов n8n-агента с маршрутизацией между tools — отдельная тема, но здесь важно: каждый tool изолирован, и агент сам решает, какой из них вызвать на основе системного промпта и контекста диалога.

Memory — Postgres Chat Memory с настраиваемым окном истории. Postgres, а не Redis, потому что мне нужна история на разборы инцидентов и для дообучения промптов — TTL выставляю на стороне БД.

Системный промпт

Промпт держу коротким и жёстким, без ролевых игр:

Ты — ассистент поддержки Acme. Отвечай только на основе результатов tool `kb_search`
или данных из других tools. Если уверенность низкая или источников нет — честно
скажи «не знаю» и предложи `escalate_to_human`.

Формат ответа:
1. Краткий ответ (2–4 предложения).
2. Блок «Источники:» — список ссылок из metadata.url найденных чанков.

Запрещено: выдумывать номера тикетов, цены, SLA, даты релизов.
При вопросах вне scope (юридические, медицинские, конкуренты) — отказ.

Ключевое — пункт про источники. Без него модель регулярно «забывает» приклеить ссылки, даже если retriever их вернул в payload.

Гибридный поиск и реранкинг

Чистый dense у меня давал плохие результаты на запросах с кодами ошибок и артикулами — там, где важен точный токен. Поэтому retriever ходит в Qdrant Query API с гибридом: dense + sparse (BM25), фьюжн через RRF, top_k=20, потом реранкер срезает до 5. Я остановился на bge-reranker-v2-m3 на собственном GPU; Cohere Rerank 3.5 даёт чуть лучшее качество на русском, но платный трафик растёт быстрее, чем мне хочется.

ACL — на этапе запроса, через payload filter. Никаких пост-фильтраций после ретрива: иначе top_k плывёт и реранкер получает мусор.

// Qdrant query с фильтром доступа и гибридом
{
  "prefetch": [
    { "query": [...], "using": "dense", "limit": 20 },
    { "query": {...}, "using": "bm25", "limit": 20 }
  ],
  "query": { "fusion": "rrf" },
  "filter": {
    "must": [
      { "key": "acl", "match": { "any": ["user-123", "team-sales"] } },
      { "key": "group_id", "match": { "value": "acme-corp" } }
    ]
  },
  "limit": 5
}

acl я пишу в payload при индексации как массив идентификаторов, у которых есть

Схема AI Agent в n8n с подключёнными инструментами: retriever, калькулятор, поиск AI Agent orchestrates несколько инструментов одновременно, динамически выбирая между векторным поиском и другими tools в зависимости от запроса.