MAATRIX / Блог / Как собрать данные для RAG парсингом сайтов

Как собрать данные для RAG парсингом сайтов

Как собрать данные для RAG парсингом сайтов

MAATRIX

Скормить модели десяток PDF и сотню страниц сайта — не значит собрать данные для RAG. В чанках оказываются меню навигации, футер с копирайтом и обрывки предложений на границах абзацев, а ассистент отвечает цитатой из блока «Политика конфиденциальности» вместо ответа по делу. Разберём пайплайн от sitemap.xml до записи в Qdrant: обход без бана, извлечение текста без вёрстки, чанкинг и загрузка эмбеддингов — с командами и текстами реальных ошибок на каждом шаге.

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

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

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

Зачем парсить сайты для RAG и почему это не разовая выгрузка

RAG (Retrieval-Augmented Generation) отвечает настолько хорошо, насколько хорош кусок текста, который система нашла и подсунула модели в контекст. Источник этого текста чаще всего — не PDF-мануалы, а живой сайт: документация продукта, база знаний поддержки, блог, wiki. Проблема в том, что сайт — это HTML, а не текст: заголовок статьи, контент, меню из тридцати пунктов, футер и баннер cookie-согласия лежат в одном файле вперемешку.

Если скормить эмбеддеру страницу целиком, вектор «размывается» между содержанием и мусором, а при поиске система с той же вероятностью достанет абзац из футера, что и нужный кусок инструкции. Симптом узнаваем: на вопрос про тарифы ассистент отвечает строкой «Политика конфиденциальности · Карта сайта · Контакты» — этот кусок HTML оказался ближе всего по косинусному расстоянию к вопросу.

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

Почему curl и BeautifulSoup «в лоб» не работают

Первая попытка обычно выглядит так: requests.get(url), потом BeautifulSoup(html, 'html.parser').get_text(). На простом блоге на статическом HTML это даже сработает. На современном сайте — нет, сразу по нескольким причинам.

JS-рендеринг. Сайты на React, Vue и Next.js отдают requests пустую оболочку:

<div id="__next"></div>
<script src="/_next/static/chunks/main.js"></script>

Контент дорисовывает JavaScript уже в браузере. requests браузер не эмулирует — вы получите валидный HTML почти без текста.

Анти-бот защита. Дефолтный User-Agent вида python-requests/2.32.3 многие сайты банят на входе, часто через Cloudflare:

requests.exceptions.HTTPError: 403 Client Error: Forbidden for url: https://docs.example.com/api/auth

Иногда вместо 403 приходит 200 с HTML-страницей челленджа «Checking your browser before accessing…» — код успешный, а текста в теле снова нет.

get_text() тянет всё подряд. .get_text() не отличает статью от сайдбара: в чанк попадут пункты меню, ссылки «похожие статьи» и баннер cookie. Тот же мусор, что и выше, но теперь он есть на каждой странице сайта.

robots.txt и бан по IP. Обход без учёта robots.txt и Crawl-delay — не только вопрос этики: агрессивный краулер без пауз получает 429 Too Many Requests и бан IP на несколько часов, а пайплайн встаёт, толком не начавшись.

Развернуть за пару минут

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

Развернуть Qdrant

Пошаговый пайплайн: от sitemap.xml до чистого текста

Рабочая цепочка — пять шагов, каждый закрывает одну проблему из раздела выше.

1. Найти список страниц через sitemap.xml. Почти любой сайт на CMS или статическом генераторе публикует карту сайта:

curl -s https://docs.example.com/sitemap.xml | grep -oP '(?<=<loc>).*?(?=</loc>)' > urls.txt
wc -l urls.txt

Если это sitemap_index.xml со ссылками на вложенные карты — обойдите их рекурсивно тем же способом.

2. Проверить robots.txt перед каждым запросом. Стандартная библиотека Python это уже умеет:

from urllib.robotparser import RobotFileParser

