Привет! На связи Николай из редакции блога YADRO. Наша команда регулярно поставляет на площадку статьи по инженерным и смежным темам. Мы смотрим на статистику, радуемся или огорчаемся, проверяем гипотезы и верим, что в ответ график роста посмотрит на нас под новым, бо́льшим углом.

Со временем число текстов в блоге YADRO неуклонно растет. А моя оперативная память редактора остается неизменной: пара-тройка последних месяцев плюс несколько ярких вспышек пораньше. Зато растет FOMO — тревога, что я мог бы найти новые возможности для развития блога, будь мой фокус шире. Поможет ли здесь искусственный интеллект? «Отличный кейс» — ответила ChatGPT, и я начал первую версию проекта.

Вначале представление о финальном продукте весьма расплывчато: нужен ИИ-помощник, который будет обладать полной информацией обо всех текстах в блоге YADRO и отвечать на вопросы по ним в формате чат-бота. Когда я начинал пост, в блоге было 223 статьи, что открывает большой простор для кросслинковки. Хочу, чтобы помощник подсказывал, на какие из предыдущих статей блога я могу сослаться в новой. Было бы здорово получать тематические подборки статей, что мы иногда добавляем в анонсы связанных митапов. Еще какие-нибудь сценарии я, наверно, придумаю на ходу.

Другая задача, как мне кажется, будет сложнее — аналитика в разрезе тем и других атрибутов статей, особенно качественных, а не количественных. Но не буду грустить заранее: это пет-проект, take it easy. К тому же в итоге получилось наоборот: именно в качественных, а не количественных вопросах прогресс чат-бота оказался заметнее.

Выбор стека

Создаю новый диалог с GPT-4o и закладываю в нее требования из предыдущих абзацев. Нейросеть бодро предлагает несколько вариантов реализации.

GPT-4 + LangChain (или ChatGPT API с RAG). «Минусы: платно (и затраты могут вырасти при больших объемах)». Тот же минус — и в варианте «решения под ключ (SaaS-инструменты): writer.com, jasper.ai, copy.ai». А есть что-нибудь на open source?

Да, причем это был первый вариант в списке: open-source LLM + векторная база (например, LLaMA 3 + FAISS / Weaviate / Qdrant). При сравнении трех опций GPT даже подчеркнул преимущества этой: «максимальная точность, контроль, гибкость». Честно говоря, ожидал от OpenAI больше саморекламы, приятно удивлен. Давай остановимся на open source, но смогу ли я осилить это в одиночку?

«Да, ты вполне можешь создать такую систему сам, особенно если у тебя есть базовые навыки Python и немного понимания в работе с API или веб-разработке». Так, мои навыки Python исчерпывающе описаны в этом посте (два года назад я прошел базовый месячный курс по языку). С веб-разработкой все точно не лучше, а с API… в общем, здесь уже только вера в себя осталась.

Далее ChatGPT разложил весь проект на этапы с подробными инструкциями. Хочу, чтобы по этой статье своего бота смогли поднять коллеги без опыта в программировании. Постараюсь не упустить никакие команды и пояснения, но из истории проекта возьму только важные этапы.

Подготовка окружения (MacOS)

Открываю Терминал — вся работа, помимо создания файлов с кодом, пройдет в нем. Устанавливаю homebrew:

/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"

Теперь нужен Python версии 3.10+. Со времен прохождения курса по языку какая-то версия на макбуке точно осталась, при каждом перезапуске MacOS ругает ее с точки зрения безопасности. И так не у меня одного, как подсказал гугл.

На момент начала проекта стадию bugfix как раз преодолел Python 3.12, установлю его, вдруг и ошибка при перезапуске исчезнет.

brew install python@3.12

Да, и правда исчезла. Теперь создаю папку llama_blog_tool, в ней проект и виртуальное окружение:

mkdir llama_blog_tool

cd llama_blog_tool

python3 -m venv venv

source venv/bin/activate

Ставлю необходимые библиотеки:

pip install --upgrade pip

pip install faiss-cpu langchain sentence-transformers llama-index

Чтобы использовать LLaMA 3 через llama.cpp (рекомендуется для локального запуска), советуют поставить еще вот это:

pip install llama-cpp-python

