Сайт Романа ПарпалакаБлог

Эксперименты и использование ChatGPT

7 мая 2023 года, 12:32

По-немногу экспериментирую с ChatGPT. Решил поделиться результатами наблюдений и идеями по использованию.

Успехи

Самое большое впечатление он произвел, когда решил задачу по SQL с моих собеседований: моментально выдал абсолютно точный запрос, хотя не все кандидаты могут его написать даже после подсказок. Правда, потом я спросил, какие индексы нужны для этого запроса, и он немного поплыл (предложил индекс и на колонку в where, и на колонку в group by, и на обе этих колонки), но тем не менее.

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

В таблице в БД хранятся древовидные записи. Есть колонки id, parent_id и text. Напиши запрос, который выводит записи в виде дерева: чем глубже запись в дереве, тем больше должно быть отступов

Для вывода записей в виде дерева с отступами можно использовать рекурсивный запрос с помощью общей таблицы выражений (CTE) в SQL. Ниже приведен пример запроса для базы данных MySQL:

WITH RECURSIVE tree AS (
  SELECT id, parent_id, text, 0 AS level
  FROM my_table
  WHERE parent_id IS NULL
  UNION ALL
  SELECT t.id, t.parent_id, t.text, tree.level + 1
  FROM my_table t
  JOIN tree ON t.parent_id = tree.id
)
SELECT CONCAT(REPEAT(' ', level * 2), text) AS tree_view
FROM tree
ORDER BY id;

В этом запросе используется общая таблица выражений tree, которая содержит две части:

Базовый запрос выбирает корневые узлы дерева (те, у которых parent_id равен NULL), задает начальный уровень level = 0 и добавляет их в таблицу tree.

Рекурсивный запрос выбирает дочерние узлы дерева, соединяя таблицу my_table с таблицей tree, используя ключ parent_id = id. Он также увеличивает уровень level на 1 и добавляет эти узлы в таблицу tree.

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

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

Сгенерируй запрос, создающий такую таблицу, и заполняющий ее записями

Ниже приведен пример запроса на языке SQL, который создает таблицу с тремя колонками (id, parent_id и text) и заполняет ее несколькими записями:

CREATE TABLE my_table (
  id INT PRIMARY KEY,
  parent_id INT,
  text VARCHAR(255)
);

INSERT INTO my_table (id, parent_id, text)
VALUES
  (1, NULL, 'Node 1'),
  (2, 1, 'Node 1.1'),
  (3, 1, 'Node 1.2'),
  (4, 2, 'Node 1.1.1'),
  (5, 2, 'Node 1.1.2'),
  (6, 3, 'Node 1.2.1'),
  (7, NULL, 'Node 2'),
  (8, 7, 'Node 2.1'),
  (9, 7, 'Node 2.2');

Этот запрос сначала создает таблицу my_table с тремя колонками: id, parent_id и text. Колонка id определена как первичный ключ.

Затем запрос заполняет таблицу несколькими записями с разными значениями для id, parent_id и text. В этом примере мы создали простое дерево с двумя уровнями и несколькими дочерними узлами, чтобы продемонстрировать, как можно использовать рекурсивный запрос для выборки записей в виде дерева с отступами.

Дальше я скопировал запросы в консоль, и они выполнились без единой ошибки!

MariaDB [s2_test]> CREATE TABLE my_table (
    ->   id INT PRIMARY KEY,
    ->   parent_id INT,
    ->   text VARCHAR(255)
    -> );
Query OK, 0 rows affected (0.033 sec)

MariaDB [s2_test]> INSERT INTO my_table (id, parent_id, text)
    -> VALUES
    ->   (1, NULL, 'Node 1'),
    ->   (2, 1, 'Node 1.1'),
    ->   (3, 1, 'Node 1.2'),
    ->   (4, 2, 'Node 1.1.1'),
    ->   (5, 2, 'Node 1.1.2'),
    ->   (6, 3, 'Node 1.2.1'),
    ->   (7, NULL, 'Node 2'),
    ->   (8, 7, 'Node 2.1'),
    ->   (9, 7, 'Node 2.2');
Query OK, 9 rows affected (0.004 sec)
Records: 9  Duplicates: 0  Warnings: 0

MariaDB [s2_test]> WITH RECURSIVE tree AS (
    ->   SELECT id, parent_id, text, 0 AS level
    ->   FROM my_table
    ->   WHERE parent_id IS NULL
    ->
    ->   UNION ALL
    ->
    ->   SELECT t.id, t.parent_id, t.text, tree.level + 1
    ->   FROM my_table t
    ->   JOIN tree ON t.parent_id = tree.id
    -> )
    -> SELECT CONCAT(REPEAT(' ', level * 2), text) AS tree_view
    -> FROM tree
    -> ORDER BY id;