rp = RobotFileParser()
rp.set_url('https://docs.example.com/robots.txt')
rp.read()
allowed = rp.can_fetch('MyRAGBot/1.0', url)
delay = rp.crawl_delay('MyRAGBot/1.0')  # None, если не задан

3. Забирать страницы с паузой и повтором. Указывайте честный User-Agent со ссылкой на контакт, ставьте timeout и обрабатывайте 429 через заголовок Retry-After:

import time, requests

def fetch(url, ua='MyRAGBot/1.0 (+mailto:you@example.com)'):
    r = requests.get(url, headers={'User-Agent': ua}, timeout=15)
    if r.status_code == 429:
        time.sleep(int(r.headers.get('Retry-After', 30)))
        r = requests.get(url, headers={'User-Agent': ua}, timeout=15)
    r.raise_for_status()
    return r.text

4. Рендерить JS-страницы через Playwright. Там, где requests вернул пустую оболочку, нужен настоящий браузер:

pip install playwright
playwright install chromium
from playwright.sync_api import sync_playwright

with sync_playwright() as p:
    browser = p.chromium.launch()
    page = browser.new_page()
    page.goto(url, wait_until='networkidle', timeout=60000)
    html = page.content()
    browser.close()

Дефолтный таймаут в 30 секунд на тяжёлых страницах выбивает playwright._impl._errors.TimeoutError: Page.goto: Timeout 30000ms exceeded. — поднимайте до 60000 и добавляйте один повтор, прежде чем считать страницу недоступной.

5. Достать основной текст, а не всю страницу. Здесь работает trafilatura — библиотека, которая эвристически отделяет статью от меню, футера и рекламы:

import trafilatura

clean = trafilatura.extract(
    html, output_format='markdown',
    include_tables=True, include_comments=False, include_links=False,
)

На выходе — markdown с заголовками и таблицами, без меню и футера. Именно этот текст, а не сырой HTML, идёт дальше на чанкинг.

Чанкинг: как резать текст, чтобы RAG находил ответы

Чистый markdown ещё не готов для эмбеддера: страница на 6000 символов — это один вектор, который «размывает» смысл десятка разных вопросов. Нужно резать на фрагменты, но не как попало.

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

from langchain_text_splitters import RecursiveCharacterTextSplitter

splitter = RecursiveCharacterTextSplitter(
    chunk_size=800, chunk_overlap=120,
    separators=['\n## ', '\n### ', '\n\n', '\n', '. ', ' '],
)
chunks = splitter.split_text(clean_markdown)

Порядок в separators важен: сплиттер режет по первому подходящему, поэтому границы заголовков — в приоритете, а разрыв по пробелу — крайний резерв.

Overlap — защита от обрыва мысли на границе, а не запас. Без пересечения фраза «настройте timeout в секции [server]» может оказаться в одном чанке, а сама секция [server] — в следующем: ответ не найдётся ни в одном фрагменте целиком. 10–15% от chunk_size — рабочий ориентир: для FAQ с короткими Q&A его можно убрать, для длинных инструкций — увеличить.

Метаданные — обязательное поле, не опция. Каждый чанк несёт источник: URL страницы, заголовок раздела (breadcrumb вида Установка > Docker > Переменные окружения), дату обхода. Без этого RAG не сошлётся на источник, а вы не поймёте, откуда взялся неверный фрагмент при разборе жалобы на галлюцинацию.

Таблицы — отдельная головная боль. Резка по символам разрывает markdown-таблицу посередине строки: заголовки колонок остаются в одном чанке, данные — в другом, и таблица целиком уже не находится. Проще исключать таблицы из общего сплиттера и добавлять их отдельными неразрезанными чанками — по проверке '|' in chunk.

Эмбеддинги и загрузка в Qdrant