Далее предлагают поставить Jupiter, если я собираюсь работать в блокнотах. Пока я лишь подозреваю, что это не просто встроенный Textedit на моем маке, но пусть будет:

pip install notebook

Среда подготовлена, перехожу к проекту.

Парсим блог в один шаг

Прежде чем выдать мне очередную простыню команд, нейросетка спросила, публичный ли у меня блог или в виде файлов, и попросила ссылку. Затем обрадовала, что его можно спарсить с помощью Python, и представила инструкцию с блоками кода.

Инструкция начинается с «Убедись, что ты активировал виртуальное окружение, затем установи…». А как убедиться? Если в начале строки Терминала есть (venv), значит, мы работаем в активированном окружении.

pip install requests beautifulsoup4 tqdm

Эту команду GPT дала без пояснений. Ну уж нет, я должен хотя бы немного понимать, что ставлю. Requests нужен для запросов, beautifulsoup для парсинга, а tqdm — это… прогресс-бар? Ладно, почему бы нет.

Теперь нужно создать файл с кодом для парсинга. GPT не уточняет где, так что создам в корневой папке проекта, где уже лежит папка с окружением. Чтобы работать с файлами кода было проще, нужно установить интегрированную среду разработки (IDE) типа PyCharm.

Если вы повторяете мои действия по ходу чтения статьи, можете прерваться. Финальная версия проекта начнется ниже, с файла collect_all_articles.py.

Создаю простой текстовый файл parse blog, меняю расширение на *.py, открываю в IDE, вставляю код и сохраняю. Вот таким получается parse blog.py:

import requests
from bs4 import BeautifulSoup
from tqdm import tqdm
import time
import json

BASE_URL = "https://habr.com"
START_URL = "https://habr.com/ru/companies/yadro/articles/"

headers = {
    "User-Agent": "Mozilla/5.0"
}

def get_article_links(page_url):
    resp = requests.get(page_url, headers=headers)
    soup = BeautifulSoup(resp.text, "html.parser")
    articles = soup.find_all("article")
    links = []
    for article in articles:
        a = article.find("a", class_="tm-title__link")
        if a:
            links.append(BASE_URL + a['href'])
    return links

def get_article_content(article_url):
    resp = requests.get(article_url, headers=headers)
    soup = BeautifulSoup(resp.text, "html.parser")
    title_tag = soup.find("h1")
    body_divs = soup.find_all("div", class_="article-formatted-body")

    title = title_tag.text.strip() if title_tag else "Без названия"
    body = "\n".join(div.text.strip() for div in body_divs)
    
    return {
        "url": article_url,
        "title": title,
        "body": body
    }

def parse_all_articles(max_pages=5):
    all_articles = []
    for page in range(1, max_pages + 1):
        print(f"? Парсим страницу {page}")
        url = START_URL + f"page{page}/"
        links = get_article_links(url)
        for link in tqdm(links, desc="Статьи"):
            try:
                article = get_article_content(link)
                all_articles.append(article)
                time.sleep(1)  # Чтобы не заддосить Habr
            except Exception as e:
                print(f"⚠️ Ошибка при обработке {link}: {e}")
    return all_articles

if __name__ == "__main__":
    articles = parse_all_articles(max_pages=3)  # можно увеличить
    with open("articles.json", "w", encoding="utf-8") as f:
        json.dump(articles, f, ensure_ascii=False, indent=2)
    print(f"✅ Сохранено {len(articles)} статей в articles.json")

Хорошо, что здесь предусмотрена пауза в запросах и есть пояснения. Теперь откроем терминал в папке проекта и запустим скрипт:

python parse_blog.py

Далее шагов за пять с ChatGPT от ошибки no such file or directory: venv/bin/activate я добрался до вердикта: «Это значит, что в zsh у тебя снова прописан алиас python, который указывает на несуществующий /usr/bin/python — отсюда и все ошибки».

Надежно — это хорошо, а то уже кажется, что жонглирую бензопилами
Надежно — это хорошо, а то уже кажется, что жонглирую бензопилами

Ура, парсер скачал статьи! Правда, только 60 из 223.

С чем это связано, догадаться несложно. В блоге компании выдается по 20 статей на страницу, а указатель после третьей страницы обрывается на многоточии. Надо улучшить скрипт, чтобы он спарсил все страницы. Для этого изложу новые требования и схематично опишу, как формируется ссылка на страницу. Новый скрипт выглядит так:

