# Веб-поиск + LLM (RAG)

**Сложность:** средний | **Скоупы:** vibe:search, vibe:ai | **Стек:** cURL / JavaScript

Связка [`POST /v1/search`](/docs/search/run) с [`POST /v1/ai/chat/completions`](/docs/ai/chat) для ответов LLM, опирающихся на свежие источники из интернета.

## Что понадобится

- API-ключ Вайбкод со скоупами `vibe:search` и `vibe:ai`
- Ненулевой баланс Вайбов либо свой BYOK-ключ поискового движка ([Свои ключи](/docs/search/credentials))

Во всех примерах `$VIBE_URL` — базовый адрес `https://vibecode.bitrix24.tech`, `$VIBE_API_KEY` — ваш API-ключ.

## Как устроено решение

1. Ищем в интернете по вопросу пользователя и получаем массив `results` со ссылками на страницы.
2. Передаём источники модели с инструкцией цитировать их маркерами `[N]` — номер совпадает с `results[].id`.
3. Включаем потоковую передачу, когда интерфейс показывает ответ по мере готовности.

Примеры шагов показывают отдельные вызовы. Готовый к запуску скрипт — в разделе «Полный код».

## Шаг 1. Веб-поиск

[`POST /v1/search`](/docs/search/run) возвращает массив `results`, где каждый элемент несёт `id`, `title`, `url` и `content`.

### cURL

```bash
curl -s -X POST -H "X-Api-Key: $VIBE_API_KEY" -H "Content-Type: application/json" \
  "$VIBE_URL/v1/search" \
  -d '{
    "query": "обновления Битрикс24 за последний месяц",
    "search_depth": "advanced",
    "max_results": 5,
    "lang": "ru"
  }'
```

### JavaScript

```javascript
const searchRes = await fetch(`${VIBE_URL}/v1/search`, {
  method: 'POST',
  headers: {
    'X-Api-Key': VIBE_API_KEY,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    query: 'обновления Битрикс24 за последний месяц',
    search_depth: 'advanced',
    max_results: 5,
    lang: 'ru',
  }),
})
const search = await searchRes.json()
```

Ответ несёт синтезированный `answer` и массив `results` — рецепт читает из него `id`, `title`, `url` и `content`. Полный набор полей — на странице [`POST /v1/search`](/docs/search/run).

```json
{
  "query": "обновления Битрикс24 за последний месяц",
  "provider": "bitrix-search",
  "answer": "В июле обновились задачи и CRM [1]. Добавлены новые поля [2].",
  "results": [
    {
      "id": 1,
      "url": "https://example.com/updates",
      "title": "Обновления платформы",
      "content": "Краткий фрагмент найденной страницы…",
      "rawContent": null,
      "score": null,
      "publishedDate": null
    }
  ],
  "cost_vibes": 5
}
```

## Шаг 2. Ответ модели по найденным источникам

Найденные страницы собираются в текстовый блок и уходят в [`POST /v1/ai/chat/completions`](/docs/ai/chat) вместе с инструкцией цитировать источники.

### cURL

```bash
curl -s -X POST -H "X-Api-Key: $VIBE_API_KEY" -H "Content-Type: application/json" \
  "$VIBE_URL/v1/ai/chat/completions" \
  -d '{
    "model": "bitrix/bitrixgpt-5.5",
    "messages": [
      { "role": "system", "content": "Отвечай только на основе источников ниже. Каждый факт сопровождай маркером [N], где N — id источника." },
      { "role": "user", "content": "Вопрос: обновления Битрикс24 за последний месяц\n\nИсточники:\n[1] Заголовок\nhttps://example.com\nТекст фрагмента" }
    ]
  }'
```

### JavaScript

```javascript
const sourcesBlock = search.results
  .map((r) => `[${r.id}] ${r.title}\n${r.url}\n${r.content}`)
  .join('\n\n')

const chatRes = await fetch(`${VIBE_URL}/v1/ai/chat/completions`, {
  method: 'POST',
  headers: {
    'X-Api-Key': VIBE_API_KEY,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    model: 'bitrix/bitrixgpt-5.5',
    messages: [
      {
        role: 'system',
        content:
          'Отвечай только на основе источников ниже. Каждый факт сопровождай маркером [N], где N — id источника.',
      },
      {
        role: 'user',
        content: `Вопрос: ${search.query}\n\nИсточники:\n${sourcesBlock}`,
      },
    ],
  }),
})
const chat = await chatRes.json()

console.log(chat.choices[0].message.content)
```

Ответ приходит в формате, совместимом с OpenAI, — без обёртки `{ success, data }`. Текст лежит в `choices[0].message.content`.

```json
{
  "id": "chatcmpl-9b8b1933947bb4fa",
  "object": "chat.completion",
  "model": "bitrix/bitrixgpt-5.5",
  "choices": [
    { "index": 0, "finish_reason": "stop", "message": { "role": "assistant", "content": "В июле обновились задачи и CRM [1]." } }
  ],
  "usage": { "prompt_tokens": 145, "completion_tokens": 93, "total_tokens": 238 }
}
```

