Люблю я кодить и стихи —
Вот, в общем, все мои грехи...А. С. Пушкин
Привет! Я Константин Хабазня, преподаватель программирования и математики, а также автор (что бы это ни значило).
N-нное время назад увлёкся NLP (Natural Language Processing), что вполне логично для писателя, который кодит (или кодера, который пишет).
Почитав интернет и пару вводных книжек, отправился учиться на ДПО в МФТИ. В качестве выпускного проекта придумал себе задачу — создать рекомендательную систему для стихов. Для песен рекомендательные системы есть, для книг — есть, для фильмов — есть, для стихов — нет. Непорядочек. А значит, этому городу нужен герой! Чип и Дейл Обезьянка с Питоном спешат на помощь!

По ходу дальнейшего текста расскажу, как собрал первый прототип системы на небольшом датасете с Kaggle, используя Flask, sqlite-vec, энкодер с Hugging Face и чистый (ну почти) HTML.
0. Как всё работает

Берём много стихов (на картинке это Data). С помощью энкодера и библиотеки Sentence-Transformers (BERT-model) превращаем стихи в 512-мерные векторы. Складываем векторы и соответствующие им тексты в векторную базу (Vector base).
Пользователь (User) вводит в систему (App) стихотворение. Модель (BERT-model) на лету превращает его в вектор (стихотворение, а не пользователя). Этот вектор сверяется с векторами базы (Vector base). Система (App) выбирает три наиболее схожих по косинусному расстоянию вектора, берёт соответствующие им тексты (включая исходный, если он есть в базе) и возвращает их пользователю (User) в качестве рекомендаций.
Фух. Вы что-нибудь поняли? Я тоже нет. Давайте разбираться.
1. Датасет
«Content is King» — сказал Билл Гейтс ещё в 1994 году и, кажется, был прав. Датасет — уже полдела, и это не преувеличение.
Данных должно быть МНОГО, они должны быть ЧИСТЫМИ. А ещё их надо ХРАНИТЬ в какой-либо удобной для использования структуре.
Я решил начать с простого. Нашёл на Kaggle датасет:

2. Векторная база
Для машины тексты бесполезны. Душевных порывов она не понимает (пока), и виршами в ресторане под луной (с бокальчиком вина) её не соблазнишь. Тексты надо превратить в векторы, которые будут храниться в векторной базе данных. Вот на векторы машина уже клюнет. Дело пошло.
Методом проб и ошибок остановил свой выбор на библиотеке sqlite-vec. Почему не облачное векторное хранилище?
Во-первых, запрос к сторонним северам удлинил бы время отклика системы. Во-вторых, для первого прототипа не хотелось плодить сущности (в которых ещё нужно разобраться) — чем проще, тем лучше.
Базу собираем в Google Colab. Грузим библиотеки и датасет, инициализируем модель:
!pip install sqlite-vec
import sqlite3
import sqlite_vec
import pandas as pd
import numpy as np
from sentence_transformers import SentenceTransformer
from typing import List
model = SentenceTransformer('distiluse-base-multilingual-cased-v1', device='cpu')
df = pd.read_csv('russianPoetryWithTheme.csv')
Из каждой строки датафрейма вычленяем автора, название, текст, комбинируем в единую строку, генерируем эмбеддинги таких строк:
texts = []
for idx in range(16694):
author = df.iloc[idx]['author']
name = df.iloc[idx]['name']
text = df.iloc[idx]['text']
texts.append(author + '\n' + str(name) + '\n' + text)
def get_normalized_embeddings(texts: List[str]) -> np.ndarray:
embeddings = model.encode(texts, convert_to_numpy=True)
return embeddings / np.linalg.norm(embeddings, axis=1, keepdims=True)
embeddings = get_normalized_embeddings(texts)
Инициализируем базу "vectors.db" и создаём в ней таблицу, в которой будут храниться пары «вектор — текст»:
db = sqlite3.connect("vectors.db")
db.enable_load_extension(True)
sqlite_vec.load(db)
db.enable_load_extension(False)
db.execute("""
CREATE VIRTUAL TABLE documents USING vec0(
embedding float[512],
text text
)
""")
Заполняем базу векторами и текстами:
from tqdm.notebook import tqdm
BATCH_SIZE = 1
with db:
for i in tqdm(range(0, len(texts), BATCH_SIZE)):
batch = [
(j+1,
embeddings[j].tobytes(),
texts[j])
for j in range(i, min(i+BATCH_SIZE, len(texts)))
]
db.executemany(
"INSERT INTO documents(rowid, embedding, text) VALUES (?, ?, ?)",
batch
)
Копируем в проект готовый файл "vectors.db".
3. Веб-приложение на Flask
Итак, подошли к самому интересному — пишем сервер на Flask. Выбрал именно этот фреймворк, так как слышал о нём как о золотой середине между простотой и функциональностью — идеально для новичка.
Помним: чем проще — тем лучше, тем быстрее разберёмся с кодом, тем меньше швов придётся латать, тем быстрее соберём работающий прототип и получим дофаминчик. Друзья скажут: «ты гений!» Работодатели завалят баксами! (Работодатели, ау, отзовитесь! Перед вами... гений... бэкенда же... вы что... разве... не понимаете... гений... бэкенда...😢)
По Flask нашёл хороший вводный туториал, который мне очень понравился и закрыл базу по созданию простенького сервера. Большего пока и не нужно.
Собираем звездолёт.
Вот так выглядит "main.py".
from flask import Flask, render_template, request
from translation_pipe import translation
from get_vec_db import search_similar_texts
app = Flask(__name__, template_folder='templates')
@app.route("/", methods=['GET'])
def text_form():
return render_template('index.html')
@app.route("/poe3", methods=['GET', 'POST'])
def similarity_form():
if request.method == 'GET':
return render_template('poe3.html')
elif request.method == 'POST':
text = request.form.get('textarea')
results = search_similar_texts(text, top_k=3)
text_match = []
for row in results:
row_list = row[1].split('\n')
text_match.append([row[2], row_list]) #f"Доля схожести: {row[2]:.4f}, Текст: {row[1]}...")
return render_template('validation.html', text_match=text_match)
@app.route("/validation", methods=['GET', 'POST'])
def validation_form():
if request.method == 'GET':
return render_template('poe3.html')
elif request.method == 'POST':
try:
score = float(request.form.get('validarea'))
except Exception:
return render_template('poe3.html', text_valid=['Не сработало. Попробуй оценить снова!'])
with open('validation.txt', 'r', encoding='utf-8') as f:
validate_list = list(map(float, f.readlines()))
validate_list[0] += 3.0
validate_list[1] += score
validate_list[2] = (validate_list[1]/validate_list[0])*100
to_file = str(validate_list[0]) + '\n' + str(validate_list[1]) + '\n' + str(validate_list[2])
with open('validation.txt', 'w', encoding='utf-8') as f:
f.write(to_file)
return render_template('congrats.html', text_valid=['Спасибо за оценку!'])
@app.route("/score", methods=['GET'])
def get_score():
with open('validation.txt', 'r', encoding='utf-8') as f:
score_list = list(map(float, f.readlines()))
return render_template('score.html', score_list=score_list)
if __name__ == "__main__":
port = 8080
app.run(debug=True, host='0.0.0.0', port=port)
Он умеет запускать сервер и назначать функции-обработчики любого запросов из браузера.
"/" — дефолтный GET, который показывает стартовую страницу сайта (не имеющую отношения к проекту).
"/poe3" — вызывает страницу с окошком и кнопкой для ввода текста пользователем, а также обрабатывает стихотворение, введённое пользователем: оно отправляется в функцию "search_similar_texts()", которая возвращает три схожих текста. Эту рекомендацию мы закидываем на страницу "validation.html", куда и редиректим пользователя. Он видит выдачу, а также запрос оценить рекомендацию.
"main.py" главный узел системы. Функция "search_similar_texts()" запакована в отдельный модуль "get_vec_db.py", который выглядит так:
import sqlite3
import sqlite_vec
import numpy as np
from sentence_transformers import SentenceTransformer
from typing import List
model = SentenceTransformer('distiluse-base-multilingual-cased-v1', use_auth_token=False)
db = sqlite3.connect('vectors.db', check_same_thread=False)
db.enable_load_extension(True)
sqlite_vec.load(db)
db.enable_load_extension(False)
def search_similar_texts(query_text: str, top_k: int = 5) -> List[tuple]:
query_embedding = model.encode(query_text, convert_to_numpy=True)
query_embedding = query_embedding / np.linalg.norm(query_embedding)
rows = db.execute(
"SELECT rowid, text, embedding FROM documents"
).fetchall()
res = []
for r in rows:
stored_vec = np.frombuffer(r[2], dtype=np.float32)
similarity = np.dot(query_embedding, stored_vec)
res.append((r[0], r[1], float(similarity)))
return sorted(res, key=lambda x: x[2], reverse=True)[:top_k]
Вернёмся к "main.py". А точнее к обработчику запроса "/validation", где мы просим пользователя оценить релевантность ответа системы после выдачи рекомендаций. Здесь остановимся подробнее.
4. Валидация
Хьюстон, у нас проблема: как валидировать релевантность выдачи, если машина равнодушна к виршам под луной и лирика ей до лампочки? Что значит, что система выдала «хорошую» рекомендацию? Каковы критерии? Мы не можем измерить «схожесть» двух стихов машинно. Это работа для человека — вопрос вкусов.
Итак, человеческая валидация. Каждый пользователь может выставить системе максимум 3 балла. Значит, максимальное количество баллов = 3*(количество пользователей, провалидировавших систему).
Однако злые уважаемые пользователи ставят «реальные» баллы: 1, 2, 3. Соответственно, общее «реальное» количество баллов меньше «максимального». Кто в школе учил доли и проценты поймёт, что будет дальше: делим «реальное» количество баллов на «максимальное», умножаем на 100 и получаем процент точности рекомендательной системы.
Именно этим и занимается обработчик запроса "/validation". В нём вся описанная выше математика работает в связке с txt-файлом, в котором мы храним текущие показатели баллов. Открываем файл, обновляем баллы, записываем обновлённые баллы в файл, закрываем файл. Приводить его не буду ввиду тривиальности, а также потому что его копия уже давно работает в деплое с текущими показателями баллов.
Их, кстати, можно увидеть по запросу "/score", который тоже обрабатывается нашим "main.py". Обработчик этого запроса открывает txt-файл с баллами и рендерит отчёт на отдельной странице.