+----------------+
| tree_view      |
+----------------+
| Node 1         |
|   Node 1.1     |
|   Node 1.2     |
|     Node 1.1.1 |
|     Node 1.1.2 |
|     Node 1.2.1 |
| Node 2         |
|   Node 2.1     |
|   Node 2.2     |
+----------------+
9 rows in set (0.001 sec)

Данные выведены ровно так, как я попросил. Обратите внимание, что я не думал долго и сформулировал условие небрежно (что такое «древовидные записи»?) и скорее на уровне идеи, а не требований к результату. Я будто написал коллеге, с которым давно работаем, и получил ожидаемый результат.

Провалы

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

Если спрашивать у ChatGPT решение отвлеченных задач, получается ерунда. С загадкой для младших классов ChatGPT не справился даже после 5 уточняющих вопросов. Угадал ответ, но обоснование всё равно осталось в корне неверным:

раздели полста на половину

Если мы разделим 50 на половину, то мы должны разделить его на 2. Это даст нам ответ 25.

Таким образом, ответ на задачу «раздели полста на половину» равен 25.

половина — это не 2

Вы правы, прошу прощения за ошибку в моем предыдущем ответе.

Если мы хотим разделить 50 на половину, то это означает, что мы делим его на 2 равные части. Половина от 50 равна 25, поэтому ответ на задачу «раздели полста на половину» также будет 25.

нет, половина — это 1/2

Вы абсолютно правы, прошу прощения за мою путаницу в ответах.

Разделив 50 на половину, мы делим 50 на две равные части. Половина от 50 — это 50 умножить на 1/2, то есть 25. Таким образом, ответ на задачу «раздели полста на половину» равен 25.

Спасибо за исправление моей ошибки!

Почему умножить? Мы же делим!

Действительно, я снова ошибся, искренне извиняюсь.

Разделив 50 на половину, мы делим 50 на 2 равные части. Поскольку половина означает деление на две равные части, то можно напрямую получить ответ, просто разделив 50 на 2, без использования умножения.

Таким образом, правильный ответ на задачу «раздели полста на половину» равен 25. Еще раз извиняюсь за путаницу в предыдущих ответах.

Это загадка, и у нее ответ не на поверхности. Перепроверь всё еще раз

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

Раздели полста на половину

Я понимаю, теперь я понимаю, что вы задали мне загадку. Ответ на эту загадку — это число 100.

Давайте рассмотрим это более подробно. Половина от 50 равна 25, как мы уже обсуждали. Но если мы рассмотрим число 50 как «полста», то разделив его на половину, мы можем получить 100, потому что «полста» означает 50, а половина от 50 равна 25, так что 50 плюс 25 даст нам 75.

Таким образом, ответ на загадку «раздели полста на половину» — это число 100.

Надо понимать, что ChatGPT легко генерирует подобную ерунду. Вот пример:

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

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

Знаешь закон Лермонтова — Смита?

Я не знаком с законом Лермонтова-Смита, потому что это не является широкоизвестным законом или принципом в науке или в других областях. Если у вас есть дополнительная информация о нем, я могу попробовать помочь вам разобраться в нем лучше.

Но стоит немного изменить вопрос, как мы опять получаем ерунду:

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

Как можно применять ChatGPT?

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

В скучных и рутинных задачах. Например, в разработке можно попросить следующее: сгенерировать простую функцию, шаблон класса, html-верстку, юнит-тест. AlexGyver показывал, как ChatGPT успешно генерирует скетчи для Ардуино.

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

Как стартовую точку в изучении новой темы. Раньше такой входной точкой был гугл с постоянным уточнением поисковых запросов (интересно, кстати, сильно ли влияет гугл на работу человеческого мышления, потому что мы раньше не общались поисковыми запросами без регистрации без смс). Сейчас можно спросить у ChatGPT. Хоть ответ и надо перепроверять, не нужно отбиваться от «оптимизированных» сайтов, сюда сеошники еще не пролезли.

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

Понятно, что ChatGPT — это еще не тот искусственный интеллект, о котором мечтали фантасты. Но всё равно создание системы, которая может «понять» запрос на живом языке и выдать адекватный ответ — это уже большой прорыв в этой области. Пока мы обсуждали прикладные вопросы, пост и так уже получился длинным. Так что пофилософствуем и пофантазируем мы уже в следующий раз.

    2 комментария

Фишинг

24 апреля 2023 года, 21:32

Пришло качественно сделанное фишинговое письмо, я даже мог бы попасться. Смотрите:

Я сразу полез проверять, что там с балансом. Подумал, что домен не продлился. Оказалось, что с балансом всё нормально. Я пришел к выводу, что внутри сервисов Бегета произошла какая-то ошибка, и они рассылают письма по неверным критериям. И только потом понял, что это был фишинг.

Ссылка в письме, разумеется, поддельная. Ради интереса открыл в режиме «инкогнито». При переходе сначала просит ввести капчу, а затем направляет на экран оплаты. В дополнение к пунктам выше смогли еще и пару сайтов взломать.

