AI помощник для Outline: Telegram бот и MCP сервер 2026 | AiManual
AiManual Logo Ai / Manual.
09 Фев 2026 Гайд

Outline превращается в ИИ-помощника: Telegram-бот + MCP-сервер для личной базы знаний

Пошаговый гайд: создаем интеллектуального помощника для базы знаний Outline с Telegram-ботом и MCP-сервером. Локальный поиск, семантический анализ, интеграция с

Ваша база знаний в Outline умнеет. Сама

Outline — отличный инструмент для заметок. Пока у вас там 10-20 документов. Потом наступает момент, когда вы точно помните, что писали про "конфигурацию Nginx с Let's Encrypt", но найти не можете. Или когда новый сотрудник спрашивает: "А где у нас про деплой в Docker Swarm?" И вы тратите полчаса на поиск.

Типичное решение — поиск по тексту. Он работает, если вы помните точные слова. Но мозг человека так не работает. Мы мыслим смыслами, ассоциациями, контекстом. "Как мы настраивали SSL для внутреннего сервиса?" — это не "Nginx proxy_pass ssl_certificate".

К февралю 2026 года локальные LLM достигли качества, достаточного для семантического поиска в личных базах знаний. Модели типа Mistral 12B или Qwen2.5 14B работают на обычном ноутбуке и понимают контекст лучше, чем Elasticsearch.

Мы соберем систему, где:

  • Telegram-бот принимает вопросы на естественном языке
  • MCP-сервер индексирует все документы Outline
  • Локальная LLM находит релевантные документы по смыслу
  • Бот возвращает ответ с цитатами и ссылками

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

Почему именно MCP-сервер? (И почему это не просто еще один скрипт)

Model Context Protocol — стандарт, который в 2025-2026 стал де-факто для интеграции внешних данных в LLM. Claude Desktop, Cursor, Windsurf — все поддерживают MCP. Если сделать MCP-сервер для Outline, ваша база знаний станет доступна не только из Telegram-бота, но и прямо в IDE или десктопном клиенте Claude.

Вы пишете код в Cursor, вспоминаете: "А как мы настраивали мониторинг для этого микросервиса?" — набираете /outline "мониторинг микросервисов" — и получаете ссылки на соответствующие документы. Без переключения окон.

💡
MCP-сервер — это мост между вашей локальной инфраструктурой и современными AI-инструментами. Вместо того чтобы писать интеграцию для каждого клиента (Telegram, Discord, Slack), вы пишете один MCP-сервер, и он работает везде.

В статье про LM Studio MCP я показывал, как запустить агента для автоматизации новостей. Здесь та же технология, но для личных данных.

Стек технологий: что нам понадобится

КомпонентТехнологияЗачем
База знанийOutline (Docker)Хранение документов, версионирование, доступ
ИндексацияChromaDB + sentence-transformersВекторный поиск по документам
Понимание запросовMistral 7B или Qwen2.5 7BСемантический поиск, перефразирование
MCP-серверPython + mcp libraryИнтеграция с Claude Desktop, Cursor
Telegram-ботpython-telegram-bot 21.xИнтерфейс для мобильных запросов
ОркестрацияDocker ComposeУправление всеми сервисами

Все компоненты бесплатные, с открытым исходным кодом. Никаких подписок на OpenAI или Anthropic.

1Поднимаем Outline в Docker

Если Outline уже стоит — отлично. Если нет, вот минимальная конфигурация:

# docker-compose.outline.yml
version: '3.8'

services:
  outline:
    image: outlinewiki/outline:latest
    ports:
      - "3000:3000"
    environment:
      - DATABASE_URL=postgres://outline:password@postgres/outline
      - REDIS_URL=redis://redis:6379
      - SECRET_KEY=${SECRET_KEY}
      - UTILS_SECRET=${UTILS_SECRET}
      - URL=http://localhost:3000
    depends_on:
      - postgres
      - redis

  postgres:
    image: postgres:15
    environment:
      - POSTGRES_USER=outline
      - POSTGRES_PASSWORD=password
      - POSTGRES_DB=outline
    volumes:
      - postgres_data:/var/lib/postgresql/data

  redis:
    image: redis:7-alpine
    volumes:
      - redis_data:/data

volumes:
  postgres_data:
  redis_data:

Запускаем: docker-compose -f docker-compose.outline.yml up -d

Открываем http://localhost:3000, создаем аккаунт, начинаем наполнять базу знаний. Не забудьте создать несколько коллекций и документов для теста.

