В предыдущей статье разбирали, как собрать структурированную 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(/## Материалы и источники[sS]*$/, '').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('nn---nn');
},
И за генерацию 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
? `Найденные статьи (цитируй их):nn${knowledgeContext}nnВопрос пользователя: ${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
Автор: fakedreams