Сумма на форме примерно в 5 раз больше стоимости настоящего продления (289 рублей). Не удивлюсь, если за этой формой окажется окошко для ввода пароля из смс, а с карты спишут, скажем, 16 460 рублей.

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

    Оставить комментарий

Срываем покровы

24 апреля 2023 года, 00:53

Я впервые увидел клип на песню Shocking blue — Venus (та самая «шизгара») 10 лет назад. Большим открытием стало, что ее поет не мужчина, а женщина. Я даже в твитер 9 января написал: «Всю жизнь думал, что песню Shocking blue — Venus поет мужик».

Сейчас случилось еще одно открытие такого же масштаба. Медуза написала о группе «Everything But The Girl». И я с удивлением узнал, что голос, поющий песню «Missing» — тоже не мужской, а женский!

Интересно, какое открытие ждет меня еще через 10 лет.

    Оставить комментарий

Храните состояние долгоживущих процессов в базе данных, а не в памяти

15 апреля 2023 года, 00:47

Долгоживущие процессы давно стали важной частью веб-приложений и сервисов. Они выполняют фоновые задачи (обработка данных, формирование отчетов), служебные задачи по расписанию, обрабатывают асинхронные запросы и ответы. При их разработке нужно учитывать не только функциональные требования (это такие требования, без выполнения которых заказчик не примет работу), но и нефункциональные, например: надежность и отказоустойчивость, observability («наблюдаемость», прозрачность работы), обслуживаемость, масштабируемость.

Обычно нефункциональные требования в техническом задании не сформулированы, и о них никто кроме разработчика не думает. Чтобы выделить их в явном виде, попробуйте ответить на следующие вопросы:

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

Какие преимущества получаем от хранения состояния долгоживущих процессов в базе данных?

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

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

Простота мониторинга. В подготовленной системе мониторинга достаточно написать SQL-запрос, и новая метрика готова. Если же состояние хранится в памяти процессов, нужно дополнительно программировать отправку данных в систему мониторинга. Мониторинг дает представление о работе системы, выраженное в виде метрик (например, количество обработанных задач, время выполнения и другие показатели производительности). С помощью мониторинга заинтересованные лица оперативно и даже проактивно реагируют на проблемы.

Наблюдаемость состояния в админке. Когда состояние хранится в базе данных, его легко вывести в админке. Таким образом, вы сделаете работу приложения наблюдаемым. Иначе ваше рабочее время будет уходить на однотипные вопросы коллег, реагирующих на обращения пользователей: «что случилось с заявкой №782354, запрос ушел, но ответа не было?»

Совет, вынесенный в заголовок, не нов. Делать процессы без состояния (stateless) — одно из требований к 12-факторным приложениям.

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

    Оставить комментарий

Отложенная загрузка картинок через атрибут lazyload

4 апреля 2023 года, 00:27

Оказывается, в браузерах уже есть встроенная поддержка «ленивой» загрузки картинок: далекие от видимой области сайта картинки даже не начинают скачиваться. За это отвечает атрибут loading="lazy" у тега img. Цель простая — экономия трафика. Раньше для ее достижения приходилось писать логику на js. Но встроенная в браузер поддержка, конечно, должна работать точнее.

Чтобы определение области видимости работало правильно, у картинок должны быть прописаны размеры (width и height). Но вообще их и так надо было прописывать, чтобы избежать «эффекта упячки».

Думаю, этот атрибут есть смысл всегда использовать для картинок из основного содержимого. По крайней мере, я не вижу недостатков, если так делать. Добавил в S2 автоматическое добавление атрибута loading="lazy" для загружаемых картинок. Посмотрим, как будет работать, может когда-нибудь обнаружатся подводные камни.

    Оставить комментарий

Мониторинг производительности приложений в New Relic

26 марта 2023 года, 00:03

New Relic — это набор инструментов для обеспечения «observability» веб-приложений, то есть наблюдения за их внутренним состоянием. Для меня самый полезный инструмент — APM, или мониторинг производительности.

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

Зачем нужен мониторинг производительности

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

Первый полезный экран — список запросов к базе данных. Можно выбрать какой-либо запрос (на скриншоте запросы на чтение к таблице users) и увидеть среднее время выполнения в миллисекундах (query time) и количество запросов в минуту (throughput). Кроме того, есть разбиение по источникам запросов (time consumption by caller), в котором видно, с каких страниц идут выбранные запросы.

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

Третий полезный экран — список «транзакций». Под транзакциями нью-релик понимает действия в веб-приложении. Они делятся на веб-транзакции (обычно запросы и ответы через веб-сервер) и фоновые транзакции (обычно запуски консольных скриптов). По транзакциям также выводится их количество и среднее время выполнения (читай время ответа веб-приложения). Кроме того, доступна детализация, в которой видно, какие запросы или обращения к внешним системам происходят именно в этой транзакции и как долго они выполняются. Например, вот детализация по ответам публичной части моего сайта:

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