import requests
from bs4 import BeautifulSoup
from tqdm import tqdm
import time
import json

BASE_URL = "https://habr.com"
COMPANY_BLOG_URL = "https://habr.com/ru/companies/yadro/articles/page{}/"

headers = {
    "User-Agent": "Mozilla/5.0"
}

articles = []

def parse_article(url):
    try:
        resp = requests.get(url, headers=headers)
        soup = BeautifulSoup(resp.text, "html.parser")

        title_tag = soup.find("h1")
        content_tag = soup.find("div", class_="tm-article-body")

        if not title_tag or not content_tag:
            return None

        return {
            "url": url,
            "title": title_tag.text.strip(),
            "content": content_tag.get_text(strip=True, separator="\n")
        }
    except Exception as e:
        print(f"[!] Error parsing {url}: {e}")
        return None

def get_article_links_from_page(page_number):
    url = COMPANY_BLOG_URL.format(page_number)
    resp = requests.get(url, headers=headers)

    if resp.status_code != 200:
        return []

    soup = BeautifulSoup(resp.text, "html.parser")
    article_tags = soup.select("a.tm-title__link")
    return [BASE_URL + a["href"] for a in article_tags]

print("? Начинаю парсинг всех страниц блога...")
page = 1
while True:
    links = get_article_links_from_page(page)
    if not links:
        print(f"✅ Парсинг завершён. Последняя страница: {page - 1}")
        break

    print(f"→ Страница {page}: найдено {len(links)} ссылок")
    for link in tqdm(links, desc=f"  Обработка статей со страницы {page}"):
        article = parse_article(link)
        if article:
            articles.append(article)
        time.sleep(1)  # пауза между запросами

    page += 1
    time.sleep(1)

# Сохраняем результат
with open("articles.json", "w", encoding="utf-8") as f:
    json.dump(articles, f, ensure_ascii=False, indent=2)

print(f"? Сохранено статей: {len(articles)} → в файл articles.json")

Работает как надо, но парсинг каждой страницы занимает примерно полминуты, на одну статью уходит полторы секунды. На момент написания этой части статьи в блоге было 12 страниц, получаем около шести минут на парсинг. Базу статей нужно обновлять с появлением каждой новой статьи — то есть где-то трижды в неделю, исходя из плана нашей редакции.

Фичи ведут в тупик

Поначалу проект развивался намного быстрее, чем я ожидал, так что я сразу замахнулся на функциональность, выходящую за пределы MVP. Было бы здорово, чтобы при каждом запуске парсер сопоставлял блог на сайте и свою базу статей, а затем загружал только недостающее. Так можно сэкономить время на парсинге. У каждой статьи уникальный title, по нему можно сверять, есть ли статья уже в базе.

В ответ на эти хотелки ChatGPT выдала новый скрипт, который сохранял тайтлы в existing_title, причем даже если парсинг был прерван. Новый парсер заработал заметно быстрее: одна секунда на одну статью. Но код при этом, на мой взгляд, значительно усложнился.

Не буду приводить весь скрипт того парсера, все равно в MVP он не попал. Лучше покажу, что навело меня на мысль, что копаю я не туда:

Это примеры ответов первой версии бота. По ним можно предположить, что весь скормленный нейросети текст имеет для нее одинаковый приоритет. А нужно, чтобы заголовки статей имели приоритет больший, поскольку там обычно заключены темы статей (игривыми тайтлами мы в блоге особо не балуемся). Также, чтобы чат-бот давал конкретные ответы, для каждой статьи нужно спарсить ссылку и — чего уж оставлять — дату публикации.

Первая версия бота, диалог с которой показан выше, едва ли поможет мне, да и вообще любому редактору. Но по пути к ней я научился худо-бедно ориентироваться в коде от ChatGPT и в голове сложилась примерная структура проекта. Дедлайны на горизонте не горят, так что пересоберу все более вдумчиво.

Парсим блог в два шага

В блоге YADRO около 250 статей, и каждую неделю к ним прибавляется три. Еженедельный, например, парсинг и векторизация такой базы не должны создать заметной нагрузки — особенно по сравнению с LLM, которая должна крутиться рядом. Так что идею с сопоставлением базы я оставлю, пусть пока грузит все с нуля.