Важно: Outline не имеет публичного API для чтения всех документов. Нам понадобится доступ к базе данных PostgreSQL или использовать веб-скрейпинг с авторизацией. В этом гайде будем использовать прямой доступ к БД — это проще и надежнее.

2Индексатор: выгружаем и векторизуем документы

Создаем Python-скрипт, который:

  1. Подключается к PostgreSQL Outline
  2. Выгружает все документы (название, содержание, URL)
  3. Разбивает на чанки по 500-1000 символов
  4. Создает эмбеддинги с помощью sentence-transformers
  5. Сохраняет в ChromaDB
# indexer.py
import psycopg2
from sentence_transformers import SentenceTransformer
import chromadb
from chromadb.config import Settings
import hashlib

# Модель для эмбеддингов — актуальна на февраль 2026
# all-MiniLM-L6-v2 легкая и быстрая, для русского лучше paraphrase-multilingual-MiniLM-L12-v2
model = SentenceTransformer('sentence-transformers/all-MiniLM-L6-v2')

# Подключаемся к БД Outline
def get_documents():
    conn = psycopg2.connect(
        host="localhost",
        database="outline",
        user="outline",
        password="password",
        port=5432
    )
    
    # Запрос зависит от схемы Outline, актуальной на 2026
    query = """
    SELECT d.id, d.title, d.text, 
           CONCAT('/doc/', d.id) as url,
           c.name as collection_name
    FROM documents d
    LEFT JOIN collections c ON d.collection_id = c.id
    WHERE d.deleted_at IS NULL
    """
    
    cur = conn.cursor()
    cur.execute(query)
    
    for doc_id, title, text, url, collection in cur:
        # Разбиваем текст на чанки
        chunks = split_text(text, chunk_size=800)
        for i, chunk in enumerate(chunks):
            yield {
                "id": f"{doc_id}_{i}",
                "title": title,
                "text": chunk,
                "url": url,
                "collection": collection,
                "full_text": text[:500]  # Для контекста
            }
    
    conn.close()

def split_text(text, chunk_size=800):
    # Простое разбиение по предложениям
    words = text.split()
    chunks = []
    current_chunk = []
    current_size = 0
    
    for word in words:
        current_chunk.append(word)
        current_size += len(word) + 1
        
        if current_size >= chunk_size:
            chunks.append(" ".join(current_chunk))
            current_chunk = []
            current_size = 0
    
    if current_chunk:
        chunks.append(" ".join(current_chunk))
    
    return chunks

# Инициализируем ChromaDB
chroma_client = chromadb.Client(Settings(
    persist_directory="./chroma_db",
    anonymized_telemetry=False
))

# Создаем или получаем коллекцию
collection = chroma_client.create_collection(
    name="outline_docs",
    metadata={"hnsw:space": "cosine"}
)

# Индексируем документы
print("Начинаем индексацию...")
docs_batch = []
ids_batch = []
embeddings_batch = []

for i, doc in enumerate(get_documents()):
    # Создаем эмбеддинг для чанка
    embedding = model.encode(doc["text"]).tolist()
    
    docs_batch.append(doc["text"])
    ids_batch.append(doc["id"])
    embeddings_batch.append(embedding)
    
    # Пакетная вставка каждые 100 документов
    if len(docs_batch) >= 100:
        collection.add(
            embeddings=embeddings_batch,
            documents=docs_batch,
            ids=ids_batch,
            metadatas=[{"title": doc["title"], "url": doc["url"], 
                       "collection": doc["collection"]} for doc in docs_batch]
        )
        docs_batch, ids_batch, embeddings_batch = [], [], []
        print(f"Проиндексировано {i+1} документов")

# Оставшиеся документы
if docs_batch:
    collection.add(
        embeddings=embeddings_batch,
        documents=docs_batch,
        ids=ids_batch,
        metadatas=[{"title": doc["title"], "url": doc["url"], 
                   "collection": doc["collection"]} for doc in docs_batch]
    )

print("Индексация завершена!")

Запускаем раз в сутки через cron или при изменении документов через webhook. ChromaDB хранит данные на диске, так что при перезапуске все остается.

3MCP-сервер: мост к современным AI-инструментам

MCP (Model Context Protocol) — это JSON-RPC поверх stdio или HTTP. Сервер предоставляет "инструменты" (tools) и "ресурсы" (resources), которые клиент (Claude Desktop, Cursor) может использовать.

Наш MCP-сервер будет предоставлять:

  • Инструмент поиска по Outline
  • Ресурс "последние документы"
  • Инструмент для получения конкретного документа
