Представьте консультанта в DNS/Ситилинке, который не навязывает «вот этот блок питания потому что остался на складе», а спокойно объясняет, чем один БП лучше другого под ваш билд, помнит, о чём вы спрашивали раньше, и ещё просит вежливый фидбек.
Я решил собрать такого консультанта в виде Telegram-бота «Кремний» — RAG-бота по железу на бесплатных инструментах: Telegram Bot API, Groq (Llama 3.1 8B), sentence-transformers и чуть-чуть боли с NumPy и Pterodactyl.


Задача: живой консультант по железу в Telegram

Хотелось не просто «ещё одного бота», а что-то ближе к человеческому диалогу: минимум кнопок, максимум свободного текста, плюс нормальный контекст про товары и ответы, основанные на фактах, а не на фантазиях модели.

Требования к «Кремнию» получились такие:

  • Отвечать на вопросы про комплектующие: видеокарты, материнки, блоки питания, корпуса и т.п. (описания, совместимость, сценарии).

  • Уметь говорить про цены и основные параметры (TDP, форм-фактор, количество линий PCIe и т.д.) в рамках небольшой базы знаний.

  • Работать как «почти человек»: пользователь пишет руками, без меню и клавиатур; бот парсит естественный язык и помогает.

  • Поддерживать RAG: вытаскивать из своей базы релевантные документы и кормить их LLM, чтобы ответы были опорой на данные.

  • Собирает фидбек, но не навязчиво — отдельной командой, с персональным ответом от LLM на отзыв.


Архитектура: простой стек, много пользы

Чтобы уложиться в «бесплатный/учебный» формат, собрал следующий стек:

  • Telegram-слой: python-telegram-bot 22.5, async-обвязка вокруг Bot API, ApplicationBuilder, MessageHandler, /start, /help, /feedback.

  • Генерация: Groq API c моделью llama-3.1-8b-instant через Python-клиент groq (OpenAI-совместимый протокол, бесплатный фритир для прототипов).

  • RAG: эмбеддинги из sentence-transformers (all-MiniLM-L6-v2) + простой векторный поиск по косинусу через numpy.

  • База знаний: вручную собранный mini-каталог комплектующих (материнские платы, видеокарты, БП, корпуса и т.д.) + блоки про доставку и гарантию.

  • Деплой: локально — обычный venv; в проде — контейнер на Pterodactyl с requirements.txt и парой сюрпризов от NumPy 2.x и PyTorch.

Компоненты общаются между собой довольно тривиально: Telegram даёт текст → RAG достаёт релевантные документы → LLM отвечает в стиле «Кремния» → опционально запрашиваем отзыв через /feedback.


Личность бота «Кремний»

Сухой консультант по железу — это скучно; на Хабре такие боты долго не живут даже в личке.

Поэтому для системного промпта зашил персоналию:

  • «Ты — Кремний, виртуальный консультант по железу и сборкам ПК».

  • Общается на «ты», без токсичности, но с лёгким ироничным тоном (в духе знакомого продавана, который сам собирает себе NAS по ночам).

  • Делает отсылки к типичным сценариям: «собираю бюджетный билд для шутеров», «тихий домашний NAS», «комп под Stable Diffusion».

  • Жёстко фиксирован: «если чего-то нет в базе знаний — честно скажи, что не знаешь, и не придумывай характеристики».

Это важно: LLM по умолчанию любит «додумывать», а RAG-боту по железу простительно не всё — особенно, когда дело доходит до совместимости БП и видеокарты.


RAG: от запроса до релевантных железок

База знаний

Базу сделал в виде обычного Python-списка словарей KNOWLEDGE_ITEMS в knowledge_base.py.

Каждый элемент — это либо product, либо блок shipping/warranty, например:

{
    "id": "gpu_4070_dual",
    "type": "product",
    "category": "видеокарты",
    "name": "GeForce RTX 4070 Dual",
    "description": (
        "RTX 4070 с двухвентиляторным охлаждением, TDP около 200 Вт. "
        "Подходит для сборок с БП от 650 Вт и хорошей вентиляцией корпуса."
    ),
    "sizes": ["2.5-слота", "длина 270 мм"],
    "price_rub": 65000,
    "chipset": "NVIDIA RTX 4070",
    "power": "1x 8-pin",
}

Такое представление удобно и для RAG (есть чистый текст), и для будущего расширения (можно потом вынести в JSON/БД без переписывания логики).

Эмбеддинги и поиск

RAG-часть лежит в bot.py и использует sentence-transformers/all-MiniLM-L6-v2:

emb_model = SentenceTransformer("sentence-transformers/all-MiniLM-L6-v2")

DOC_TEXTS = [doc_to_text(item) for item in KNOWLEDGE_ITEMS]
DOC_EMB = emb_model.encode(DOC_TEXTS, normalize_embeddings=True)
DOC_EMB = np.array(DOC_EMB, dtype="float32")

doc_to_text собирает внятный текст из полей продукта: название, категория, описание, питалово, форм-фактор, цены и т.п., чтобы модель embeddings не ломала голову над сырым JSON.

Дальше всё по канону:

def retrieve_relevant_docs(query: str, top_k: int = 3):
    query_emb = emb_model.encode([query], normalize_embeddings=True)[0]
    sims = DOC_EMB @ query_emb  # косинус, т.к. всё уже нормализовано
    top_idx = np.argsort(-sims)[:top_k]
    ...

Так как база небольшая (десятки/сотни элементов), никакой FAISS, Chroma и прочий зоопарк здесь не нужен — обычный numpy вполне достаточно.


LLM: Groq + Llama 3.1 8B по OpenAI протоколу

Чтобы не упираться в платный OpenAI и одновременно не поднимать локальную LLM, взял Groq: он даёт быстрый доступ к Llama 3.1 8B/70B через OpenAI совместимый API.