Чтобы лучше понимать происходящее, разделю парсинг блога на два этапа. Сначала сбор ссылок в отдельный файлик с помощью collect_all_articles.py. Этот файл тоже нужно положить в корневую папку проекта:

import requests
from bs4 import BeautifulSoup
from tqdm import tqdm
import re

BASE_URL = "https://habr.com/ru/companies/yadro/articles/"
OUTPUT_FILE = "article_links.txt"
HEADERS = {"User-Agent": "Mozilla/5.0"}

def get_total_pages():
    response = requests.get(BASE_URL, headers=HEADERS)
    soup = BeautifulSoup(response.text, "html.parser")
    pagination = soup.find_all("a", class_="tm-pagination__page")

    page_numbers = []
    for p in pagination:
        try:
            page = int(p.text.strip())
            page_numbers.append(page)
        except ValueError:
            continue

    return max(page_numbers) if page_numbers else 1

def extract_links_from_page(page_number):
    url = BASE_URL + f"page{page_number}/"
    response = requests.get(url, headers=HEADERS)
    if response.status_code != 200:
        return []

    soup = BeautifulSoup(response.text, "html.parser")
    links = []

    for a in soup.find_all("a", href=True):
        href = a["href"]
        if href.startswith("/ru/companies/yadro/articles/"):
            full_link = "https://habr.com" + href.split("?")[0]

            # Фильтруем ненужные ссылки
            if (
                full_link.endswith("comments/") or
                "/page" in full_link or
                full_link == BASE_URL
            ):
                continue

            links.append(full_link)

    return list(set(links))

def main():
    total_pages = get_total_pages()
    all_links = set()

    for i in tqdm(range(1, total_pages + 1), desc="? Собираем ссылки"):
        links = extract_links_from_page(i)
        if not links:
            break
        all_links.update(links)

    with open(OUTPUT_FILE, "w", encoding="utf-8") as f:
        for link in sorted(all_links):
            f.write(link + "\n")

    print(f"✅ Сохранено {len(all_links)} статей в {OUTPUT_FILE}")

if __name__ == "__main__":
    main()