Модель эмбеддингов переводит чанк текста в вектор фиксированной размерности — по нему Qdrant ищет ближайших соседей. Выбор модели определяет размерность коллекции, требования к памяти и нужен ли внешний API вообще.

МодельРазмерностьГде считаетсяВес модели
text-embedding-3-small (OpenAI)1536облако, платный APIвеса не раздаются
intfloat/multilingual-e5-large1024локально~2,2 ГБ
nomic-embed-text (через Ollama)768локально~270 МБ
all-MiniLM-L6-v2384локально~90 МБ

Для RU/EN текстов берите мультиязычную модель вроде multilingual-e5-large — англоязычные модели вроде MiniLM на кириллице заметно теряют в качестве поиска. Локальный вариант через sentence-transformers не требует API-ключа и не отправляет содержимое сайта наружу:

from sentence_transformers import SentenceTransformer

model = SentenceTransformer('intfloat/multilingual-e5-large')
vectors = model.encode(chunks, normalize_embeddings=True)

Qdrant поднимается одним контейнером:

docker run -d --name qdrant -p 6333:6333 -p 6334:6334 \
  -v $(pwd)/qdrant_storage:/qdrant/storage \
  qdrant/qdrant:v1.12.0

Порт 6333 — REST и веб-панель http://localhost:6333/dashboard, 6334 — gRPC. Размерность коллекции обязана совпасть с моделью:

from qdrant_client import QdrantClient
from qdrant_client.models import VectorParams, Distance

client = QdrantClient(host='localhost', port=6333)
client.create_collection(
    collection_name='site_docs',
    vectors_config=VectorParams(size=1024, distance=Distance.COSINE),
)

Собрали коллекцию под MiniLM (384), затем переключились на multilingual-e5-large (1024) — Qdrant отказывает предметно:

{"status":{"error":"Wrong input: Vector dimension error: expected dim: 384, got 1024"},"time":0.000123}

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

httpx.ConnectError: [Errno 111] Connection refused

Память. Qdrant по умолчанию держит индекс в RAM: объём ≈ число векторов × размерность × 4 байта, плюс расходы на граф HNSW. Для 100 000 чанков на multilingual-e5-large (1024 измерения): 100 000 × 1024 × 4 = 409 600 000 байт, около 390 МБ только на сырые векторы — с индексом и payload выше. От нескольких миллионов точек документация рекомендует on_disk: true или квантование.

Нюансы, о которых забывают: обновление, дедупликация, права

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

Инкрементальный обход, а не полная перекачка каждый раз. Гонять весь сайт заново ежедневно — расточительно и по времени, и по нагрузке на чужой сервер. Сохраняйте хэш чистого текста в payload каждого чанка:

import hashlib
content_hash = hashlib.sha256(clean_markdown.encode('utf-8')).hexdigest()

Совпал хэш при повторном обходе — страница не менялась, пропускайте пере-эмбеддинг. Заголовки Last-Modified и ETag тоже помогают не скачивать страницу целиком ради проверки.

Удалённые страницы оставляют мёртвые векторы. Без обработки их чанки в Qdrant останутся навсегда и будут всплывать в ответах на несуществующий контент. Помечайте в payload номер обхода (crawl_batch_id) и после его завершения удаляйте точки, чей crawl_batch_id не совпал с актуальным — «что не подтвердилось, то удаляем».

Дубли между зеркалами сайта. Одна и та же документация часто лежит под /en/ и /en-us/, или под доменом и поддоменом. Без дедупликации оба URL попадут в индекс как разные чанки с одинаковым текстом, и поиск начнёт возвращать два одинаковых результата вместо одного релевантного и одного другого. Хэш контента решает и эту задачу — проверяйте content_hash перед вставкой.

Права и этика. robots.txt — не юридический документ, но его нарушение — первый аргумент против вас при претензии владельца сайта. Публичную документацию и базу знаний обычно можно парсить для внутреннего RAG без вопросов; перепродажа контента или обход платного доступа — другая история. Персональные данные — для источников из ЕС и Великобритании это территория GDPR, и такие страницы стоит исключать на этапе фильтрации URL.

