Индекс RAG имеет срок годности. Как только источник меняется, индекс начинает отставать от него: обновляется страница с ценами, переписывается страница документации, URL выводится из употребления. Пайплайн, который однократно обходит страницы и строит эмбеддинги, никак не может это заметить, и модель продолжает отвечать по версии веба, которой уже не существует.

Очевидное решение: заново обойти все страницы и пересчитать все эмбеддинги. Это работает, но неизменная и переписанная страницы при этом рассматриваются как одна и та же задача. В реальном корпусе большинство повторно обойденных страниц идентичны уже проиндексированным, а за их разбиение на чанки и построение эмбеддингов вы все равно платите. В этой статье строится альтернатива, инкрементальный индекс RAG: Crawlbase Enterprise Crawler повторно обходит страницы по расписанию и доставляет каждую на вебхук, хеш содержимого определяет, изменилось ли что-нибудь, эмбеддинги в pgvector пересчитываются только для измененных страниц, а каждая цитата содержит время последней проверки своего источника.

Коротко
  • Повторный обход нужен, чтобы узнать, что изменилось. Повторное построение эмбеддингов можно пропустить, если ничего не изменилось.
  • Регистрируйте rid каждой доставки до начала любой работы, чтобы повторно присланный вебхук никогда не проиндексировал одну и ту же страницу дважды.
  • Хешируйте нормализованный Markdown, а не сырой HTML, и сравнивайте результат с сохраненным хешем. Тот же хеш: обновите временную метку. Новый хеш: заново разбейте страницу на чанки и постройте эмбеддинги.
  • Считайте 404 и 410 удалением. Исчезнувшая страница должна покинуть индекс, а не задерживаться в нем.
  • Следите за работоспособностью вебхука. Неудачная доставка обходится и оплачивается повторно, а неисправный эндпоинт приостанавливает краулер.
Каждый повторный обход заканчивается одним из трех состояний. Обход выполняется всегда; что происходит с индексом, зависит от страницы. Неизменные страницы получают свежую временную метку без вызова эмбеддинга, для измененных страниц заново строятся эмбеддинги, а страницы, отвечающие 404 или 410, удаляются.

Рабочий код находится в ScraperHub/incremental-rag-index-with-crawler-webhooks-and-pgvector, с поэтапными контрольными точками в steps/ и полным приложением в final/. Все фрагменты кода ниже взяты из final/.

Почему полная переиндексация не масштабируется

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

  1. Повторно обходить каждый URL по расписанию.
  2. Определять, действительно ли изменилось его проиндексированное содержимое.
  3. Заново строить эмбеддинги для изменившихся страниц.
  4. Не трогать неизменные страницы, но фиксировать, что они были проверены.
  5. Удалять страницы, которых больше не существует.

Обход устанавливает текущее состояние источника. Все последующие шаги решают, требует ли это состояние изменения индекса. В этом разделении и заключается весь замысел: стоимость обхода зависит от размера корпуса, а стоимость эмбеддингов зависит от скорости изменений.

Архитектура

Один путь обхода, две точки входа. Начальная отправка URL и плановые повторные обходы проходят через один и тот же Enterprise Crawler. Вебхук регистрирует rid и сразу подтверждает получение; загрузка выполняется в фоне и решает, нужно ли менять pgvector.

Начальные URL отправляются командой python push.py; позже плановое задание выбирает действующие URL из таблицы pages и отправляет их снова. В обоих случаях используется один и тот же Enterprise Crawler с параметрами crawler=NAME и callback=true. Отправка возвращает rid сразу; очередью, параллелизмом, повторными попытками и доставкой управляет краулер.

Каждая доставка приходит на вебхук FastAPI в виде запроса POST с телом, содержащим страницу, и заголовками, которые передают метаданные: rid, url, original_status (что ответил сайт) и cb_status (результат Crawlbase). Вебхук регистрирует rid в PostgreSQL, возвращает 200, после чего передает страницу фоновой задаче. Этап загрузки помечает страницы с 404 и 410 как удаленные, пропускает страницы с отличным от 200 cb_status, хеширует остальные и заново строит эмбеддинги только при изменении хеша. Эндпоинт /query выполняет поиск в pgvector и возвращает цитаты с полем last_verified_at.

Модель данных

PostgreSQL 16 с расширением pgvector хранит три таблицы. deliveries записывает каждый rid для того, чтобы повторную доставку можно было подтвердить, не обрабатывая ее дважды. pages хранит по одной строке на URL с состоянием, которое нужно хеш-фильтру: content_hash, last_verified_at, deleted_at и original_status. chunks хранит фрагменты текста и их эмбеддинги.