Кстати, о страницах.
5. HTML + Jinja2 = ❤️
Jinja2 – серый кардинал происходящего. До настоящего момента он управлял нашим фронтендом, а мы и не замечали. Его вотчина — HTML-шаблоны, в которые можно встраивать Python-код. Именно благодаря такому функционалу обработчики запросов из "main.py" могут отдавать в HTML-страницу структуры данных Python, а те будут корректно с ними работать.
Я понимаю, вы уже устали. Приведу для примера код страницы валидации "score.html", скрин которой мы только что видели:
{% extends "base.html" %}
{% block title %}Страница валидации{% endblock %}
{% block content %}
<h1>Страница валидации</h1>
<div>Максимальная оценка: {{ score_list[0] }}</div>
<br>
<div>Фактическая оценка: {{ score_list[1] }}</div>
<br>
<div>Точность в процентах: {{ score_list[2] }} %</div>
<br>
{% endblock %}
Смотрите, что происходит. С помощью хитроумного синтаксиса я могу обрабатывать в этом HTML-коде питоновский список — и всё работает.
Ещё один маленький рывочек. Строчка "{% extends "base.html" %}" означает, что шаблон страницы валидации наследует базовому шаблону "base.html", чтобы каждый раз руками не прописывать одни и те же постоянные элементы (например, ссылки на мои соцсети, на которые вы сразу же подпишитесь, потому что вам очень понравилась моя статья, и вообще, я смешной, ведь правда?😊)
<!DOCTYPE html>
<html lang="en, ru">
<head>
<meta charset="UTF-8">
<title>{% block title %}Default Title{% endblock %}</title>
</head>
<body>
<p></p>
{% block content %}{% endblock %}
<br>
<br>
<a href="https://t.me/habaznyaclub">откровения технаря о поэзии 📖</a>
<br>
<br>
<a href="https://t.me/habaznya_math">откровения поэта о коде, AI, NLP 🛠</a>
</body>
</html>
Ну всё-всё, не плачьте, мы уже закончили, всё позади. Я же сказал: как комарик укусит. И ничего в итоге сложного — всё просто и понятно, да?
6. Бонус-трек: деплой на сервер
Есть вещи, которые выходят за рамки нашей истории. Например, работа Flask и Jinja2, синтаксис sqlite-vec и особенности энкодеров Hugging Face. Детали деплоя — одна из таких вещей. В сети куча материалов — гуглите, чатботьте, пишите в поддержку своего хостера.
У моего есть такая классная фича — подгружать деплой из git-репозитория. Делаешь новый push, сервер подхватывает его на лету, деплоит обновления сам. Звучит прекрасно, но, чтобы настроить такой деплой, разобраться с зависимостями и так далее, у меня ушло едва ли не больше времени, чем на сам проект.
В общем, в этом месте вы вспомните все нецензурные слова, которые знаете. Удачи!
7. Наконец-то вывод
Итак, разобравшись с азами фреймворков Flask и Jinja2, почитав документацию sqlite-vec, научившись пользоваться энкодерами из коробки (fine-tuning не делали), потратив определённое время и пару метров нервов на репозиторий, зависимости, деплой, мы собрали собственную рекомендательную систему для стихов. Точность у неё небольшая — 67%, но для первого приближения неплохо.
Анализируя опыт пользователей, понял важную вещь. Эмбеддинг кодирует прежде всего семантику текста, а не стилистику, ритм, созвучия.
Я ввожу первые две строчки стихотворения, которое точно содержится в базе, но не получаю его в выдаче. Или получаю, но не первым. Что это значит? Вектор двух строчек может не вобрать в себя семантику всего стихотворения, поэтому в действительности будет далёк от вектора целого стихотворения.
Речь идёт именно о рекомендательной системе. Целое-к-целому. Как в «Яндекс Музыке». А не поиск по фрагменту, как в «Шазаме».
С вами были Обезьянка и Питон. Если было полезно или, по крайней мере, смешно, поделитесь статьей с друзьями, накидайте реакций и комментариев, а может быть даже подпишитесь на мои откровения технаря о литературе и/или литератора о коде.
Чем больше в моих каналах будет людей, тем больше все будут думать, что я деловой и умный перец. Но мы-то с вами знаем правду😉
