Для AI-агентов: markdown этой страницы — /docs-content/recipes/web-search-with-llm.md индекс документации — /llms.txt
Веб-поиск + LLM (RAG)
Сложность: средний | Скоупы: vibe:search, vibe:ai | Стек: cURL / JavaScript
Связка POST /v1/search с POST /v1/ai/chat/completions для ответов LLM, опирающихся на свежие источники из интернета.
Что понадобится
- API-ключ Вайбкод со скоупами
vibe:searchиvibe:ai - Ненулевой баланс Вайбов либо свой BYOK-ключ поискового движка (Свои ключи)
Во всех примерах $VIBE_URL — базовый адрес https://vibecode.bitrix24.tech, $VIBE_API_KEY — ваш API-ключ.
Как устроено решение
- Ищем в интернете по вопросу пользователя и получаем массив
resultsсо ссылками на страницы. - Передаём источники модели с инструкцией цитировать их маркерами
[N]— номер совпадает сresults[].id. - Включаем потоковую передачу, когда интерфейс показывает ответ по мере готовности.
Примеры шагов показывают отдельные вызовы. Готовый к запуску скрипт — в разделе «Полный код».
Шаг 1. Веб-поиск
POST /v1/search возвращает массив results, где каждый элемент несёт id, title, url и content.
cURL
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
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.
{
"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 вместе с инструкцией цитировать источники.
cURL
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
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.
{
"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
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
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.
Какой движок выбрать
- Без явного
providerзапрос идёт через движок по умолчанию, настроенный на инстансе (его показывает полеdefaultProviderвGET /v1/me). Платформенный движок уже возвращает синтезированныйanswer, и тогда шаг с LLM можно пропустить. Есть ли в этом ответе маркеры[N]— зависит от движка инстанса, смотрите матрицу возможностей. - Источники с фильтрами по доменам или времени →
provider: "tavily"(BYOK). Нужен токен Tavily — добавляется черезPOST /v1/search/credentials. - Поиск без синтеза, для RAG поверх своей LLM →
provider: "brave"(BYOK).
Для более полного контекста у поддерживающих движков можно запросить полный текст страниц через include_raw_content: true — он приходит в results[].rawContent и подаётся в LLM вместо короткого фрагмента. Параметр include_images: true добавляет в ответ верхнеуровневый массив images со ссылками на изображения. Какие движки это поддерживают — показывает GET /v1/search/providers.
Подробнее о различиях — Web Search для AI.
Ограничения
Пределы запроса. Параметр max_results принимает от 1 до 20, по умолчанию 5. Значение больше 20 запрос отклоняет. Текст запроса — не длиннее 400 символов. Длинный вопрос пользователя перед поиском сокращают до сути, а не отправляют целиком.
Отказ приходит без success. При нулевом балансе поиск отвечает 402. Конверт здесь отличается от остальных эндпоинтов: поля success в нём нет, поэтому проверяйте наличие results, а не success.
{
"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
}
}
Полный перечень кодов — Ошибки.
Лимит частоты. Портал выполняет не больше 60 поисковых запросов в минуту. Превышение отклоняется кодом RATE_LIMITED со статусом 429, а сколько ждать — говорит заголовок Retry-After. Пакетная обработка очереди вопросов упрётся в этот потолок, поэтому ставьте паузу между циклами и повторяйте отклонённый запрос, а не бросайте его.
Санитизация на вашей стороне. Поле rawContent — неочищенный текст из открытого интернета, а images[] — сторонние ссылки. И то и другое попадает в ответ как есть: очищайте текст перед показом в браузере и проверяйте ссылки.
Качество источников. Ответ модели опирается только на переданные источники, но их достоверность рецепт не проверяет. Если поиск вернул страницу с ошибочными данными, модель процитирует её с маркером как факт.
Стоимость одного цикла
- Поиск: платформенный движок тарифицируется в Вайбах — актуальная цена режима
advancedвGET /v1/search/providers. Для BYOK-провайдеров (Tavily / Brave и других) — 0 Ꝟ на стороне платформы. - LLM: зависит от модели и количества токенов. Прайс — в AI Router.
Полный код
// 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)