## Шаг 3. Потоковая передача

Когда интерфейс показывает прогресс (chat-приложение, ассистент), используйте `stream: true` — события приходят по мере готовности.

### cURL

```bash
curl -sN -X POST -H "X-Api-Key: $VIBE_API_KEY" -H "Content-Type: application/json" \
  -H "Accept: text/event-stream" \
  "$VIBE_URL/v1/search" \
  -d '{
    "query": "обновления Битрикс24 за последний месяц",
    "search_depth": "advanced",
    "stream": true
  }'
```

### JavaScript

```javascript
const streamRes = await fetch(`${VIBE_URL}/v1/search`, {
  method: 'POST',
  headers: {
    'X-Api-Key': VIBE_API_KEY,
    'Content-Type': 'application/json',
    Accept: 'text/event-stream',
  },
  body: JSON.stringify({
    query: 'обновления Битрикс24 за последний месяц',
    search_depth: 'advanced',
    stream: true,
  }),
})

const reader = streamRes.body.getReader()
const decoder = new TextDecoder()
let buffer = ''

while (true) {
  const { value, done } = await reader.read()
  if (done) break
  buffer += decoder.decode(value, { stream: true })

  let idx
  while ((idx = buffer.indexOf('\n\n')) !== -1) {
    const block = buffer.slice(0, idx)
    buffer = buffer.slice(idx + 2)

    const eventLine = block.match(/^event: (.+)$/m)
    const dataLine = block.match(/^data: (.+)$/m)
    if (!eventLine || !dataLine) continue

    const event = eventLine[1]
    const payload = JSON.parse(dataLine[1])

    if (event === 'thinking') process.stdout.write('.')
    if (event === 'answer_delta') process.stdout.write(payload.content)
    if (event === 'done') console.log('\nГотово')
  }
}
```

Поток несёт семь видов событий: `start` — поиск начался, `thinking` — рассуждение модели, `tool_call` и `tool_result` — обращение к поисковому механизму и его итог, `answer_delta` — очередной кусок ответа, `done` — финальный кадр с полным ответом и списком источников, `error` — отказ.

```
event: start
data: {"search_id":"ws_20260721091402_8e21a3c7","provider":"bitrix-search","search_depth":"advanced"}

event: answer_delta
data: {"content":"За последний месяц вышли "}

event: done
data: {"query":"обновления Битрикс24 за последний месяц","answer":"…","results":[…],"images":[]}
```

Собирать ответ по кускам `answer_delta` не обязательно — кадр `done` несёт его целиком вместе с источниками.