# mcp_server.py
import json
import sys
from typing import Any, List
import chromadb
from sentence_transformers import SentenceTransformer
from mcp import Server, StdioServerParameters
import asyncio

# Те же модели, что и в индексаторе
model = SentenceTransformer('sentence-transformers/all-MiniLM-L6-v2')
chroma_client = chromadb.PersistentClient(path="./chroma_db")
collection = chroma_client.get_collection("outline_docs")

server = Server("outline-knowledge-base")

@server.list_tools()
async def handle_list_tools() -> List[Any]:
    return [
        {
            "name": "search_outline",
            "description": "Ищет документы в базе знаний Outline по семантическому сходству",
            "inputSchema": {
                "type": "object",
                "properties": {
                    "query": {
                        "type": "string",
                        "description": "Поисковый запрос на естественном языке"
                    },
                    "limit": {
                        "type": "number",
                        "description": "Количество результатов (по умолчанию 5)",
                        "default": 5
                    }
                },
                "required": ["query"]
            }
        },
        {
            "name": "get_outline_document",
            "description": "Получает конкретный документ по ID",
            "inputSchema": {
                "type": "object",
                "properties": {
                    "document_id": {
                        "type": "string",
                        "description": "ID документа в формате 'docid_chunkindex'"
                    }
                },
                "required": ["document_id"]
            }
        }
    ]

@server.call_tool()
async def handle_call_tool(name: str, arguments: Any) -> Any:
    if name == "search_outline":
        query = arguments["query"]
        limit = arguments.get("limit", 5)
        
        # Создаем эмбеддинг для запроса
        query_embedding = model.encode(query).tolist()
        
        # Ищем в ChromaDB
        results = collection.query(
            query_embeddings=[query_embedding],
            n_results=limit,
            include=["documents", "metadatas", "distances"]
        )
        
        # Форматируем результаты
        formatted_results = []
        for i in range(len(results["ids"][0])):
            doc_id = results["ids"][0][i]
            text = results["documents"][0][i]
            metadata = results["metadatas"][0][i]
            distance = results["distances"][0][i]
            
            # Преобразуем расстояние в оценку релевантности (0-100)
            relevance_score = int((1 - min(distance, 1.0)) * 100)
            
            formatted_results.append({
                "id": doc_id,
                "title": metadata.get("title", "Без названия"),
                "snippet": text[:200] + "..." if len(text) > 200 else text,
                "url": f"http://localhost:3000{metadata.get('url', '')}",
                "collection": metadata.get("collection", ""),
                "relevance": relevance_score
            })
        
        return {
            "content": [{
                "type": "text",
                "text": json.dumps(formatted_results, ensure_ascii=False, indent=2)
            }]
        }
    
    elif name == "get_outline_document":
        doc_id = arguments["document_id"]
        
        # Получаем документ из ChromaDB
        result = collection.get(
            ids=[doc_id],
            include=["documents", "metadatas"]
        )
        
        if not result["ids"]:
            return {
                "content": [{
                    "type": "text",
                    "text": f"Документ {doc_id} не найден"
                }]
            }
        
        return {
            "content": [{
                "type": "text",
                "text": json.dumps({
                    "id": doc_id,
                    "title": result["metadatas"][0]["title"],
                    "content": result["documents"][0],
                    "url": f"http://localhost:3000{result['metadatas'][0]['url']}"
                }, ensure_ascii=False, indent=2)
            }]
        }

async def main():
    async with server.run_stdio(StdioServerParameters()):
        # Ждем бесконечно
        await asyncio.Future()

if __name__ == "__main__":
    asyncio.run(main())

Теперь регистрируем сервер в Claude Desktop (config.json в папке Claude):

{
  "mcpServers": {
    "outline": {
      "command": "python",
      "args": [
        "/путь/к/mcp_server.py"
      ],
      "env": {
        "PYTHONPATH": "/путь/к/проекту"
      }
    }
  }
}

Перезапускаем Claude Desktop. Теперь в чате можно писать: "Найди в Outline информацию о настройке Docker" — и Claude использует наш инструмент search_outline.

4Telegram-бот: доступ с мобильного

Telegram-бот — самый простой способ задавать вопросы из любого места. Создаем бота через @BotFather, получаем токен.

Бот будет:

  1. Принимать текстовые сообщения
  2. Искать в ChromaDB
  3. Форматировать результаты в читаемый вид
  4. Отправлять ссылки на оригинальные документы