PDF и вложения — тема отдельной статьи. trafilatura работает с HTML, PDF-мануалы она не развернёт. Нужен отдельный шаг с pdfplumber или pypdf — закладывайте это отдельной веткой пайплайна, а не рассчитывайте, что HTML-краулер заберёт их заодно.

Какой сервер брать под парсинг и Qdrant в MAATRIX

У пайплайна два независимых потребителя ресурсов: краулер (особенно Playwright с headless Chromium) и Qdrant с индексом в памяти. Разница между «еле работает» и «спокойно работает» — это прежде всего RAM, а не CPU.

Честный минимум: 2 vCPU, 4 ГБ RAM, 40 ГБ NVMe. Хватает для последовательного обхода без параллельного Playwright — статического сайта или с редким JS-рендерингом на несколько тысяч страниц, и коллекции Qdrant на десятки тысяч векторов размерностью 384–768. Один контекст headless Chromium занимает несколько сотен мегабайт RAM, и два-три параллельных контекста на 4 ГБ уже толкают систему в своп: dmesg покажет Out of memory: Killed process 18422 (chrome).

Комфортный вариант: 4–8 vCPU, 8–16 ГБ RAM, 80–100 ГБ NVMe. Помещаются 4–6 параллельных контекстов Playwright, локальная модель эмбеддингов на CPU и коллекция на сотни тысяч — миллион точек с запасом на пересборку индекса без простоя. Для сайта с активным JS и ежедневным инкрементальным обходом — разумная отправная точка, а не запас на вырост.

Qdrant есть в каталоге приложений MAATRIX — при заказе он разворачивается автоматически поверх Ubuntu, вручную поднимать контейнер не нужно: адрес, порт и API-ключ появляются в личном кабинете. Команды docker run выше пригодятся, если сначала хотите прогнать пайплайн локально, а на сервер перенести готовый скрипт.

Локация — Великобритания (Лондон). У сайтов документации и баз знаний из ЕС и Британии геоблокировки и троттлинг по IP из России срабатывают заметно чаще, чем по европейским адресам — краулер с российского IP рискует упереться в 403 или капчу раньше, чем соберёт хотя бы половину сайта. С лондонского адреса таких проблем меньше, а пинг до европейской аудитории RAG — тоже ниже. Если источники и аудитория российские, логика разворачивается в обратную сторону и подходит локация RU.

Оплата — картой российского банка, по СБП, криптовалютой или токеном MAAT, без иностранной карты для сервера в Лондоне. Сервер приходит с чистой Ubuntu 24.04; Qdrant разворачивается автоматически, а краулер и расписание обхода через cron — уже ваш код.

Развернуть за пару минут

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

Развернуть Qdrant

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

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

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

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

Парсинг чужого сайта для RAG законен?

Для внутреннего использования публичной документации и баз знаний — как правило, да, если вы уважаете robots.txt и условия использования сайта. Перепродажа контента, обход платного доступа или сбор персональных данных — другая история, особенно для источников из ЕС и Великобритании, где действует GDPR.

Можно обойтись без Qdrant и хранить эмбеддинги в Postgres с pgvector?

Да, для небольших объёмов (условно до сотни тысяч векторов) pgvector — рабочий и честный вариант, особенно если Postgres у вас уже есть. Qdrant выигрывает на бо́льших объёмах и там, где нужна гибкая фильтрация по payload вместе с векторным поиском.

Как понять, что чанкинг настроен плохо?

По симптомам в ответах RAG: цитаты обрываются на середине предложения, всплывает текст меню или футера, один и тот же кусок документации находится сразу в нескольких почти одинаковых чанках. Повод пересмотреть chunk_size, chunk_overlap и то, что именно достаёт со страницы trafilatura.

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

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