Для создания сборщика ссылок мне было достаточно описать в GPT, как выглядит страница блога со списком статей (https://habr.com/ru/companies/yadro/articles/pageX/, где X — номер страницы) и ссылки на статьи (https://habr.com/ru/companies/yadro/articles/...).

Выше — финальная версия скрипта, которая сама определяет, сколько страниц со статьями есть в блоге. Тот, что ChatGPT предложила изначально, был ограничен сканированием 20 страниц, но нейросеть сама предложила его улучшить — этот апгрейд содержится в функции main().

На числе страниц проблемы не закончились. После фикса выше ссылок, наоборот, получилось слишком много. Но я специально разделил парсинг, чтобы легче понимать причины проблем. Оказалось, что каждая ссылка на статью продублировалась с хвостом …/comments и ссылки на страницы блога, каталога статей, тоже сохранились в файле. ChatGPT помог мне их отфильтровать — по этому поводу в коде выше есть комментарий.

Теперь можно загрузить статьи по списку ссылок. Загрузчик пережил несколько итераций: он был то объединен, то разделен с парсером ссылок, сохранял все статьи то в одном файле, то по отдельности, то в txt, то в json. В итоге я остановился на json и раздельном сохранении: так проще проверить количество загруженных статей.

Перед запуском загрузчика установил Selenium и ChromeDriver. Selenium нужен, чтобы сайт принял наш скрипт за человека, а расширение ChromeDriver позволяет Selenium управлять браузером Chrome. Команда для установки:

pip install selenium undetected-chromedriver

Финальный download_articles.py:

import os
import time
import json
import random
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.common.by import By
from tqdm import tqdm

time.sleep(random.uniform(2, 5))

DATA_DIR = "articles_json"
LINKS_FILE = "article_links.txt"
FAILED_LINKS_FILE = "failed_links.txt"

os.makedirs(DATA_DIR, exist_ok=True)

def load_links():
    with open(LINKS_FILE, "r", encoding="utf-8") as f:
        return [line.strip() for line in f if line.strip()]

def init_driver():
    chrome_options = Options()
    chrome_options.add_argument("--headless")
    chrome_options.add_argument("--disable-gpu")
    driver = webdriver.Chrome(options=chrome_options)
    return driver

def parse_article(driver, url):
    try:
        driver.get(url)
        time.sleep(3)
    except Exception as e:
        print(f"⚠️ Ошибка при загрузке {url}: {e}")
        raise

    # Заголовок
    try:
        title = driver.find_element(By.TAG_NAME, "h1").text.strip()
    except:
        title = "Без названия"

    # Дата
    try:
        date_elem = driver.find_element(By.CLASS_NAME, "tm-article-datetime-published")
        date = date_elem.text.strip()
    except:
        date = "Дата не найдена"

    # Основной текст
    text = ""
    try:
        # Стандартный способ — через класс и <p>
        article_body = driver.find_element(By.CLASS_NAME, "tm-article-presenter__body")
        paragraphs = article_body.find_elements(By.TAG_NAME, "p")
        text = "\n".join(p.text.strip() for p in paragraphs if p.text.strip())
    except:
        pass

    # Fallback: если текст пустой — пробуем собрать весь текст из article-formatted-body
    if not text:
        try:
            alt_body = driver.find_element(By.CLASS_NAME, "article-formatted-body")
            text = alt_body.text.strip()
        except:
            pass

    # Ещё один fallback: собрать все <div> в article, где может быть текст
    if not text:
        try:
            all_divs = driver.find_elements(By.CLASS_NAME, "tm-article-presenter__body")
            text_chunks = []
            for div in all_divs:
                div_text = div.text.strip()
                if len(div_text) > 50:
                    text_chunks.append(div_text)
            text = "\n\n".join(text_chunks)
        except:
            pass

    return {
        "url": url,
        "title": title,
        "date": date,
        "text": text
    }

def main():
    driver = init_driver()
    links = load_links()
    errors = []

    for url in tqdm(links, desc="? Загружаем статьи"):
        article_id = url.strip("/").split("/")[-1]
        filename = os.path.join(DATA_DIR, f"{article_id}.json")

        if os.path.exists(filename):
            continue

        try:
            article_data = parse_article(driver, url)
            if article_data["text"]:
                with open(filename, "w", encoding="utf-8") as f:
                    json.dump(article_data, f, ensure_ascii=False, indent=2)
            else:
                print(f"⚠️ Пропущено (пустой текст): {url}")
                errors.append(url)
        except Exception as e:
            print(f"⚠️ Ошибка при обработке {url}: {e}")
            errors.append(url)
            continue

    driver.quit()

    if errors:
        with open(FAILED_LINKS_FILE, "w", encoding="utf-8") as f:
            f.write("\n".join(errors))
        print(f"? Не удалось загрузить {len(errors)} статей. Ссылки сохранены в '{FAILED_LINKS_FILE}'")
    else:
        print("✅ Все статьи успешно загружены.")

if __name__ == "__main__":
    main()

В итоге для каждой статьи получился json:

Ссылка, заголовок, дата публикации и основной текст — всю важную информацию загрузчик вытянул и структурировал
Ссылка, заголовок, дата публикации и основной текст — всю важную информацию загрузчик вытянул и структурировал

Внимательный читатель заметит в скрипте некие failed_links.txt и fallback. Текстовый файл содержит ссылки на те статьи, которые парсер не загрузил, — таких у меня было 22. ChatGPT сама предложила изменить скрипт, чтобы ссылки на такие статьи собирались в отдельный файл. При следующих запусках я могу подставлять его в LINKS_FILE, чтобы пробовать парсить их отдельно.

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

У всех проблемных статей текст не заключен в тег абзаца, <p>
У всех проблемных статей текст не заключен в тег абзаца, <p>
У остальных статей тег <p> обрамляет каждый абзац
У остальных статей тег <p> обрамляет каждый абзац

Свое открытие я изложил ChatGPT, и она быстро расширила скрипт fallback-сценариями. После запуска спарсились, наконец, все статьи в блоге, и итоговые json’ы проблемных статей не отличались.

Чанки, эмбеддинги, векторизация

Переходим к адаптации текстов для нейросети. Как я писал выше, в ответах бот должен больше ориентироваться на заголовки статей. ChatGPT сказала, что это можно сделать через промпт чат-бота или через указание заголовков в качестве метаданных при векторизации.

Первый способ выглядит проще, но с чат-ботом мне и так светит еще много экспериментов. Во избежание путаницы лучше описать метаданные при векторизации. Вот итоговый код скрипта векторизации, build_faiss_index.py:

import os
import json
import pickle
from langchain_community.vectorstores import FAISS
from langchain_huggingface import HuggingFaceEmbeddings
from langchain_core.documents import Document
from langchain.text_splitter import RecursiveCharacterTextSplitter

ARTICLES_DIR = "articles_json"
INDEX_DIR = "faiss_index"
EMBEDDINGS_CACHE = "embeddings.pkl"

def load_articles():
    docs = []
    for filename in os.listdir(ARTICLES_DIR):
        if filename.endswith(".json"):
            with open(os.path.join(ARTICLES_DIR, filename), "r", encoding="utf-8") as f:
                data = json.load(f)
                content = data.get("text", "").strip()
                if not content:
                    continue  # пропускаем пустые тексты
                metadata = {
                    "title": data.get("title", ""),
                    "date": data.get("date", ""),
                    "url": data.get("url", "")
                }
                docs.append(Document(page_content=content, metadata=metadata))
    return docs

def main():
    print("? Загружаем статьи...")
    raw_docs = load_articles()
    print(f"? Загружено статей: {len(raw_docs)}")

    if not raw_docs:
        print("⚠️ Нет доступных документов для векторизации. Проверь папку 'articles_json'.")
        return

    print("✂️ Разбиваем на фрагменты...")
    splitter = RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=200)
    docs = splitter.split_documents(raw_docs)

    # Удалим фрагменты без текста
    docs = [doc for doc in docs if doc.page_content.strip()]
    print(f"? Фрагментов после фильтрации: {len(docs)}")
    if not docs:
        print("⚠️ После разбиения не осталось фрагментов. Прерываем.")
        return

    print("? Создаем эмбеддинги...")
    embeddings = HuggingFaceEmbeddings(model_name="intfloat/multilingual-e5-large")

    print("? Строим FAISS-индекс...")
    vectorstore = FAISS.from_documents(docs, embeddings)

    print(f"? Сохраняем индекс в '{INDEX_DIR}'...")
    vectorstore.save_local(INDEX_DIR)

    print(f"? Сохраняем embeddings в '{EMBEDDINGS_CACHE}'...")
    with open(EMBEDDINGS_CACHE, "wb") as f:
        pickle.dump(embeddings, f)

    print("✅ Индекс успешно создан!")