# telegram_bot.py
import logging
from telegram import Update
from telegram.ext import Application, CommandHandler, MessageHandler, filters, ContextTypes
import chromadb
from sentence_transformers import SentenceTransformer

# Настройка логирования
logging.basicConfig(
    format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
    level=logging.INFO
)
logger = logging.getLogger(__name__)

# Инициализация моделей и БД
model = SentenceTransformer('sentence-transformers/all-MiniLM-L6-v2')
chroma_client = chromadb.PersistentClient(path="./chroma_db")
collection = chroma_client.get_collection("outline_docs")

async def start(update: Update, context: ContextTypes.DEFAULT_TYPE):
    await update.message.reply_text(
        "🔍 Привет! Я бот для поиска в вашей базе знаний Outline.\n"
        "Просто напишите вопрос, и я найду relevantные документы.\n\n"
        "Примеры запросов:\n"
        "• 'Как настроить Nginx?'\n"
        "• 'Документация по API'\n"
        "• 'Процедура деплоя'"
    )

async def search_documents(update: Update, context: ContextTypes.DEFAULT_TYPE):
    query = update.message.text
    
    # Показываем статус "печатает"
    await context.bot.send_chat_action(
        chat_id=update.effective_chat.id, 
        action="typing"
    )
    
    # Создаем эмбеддинг для запроса
    query_embedding = model.encode(query).tolist()
    
    # Ищем в ChromaDB
    results = collection.query(
        query_embeddings=[query_embedding],
        n_results=5,
        include=["documents", "metadatas", "distances"]
    )
    
    if not results["ids"][0]:
        await update.message.reply_text(
            "❌ По вашему запросу ничего не найдено.\n"
            "Попробуйте перефразировать или использовать другие ключевые слова."
        )
        return
    
    # Форматируем ответ
    response_lines = [f"🔍 Результаты поиска по запросу: *{query}*\n"]
    
    for i in range(len(results["ids"][0])):
        doc_id = results["ids"][0][i]
        text = results["documents"][0][i]
        metadata = results["metadatas"][0][i]
        distance = results["distances"][0][i]
        
        # Оценка релевантности
        relevance = int((1 - min(distance, 1.0)) * 100)
        
        # Обрезаем текст для превью
        snippet = text[:150] + "..." if len(text) > 150 else text
        
        # Формируем строку результата
        title = metadata.get("title", "Без названия")
        url = f"http://ваш-сервер:3000{metadata.get('url', '')}"
        
        response_lines.append(
            f"\n*{i+1}. {title}* ({relevance}%)\n"
            f"{snippet}\n"
            f"📁 {metadata.get('collection', '')}\n"
            f"🔗 [Открыть в Outline]({url})"
        )
    
    # Добавляем подсказку
    response_lines.append(
        "\n---\n"
        "💡 *Совет:* Используйте более конкретные запросы для точных результатов.\n"
        "Например, вместо 'документация' напишите 'документация по REST API'."
    )
    
    # Отправляем сообщение с поддержкой Markdown
    await update.message.reply_text(
        "\n".join(response_lines),
        parse_mode="Markdown",
        disable_web_page_preview=True
    )

def main():
    # Токен бота из @BotFather
    application = Application.builder().token("ВАШ_TELEGRAM_BOT_TOKEN").build()
    
    # Обработчики команд
    application.add_handler(CommandHandler("start", start))
    application.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, search_documents))
    
    # Запуск бота
    application.run_polling(allowed_updates=Update.ALL_TYPES)

if __name__ == "__main__":
    main()

Запускаем бота: python telegram_bot.py

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

💡
Если вы хотите профессионально развивать навыки создания ботов, курс по созданию Telegram-ботов даст системное понимание архитектуры и лучших практик.

5Объединяем все в Docker Compose

Теперь соберем все компоненты в один docker-compose.yml для простого развертывания:

# docker-compose.yml
version: '3.8'

