RAG-пайплайны на основе веб-данных: от парсинга до векторной базы
Как собрать end-to-end RAG-систему на данных, извлечённых со внешних сайтов — от очистки текста до chunking-стратегии.

RAG (Retrieval-Augmented Generation) решает главную проблему LLM — модель не знает того, что произошло после даты обучения, и не знает ваших конкретных данных. Когда источник этих данных — внешние сайты (конкуренты, новости, документация партнёров), пайплайн приобретает специфику, которой нет при работе с уже готовыми документами.
Архитектура: от URL до ответа модели
Список URL → Скрапинг → Очистка текста → Chunking → Embedding → Векторная БД → Retrieval → LLM
Каждый этап добавляет свои источники ошибок — плохой скрапинг даёт мусорные чанки, плохой chunking рвёт смысл посередине предложения, неправильный embedding-модель не ловит смысловую близость на нужном языке.
Шаг 1: скрапинг с очисткой, а не просто HTML
Для RAG важен чистый текст, а не разметка — лишние теги и навигационные блоки только засоряют контекст модели.
from playwright.sync_api import sync_playwright
def scrape_clean_text(url: str) -> str:
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page()
page.goto(url, wait_until="networkidle")
text = page.evaluate("""
() => {
document.querySelectorAll('script,style,nav,header,footer,aside').forEach(el => el.remove());
return document.body.innerText;
}
""")
browser.close()
return " ".join(text.split()) # схлопнуть лишние пробелы/переносыШаг 2: chunking с учётом структуры, а не по фиксированной длине
Наивный подход — резать текст каждые 500 символов — разрывает предложения и мысли посередине. Лучше резать по смысловым границам (заголовки, абзацы) и добавлять overlap, чтобы не терять контекст на стыке чанков.
def chunk_by_headings(text: str, max_chars: int = 1000, overlap: int = 100) -> list[str]:
paragraphs = text.split("\n\n")
chunks, current = [], ""
for para in paragraphs:
if len(current) + len(para) > max_chars and current:
chunks.append(current.strip())
current = current[-overlap:] + para # хвост предыдущего чанка для контекста
else:
current += " " + para
if current.strip():
chunks.append(current.strip())
return chunksСовет
Храните вместе с каждым чанком метаданные: URL источника, дату скрапинга, заголовок раздела. Без этого модель не сможет сослаться на источник в ответе, а вы не сможете отследить, откуда взялась устаревшая информация.
Шаг 3: embedding и запись в векторную БД
import openai
import psycopg2
def embed_and_store(chunks: list[str], source_url: str, conn):
for chunk in chunks:
embedding = openai.embeddings.create(
model="text-embedding-3-small", input=chunk
).data[0].embedding
conn.execute(
"INSERT INTO documents (content, embedding, source_url, scraped_at) "
"VALUES (%s, %s, %s, NOW())",
(chunk, embedding, source_url),
)Шаг 4: retrieval + генерация ответа
def answer_question(question: str, conn) -> str:
q_embedding = embed(question)
rows = conn.execute(
"SELECT content, source_url FROM documents "
"ORDER BY embedding <-> %s LIMIT 5",
(q_embedding,),
).fetchall()
context = "\n\n".join(f"[{r.source_url}]\n{r.content}" for r in rows)
prompt = f"Ответь на вопрос, используя только контекст ниже. Если ответа нет в контексте, скажи об этом.\n\nКонтекст:\n{context}\n\nВопрос: {question}"
return llm_call(prompt)Поддержание индекса в актуальном состоянии
Живые сайты меняются — без переиндексации RAG постепенно отвечает устаревшими данными. Практичная схема:
- Хранить хэш содержимого страницы вместе с чанками.
- Периодически (по расписанию) повторно скрапить источники.
- Сравнивать новый хэш со старым — переиндексировать только изменившиеся страницы, а не весь корпус.
- Удалять чанки для страниц, которые больше не существуют (404).
Заключение
RAG на внешних веб-данных требует больше инженерной дисциплины, чем RAG на статичных документах: нужна очистка от навигационного шума, продуманный chunking и стратегия переиндексации для меняющихся источников. Экономия времени на любом из этих шагов напрямую конвертируется в галлюцинации и устаревшие ответы модели.
Частые вопросы
Чем RAG на веб-данных отличается от RAG на внутренних документах?+
Внутренние документы (PDF, вики) обычно чистые и структурированные. Веб-страницы содержат навигацию, рекламу, повторяющиеся блоки — перед индексацией их нужно очистить, иначе в векторной базе окажется много "шума", который снижает релевантность поиска.
Как часто нужно обновлять индекс, если источник — живой сайт?+
Зависит от скорости изменения контента. Для новостей — почасово, для каталога товаров — раз в день, для статичной документации — раз в неделю. Практичный подход: инкрементальная переиндексация только изменившихся страниц (diff по хэшу контента), а не полный ре-краул.
Нужна ли отдельная векторная база, если данных немного (сотни страниц)?+
Не обязательно — pgvector поверх уже используемого PostgreSQL часто достаточно для сотен тысяч чанков и избавляет от отдельной инфраструктуры. Специализированные векторные БД (Pinecone, Weaviate) оправданы при миллионах векторов или необходимости в продвинутых фильтрах/гибридном поиске.
Похожие материалы

Векторные базы для RAG: Pinecone, Weaviate, pgvector — что выбрать
Сравниваем три популярных варианта хранения embeddings по масштабируемости, стоимости и сложности эксплуатации.

AI-агенты для парсинга данных: как LLM меняет извлечение информации
Разбираем, чем агентный подход к скрапингу отличается от селекторов, и когда LLM-агент действительно экономит время инженеров.

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