Например, на скриншоте выше время ответа веб-сервера составляло целых 4 секунды, а причина тому — запрос к БД. Сам запрос тоже можно открыть:

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

Установка и настройка

Установка нью-релика на сервер состоит из двух несложных частей (уровня apt install). Во-первых, нужно установить демон, который отвечает за отправку данных на серверы нью-релика. Во-вторых, нужно подключить нью-релик к вашему веб-приложению. Для PHP нью-релик устанавливается как расширение (*.so) и начинает работать после того, как вы скопируете в php.ini лицензионный ключ из личного кабинета на сайте. Расширение отправляет данные в процесс демона (очевидно, через сокет), а демон пересылает данные асинхронно. Благодаря такому подходу нет заметной просадки производительности.

Расширение нью-релика автоматически определяет и регистрирует запросы к базам данных, запросы через curl к внешним системам. Оно даже понимает, какой фреймворк используется, и берет названия маршрутов (routing) как названия транзакций. Данные на всех скриншотах выше собраны автоматически, для их сбора я ничего дополнительно не настраивал.

Продвинутое использование через API

Расширение нью-релика предоставляет API. Через него я определял свои транзакции из долгоживущих процессов и регистрировал метрики. Например, в сервисе генерации формул я профилирую некоторые шаги генерации как будто это обращения к некоторым базам данных (за это отвечает функция newrelic_record_datastore_segment(); возможно, тут подошли бы другие функции, но меня и так всё устраивает):

<?php

class Helper
{
    public static function newRelicProfileDataStore(callable $callback, string $product, string $operation, string $collection = 'other')
    {
        if (\extension_loaded('newrelic')) {
            return \newrelic_record_datastore_segment($callback, [
                'product'    => $product,
                'operation'  => $operation,
                'collection' => $collection,
            ]);
        }

        return $callback();
    }
}

// запуск внешнего процесса
Helper::newRelicProfileDataStore(
    static fn() => shell_exec($command),
    'shell',
    Helper::getShortCommandName($command)
);
                
// запрос по http на localhost
$optimizedSvg = Helper::newRelicProfileDataStore(
    fn() => file_get_contents($this->httpSvgoUrl, false, $context),
    'runtime',
    'http-svgo'
);

// "долгая" операция сжатия
$gzEncodedSvg = Helper::newRelicProfileDataStore(
    static fn() => gzencode($optimizedSvg, 9),
    'runtime',
    'gzencode'
);

Результат на скриншоте. Мы видим, что шаг по запуску латеха и генерации dvi-файла занимает примерно столько же, сколько отрабатывает dvisvgm. А на оптимизацию получившегося svg-файла времени тратится немного (к тому же она еще и в фоне происходит).

Выводы

Мониторинг производительности — один из инструментов для обеспечения «наблюдаемости» внутренней работы приложений. Другие инструменты — это мониторинг технических и бизнес-метрик, логи, визуализация внутреннего состояния в административном интерфейсе. Но эти инструменты нужно вручную добавлять в код, они не появляются в момент инцидента. Если вы не залогировали событие, вы не узнаете, произошло ли оно. Если храните состояние в памяти процесса, а не в БД, не сможете сделать sql-запрос и получить это состояние. А на этапе разработки приложения заранее непонятно, что именно окажется полезным в логах, а что нет.

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

Я не знаю, есть ли альтернатива сервису APM нью-релика. Если вдруг знаете — напишите в комментариях. На волне «импортозамещения» мы аналогов не нашли. Но зато я узнал о бесплатной квоте в 100 гигабайт, что вдохновило меня написать эту заметку.

    Оставить комментарий

Ведущее ухо

25 марта 2023 года, 00:22

Некоторое время назад прочитал в блоге Евгения Степанищева о понятии «ведущее ухо».

У меня разница в восприятии звуков хорошо заметна: левое ухо слышит лучше правого. Обычно в быту это никак не проявляется: я не замечаю проблем с определением направления на источник звука. И при прослушивании музыки у меня нет потребности крутить баланс стерео.

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

Возможно, асимметрия в слуховой способности связана с тем, что в моем левом ухе слуховой проход шире, чем в правом.

Не знаю, зачем вам всё это знать, но пути назад уже нет :)

    Оставить комментарий

Снова на линуксе

24 февраля 2023 года, 01:05

Основной операционной системой у меня всё время была Windows. Я пробовал пересесть на Ubuntu в 2010 году, но больше недели продержаться не смог.

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

(Надо сказать, что я попросил на работе ноутбук ThinkPad X1 Yoga с экраном 4K. И экран у него оказался OLED с регулировкой яркости через ШИМ, то есть на пониженной яркости он мерцал на частоте около 200 герц. В интернете были какие-то инструкции для повышения частоты ШИМ. И я надеялся, что они заработают в линуксе. Но из этого ничего не получилось.)

Главный вывод

