В предыдущей статье разбирали, как собрать структурированную wiki из markdown-файлов на Astro/Starlight — на примере личного карьерного менеджера. В комментариях появились закономерные вопросы: «почему именно так?», «что за странный выбор стека?», «а для чего ещё это можно использовать, кроме как для себя?».
Хороший вопрос. Эта статья отвечает на него делом.
Та же механика — wiki из markdown — но теперь с Telegram-ботом поверх. Бот умеет искать по базе знаний и отвечать с цитатами и ссылками на источники. В качестве предметной области выбрана психология и философия: получился @pif_bbot — эмпатичный помощник, который работает на основе открытой базы знаний по НВО, Юнгу, Франклу и другим авторам.
Весь код — в репозитории на GitHub, папка bot/.
Когда хочется сделать «умного» бота на своих данных, первая мысль — RAG: векторная БД, эмбеддинги, Pinecone или pgvector. Это работает, но тащит за собой инфраструктуру, зависимости и расходы.
Есть подход проще — LLM Wiki: берём статичную базу знаний в markdown, разбиваем на чанки, ищем по ним keyword-поиском и подсовываем найденное в контекст LLM. Никаких эмбеддингов, никаких векторных БД, никаких внешних API для retrieval. Если предметная область достаточно специализирована — психология, юриспруденция, техническая документация — результат сравним с полноценным RAG.
В этой статье мы построим Telegram-бота, который:
ищет по базе знаний с помощью алгоритма Jaccard — детерминированно и быстро
цитирует источники со ссылками на конкретные статьи wiki
помнит историю диалога между сессиями через Cloudflare KV
деплоится одной командой на Cloudflare Workers — бесплатно при умеренной нагрузке
Стек
TypeScript — единый язык и для бота, и для скриптов сборки
Telegraf — фреймворк для Telegram Bot API
Groq API — бесплатный LLM (Llama-3.1-8b-instant, очень низкая латентность)
Cloudflare Workers — serverless edge, cold start < 5ms, бесплатный tier
Cloudflare KV — хранение истории сессий
Архитектура
Wiki (Markdown) ──► build-knowledge.ts ──► knowledge.ts │ 256 чанков с предвычисленными ключевыми словами │ Telegram ──► CF Worker ──► Retriever ──────────┘ │ (Jaccard) ▼ Groq LLM ──► ответ с цитатами ▲ KV (история)
Главная идея: база знаний встроена прямо в код. При деплое knowledge.ts с 256 чанками загружается в память воркера — никаких запросов к БД, нулевая латентность поиска. Звучит немного безумно, но на практике работает отлично: 29 статей, ~620KB, поиск занимает единицы миллисекунд.
Подготовка
Нужно:
Node.js 20+
Аккаунт Cloudflare (бесплатный)
Токен Telegram-бота — получить у @BotFather
API-ключ Groq (бесплатный tier)
Структура проекта (wiki уже есть из предыдущей статьи, добавляем папку bot/):
pif/ ├── src/content/docs/ # Wiki из предыдущей статьи │ ├── authors/ │ │ ├── jung/ │ │ │ └── shadow.md │ │ └── frankl/ │ │ └── logotherapy.md │ └── practices/ │ └── nvc.md └── bot/ ├── src/ │ ├── index.ts # точка входа CF Workers │ ├── bot.ts # Telegram-обработчики │ ├── knowledge.ts # автогенерированный индекс (не редактировать) │ ├── retriever.ts # поиск │ ├── llm.ts # клиент Groq │ ├── session.ts # сессии через KV │ └── prompts.ts # system prompt ├── scripts/ │ └── build-knowledge.ts └── wrangler.toml
Установка зависимостей:
cd bot npm init -y npm install telegraf npm install -D wrangler tsx typescript @cloudflare/workers-types
Шаг 1. Генерация базы знаний
Первый шаг — превратить markdown-файлы wiki в индекс для поиска.
Скрипт scripts/build-knowledge.ts делает три вещи:
Сканирует
src/content/docs/**/*.mdРазбивает каждую страницу на секции по
## заголовкамДля каждой секции генерирует список ключевых слов
interface WikiChunk { id: string; // "authors/jung/shadow#Тень" title: string; // заголовок страницы sourcePath: string; // "authors/jung/shadow.md" section: string; // "## Тень" text: string; // текст секции keywords: string[]; // предвычисленные ключевые слова }
Ключевая функция — разбивка страницы на чанки:
function chunkPage(page: WikiPage): WikiChunk[] { const chunks: WikiChunk[] = []; // Убираем секцию "Материалы и источники" — не нужна для поиска const body = page.content.replace(/## Материалы и источники[\s\S]*$/, '').trim(); // Разбиваем по ## заголовкам const sections = body.split(/(?=^## )/m); for (const section of sections) { const headerMatch = section.match(/^## (.+)$/m); const sectionName = headerMatch ? headerMatch[1].trim() : ''; const text = section.replace(/^## .+\n*/m, '').trim(); if (!text || text.length < 20) continue; // Ключевые слова: токенизация заголовка + секции + первых 500 символов текста const keywords = tokenize(`${page.title} ${sectionName} ${text.slice(0, 500)}`); chunks.push({ id: `${page.path}#${sectionName}`, title: page.title, sourcePath: page.path, section: sectionName, text, keywords, }); } return chunks; }
Токенизация простая: разбиваем на слова, фильтруем стоп-слова (русские + английские), убираем слова короче 3 символов, дедуплицируем:
const STOPWORDS = new Set([ 'и', 'в', 'во', 'не', 'что', 'он', 'на', 'я', 'с', 'со', // ... полный список в репозитории 'the', 'a', 'an', 'and', 'or', 'but', 'in', 'on', ]); function tokenize(text: string): string[] { const words = text.toLowerCase().match(/[а-яёa-z]+/gi) || []; return [...new Set(words.filter(w => w.length > 2 && !STOPWORDS.has(w)))]; }
Результат — файл src/knowledge.ts с массивом KNOWLEDGE_CHUNKS. При 29 страницах wiki получается ~256 чанков.
npm run build # запускает build-knowledge.ts через tsx
Важно:
knowledge.ts— автогенерированный файл, его не нужно редактировать вручную. Каждый раз при обновлении wiki запускайтеnpm run buildперед деплоем.
Шаг 2. Retriever: поиск по чанкам
Файл src/retriever.ts — поиск по чанкам базы знаний.
Для поиска используем Jaccard-подобное сходство по ключевым словам:
score = |queryTokens ∩ chunkKeywords| / |queryTokens ∪ chunkKeywords|
Чем больше общих слов между запросом и чанком — тем выше score. Берём top-K чанков с ненулевым score.
export function createRetriever(chunks: WikiChunk[], baseUrl: string): Retriever { return { retrieve(query: string, topK: number = 3): RetrievedChunk[] { const queryTokens = tokenize(query); if (queryTokens.length === 0) return []; const scored = chunks.map(chunk => { const overlap = queryTokens.filter(t => chunk.keywords.includes(t)).length; const union = new Set([...queryTokens, ...chunk.keywords]); const score = union.size > 0 ? overlap / union.size : 0; return { chunk, score }; }); return scored .sort((a, b) => b.score - a.score) .slice(0, topK) .filter(c => c.score > 0) .map(c => ({ ...c.chunk })); }, // ... }; }
Почему не векторы?
Вопрос из комментариев к первой статье — отвечаю: семантический поиск через эмбеддинги действительно лучше понимает синонимы и смысловые связи. Но за это нужно платить: API для генерации эмбеддингов, векторное хранилище, дополнительный сетевой вызов на каждый запрос.
Jaccard по ключевым словам оправдан, когда:
Предметная область узкая и имеет чёткую терминологию
Пользователи используют термины из самой базы знаний
Нужна детерминированность — один и тот же запрос всегда даёт одинаковый результат
Важна минимальная инфраструктура и нулевые операционные расходы
Для психологической базы знаний это работает: запрос «тревога и страх» найдёт чанк про страх в логотерапии Франкла, «конфликт в отношениях» — чанк про амортизацию по Литваку. Проверьте сами.
Retriever также отвечает за форматирование найденных чанков для LLM:
formatContext(entries: RetrievedChunk[]): string { if (entries.length === 0) return ''; return entries.map((e, i) => { const url = `${baseUrl}${wikiPathToUrl(e.sourcePath)}`; return `[Источник ${i + 1}]: ${e.title} → ${url} > ${e.section ? `*${e.section}*` : ''} > ${e.text.split('\n').map(line => `> ${line}`).join('\n')}`; }).join('\n\n---\n\n'); },
И за генерацию URL из пути к файлу:
function wikiPathToUrl(sourcePath: string): string { const withoutExt = sourcePath.replace(/\.md$/, ''); if (withoutExt.endsWith('/index')) { return '/' + withoutExt.replace('/index', '') + '/'; } return '/' + withoutExt + '/'; } // "authors/jung/shadow.md" → "/authors/jung/shadow/"
Шаг 3. LLM-клиент
Файл src/llm.ts — минималистичный враппер над Groq API.
Никаких SDK — только fetch. Это принципиально для Cloudflare Workers: крупные SDK вроде официального OpenAI-клиента могут не поддерживать Workers runtime или тащить за собой полтонны зависимостей. Простой fetch-враппер надёжнее.
export function initLLM(config: LLMConfig): LLMClient { return { async chat(messages) { const response = await fetch('https://api.groq.com/openai/v1/chat/completions', { method: 'POST', headers: { 'Authorization': `Bearer ${config.apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ model: config.model, messages, temperature: 0.7, max_tokens: 2048, }), }); if (!response.ok) { const err = await response.text(); throw new Error(`Groq API error ${response.status}: ${err}`); } const data = await response.json() as any; return data.choices?.[0]?.message?.content || ''; }, }; }
Groq выбран по трём причинам: бесплатный tier с приличными лимитами, Llama-3.1-8b-instant отвечает за 200–500ms, API совместим с OpenAI — при желании можно поменять провайдер одной строкой. Модель задаётся через env GROQ_MODEL, так что для смены модели не нужен редеплой.
Шаг 4. Сессии в Cloudflare KV
Файл src/session.ts — история диалога.
Cloudflare Workers stateless: каждый входящий запрос — чистый контекст. Историю диалога нужно хранить снаружи. KV — идеальный выбор: глобально распределённый key-value store, бесплатный tier включает 100K операций чтения в день.
const MAX_HISTORY = 20; export function initSessionStore(env: { SESSIONS?: KVNamespace }): SessionStore { const kv = env.SESSIONS; return { async get(userId: number): Promise<SessionMessage[]> { if (!kv) return []; const raw = await kv.get(`session:${userId}`, 'text'); return raw ? JSON.parse(raw) : []; }, async add(userId: number, message: SessionMessage): Promise<void> { if (!kv) return; const history = await this.get(userId); history.push(message); // Храним не больше 20 последних сообщений const trimmed = history.slice(-MAX_HISTORY); await kv.put(`session:${userId}`, JSON.stringify(trimmed), { expirationTtl: 86400 * 7, // TTL 7 дней }); }, async clear(userId: number): Promise<void> { if (!kv) return; await kv.delete(`session:${userId}`); }, }; }
Ключ сессии: session:{telegramUserId}. TTL 7 дней — старые сессии удаляются автоматически, ручная очистка не нужна.
Лимит в 20 сообщений — защита от переполнения контекста LLM. Если история всё равно оказалась слишком большой (Groq вернул Request too large), бот сообщает пользователю и предлагает написать /clear.
Шаг 5. System prompt и сборка контекста
Файл src/prompts.ts определяет личность и поведение бота.
export function SYSTEM_PROMPT(): string { return ` Ты — ПиФ, эмпатичный психологический помощник. База знаний: ННО (Розенберг), Юнг, Франкл, Уилбер, Минделл, Адизес, Литвак. ## Правила ### Структура ответа - Валидация — отрази чувства - Наблюдение — факты без оценок - Концепция — 1-2 предложения из базы знаний - Вопрос — открытый вопрос или техника ### Цитирование Когда тебе переданы статьи в контексте: - Используй ТОЛЬКО URL, которые даны в контексте — копируй как есть - Формат цитаты: > текст\n> -- [Название](URL) - НИКОГДА не выдумывай цитаты ### Безопасность При суициде/самоповреждении: «Пожалуйста, позвони 112 или 8-800-2000-122». `; }
Теперь самое интересное — как бот собирает запрос к LLM в src/bot.ts:
bot.on('text', async (ctx) => { const userId = ctx.from.id; const userMessage = ctx.message.text; await ctx.sendChatAction('typing'); // 1. Получаем историю диалога из KV const history = await config.sessions.get(userId); // 2. Ищем релевантные статьи в базе знаний const relevant = config.retriever.retrieve(userMessage, 2); const knowledgeContext = config.retriever.formatContext(relevant); // 3. Собираем messages для LLM const messages = [ { role: 'system', content: config.systemPrompt }, ...history.map(m => ({ role: m.role, content: m.content })), ]; // 4. Инжектируем контекст в сообщение пользователя const userContent = knowledgeContext ? `Найденные статьи (цитируй их):\n\n${knowledgeContext}\n\nВопрос пользователя: ${userMessage}` : userMessage; messages.push({ role: 'user', content: userContent }); // 5. Запрос к LLM const response = await config.llm.chat(messages); // 6. Сохраняем в историю (оригинальное сообщение, без RAG-контекста) await config.sessions.add(userId, { role: 'user', content: userMessage, timestamp: Date.now() }); await config.sessions.add(userId, { role: 'assistant', content: response, timestamp: Date.now() }); await ctx.reply(response, { parse_mode: 'Markdown' }); });
Обратите внимание на шаг 6: в историю сохраняется оригинальное сообщение пользователя, без RAG-контекста. Это важно — иначе история раздуется очень быстро. Каждое следующее сообщение потянуло бы за собой несколько статей из базы знаний, и через несколько обменов контекст LLM переполнился бы.
Вот что получает LLM в userContent:
Найденные статьи (цитируй их): [Источник 1]: Тень (Юнг) → https://anatolii-iumashev.github.io/pifai/authors/jung/shadow/ > *## Что такое Тень* > > Тень — это та часть нашей личности, которую мы отвергаем... --- [Источник 2]: Эмоции и потребности → https://anatolii-iumashev.github.io/pifai/basics/emotions/ > *## Чувства как сигнал* > > В ННО чувства — это индикатор удовлетворённости потребностей... Вопрос пользователя: почему я злюсь на близких без причины?
Шаг 6. Точка входа: Cloudflare Workers
Файл src/index.ts — HTTP-обработчик для Workers.
let botInstance: ReturnType<typeof createBot> | null = null; export default { async fetch(request: Request, env: Env): Promise<Response> { const url = new URL(request.url); // Health check if (request.method === 'GET' && url.pathname === '/health') { return new Response(JSON.stringify({ status: 'ok', knowledgeVersion: env.KNOWLEDGE_VERSION || '1.0.0', }), { headers: { 'Content-Type': 'application/json' } }); } // Telegram webhook if (request.method === 'POST' && url.pathname === '/webhook') { // Lazy init — создаём бота один раз if (!botInstance) { const llm = initLLM({ apiKey: env.GROQ_API_KEY, model: env.GROQ_MODEL || 'llama-3.1-8b-instant' }); const sessions = initSessionStore(env); const retriever = createRetriever(KNOWLEDGE_CHUNKS, env.KNOWLEDGE_BASE_URL); botInstance = createBot({ token: env.TELEGRAM_BOT_TOKEN, llm, sessions, systemPrompt: SYSTEM_PROMPT(), retriever }); } const update = await request.json() as any; await botInstance.handleUpdate(update); return new Response('ok', { status: 200 }); } return new Response('Not found', { status: 404 }); }, };
Два момента, которые важно понять:
Lazy init. Воркер инициализируется при первом запросе и переиспользует экземпляр botInstance. Cloudflare Workers не гарантирует, что один и тот же инстанс будет жить вечно, но на практике при регулярном трафике он живёт долго — cold start случается редко.
Всегда 200 для Telegram. Если вернуть 4xx/5xx, Telegram начнёт повторять запрос с нарастающими интервалами. Мы возвращаем 200 даже при ошибке — Telegram считает, что сообщение доставлено, и не засыпает бота ретраями.
Конфигурация: wrangler.toml
name = "pif-bot" main = "src/index.ts" compatibility_date = "2026-05-01" compatibility_flags = ["nodejs_compat"] # KV для сессий [[kv_namespaces]] binding = "SESSIONS" id = "your-kv-namespace-id" # Переменные окружения [vars] GROQ_MODEL = "llama-3.1-8b-instant" KNOWLEDGE_VERSION = "1.0.0" KNOWLEDGE_BASE_URL = "https://anatolii-iumashev.github.io/pifai"
Флаг nodejs_compat нужен, потому что Telegraf использует некоторые Node.js API. Без него при деплое получите ошибки.
Деплой
1. Создаём KV namespace
npx wrangler kv namespace create SESSIONS
Берём id из вывода и прописываем в wrangler.toml.
2. Добавляем секреты
npx wrangler secret put TELEGRAM_BOT_TOKEN # вводим токен бота npx wrangler secret put GROQ_API_KEY # вводим ключ Groq
3. Собираем базу знаний и деплоим
npm run build # генерирует src/knowledge.ts из wiki npm run deploy # wrangler deploy
После деплоя Wrangler выведет URL воркера вида https://pif-bot.username.workers.dev.
4. Регистрируем webhook
curl "https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/setWebhook?url=https://pif-bot.username.workers.dev/webhook"
Проверяем:
curl "https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/getWebhookInfo"
Должны увидеть "url": "https://pif-bot.username.workers.dev/webhook" и "pending_update_count": 0.
5. Проверяем health
curl https://pif-bot.username.workers.dev/health # {"status":"ok","knowledgeVersion":"1.0.0"}
Всё — бот работает. Открываем Telegram и пишем.
Локальная разработка
Для тестирования без деплоя создаём файл bot/.dev.vars:
TELEGRAM_BOT_TOKEN=your_token_here GROQ_API_KEY=your_key_here
И запускаем:
npm run dev # node --env-file=.dev.vars --import tsx src/index.ts
В локальном режиме Workers-среды нет, KV тоже нет — история не сохраняется. Но LLM-ответы с RAG работают. Для отладки webhook локально используйте ngrok или wrangler dev с туннелем.
Обновление базы знаний
Один из неочевидных плюсов такого подхода — насколько просто обновлять знания бота. Добавили статью в wiki:
npm run build # перегенерирует knowledge.ts npm run deploy # загружает обновлённый воркер
Два шага. Никаких миграций, никакого переиндексирования, никаких embedding-батчей.
Если wiki живёт в отдельном репозитории или как submodule, это легко автоматизируется через GitHub Actions:
on: push: paths: - 'src/content/docs/**' jobs: deploy-bot: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: npm ci - run: npm run build - run: npm run deploy env: CLOUDFLARE_API_TOKEN: ${{ secrets.CF_API_TOKEN }}
Теперь каждый коммит в wiki автоматически обновляет знания бота.
Что получилось
Итоговый бот @pif_bbot умеет:
Находить релевантные статьи по запросу — даже при неточных формулировках
Цитировать источники со ссылками на конкретные страницы wiki
Помнить контекст диалога — отвечает с учётом предыдущих сообщений
Сбрасывать историю командой
/clearРеагировать на кризисные ситуации — сразу давать номера телефонов доверия
При этом инфраструктура минимальная: Cloudflare Workers free tier + Groq free tier = ~0₽/мес при умеренной нагрузке. Для личного проекта или небольшого сообщества — идеально.
Что можно улучшить
Семантический поиск. Jaccard хорошо работает для предметных областей с устойчивой терминологией. Для более размытых запросов стоит посмотреть на Cloudflare Vectorize с Workers AI для генерации эмбеддингов — всё в рамках той же платформы, никаких внешних сервисов.
Автоматическое определение кризисных состояний. В prompts.ts уже есть CRISIS_DETECTION_PROMPT() — промпт для классификации сообщений. Можно добавить предварительный вызов LLM перед основным ответом: если бот распознал кризис — сразу переключается на кризисный сценарий, не дожидаясь конца диалога.
Hybrid search. Jaccard + BM25 улучшат поиск по длинным запросам без перехода на векторы.
Мониторинг. Cloudflare Workers Analytics из коробки показывает запросы, ошибки и latency. Можно добавить структурированное логирование через R2 для более детального анализа.
Код проекта: github.com/anatolii-iumashev/pifai (папка bot/)
База знаний: anatolii-iumashev.github.io/pifai
Бот: @pif_bbot
Предыдущая статья — Создание wiki на Astro/Starlight