if __name__ == "__main__":
    main()

Вот так выглядит успешное выполнение скрипта:

ChatGPT любит вставлять в комментарии эмодзи, и они отлично работают в качестве визуальных якорей
ChatGPT любит вставлять в комментарии эмодзи, и они отлично работают в качестве визуальных якорей

При запуске скрипта больше всего проблем вызвали импорты из langchain. Терминал часто сыпал ошибками: то интерфейс FAISS изменился, то HuggingFaceEmbeddings нужно импортировать из другого места, то еще что-нибудь. Часто после них индексация все-таки проходила, но хотелось совсем избавиться от предупреждений хотя бы сейчас, до будущих обновлений langchain.

Когда я анализировал, почему еще самая ранняя версия чат-бота плохо справляется с ответами, ChatGPT предложила мне скрипт, который выведет количество чанков и примеры метаданных. 4130 чанков на 233 статьи — это, по мнению ChatGPT, неплохой показатель. Но при этом «для средней статьи объемом 2000–4000 символов можно ожидать 10–20 чанков, особенно если она длинная или содержит много параграфов». Статьи в нашем блоге точно крупнее, и в будущем стоит поэкспериментировать с количеством чанков.

Теперь к примерам метаданных:

Текст выводится не целиком, это нормально, так как нейросеть поставила ограничение в скрипте вывода примеров. А вот с отсутствием ссылок надо разбираться. Я несколько раз спрашивал ChatGPT, в чем дело, но ответа толком не получал. Начал изучать код сам и нашел проблему: в download_articles.py метаданные ссылки значились как url, а build_faiss_index.py искал там link. Когда много раз пересобираешь даже в рамках одного треда ChatGPT, нейросеть не всегда одинаково называет одни и те же смысловые единицы.

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

Хе-хе, нейросеть, я пофиксил, а ты нет (кадр из х/ф «Терминатор»)
Хе-хе, нейросеть, я пофиксил, а ты нет (кадр из х/ф «Терминатор»)