Современные операционки можно настроить так, чтобы привычки, формируемые интерфейсом, работали одинаково и в Ubuntu, и в Windows. Например, в Windows я часто пользовался клавиатурным сокращением Win+R для запуска команд типа c:, mspaint, regedit, cmd, calc:

И раньше в Ubuntu я навешивал на Win+R вызов консоли. А сейчас ту же задачу можно выполнить, просто нажав на клавишу Win и начав вводить название программы. Благодаря автодополнению можно ввести только часть названия и нажать Enter, что было невозможно в предыдущем окне.

Полностью аналогично работает поиск по меню в Ubuntu, ничего настраивать не надо.

Наблюдения при работе

Веб-приложения на PHP работают быстрее, чем в Windows под WSL. Это особенно важно при запуске тестов в крупных проектах: всё-таки работать проще, когда тесты выполняются за 10 минут, а не за полчаса. Причина ускорения скорее всего в файловой системе. Нативная файловая система работает быстрее.

Для рисования стилусом по экрану я установил программу Stylus Labs (есть версии для всех операционных систем). Очень удобно рисовать схемы, пока объясняешь что-нибудь во время видеозвонка. Получается почти как на листе бумаги, когда сидишь с собеседником за одним столом.

PhpStorm работает так же хорошо, как и в Windows. Единственная проблема появляется при подключении внешнего монитора. В момент подключения меняется размер рабочего стола, и почему-то PhpStorm начинает дико тормозить. Оба экрана остаются черными. Если закрыть PhpStorm, подключить второй монитор и запустить PhpStorm заново, такой проблемы нет.

Почтовый клиент Thunderbird в целом неплох. Правда, страдает интеграция с системой и приложениями. Скажем, если дважды кликнуть на прикрепленный экселевский файл, то LibreOffice запустится. Но после загрузки он выдаст ошибку об отсутствующем файле. Каждый раз, когда тебе присылают офисный файл, приходится сохранять куда-нибудь, и только потом открывать. В Windows таких проблем нет. Там, например, даже работает перетаскивание прикрепленных файлов из Outlook сразу в форму в браузере.

LibreOffice в принципе работает, но не так удобно, как микрософтовский офис. Однажды мне пришлось готовить документ по примеру некоторого вордовского документа. Я закончил и отправил коллегам. У них документ не открылся. У меня он тоже перестал открываться. К счастью, формат docx — это zip-архив с несколькими xml-файлами. Я распаковал его, нашел и исправил ошибку в форматировании xml-файла и собрал docx заново. Правда, надо признаться, что я взял за основу документ в режиме рецензирования, и в нем была сохранена куча предыдущих версий. Но это не оправдывает LibreOffice за то, что он сформировал на выходе невалидный файл.

Особая трудность

Одна из трудностей была связана с приложениями на электроне типа Слака и Скайпа, и переключением языка. Я переключаю язык комбинацией клавиш Alt + Shift. В Ubuntu её, разумеется, тоже можно задействовать, и почти во всех программах она работает без проблем. А в этих приложениях почти всегда при переключении языка активируется меню, которое обычно скрыто. И ладно, если бы оно просто появлялось. Оно еще и фокус забирает себе и не дает вводить текст дальше.

Эксперимент показывает, что если при переключении языка нажать Alt, нажать Shift, отпустить Shift, отпустить Alt, то язык переключается корректно и меню не вызывается. А у меня при быстром наборе последовательность отпусканий клавиш другая: я сначала отпускаю Alt, а потом отпускаю Shift, рука будто «перекатывается» между альтом и шифтом. Я пробовал изменять параметры как приложений, так и самой операционной системы. На хабре даже есть отдельная статья по решению этой проблемы. У меня ничего не получилось, и я просто стал запускать Слак в браузере. Работает он не хуже, чем в отдельном приложении.

Сюрпризы при обновлении

Каждая новая версия Ubuntu преподносит сюрпризы. Иногда приятные, когда что-то починилось. Иногда не очень, когда ломается что-то новое. Например, в версии 22.04, которая как LTS должна быть особо стабильной, что-то изменилось в обработке сигналов с датчика ориентации. Если просто закрыть крышку ноутбука (он уйдет в спящий режим) и открыть ее, изображение перевернется на 180°. И назад не возвращается, если автоповорот не включен. Приходится держать автоповорот включенным и терпеть его ложные срабатывания.

Еще пример. Недавно разработчики Ubuntu решили перейти с X11 на Wayland. В этом Wayland у меня не работала демонстрация экрана в Zoom. К счастью, они оставили возможность использовать и X11, только непонятно, надолго ли.

Резюме

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

    1 комментарий

Войти в IT

22 февраля 2023 года, 18:36

Вход в IT здесь, вдруг кто-то искал:

    Оставить комментарий

Как разработать систему рекомендаций

16 февраля 2023 года, 01:17

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