sql
-- final/sql/schema.sql (excerpt)
CREATE TABLE IF NOT EXISTS chunks (
    id BIGSERIAL PRIMARY KEY,
    url TEXT NOT NULL REFERENCES pages (url) ON DELETE CASCADE,
    chunk_index INTEGER NOT NULL,
    content TEXT NOT NULL,
    embedding vector(1536) NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    UNIQUE (url, chunk_index)
);

CREATE TABLE IF NOT EXISTS deliveries (
    rid TEXT PRIMARY KEY,
    url TEXT,
    original_status INTEGER,
    cb_status INTEGER,
    received_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

Для столбца эмбеддингов выбран тип vector(1536) по той причине, что в примере используется модель OpenAI text-embedding-3-small, а поиск по сходству выполняется по индексу HNSW с vector_cosine_ops. Кроме того, в схеме есть столбец recrawl_every (текущий код его пока не использует); повторный обход работает по одному глобальному интервалу, а периодичность для отдельных URL рассматривается в конце.

Одно свойство модели понадобится позже: chunks.content содержит перекрывающиеся фрагменты для поиска, а не копию страницы. Восстановить из них исходный Markdown невозможно, поэтому смена стратегии разбиения на чанки или модели эмбеддингов означает повторное получение источника.

Отправка URL в Enterprise Crawler

Создайте именованный краулер в консоли Crawlers с доставкой через вебхук и публичным HTTPS URL обратного вызова. Для локальной разработки FastAPI-приложение можно открыть наружу через туннель, например ngrok или Cloudflare Tunnel. Выберите тип токена под цель: токен JavaScript, когда страницам нужен рендеринг или page_wait и ajax_wait.

python
# final/app/crawler.py
def push_options() -> dict:
    options = {
        "crawler": settings.crawler_name,
        "callback": "true",
        "format": "md",
        "md_readability": "true",
    }
    if settings.page_wait is not None:
        options["page_wait"] = str(settings.page_wait)
    if settings.ajax_wait is not None:
        options["ajax_wait"] = str(settings.ajax_wait)
    return options

def push_url(url: str) -> str:
    """Enqueue one URL. Returns the Crawlbase rid."""
    api = _client()
    res = api.get(url, push_options())
    rid = _rid_from_response(res if isinstance(res, dict) else {})
    if not rid:
        raise RuntimeError(f"Crawler push failed for {url!r}: {res!r}")
    return rid

Опубликованный Python-пакет crawlbase предоставляет это через CrawlingAPI.get: отправка в краулер представляет собой запрос к Crawling API с параметрами crawler и callback=true. format=md применяется при отправках в краулер, поэтому вебхук получает GitHub Flavored Markdown. Проход readability сейчас не входит в преобразование краулера в Markdown, поэтому ожидайте полную страницу, включая навигацию и подвал, и планируйте этап хеширования с учетом этого.

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

Идемпотентный вебхук

Вебхук служит границей между асинхронным краулером и этапом загрузки, и у него три задачи: декодировать тело, распознавать проверки работоспособности и регистрировать доставку до начала реальной работы. Доставки сжимаются gzip с заголовком Content-Encoding: gzip, включая Markdown, а доставка в Markdown дополнительно несет заголовок Content-Type: text/markdown; charset=utf-8.

python
# final/app/webhook.py (inside the /webhook handler)
raw = await request.body()
markdown = decode_body(raw, request.headers.get("content-encoding"))
if is_monitor_probe(request.headers.get("user-agent", ""), markdown):
    return Response(status_code=200)

if settings.webhook_token and token != settings.webhook_token:
    raise HTTPException(status_code=401, detail="invalid webhook token")

# ... read rid, url, original_status and cb_status from the headers ...

if not claim_delivery(rid, url, original_status, cb_status):
    return Response(status_code=200)

background_tasks.add_task(
    ingest_delivery, rid, url or "", original_status, cb_status, markdown
)
return Response(status_code=200)

claim_delivery выполняет INSERT INTO deliveries ... ON CONFLICT (rid) DO NOTHING и проверяет число строк: одна вставленная строка означает, что доставка принадлежит этому запросу, ноль означает, что rid уже был обработан. Считайте, что доставка происходит как минимум один раз. Повторная попытка после тайм-аута несет тот же rid, а регистрация до планирования загрузки гарантирует, что две копии одной доставки никогда не смогут обе записать чанки.

Проверкам работоспособности нужен собственный путь. Crawlbase проверяет адрес обратного вызова примерно каждые пять минут запросом с заголовком User-Agent: Crawlbase Monitoring Bot 1.0; это не доставка страницы, поэтому обработчик отвечает 200 и останавливается. Только ответы 200, 201 или 204 считаются признаком работоспособности. Если проверка продолжает завершаться неудачей, краулер перестает брать работу и сам возобновляет ее, когда эндпоинт восстанавливается, так что сломанный деплой не опустошает очередь, отправляя ее в неработающий эндпоинт.

Отсюда следуют два эксплуатационных момента. Во-первых, аутентифицируйте доставки токеном в URL обратного вызова (?token=..., задается как WEBHOOK_TOKEN), а не списком разрешенных IP. Во-вторых, подтверждайте получение быстро и выносите построение эмбеддингов за пределы запроса: неудачная доставка снова ставится в очередь, обходится и оплачивается повторно, поэтому медленный обработчик превращает проблему приложения в расходы на обход. Механизма FastAPI BackgroundTasks для примера достаточно; в продакшене следующим шагом станет постоянная очередь воркеров.

Повторное построение эмбеддингов с хеш-фильтром

Именно здесь возникает экономия. Страница нормализуется, хешируется с помощью SHA-256 и сравнивается с сохраненным хешем до любого вызова эмбеддинга.

python
# final/app/ingest.py (inside ingest_delivery)
with get_conn() as conn:
    if original_status in (404, 410):
        tombstone_page(conn, url, original_status)
        conn.commit()
        return "deleted"

    if cb_status is not None and cb_status != 200:
        log.warning("skip rid=%s: cb_status=%s (not embedding)", rid, cb_status)
        conn.commit()
        return "skipped"

    normalized = normalize_markdown(markdown)
    content_hash = content_sha256(normalized)
    page = fetch_page(conn, url)
    if page and page["content_hash"] == content_hash and page["deleted_at"] is None:
        touch_page(conn, url, original_status)
        conn.commit()
        return "unchanged"

    texts = chunk_markdown(normalized)
    vectors = embed_texts(texts)
    upsert_page(conn, url, content_hash, original_status)
    replace_chunks(conn, url, texts, vectors)
    conn.commit()
    return "embedded"
В порядке проверок и заключается замысел. Сначала решается вопрос удаления, затем успешность Crawlbase, и только потом хеш. Страница с отличным от 200 cb_status никогда не попадает к модели эмбеддингов, а неизменный хеш никогда ее не вызывает.

Нормализация намеренно минимальна: Unicode NFC, преобразование переводов строк Windows, удаление пробельных символов в конце каждой строки и сжатие последовательностей пустых строк максимум до двух. Это убирает шум из пробелов, не пытаясь угадывать смысл. Измененные страницы разбиваются с помощью tiktoken на чанки примерно по 512 токенов с перекрытием в 64 токена, для них строятся эмбеддинги, и они заменяют предыдущие чанки страницы.

Поскольку отправки в краулер доставляют полную страницу, все, что меняется при каждом рендеринге, например дата в подвале, баннер сессии или ротируемый промоблок, меняет хеш и запускает повторное построение эмбеддингов. Если ваши источники так себя ведут, удаляйте известный шаблонный текст перед хешированием. Это несколько строк в normalize_markdown, и именно это превращает «страница была получена» в «содержимое изменилось».

Выигрыш очевиден. Повторный обход по-прежнему расходует запросы Crawlbase, но эмбеддинги и записи в индекс следуют скорости изменений: повторно обойдите 1 000 документов, из которых изменились 30, и вызовов эмбеддинга будет 30.

Удаления, коды статуса и редиректы

Решение об удалении принимается раньше всего остального. Страница, сайт которой отвечает 404 или 410, помечается как удаленная: deleted_at устанавливается, content_hash очищается, а ее чанки удаляются, поэтому она больше не может появиться в результатах поиска. Не смешивайте два статуса: original_status отражает ответ сайта, а cb_status показывает, получил ли Crawlbase пригодный ответ. Ответ может содержать original_status 200 вместе с отличным от 200 cb_status, и такое тело нельзя превращать в эмбеддинги.

Для редиректов нужно принять одно решение. Когда краулер следует HTTP-редиректу, заголовок url содержит конечный URL, а original_status содержит код 3xx. Поэтому отправка http://example.com/doc может вернуться как https://example.com/doc/ и создать в pages вторую строку. В примере страницы идентифицируются по доставленному URL, и повторно обходится именно он. Если нужна стабильная идентичность при редиректах, храните отдельный pushed_url и управляйте связью явно, а не позволяйте одной странице незаметно заменять другую.

Плановые повторные обходы и состояние краулера

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

python
# final/app/recrawl.py
def run_recrawl() -> dict[str, str]:
    results: dict[str, str] = {}
    for url in list_live_urls():
        try:
            results[url] = push_url(url)
            log.info("recrawl queued url=%s rid=%s", url, results[url])
        except Exception:
            log.exception("recrawl push failed url=%s", url)
            results[url] = "error"
    return results

APScheduler запускает его каждые RECRAWL_INTERVAL_HOURS внутри приложения (0 отключает его); final/recrawl.py запускает то же задание из cron, а POST /recrawl запускает его по запросу. Для контроля состояния очереди GET /crawler-stats проксирует эндпоинт статистики краулера, который перечисляет все краулеры на токене, указывая число ожидающих запросов, параллелизм, задержку и флаг paused для каждого из них. Этот флаг первым делом стоит проверить после деплоя:

bash
curl "https://api.crawlbase.com/crawler/YOUR_TOKEN/stats"

Ответы с учетом свежести

Путь запроса строит эмбеддинг вопроса, вычисляет косинусное сходство по chunks, а затем соединяет результат с pages для того, чтобы каждое совпадение несло время своей проверки:

python
# final/app/query.py
cur.execute(
    """
    SELECT c.content, c.url, p.last_verified_at
    FROM chunks c
    JOIN pages p ON p.url = c.url
    WHERE p.deleted_at IS NULL
    ORDER BY c.embedding <=> %s::vector
    LIMIT %s
    """,
    (qvec, k),
)

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

Crawlbase Enterprise Crawler

Отправляйте URL, получайте каждую страницу на свой вебхук в виде Markdown и доверьте краулеру очередь, повторные попытки и темп. Неудачные обходы не оплачиваются. Для старта доступно до 5 000 бесплатных запросов, карта не нужна.

Эксплуатация в продакшене

Сохраняйте копию источника, если может понадобиться повторное разбиение на чанки. Поскольку chunks не позволяет восстановить страницу, новая стратегия разбиения или новая модель эмбеддингов означает повторное получение каждой страницы, если вы их не сохранили. Добавление store=true к отправке с вебхуком также сохраняет сырой HTML каждой успешно доставленной страницы в Cloud Storage, по цене половины кредита за хранимую страницу в месяц, поэтому последующее повторное разбиение может выполнять преобразование из хранилища вместо повторного обхода. Вместо этого краулер можно создать в режиме Storage, который доставляет в хранилище, а не на вебхук; краулер использует либо один режим доставки, либо другой.

Задайте каждому URL собственную периодичность. Планировщик использует один глобальный интервал, но в схеме уже есть pages.recrawl_every. Планировщик с периодичностью на уровне URL может запускать повторный обход, как только last_verified_at + recrawl_every остается в прошлом, так что страница с ценами проверяется ежечасно, а архивный журнал изменений ежемесячно.

Оценивайте экономию честно. Для 800 страниц примерно по 2 000 токенов эмбеддинга на страницу полная ночная переиндексация строит эмбеддинги примерно для 1,6 миллиона токенов. Если меняются 4% страниц, хеш-фильтр сокращает это примерно до 64 000 токенов, а объем обхода остается прежним. Выбирайте интервал повторного обхода исходя из того, насколько устаревшим может становиться источник: обход оплачивает проверку свежести, а хеширование делает объем эмбеддингов пропорциональным изменениям.

Заключение

Индекс устаревает в день своего создания, а полная перестройка покупает свежесть по цене, которая растет вместе с корпусом. Разделение задачи на две части решает проблему стоимости: Enterprise Crawler повторно обходит и доставляет страницы по расписанию, а слой загрузки по хешу содержимого решает, нужно ли вообще менять индекс. Неизменные страницы стоят одну временную метку, измененные стоят своих эмбеддингов, а удаленные покидают индекс.

Полная реализация находится в ScraperHub/incremental-rag-index-with-crawler-webhooks-and-pgvector. Чтобы запустить ее, создайте бесплатный аккаунт Crawlbase и настройте краулер с вебхуком.

Часто задаваемые вопросы (FAQ)

Избавляет ли инкрементальная индексация от необходимости повторного обхода?

Нет. Страницы все равно нужно повторно обходить, чтобы узнать, изменились ли они или исчезли. Инкрементальная индексация устраняет построение эмбеддингов и запись в индекс для страниц, которые не изменились.

Почему хешируется нормализованный Markdown, а не сырой HTML?

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

Что происходит, если страница не изменилась?

Новый хеш совпадает с pages.content_hash, существующие чанки остаются, вызов эмбеддинга не выполняется, а last_verified_at обновляется, чтобы зафиксировать проверку.

Как удаленные страницы убираются из индекса?

Доставка с original_status 404 или 410 выводит страницу из индекса: она помечается как удаленная, ее хеш очищается, а чанки удаляются, поэтому она больше не может появиться в результатах поиска.

Во что обходится неудачная доставка вебхука?

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

Начать создавать

Обходите любой сайт в масштабе, без борьбы с инфраструктурой.

Crawlbase берёт на себя прокси, отпечатки и CAPTCHA, чтобы ваша команда выпускала конвейеры данных вместо поддержки обвязки краулинга. 1 000 запросов бесплатно, без карты.

Самообслуживание · Звонок отдела продаж не требуется · Доступны корпоративные объёмы краулинга