Инициализация клиента элементарна:

from groq import Groq

groq_client = Groq(api_key=os.getenv("GROQ_API_KEY"))
LLM_MODEL = "llama-3.1-8b-instant"

Вызов чата тоже классический:

completion = groq_client.chat.completions.create(
    model=LLM_MODEL,
    messages=[
        {"role": "system", "content": SYSTEM_PROMPT},
        {"role": "system", "content": rag_context_block},
        {"role": "user", "content": user_message},
    ],
    temperature=0.4,
    max_tokens=800,
)
answer = completion.choices[0].message.content.strip()

Бонус Groq — для учебных и pet-проектов этого объёма бесплатного лимита хватает с запасом, а задержки по сравнению с OpenAI заметно меньше.


Telegram-слой: минимум кнопок, максимум текста

С точки зрения Telegram бот — обычное приложение на python-telegram-bot 22.5:

  • /start — приветствие и краткая инструкция: как задавать вопросы, какие темы «Кремний» покрывает.

  • /help — краткое повторение возможностей плюс примеры запросов (про «тихий билд в mini-ITX», «комп для ML» и т.п.).

  • /feedback — запуск режима обратной связи; следующее сообщение пользователя считается отзывом.

Все остальные текстовые сообщения без слэшей идут в handle_message, который решает, что делать: ожидать отзыв или обрабатывать запрос через RAG+LLM.

Кнопок по минимуму: никакой клавиатуры с «Узнать цену», «Показать доставку» — пользователь пишет свободным текстом, а задача бота — понять, что имелось в виду (как минимум в пределах базы знаний).


Обратная связь: /feedback вместо спама после каждого ответа

Первую версию я сделал по ТЗ: бот после каждого ответа просил оценить общение. Выглядело это как надоедливый поп-ап на сайте:

Пользователь: «Собери мне билд под 100к» → бот отвечает → сразу «Поставь оценку от 1 до 5».

В реальном сценарии это то, за что хочется снижать карму, а не повышать. Поэтому в версии для «Хабра» логика другая:

  • Фидбек запрашивается только, если пользователь сам вызывает /feedback.

  • Бот хранит последнюю реплику пользователя и свой ответ в USER_STATE[user_id].

  • Когда приходит отзыв, отдельный вызов LLM анализирует, что было в диалоге, и генерит человеческую благодарность/извинение/ответ.

Код примерно такой:

USER_STATE = {
    user_id: {
        "awaiting_feedback": bool,
        "last_user_message": str,
        "last_bot_answer": str,
    }
}

async def feedback_command(update, context):
    state = USER_STATE.setdefault(user_id, {...})
    if not state["last_bot_answer"]:
        await update.message.reply_text(
            "Мы ещё толком не пообщались — сначала задай вопрос, "
            "а потом уже можно оценить беседу."
        )
        return

    state["awaiting_feedback"] = True
    await update.message.reply_text(
        "Напиши, пожалуйста, короткий отзыв о нашей беседе."
    )

Заодно такая схема хорошо демонстрирует на собеседовании, как можно использовать LLM не только «для ответов», но и для обработки мета-информации (фидбек, теги, аннотации).


Немного боли: Pterodactyl, NumPy 2.x и PyTorch

Отдельная мини-история достойна раздела «Что пошло не так».

Шаг 1. OOM из-за жирного стека

Сначала я честно сделал pip freeze на локальной машине и скормил этот requirements.txt Pterodactyl-контейнеру.

В результате контейнер радостно начал ставить torch==2.9.1 и заодно потащил за собой кучу nvidia-* пакетов для CUDA, пока не умер с Exit code: 137 и Out of memory: true.

Лекарство: оставить только то, что реально нужно, и явно указать CPU-сборку торча:

python-telegram-bot==22.5
groq==0.36.0
python-dotenv==1.2.1
numpy==1.26.4

sentence-transformers==3.0.1
--extra-index-url https://download.pytorch.org/whl/cpu
torch==2.2.2

Так удалось избавиться от лишних CUDA-зависимостей и ужаться до адекватного размера по памяти.

Шаг 2. NumPy 2.3.5 vs PyTorch

Следующая засада выглядела так:

A module that was compiled using NumPy 1.x cannot be run in NumPy 2.3.5...
RuntimeError: Numpy is not available

Это «подарок» от NumPy 2.x: многие бинарные модули (включая часть PyTorch-сборок) собраны против NumPy 1.x, и при наличии NumPy 2.3.5 PyTorch просто считает, что NumPy у тебя «нет».

Решение максимально приземлённое и советуется во всех обсуждениях: зафиксировать numpy, обычно 1.26.4, как в примере выше.

После понижения NumPy:

  • Предупреждение исчезло.

  • sentence-transformers спокойно делает emb.numpy() внутри .encode, и бот успешно считает эмбеддинги при старте.


Что можно допилить дальше

Даже в таком «учебном» виде у бота уже есть чем похвастаться: живая персоналия, RAG по базе железа, фидбек через LLM и продакшен-подобный деплой на Pterodactyl.

Но если захочется развивать идею, вот несколько направлений:

  • Нормальный векторный стор: FAISS или аналог, чтобы держать сотни/тысячи позиций, а не десятки.

  • Переранкер: поверх MiniLM добавить reranking (например, через тот же LLM), чтобы ещё точнее выбирать контекст.

  • Диалоговая память: хранить не только последний вопрос/ответ, а сессию, и давать LLM больше истории общения (особенно важно, когда пользователь уточняет: «а блок питания к нему подойдёт?»).

  • Онлайн-цены и наличие: завести лёгкий интеграционный слой с API магазина (если это уже не учебный, а боевой проект).


Комментарии (0)