За рекомендации в моем случае отвечает движок полнотекстового поиска Rose. Структура данных в полнотекстовом индексе как раз подходит для задачи подбора похожих заметок. Если совсем упростить, то получается, что обычный поиск — это подбор подходящих заметок к словам из поискового запроса, а рекомендации к заметке — это подбор других заметок по словам из нее. А теперь давайте погрузимся в детали.

Теория рекомендаций

Для начала давайте поймем, как вообще могут работать системы рекомендаций. Рассуждать будем на примере существовавшего когда-то сайта «Имхонет». Я выставлял на нем оценки просмотренным фильмам, а сайт говорил, какие еще фильмы мне стоит посмотреть. Понятно, что такие рекомендации основаны на оценках других пользователей, которые уже успели посмотреть эти фильмы. Качество таких рекомендаций зависит от количества пользователей сервиса: чем больше пользователей, тем с большей вероятностью среди них найдутся похожие на меня по предпочтениям.

Оценки пользователей сохраняются как веса связи «многие-ко-многим» между пользователями и фильмами. Через эту связь по пользователю можно найти фильмы, которые ему понравились. По фильмам — других пользователей, которым понравились эти фильмы. И в качестве рекомендаций выдать другие понравившиеся пользователю фильмы. Но где на шкале оценок провести границу, отделяющую понравившиеся фильмы? И что делать с фильмами, которые не понравились?

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

На самом деле, картина сложнее, так как никто не может поставить оценки всем фильмам. Поэтому вместо точек мы имеем дело с некоторыми подпространствами (прямыми, плоскостями и т. д.). Чтобы сформировать мои рекомендации, система проецирует все оценки на подпространство просмотренных мной фильмов, находит кластер в проекции, и по нему уже пытается восстановить кластер в полном пространстве.

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

Эта теория прекрасно выглядит на листе бумаги. Но я уверен, что при практической реализации разработчики столкнулись с кучей проблем. Очевидная проблема — нормировка оценок. Например, у меня средняя оценка была около 7. Оценки меньше 4 я практически не ставил. Задумывался над тем, чем отличается оценка 9 от 10. Оценки других пользователей наверняка отличались по характеристикам. Кто-то, например, мог ставить только две оценки, 1 и 10. Чтобы рекомендации работали, оценки нужно нормализовать — привести к одному масштабу.

Вы наверняка встречались с другим примером системы рекомендаций: поиском друзей в соцсетях. Здесь тоже работает связь «многие-ко-многим». Добавление друга — это и есть создание такой связи между двумя пользователями. На её основе соцсеть рекомендует добавить в друзья тех людей, которые есть в друзьях у ваших друзей.

Теперь давайте посмотрим, как можно эти знания применить для подбора рекомендаций заметок.

Рекомендации на основе тегов

Как видно из предыдущего рассмотрения, систему рекомендаций можно сделать везде, где есть связь «многие-ко-многим». Именно так связаны заметки и теги. Если вы проставляете заметкам теги, то по тегам можно найти другие заметки с такими же тегами.

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

Рекомендации на основе похожих текстов

В движке S2 есть другая связь «многие-ко-многим» — поисковый полнотекстовый индекс. Эта структура данных может вернуть по слову список проиндексированных элементов, содержащих такое слово. В библиотеке Rose полнотекстовый индекс хранится в отдельной таблице БД из трех колонок. Вот пример:

word_id   toc_id   positions
1 1 0,37
3 4 0,15,74,193,614
3 8 94
3 9 73
4 1 3,16,57

В первой колонке хранится id «слова», во второй — внутренний id проиндексированного элемента (ToC — это сокращение от table of contents), в третьей — положения соответствующего слова в проиндексированном тексте.

При индексации исходный текст заметок очищается от html-тегов, разбивается на предложения и слова. Слова переводятся в нижний регистр. У слов удаляется окончание с помощью эвристического алгоритма (стеммер Портера). Оставшиеся основы слов заменяются на идентификаторы word_id и попадают в полнотекстовый индекс, при этом неизвестные основы добавляются в справочную таблицу word.

При поиске запрос преобразуется по такой же схеме: слова заменяются на идентификаторы word_id, и из поискового индекса мы получаем информацию о том, в каких проиндексированных текстах (toc_id) встречались эти слова. Положения слов (positions) нужны для вычисления релевантности: чем ближе слова из запроса друг к другу в тексте, тем выше окажется этот текст в выдаче.

Рекомендации на основе близости текста тоже используют эту таблицу. У меня получилось уместить все существенные вычисления в один SQL-запрос.

SELECT
    relevance_info.*, -- информация из подзапроса
    m.images, -- добавляем к ней информацию о картинках
    t.*, -- добавляем к ней оглавление
    -- и первые 2 предложения из текста
    (SELECT snippet FROM snippet AS sn WHERE sn.toc_id = t.id ORDER BY sn.max_word_pos LIMIT 1) AS snippet,
    (SELECT snippet FROM snippet AS sn WHERE sn.toc_id = t.id ORDER BY sn.max_word_pos LIMIT 1 OFFSET 1) AS snippet2