Создаем чат-бота

Чат-бот я создаю на основе open-source модели Llama 3. Использую отдельную системную инструкцию — иначе, как показала практика, бот все время перескакивает на английский язык. Создаю файл Modelfile (без расширения), открываю в Textedit и прописываю промпт:

FROM llama3
SYSTEM Отвечай строго на русском языке. Игнорируй другие языки, если они встречаются. Поддерживай профессиональный и информативный стиль.

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

Ставлю Llama через homebrew в Терминале:

brew install ollama

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

Собираю нейросеть с Modelfile. Вместо llama3-rus можно поставить другое название:

ollama create llama3-rus -f Modelfile

Это будет довольно долго
Это будет довольно долго

Теперь собираю чат-бота, chatbot.py:

import os
import pickle
from langchain_community.vectorstores import FAISS
from langchain_community.embeddings import HuggingFaceEmbeddings
from langchain_ollama import OllamaLLM
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.runnables import RunnableMap

INDEX_DIR = "faiss_index"
EMBEDDINGS_CACHE = "embeddings.pkl"

llm = OllamaLLM(
    model="llama3-rus",
    system_message="Ты русский помощник. Всегда отвечай на русском, даже если вопрос задан на другом языке."
)

with open(EMBEDDINGS_CACHE, "rb") as f:
    embeddings = pickle.load(f)

vectorstore = FAISS.load_local(INDEX_DIR, embeddings, allow_dangerous_deserialization=True)
retriever = vectorstore.as_retriever(search_kwargs={"k": 20}, search_type="mmr")

# Шаблон
prompt_template = ChatPromptTemplate.from_template(
    """Ты помощник по корпоративному блогу. 
Всегда отвечай исключительно на русском языке, даже если вопрос задан иначе. 
Не переключайся на другие языки.

На основе приведённых фрагментов статей ответь на вопрос пользователя.
Обязательно используй заголовки статей (если доступны) и указывай ссылки на них.

Контекст:
{context}

Вопрос:
{question}

Ответ:"""
)

# Цепочка
chain = (
    RunnableMap({
        "context": lambda x: retriever.invoke(x["question"]),
        "question": lambda x: x["question"]
    })
    | prompt_template
    | llm
)


def rag_chain(question):
    result = chain.invoke({
        "question": question
    })
    return result.content if hasattr(result, "content") else result


if __name__ == "__main__":
    print("? Чат-бот активен. Введите вопрос на русском (или 'exit'):")
    while True:
        query = input("? Вопрос: ").strip()
        if query.lower() in ["exit", "quit"]:
            break
        answer = rag_chain(query)
        print(f"? Ответ:\n{answer}\n")

Эта версия чат-бота далеко не первая. Опишу по своему опыту, на что важно обращать внимание.

В блоке с импортами (from… import…) не должно быть отличающихся строк по сравнению с импортами в других файлах. Другими словами, если нечто импортируется сразу в разных файлах, то это должно импортироваться из одного и того ресурса. Иначе вас ждут постоянные ошибки или как минимум предупреждения, которые будут мозолить глаза, пока вы не унифицируете импорты.

В строчке llm должна быть указано имя нейросети, которую вы создали ранее. Никто не запрещает запустить сразу несколько экземпляров llama с разными системными инструкциями, Modelfile. Важно не запутаться в этом разнообразии. Полный список запущенных «лам» можно посмотреть командой llama list.

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

Чуть не забыл: чат-бот запускается командой python chatbot.py (если файл с кодом бота называется chatbot.py).

Что получилось

Время хвалиться делиться результатами. Вот несколько вырезок из диалогов:

Немногословно, но в целом верно
Немногословно, но в целом верно
Первый сценарий из запланированных в начале: поиск других статей по теме. Поиск удался, хоть и вкралась одна статья по Go. Отдельного интереса заслуживает приветственный вопль
Первый сценарий из запланированных в начале: поиск других статей по теме. Поиск удался, хоть и вкралась одна статья по Go. Отдельного интереса заслуживает приветственный вопль
С такими однозначными, количественными вопросами к текущей версии бота лучше не ходить
С такими однозначными, количественными вопросами к текущей версии бота лучше не ходить
Вписал котиков случайно для примера, а бот утер мне нос и таки нашел статью с ними. Да еще и близкие темы описал. Молодец! Хотя четкого ответа я так и не получил…
Вписал котиков случайно для примера, а бот утер мне нос и таки нашел статью с ними. Да еще и близкие темы описал. Молодец! Хотя четкого ответа я так и не получил…
С заголовками иногда галлюцинирует больше обычного
С заголовками иногда галлюцинирует больше обычного
«Яндекс. Найдется везде». Это ответ чуть измененной версии чат-бота по сравнению с той, что я привел выше. Но промпт в ней был тот же. Как и база текстов для ответов, что интересно
«Яндекс. Найдется везде». Это ответ чуть измененной версии чат-бота по сравнению с той, что я привел выше. Но промпт в ней был тот же. Как и база текстов для ответов, что интересно
«Identity theft is not a joke Jim!» — Дуайт Шрут, сериал «Офис»
«Identity theft is not a joke Jim!» — Дуайт Шрут, сериал «Офис»
«Осознание» всего блога целиком станет важным приоритетом в развитии чат-бота
«Осознание» всего блога целиком станет важным приоритетом в развитии чат-бота

Is it… MVP?

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

  • collect_all_articles.py — собирает ссылки на Хабре в txt,

  • download_articles.py — сохраняет содержимое по собранным ссылкам в json,

  • build_faiss_index.py — готовит тексты для нейросети,

  • Modelfile — задает системный промпт для модели (необязательный этап),

  • chatbot.py — создает чат-бота на основе модели для взаимодействия через Терминал.

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

Что умеет бот на данный момент:

  • Всегда говорить по-русски. Реализовать это было сложнее, чем я ожидал.

  • Выдавать названия статей на заданные темы и ссылки. Статьи выдает не все, иногда ошибается с определением темы. Но некоторую пользу приносит каждый раз.

  • Анализировать темы блога. Пока что не все, без приоритизации и уж тем более статистики.

Что будет дальше

На своем MVP-распутье я насчитал как минимум три дороги — и это только при взгляде с вертолета.

Развивать бэкенд чат-бота. ChatGPT предлагает попробовать LoRa для обучения нейросети более узким задачам. Задач я пока много не придумал, так что идея актуальна. Можно встроить сохранение истории чатов и дообучение на ней. Или, раз уж запросов немного, формализовать их и подстроить ответы по отдельным кейсам.

Создать удобную оболочку чат-бота. Проект пока живет только локально в Терминале. Хорошо бы разместить его на сервере с GPU, наладить регулярное обновление базы статей и другие внутренние процессы, чтобы в итоге вывести проект для редакции YADRO в виде телеграм-бота. Интересно, как это отразится на скорости ответов? Локально на Macbook Pro с M1 Pro я жду примерно полминуты после каждой реплики.

Вывести проект в open source. Думаю, мой проект может быть интересен редакторам корпоративных блогов на Хабре. Стоит создать общедоступный репозиторий без привязки к YADRO, где будет удобно развивать бота дальше и делиться прогрессом.

Сейчас меня больше волнует галлюцинирование и неполное использование контекста — это я бы хотел победить в первую очередь. Коллеги подсказали, что галлюцинации можно уменьшить через температуру модели, а также стоит углубиться в параметры и гиперпараметры — это наверняка даст простор для развития.

Если мой проект отозвался у вас идеями и предложениями — это здорово, давайте обсудим :)

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


  1. Kuch
    01.07.2025 11:10

    Но что делать с блокировками парсеров, краулеров и капчей?


    1. klauss_z Автор
      01.07.2025 11:10

      У меня проблем с парсингом Хабра не было. Нагрузка с моей стороны была небольшая, к сайту обращения были только при парсинге, да и там между запросами паузы прописаны. Думаю, если натравить LLM прямо на сайт, то могут быть проблемы, но если парсить время от времени, как описано в статье, то все ок.


    1. JerryI
      01.07.2025 11:10

      Если качать слишком интенсивно срабатывает ddos защита и провайдер хабра блочит по IP ;)


      1. klauss_z Автор
        01.07.2025 11:10

        Пока писал и экспериментировал, стремновато было, да. Но вроде все сработало, думаю, дальше интенсивность обращений к сайту в проекте сильно не вырастет -)