Ваша база знаний в 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 "мониторинг микросервисов" — и получаете ссылки на соответствующие документы. Без переключения окон.
В статье про 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-скрипт, который:
- Подключается к PostgreSQL Outline
- Выгружает все документы (название, содержание, URL)
- Разбивает на чанки по 500-1000 символов
- Создает эмбеддинги с помощью sentence-transformers
- Сохраняет в 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, получаем токен.
Бот будет:
- Принимать текстовые сообщения
- Искать в ChromaDB
- Форматировать результаты в читаемый вид
- Отправлять ссылки на оригинальные документы
# 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. Особенно удобно с мобильного.
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 с интеграциями, собственные разработки. Все упирается в одно: чтобы система использовалась, она должна быть проще, чем альтернатива.
Альтернатива — спросить у коллеги. Или погуглить. Или найти в чате.
Наша система выигрывает потому что:
- Не требует переключения контекста — спросил в Telegram, получил ответ
- Понимает смысл, а не только ключевые слова — как работает мозг
- Интегрируется в существующие инструменты (Cursor, Claude Desktop) — не нужно ничего нового учить
- Работает локально — никаких согласований с безопасностью, никаких подписок
Самое важное — она решает конкретную боль: "Я знаю, что эта информация где-то есть, но не могу найти".
Попробуйте. Начните с индексатора и Telegram-бота. Когда увидите, как бот находит документ, который вы сами не могли найти неделю, — поймете, что это не просто еще один туториал. Это реальный инструмент, который меняет то, как вы работаете с информацией.
И помните: лучшая база знаний — та, которой пользуются. А чтобы ей пользовались, нужно сделать поиск проще, чем держать все в голове.