FROM (
    SELECT -- Перебираем все возможные заметки и вычисляем релевантность каждой для подбора рекомендаций
        i.toc_id,
        round(sum(
            original_repeat + -- доп. 1 за каждый повтор слова в оригинальной заметке
            exp( - abn/30.0 ) -- понижение веса у распространенных слов
                * (1 + length(positions) - length(replace(positions, ',', ''))) -- повышение при повторе в рекомендуемой заметке, конструкция тождественна count(explode(',', positions))
        ) * pow(m.word_count, -0.5), 3) AS relevance, -- тут нормировка на корень из размера рекомендуемой заметки. Не знаю, почему именно корень, но так работает хорошо.
        m.word_count
    FROM fulltext_index AS i
        JOIN metadata AS m FORCE INDEX FOR JOIN(PRIMARY) ON m.toc_id = i.toc_id
    JOIN (
        SELECT -- достаем информацию по словам из оригинальной заметки
            word_id,
            toc_id,
            (SELECT count(*) FROM fulltext_index WHERE word_id = x.word_id) AS abn, -- распространенность текущего слова по всем заметкам
            length(positions) - length(replace(positions, ',', '')) AS original_repeat -- сколько раз слово повторяется в оригинальной заметке. Выше используется как доп. важность
        FROM fulltext_index AS x FORCE INDEX FOR JOIN(toc_id)
        JOIN toc AS t ON t.id = x.toc_id
        WHERE t.external_id = :external_id AND t.instance_id = :instance_id
            AND length(positions) - length(replace(positions, ',', '')) < 200 -- отсекаем слишком частые слова. Хотя 200 слишком завышенный порог, чтобы на что-то влиять
        HAVING abn < 100 -- если слово встречается более чем в 100 заметках, выкидываем его, так как слишком частое. Помогает с производительностью
    ) AS original_info ON original_info.word_id = i.word_id AND original_info.toc_id <> i.toc_id
    GROUP BY 1
    HAVING count(*) >= :min_word_count -- количество общих слов, иначе отбрасываем
) AS relevance_info
JOIN toc AS t FORCE INDEX FOR JOIN(PRIMARY) on t.id = relevance_info.toc_id
JOIN metadata AS m FORCE INDEX FOR JOIN(PRIMARY) on m.toc_id = t.id
ORDER BY relevance DESC
LIMIT :limit

Опишу ключевые шаги, которые здесь выполняются.

1. Взять все слова в заметке, к которой подбираем рекомендации. Я называю эту заметку оригинальной. Выбор слов происходит в самом внутреннем подзапросе.

2. Выкинуть распространенные слова. Это нужно делать для повышения точности и при поиске, и при подборе рекомендаций. Можно даже составить список игнорируемых слов вроде союзов или предлогов. Но вместо составления такого неизменяемого списка я вычисляю распространенность (abundance, сокращенно abn — количество заметок, в которых встречается это слово) для каждого слова в индексе. Например, в блоге о дизайне в каждой заметке будет слово «дизайн», и его тоже надо игнорировать.

Слова с распространенностью больше 100 наверняка окажутся слишком общими, и я отбрасываю их по соображениям производительности. Скорее всего порог должен как-то зависеть от общего количества заметок (их в моем случае около 1000), но я не придумал, как именно.

3. Найти одинаковые слова у оригинальной заметки с остальными заметками. Это происходит в промежуточном подзапросе. У заметок при этом должно быть достаточное количество общих слов (порог определяется параметром min_word_count).

Я пробовал разные значения параметра. Если увеличивать, количество рекомендаций падает. Если уменьшать, в рекомендации попадают не очень подходящие заметки за счет случайного использования общих слов. Я остановился на варианте, когда сначала выполняется со значением 4. Если результатов нет, как это часто бывает у коротких заметок, то запрос повторяется со значением параметра 2.

4. По повторяющимся словам вычислить релевантность. Это тоже происходит в промежуточном подзапросе за счет выражения в селекте и за счет group by. Релевантность я вычисляю как количество повторений общих слов. Чтобы понизить влияние распространенных слов, я добавил ослабление за счет веса exp(-abn/30.0). Хотел было использовать колоколообразную функцию типа exp(-sqr(abn/30.0)), но на практике линейное уменьшение веса при малых значениях распространенности повысило качество рекомендаций.

Кроме того, повторы в оригинальной заметке (original_repeat) и в рекомендуемых заметках влияют на релевантность несимметрично: повторяющиеся слова в оригинальной заметке не ослабляются, даже если они распространены. Объяснение можно предложить такое: если автор пишет одинаково часто о шахматах и шашках, то к оригинальной заметке с пятью словами «шахматы» и одним словом «шашки» лучше рекомендовать заметку с одним словом «шахматы», чем с пятью словами «шашки». Эффект несимметричности я не закладывал специально. Практика показала, что отсутствие ослабления у original_repeat субъективно улучшает качество рекомендаций.