services:
  outline:
    image: outlinewiki/outline:latest
    ports:
      - "3000:3000"
    environment:
      - DATABASE_URL=postgres://outline:password@postgres/outline
      - REDIS_URL=redis://redis:6379
      - SECRET_KEY=${SECRET_KEY}
      - UTILS_SECRET=${UTILS_SECRET}
      - URL=http://localhost:3000
    depends_on:
      - postgres
      - redis
    volumes:
      - outline_data:/var/lib/outline

  postgres:
    image: postgres:15
    environment:
      - POSTGRES_USER=outline
      - POSTGRES_PASSWORD=password
      - POSTGRES_DB=outline
    volumes:
      - postgres_data:/var/lib/postgresql/data
      - ./init.sql:/docker-entrypoint-initdb.d/init.sql

  redis:
    image: redis:7-alpine
    volumes:
      - redis_data:/data

  indexer:
    build: ./indexer
    volumes:
      - ./chroma_db:/app/chroma_db
      - ./indexer:/app
    depends_on:
      - postgres
    # Запускаем индексацию раз в сутки и при старте
    command: sh -c "python indexer.py && cron -f"

  mcp_server:
    build: ./mcp_server
    volumes:
      - ./chroma_db:/app/chroma_db
      - ./mcp_server:/app
    depends_on:
      - indexer
    stdin_open: true
    tty: true
    # MCP сервер работает через stdio

  telegram_bot:
    build: ./telegram_bot
    volumes:
      - ./chroma_db:/app/chroma_db
      - ./telegram_bot:/app
    depends_on:
      - indexer
    environment:
      - TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN}
    restart: unless-stopped

volumes:
  outline_data:
  postgres_data:
  redis_data:
  chroma_db:

Создаем Dockerfile для каждого сервиса (простейшие):

# indexer/Dockerfile
FROM python:3.11-slim

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

CMD ["python", "indexer.py"]
# mcp_server/Dockerfile
FROM python:3.11-slim

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

CMD ["python", "mcp_server.py"]

Что ломается чаще всего (и как это чинить)

После десятка таких развертываний я собрал коллекцию типичных проблем:

ПроблемаПричинаРешение
ChromaDB не находит документы после перезапускаТом не монтируется или путь изменилсяПроверить volumes в docker-compose, права на папку chroma_db
MCP-сервер не подключается к Claude DesktopНеправильный путь в config.json или проблемы с stdioЗапустить сервер вручную, проверить вывод. В config.json использовать полные пути
Телеграм-бот падает с ошибкой "Conflict"Два экземпляра бота используют один токенОстановить все старые процессы: pkill -f telegram_bot.py
Поиск возвращает нерелевантные результатыПлохие эмбеддинги или нужно чистить текстУдалить HTML-теги из текста перед индексацией, попробовать другую модель эмбеддингов
Outline не отдает данные через БДСхема БД изменилась в новой версииПроверить актуальные таблицы: зайти в psql и сделать \d

Куда развивать систему дальше

Базовая система работает. Но это только начало. Вот что можно добавить:

  • Голосовой интерфейс — как в голосовом ассистенте на Python, но для поиска в базе знаний
  • Автоматическое тегирование — LLM анализирует новые документы и предлагает теги
  • RAG (Retrieval-Augmented Generation) — не просто поиск, а ответы на основе найденных документов. Использовать локальную LLM типа Mistral 7B для генерации ответов
  • Веб-интерфейс — отдельный сайт с поиском по базе знаний для всей команды
  • Интеграция с GitHub/GitLab — индексация README и документации из репозиториев

Самое интересное — когда система начинает предлагать информацию до того, как вы спросили. Видите, что кто-то в чате обсуждает проблему с Docker — бот автоматически присылает ссылку на соответствующую документацию. Как в AI-агенте 3-го уровня, но для базы знаний.

Предупреждение: не пытайтесь сразу сделать все. Начните с простого поиска. Когда он станет частью workflow, добавляйте новые функции. Иначе утонете в сложности, как многие проекты из статьи про провалы fine-tuning.

Вместо заключения: почему это работает, когда другие системы не работают

Я видел десятки попыток сделать "корпоративную базу знаний". Confluence с плагинами, Notion с интеграциями, собственные разработки. Все упирается в одно: чтобы система использовалась, она должна быть проще, чем альтернатива.

Альтернатива — спросить у коллеги. Или погуглить. Или найти в чате.

Наша система выигрывает потому что:

  1. Не требует переключения контекста — спросил в Telegram, получил ответ
  2. Понимает смысл, а не только ключевые слова — как работает мозг
  3. Интегрируется в существующие инструменты (Cursor, Claude Desktop) — не нужно ничего нового учить
  4. Работает локально — никаких согласований с безопасностью, никаких подписок

Самое важное — она решает конкретную боль: "Я знаю, что эта информация где-то есть, но не могу найти".

Попробуйте. Начните с индексатора и Telegram-бота. Когда увидите, как бот находит документ, который вы сами не могли найти неделю, — поймете, что это не просто еще один туториал. Это реальный инструмент, который меняет то, как вы работаете с информацией.

И помните: лучшая база знаний — та, которой пользуются. А чтобы ей пользовались, нужно сделать поиск проще, чем держать все в голове.