Полный список событий потока и полей — [`POST /v1/search`](/docs/search/run#потоковая-передача-sse).

## Какой движок выбрать

- **Без явного `provider`** запрос идёт через движок по умолчанию, настроенный на инстансе (его показывает поле `defaultProvider` в [`GET /v1/me`](/docs/keys-auth)). Платформенный движок уже возвращает синтезированный `answer`, и тогда шаг с LLM можно пропустить. Есть ли в этом ответе маркеры `[N]` — зависит от движка инстанса, смотрите матрицу возможностей.
- **Источники с фильтрами по доменам или времени** → `provider: "tavily"` (BYOK). Нужен токен Tavily — добавляется через [`POST /v1/search/credentials`](/docs/search/credentials/create).
- **Поиск без синтеза, для RAG поверх своей LLM** → `provider: "brave"` (BYOK).

Для более полного контекста у поддерживающих движков можно запросить полный текст страниц через `include_raw_content: true` — он приходит в `results[].rawContent` и подаётся в LLM вместо короткого фрагмента. Параметр `include_images: true` добавляет в ответ верхнеуровневый массив `images` со ссылками на изображения. Какие движки это поддерживают — показывает [`GET /v1/search/providers`](/docs/search/providers).

Подробнее о различиях — [Web Search для AI](/docs/search#когда-какой-провайдер-выбрать).

## Ограничения

**Пределы запроса.** Параметр `max_results` принимает от 1 до 20, по умолчанию 5. Значение больше 20 запрос отклоняет. Текст запроса — не длиннее 400 символов. Длинный вопрос пользователя перед поиском сокращают до сути, а не отправляют целиком.

**Отказ приходит без `success`.** При нулевом балансе поиск отвечает `402`. Конверт здесь отличается от остальных эндпоинтов: поля `success` в нём нет, поэтому проверяйте наличие `results`, а не `success`.

```json
{
  "error": {
    "code": "INSUFFICIENT_BALANCE",
    "message": "Insufficient vibes for this search.",
    "userMessage": "Недостаточно Вайбов для этого запроса.",
    "hint": "Add a BYOK key to search for free — see GET /v1/search/providers",
    "required": 5
  }
}
```

Полный перечень кодов — [Ошибки](/docs/errors).

**Лимит частоты.** Портал выполняет не больше 60 поисковых запросов в минуту. Превышение отклоняется кодом `RATE_LIMITED` со статусом 429, а сколько ждать — говорит заголовок `Retry-After`. Пакетная обработка очереди вопросов упрётся в этот потолок, поэтому ставьте паузу между циклами и повторяйте отклонённый запрос, а не бросайте его.

**Санитизация на вашей стороне.** Поле `rawContent` — неочищенный текст из открытого интернета, а `images[]` — сторонние ссылки. И то и другое попадает в ответ как есть: очищайте текст перед показом в браузере и проверяйте ссылки.

**Качество источников.** Ответ модели опирается только на переданные источники, но их достоверность рецепт не проверяет. Если поиск вернул страницу с ошибочными данными, модель процитирует её с маркером как факт.

## Стоимость одного цикла

- **Поиск:** платформенный движок тарифицируется в Вайбах — актуальная цена режима `advanced` в [`GET /v1/search/providers`](/docs/search/providers). Для BYOK-провайдеров (Tavily / Brave и других) — 0 Ꝟ на стороне платформы.
- **LLM:** зависит от модели и количества токенов. Прайс — в [AI Router](/docs/ai).

## Полный код

```javascript
// rag.mjs — ответ модели по свежим источникам из интернета
// Расширение .mjs обязательно: скрипт использует await на верхнем уровне,
// а файл .js без "type": "module" Node читает как CommonJS и падает на разборе.
const VIBE_URL = process.env.VIBE_URL ?? 'https://vibecode.bitrix24.tech'
const VIBE_API_KEY = process.env.VIBE_API_KEY
if (!VIBE_API_KEY) throw new Error('Переменная окружения VIBE_API_KEY не задана')

// Отказ 429 — потолок в 60 поисков в минуту (RATE_LIMITED) либо занятая очередь
// портала. Запрос до поставщика не дошёл, повтор безопасен и денег не стоит.
// Сколько ждать, говорит заголовок Retry-After; к паузе добавляется случайная
// К паузе добавляется случайная доля секунды.
const MAX_RETRIES = 5

async function postJson(url, payload) {
  for (let attempt = 0; ; attempt++) {
    const res = await fetch(url, {
      method: 'POST',
      headers: {
        'X-Api-Key': VIBE_API_KEY,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify(payload),
    })
    const body = await res.json().catch(() => null)

    if (res.status === 429 && attempt < MAX_RETRIES) {
      // Рекомендованную паузу берём как есть: умножать её на номер попытки
      // нельзя — из Retry-After: 3 на пятой попытке вышло бы 48 секунд.
      // Экспонента нужна только когда сервер паузу не назвал.
      const advised = res.headers.get('Retry-After') ?? body?.error?.retryAfter ?? null
      // Потолок в 60 с: рекомендованную паузу мы уважаем, но неожиданно
      // большое значение от промежуточного прокси не должно вешать цикл.
      const base = advised === null ? Math.min(2 ** attempt, 30) : Number(advised)
      const wait = Math.min(base, 60) + Math.random()
      console.warn(`Лимит запросов исчерпан, повтор через ${Math.round(wait)} с`)
      await new Promise((resolve) => setTimeout(resolve, wait * 1000))
      continue
    }

    return body
  }
}

// ── 1. Веб-поиск ────────────────────────────────────────────
const search = await postJson(`${VIBE_URL}/v1/search`, {
  query: 'обновления Битрикс24 за последний месяц',
  search_depth: 'advanced',
  max_results: 5,
  lang: 'ru',
})
// Поиск и модель сообщают отказ в теле ответа. Без проверки нулевой баланс даёт
// TypeError на search.results.map вместо понятного 402 INSUFFICIENT_BALANCE.
if (!search.results) throw new Error(search.error?.message ?? 'поиск отклонён')

// ── 2. Собираем блок источников для LLM ─────────────────────
const sourcesBlock = search.results
  .map((r) => `[${r.id}] ${r.title}\n${r.url}\n${r.content}`)
  .join('\n\n')

// ── 3. Запрос к LLM с инструкцией цитировать ────────────────
const chat = await postJson(`${VIBE_URL}/v1/ai/chat/completions`, {
  model: 'bitrix/bitrixgpt-5.5',
  messages: [
    {
      role: 'system',
      content:
        'Отвечай только на основе источников ниже. Каждый факт сопровождай маркером [N], где N — id источника.',
    },
    {
      role: 'user',
      content: `Вопрос: ${search.query}\n\nИсточники:\n${sourcesBlock}`,
    },
  ],
})
if (!chat.choices?.length) throw new Error(chat.error?.message ?? 'модель не ответила')

console.log(chat.choices[0].message.content)
```

## Смотрите также

- [Web Search для AI](/docs/search)
- [Поиск (POST /v1/search)](/docs/search/run)
- [Свои ключи (BYOK)](/docs/search/credentials)
- [AI Router](/docs/ai)
- [Лимиты и оптимизация](/docs/optimization)