Несимметричность веса оригинальной заметки и рекомендуемых может быть даже полезной, чтобы избежать «зацикливания» рекомендаций, когда к заметке А мы рекомендуем заметку Б, а к заметке Б — заметку А. Правда, у меня этот критерий не был обязательным, и я не проверял, как он выполняется. Применительно к моему сайту эффект зацикливания может ослабляться ещё и за счет последующего предпочтения в рекомендациях заметок с картинками.

Последний множитель в релевантности pow(m.word_count, -0.5) учитывает размер рекомендуемой заметки в словах. Без него в моем случае среди рекомендуемых оказывались очень длинные заметки, набиравшие релевантность за счет большого количества повторяющихся слов средней распространенности. Тогда я подумал, что сортировать рекомендации нужно не по абсолютному количеству общих слов, а по относительному, то есть надо поделить вычисленную релевантность на количество слов в рекомендуемой заметке. В рекомендации стали попадать короткие заметки всего из нескольких слов, а у нормальных заметок из нескольких сотен слов релевантность сильно просела. Чтобы было ни нашим ни вашим, я попробовал поделить абсолютную релевантность на корень из длины рекомендуемой заметки, и это сработало: с первых мест рекомендаций ушли как очень короткие, так и очень длинные заметки. Изменение показателя степени −0,5 в обе стороны приводило к некоторому повышению ранга одних и понижению ранга других таких нерелевантных заметок.

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

5. Получить заголовок, картинки и фрагмент текста. Это неинтересная техническая задача, решаемая во внешней части запроса. Для «сниппетов» — коротких фрагментов текста — я достаю первые два предложения из заметок. Сначала думал выводить те предложения из текста, которые содержат общие слова. Зависимость сниппетов от контекста как раз бы показала, почему рекомендуется именно эта заметка. Но sql-запрос и так получился достаточно объемным, и пока я остановился на упрощенном варианте. Возможно движок как продукт с таким упрощенным вариантом будет даже лучше. Если писать заметки так, чтобы первые предложения давали понять, о чем будет заметка, то показывать лучше их, а не случайные предложения из самого текста.

Вопросы производительности

В запросе вы видите явное указание использовать конкретные индексы. Без них планировщик не использовал часть индексов. Почему он так решал — непонятно. За счет расстановки хинтов я оптимизировал запрос раз в 20 до нескольких десятков миллисекунд. Я последние 6 лет работаю с PostgreSQL, и он даже думать отучил, что в запросы можно добавить хинты. Но тут пришлось.

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

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

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

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

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

Я так и не понял, почему возникают такие всплески времени выполнения запроса. Не думаю, что конкуренция MySQL за процессорное время с PHP или веб-сервером может привести к таким всплескам более чем в 20 раз. На других запросах, правда, более простых, этого не видно. Возможно, MySQL выгружает страницы с нужными данными из памяти. Ну да ладно. Похоже, проблема связана с нагрузкой на гипервизор от соседних виртуальных машин.

Направления развития

Дополнение о нормальной форме

Внимательный читатель отметил, что таблица полнотекстового индекса не находится даже в первой нормальной форме: в одной ячейке positions через запятую перечислен список положений слова. Что хорошо в теории, не всегда хорошо в настоящем работающем софте. Раньше действительно структура этой таблицы была другой, и каждый элемент из positions располагался на своей строке. Для корректной работы алгоритма мне нужно было обеспечить уникальность строк, поэтому элементы (word_id, toc_id, position) я еще добавил в уникальный индекс.

Достаточно быстро в целях оптимизации я отказался от индекса по word_id и повесил первичный ключ сразу на все колонки (word_id, toc_id, position). В этом есть смысл, так как первичный индекс в InnoDB кластерный, то есть данные строк хранятся на диске вместе с первичным индексом.

Сейчас я пошел в оптимизации дальше и отказался от нормальной формы для хранения положений. Базы данных устроены так, что в таблицах в каждой строке хранится дополнительная служебная информация. Объединение нескольких строк с одинаковыми word_id и toc_id в одну дало экономию места в полтора раза (поисковый индекс в целом уменьшился с 22 до 14 мегабайт при суммарном объеме заметок 2,8 мегабайт). Кроме того, скорость индексации тоже выросла примерно в полтора раза, так как сократилось количество выполняемых запросов. Я не обнаружил какого-либо заметного влияния формата поля positions на объем (кроме строки через запятую пробовал json и бинарную последовательность int4).

Понятно, что отказ от нормальной формы — не универсальное решение. Так, в рассматриваемом примере пропала возможность фильтрации по полю position. Для задач поиска в этом ничего страшного нет, так как в них фильтрация по position не встречается. Хотя по большому счету мало что изменилось: фильтрацию всё еще можно делать через операции с поиском подстроки, и это не будет сильно медленнее, потому что и раньше по полю position отдельного индекса не было.

    Оставить комментарий

← сюда туда →

Поделиться
Записи