В этой главе
• Перевод вопросов в запросы.
• Создание интерфейсов на естественном языке.
• Анализ табличных данных.
• Анализ графовых данных.
Значительная часть информации в мире хранится в виде структурированных данных. Структурированными, по сути, являются данные, хранящиеся в стандартизированном формате. Например, популярными типами структурированных данных являются табличные данные (скажем, данные, которые можно найти в электронной таблице Excel), а также данные, описывающие сущности и их связи в виде графов (скажем, набор данных, описывающий социальную сеть).
Инструменты для обработки структурированных данных доступны уже много десятилетий. Такие данные имеют стандартизированный формат, оптимизированный для облегчения обработки компьютерами. Так зачем же использовать для обработки этих данных большие языковые модели? Проблема имеющихся инструментов, предназначенных для этой цели, — их интерфейс. Как правило, каждый инструмент (или по крайней мере каждая категория инструментов для определенных типов структурированных данных) поддерживает собственный формальный язык запросов.
Используя этот язык, пользователи, как правило, могут выполнять широкий спектр аналитических операций со структурированными данными. Но на изучение таких языков запросов требуется время! Не правда ли, было бы замечательно иметь возможность отправлять запросы ко всем системам на одном языке, в идеале на естественном (например, русском)?
Именно здесь могут помочь большие языковые модели. Они способны переводить вопросы, заданные на естественном языке, на множество формальных языков. Поэтому их можно использовать в качестве универсального интерфейса к различным инструментам анализа данных, поддерживающим широкий спектр типов структурированных данных. В этой главе мы создадим интерфейсы, позволяющие писать запросы на естественном языке к структурированным данным различных типов. Такие интерфейсы позволят нам (или другим людям) анализировать данные, вводя вопросы на естественном языке. Затем система переведет наши вопросы в формальные запросы, выполнит их и представит результаты.
В этой главе мы создадим несколько интерфейсов запросов на естественном языке. В общем случае интерфейс запросов на естественном языке обрабатывает запросы к данным, сформулированные на естественном языке. В главе рассматриваются различные типы структурированных данных. Сначала мы создадим интерфейсы на естественном языке, которые будут обрабатывать запросы о табличных данных. Затем создадим интерфейс, обрабатывающий запросы о графах.
Принцип одинаков в обоих случаях. Предполагается, что данные обрабатываются с использованием характерного для конкретного типа данных инструмента. Например, для табличных данных применяется реляционная система управления базами данных (РСУБД). Для анализа графов используется система управления графовыми данными. Затем с помощью большой языковой модели вопросы на естественном языке переводятся на язык запросов, поддерживаемый конкретным инструментом. Для РСУБД это, как правило, язык структурированных запросов (Structured Query Language, SQL). Системы управления графовыми базами данных поддерживают различные специфичные для графовых данных языки запросов. Мы будем использовать язык запросов Cypher (описывающий аналитические операции с графовыми данными). Более подробно оба языка обсудим в следующих разделах.
Чтобы переводить вопросы в формальные запросы, языковой модели нужен доступ к вопросу (разумеется), а также информация о целевом языке (например, хотим ли мы писать запросы на SQL или Cypher?) и структуре запрашиваемых данных. К примеру, для структуры табличных данных характерны, помимо прочего, имена таблиц, заголовки столбцов, которые появляются в этих таблицах (предположительно, они помогают понять, какова семантика табличных данных), и типы данных каждого столбца (хранятся ли в нем целочисленные значения или строки?). После того как мы предоставим языковой модели все фрагменты информации, добавив их в запрос, модель должна быть способна создать формальный запрос, отражающий семантику нашего вопроса.
Этот запрос можно обработать с помощью специализированного инструмента и получить результат. При условии, что перевод запроса был корректным, результат будет представлять собой ответ на исходный вопрос. Процесс показан на рис. 5.1.

Рис. 5.1. Интерфейс запросов на естественном языке переводит вопросы, заданные на естественном языке, в формальные запросы, принимая во внимание структуру данных. Затем с помощью специализированного инструмента проводится обработка формальных запросов к данным и генерируется результат запроса
Зачем использовать внешние инструменты
Мы выяснили, что языковые модели способны решать разнообразные задачи. Так почему бы просто не использовать их для анализа структурированных данных напрямую? Почему мы применяем для этого внешние инструменты, а языковую модель используем лишь в качестве переводчика?
Основная причина — эффективность. Использовать большие языковые модели дорого, и, по крайней мере в больших наборах данных, размер данных может легко превысить максимальный размер входных данных модели. Следовательно, более разумное решение — использовать существующие инструменты, способные работать с большими структурированными наборами данных.
ПРЕДУПРЕЖДЕНИЕ В следующих разделах языковые модели используются для написания команд обработки данных. Несмотря на то что во многих вариантах использования модели показывают удивительно высокую эффективность, не стоит рассчитывать на абсолютную корректность их выводов в каждом отдельно взятом сценарии. В одних случаях модели могут писать некорректные запросы. В других — могут написать команды для изменения или удаления ваших данных или изменения конфигурации вашей системы. Всегда сохраняйте резервную копию важных данных, прежде чем разрешать доступ к ним через языковые модели.
Снова вернемся в компанию Banana. Выяснилось, что ваш начальник — заядлый любитель компьютерных игр. Он не только любит играть по вечерам, но и обожает анализировать данные о компьютерных играх. Получив на днях набор данных о продажах компьютерных игр, ваш начальник хочет извлечь из него статистическую информацию. Для анализа табличных данных, как правило, используют язык SQL, но начальник толком не умеет писать SQL-запросы. Зная, что вы эксперт в том, что касается языковых моделей и анализа данных, он спрашивает, возможно ли создать интерфейс, который будет переводить вопросы о компьютерных играх в соответствующие SQL-запросы.
Подумав, вы понимаете, что это интересный проект, который можно легко реализовать с помощью языковых моделей. Начнем!
Прежде чем мы сможем анализировать табличные данные с помощью SQL-запросов, нужно загрузить эти данные в РСУБД — инструмент для эффективной обработки табличных данных. В этом подразделе вы узнаете, как загрузить данные о компьютерных играх в популярную РСУБД SQLite.
В первую очередь убедитесь, что у вас установлена SQLite. Здесь мы будем пользоваться SQLite версии 3. Проверить, установлена ли у вас данная версия SQLite, можно, набрав в терминале следующую команду:
sqlite3 --version
Если появится сообщение, подобное приведенному ниже, то дополнительная установка не требуется:
3.33.0 2020-08-14 13:23:32 fca8...
Если отобразится сообщение об ошибке, то перейдите на сайт www.sqlite.org/download.html. Выберите версию, совместимую с вашей операционной системой, скачайте все соответствующие файлы и следуйте инструкциям по установке SQLite. В случае возникновения проблем зайдите на сайт книги, перейдите в раздел дополнительных материалов к главе 5 и щелкните на ссылке SQLite Installation. На открывшейся странице вы найдете подробные инструкции по установке SQLite на различных платформах. После этого запустите предыдущую команду, чтобы убедиться, что РСУБД SQLite установлена корректно.
Далее с помощью SQLite необходимо создать реляционную базу данных. Такая база, по сути, представляет собой коллекцию таблиц данных. Можете считать каждую такую таблицу обычной электронной таблицей. В ней есть именованные столбцы, связанные с типом данных, и строки (возможно, их много), которые содержат значения для каждого из столбцов. В табл. 5.1 показан пример таблицы с информацией о видеоиграх, где каждая строка представляет одну игру. В таблице четыре столбца. Три из них («Название», «Платформа» и «Жанр») содержат строки (то есть текст). В столбце «Год» приводятся цифры — год выпуска игры.
Таблица 5.1. Пример таблицы данных. Каждая строка таблицы описывает одну видеоигру
| Название | Платформа | Год | Жанр |
| Wii Sports | Wii | 2006 | Спортивная игра |
| «Супербратья Марио» | NES | 1985 | Платформенная игра |
| Mario Kart Wii | Wii | 2008 | Гонки |
| Wii Sports Resort | Wii | 2009 | Спортивная игра |
| Pokemon Red/Pokemon Blue | GB | 1996 | Ролевая игра |
В этой таблице показан небольшой фрагмент набора данных, который доступен по ссылке Games, размещенной на сайте книги, в разделе дополнительных материалов к главе 5. Мы используем этот набор в следующих разделах, чтобы создать интерфейс запросов на естественном языке, позволяющий пользователям анализировать данные с помощью команд на естественном языке. Но сначала необходимо загрузить эти данные в систему базы данных SQLite.
СОВЕТ Мы выполним все действия, необходимые для загрузки табличных данных в SQLite. Это полезно, если требуется загружать другие данные, помимо обсуждаемых здесь. Если вы не планируете создавать собственную базу, то можете пропустить этот подраздел и скачать файл games.db по ссылке Games SQLite, доступной на сайте книги в разделе дополнительных материалов к главе.
Запустим интерфейс командной строки SQLite. Введите в терминале следующую команду и нажмите клавишу Enter:
sqlite3 games.db
Команда создаст новую базу данных, хранящуюся в файле games.db. Одновременно она откроет интерфейс командной строки SQLite. Теперь можно дать инструменту SQLite указание загрузить набор данных, который мы будем использовать в следующих разделах. Сначала необходимо предоставить SQLite некоторые сведения о структуре данных. Нам нужно загрузить расширенную версию табл. 5.1, содержащую дополнительные столбцы. Чтобы описать структуру данных, выполните следующую команду:

Эта команда описывает структуру единственной таблицы games ❶ (ведь в ней будет храниться информация о видеоиграх); в скобках мы указываем полный список столбцов таблицы (разделенных запятыми) ❷. Каждый столбец определяется по имени столбца (например, rank, name или genre), за которым следует обозначение типа столбца. Например, int означает, что столбец содержит целые числа, а text — что в столбце хранятся текстовые данные. Все строки таблицы должны предоставлять значения для каждого столбца в соответствии с его типом данных.
После определения структуры таблицы можно загрузить в нее данные из файла .csv, доступного по ссылке Games, размещенной на сайте книги в разделе дополнительных материалов. Скачайте файл, если еще не сделали этого. Для следующих команд предположим, что файл сохранен в папке /Downloads/videogames.csv. Просто замените этот путь на путь к файлу в вашей системе. Загрузите данные с помощью таких команд (все еще в интерфейсе SQLite):

Первая команда подготавливает SQLite к загрузке данных из файла .csv (применима к загружаемому файлу) ❶. Вторая команда ❷ импортирует данные: первый параметр — это путь к файлу, из которого требуется загрузить данные, а второй — имя таблицы данных, в которую нужно загрузить файл. В этом случае мы ссылаемся на таблицу, структуру которой определили ранее (games). Чтобы проверить, были ли данные загружены успешно (должны, если не появлялось сообщение об ошибке), выполните в SQLite следующую команду:
SELECT count(*) FROM games;
Если были загружены все данные, то должен отобразиться результат 16599. Если отображается меньшее значение, проверьте сообщения об ошибках в выводе SQLite. Выйти из консоли SQLite можно с помощью команды .quit (не забудьте добавить точку в начале команды, чтобы она сработала). Далее предполагается, что данные были загружены и хранятся в файле SQLite games.db.
Безусловно, с помощью языковой модели можно перевести вопросы в SQL-запросы (которые поймет база данных SQLite). Но стоит ли доверять ее переводам? Обдумав этот вопрос, вы понимаете, что вам не помешало бы освоить хотя бы основы SQL, чтобы проверить вывод языковой модели, прежде чем представлять интерфейс своему начальнику. Этим мы и займемся в текущем подразделе. Разумеется, подробное описание SQL не входит в задачи этой книги. Если оно вам нужно, то вы можете найти его на сайте www.databaselecture.com. А здесь мы обсудим основы SQL, которые помогут реализовать интерфейс запросов на естественном языке.
SQL-запросы используются для анализа таблиц данных. Результаты могут быть получены из одной таблицы или путем объединения данных из нескольких таблиц. В нашем примере базы данных, созданном в прошлом подразделе, — всего одна таблица. Используя SQL-запросы, можно, например, подсчитывать строки с определенными свойствами (скажем, все игры одного издателя), фильтровать данные (допустим, показывать только игры, выпущенные в 2017 году) или выполнять различные операции агрегирования (к примеру, для каждого издателя рассчитывать среднюю выручку за одну игру).
В общем случае SQL-запрос описывает таблицу, которую нужно сгенерировать (используя данные, уже имеющиеся в базе). Большинство примеров запросов в этом разделе имеют следующую структуру:

Предложение FROM ❷ (код, идущий после ключевого слова FROM и до ключевого слова WHERE) описывает исходные данные, используемые для анализа. Например, предложение FROM может содержать список имен таблиц, разделенных запятыми. В нашем примере базы данных есть только одна таблица для обработки (в сложных запросах одно и то же имя таблицы может появляться в предложении FROM несколько раз, создавая разные копии одной и той же таблицы). Запросы, которые вы увидите в следующих разделах, будут содержать единственную запись в предложении FROM: таблицу games.
Предложение WHERE ❸ определяет предикаты для таблиц, которые появляются в предложении FROM. Например, оно может содержать условие, ограничивающее выборку играми от конкретного издателя. Предложение WHERE может содержать как простые условия (которые могут быть выражены равенством или неравенством для столбца таблицы), так и сложные (соединяющие несколько простых условий с помощью операторов AND или OR). Эти условия используются для фильтрации строк из таблиц в предложении FROM. Строки, которые не удовлетворяют условию в предложении WHERE, отбрасываются и не появляются в результатах запроса.
Наконец, в предложении SELECT ❶ задаются столбцы желаемой итоговой таблицы. Точнее, мы указываем список определений столбцов, разделенных запятыми. Столбцы могут определяться по имени столбца (столбец, имеющийся в одной из таблиц в предложении FROM) или более сложным выражением: например, арифметическим выражением, связывающим несколько столбцов. Как вариант, в предложении SELECT можно указать агрегаты, например count(*), который подсчитывает количество строк. Результат запроса содержит таблицу с указанными столбцами, значения в которых заполняются в соответствии с определением столбца.
Допустим, мы хотим подсчитать количество видеоигр, изданных в 2017 году. В этом случае результат запроса должен содержать один столбец с агрегатом count. Кроме того, в предложении WHERE должно быть прописано условие фильтрации, ограничивающее выборку видеоиграми за 2017 год. Наше предложение FROM, естественно, будет содержать имя единственной таблицы в базе данных (games). Приведенный ниже запрос генерирует желаемый результат:
SELECT Count(*)
FROM games
WHERE year = 2017
Чтобы немного усложнить задачу, предположим, что нам нужно рассчитать суммарное количество продаж в Европе и Японии всех видеоигр, выпущенных в 2017 году конкретным разработчиком (Activision). Желаемый результат запроса содержит два столбца: название игры и количество продаж. В таблице данных есть два столбца с продажами: в Европе и в Японии (eusales и japansales), поэтому согласно нашему определению таблицы в предыдущем подразделе желаемый итоговый столбец можно описать, сложив их данные (eusales + japansales). Поскольку выборку нужно ограничить видеоиграми за 2017 год, выпущенными компанией Activision, то в предложении WHERE можно использовать сложный предикат: year = 2017 AND publisher = 'Activision'. Обратите внимание на одинарные кавычки по обеим сторонам названия Activision — они необходимы потому, что в условиях запроса строковые значения заключаются в одинарные кавычки, а числа — нет. Приведенный ниже запрос генерирует желаемый результат:
SELECT name, eusales + japansales
FROM games
WHERE year = 2017 AND publisher = 'Activision'
При необходимости, указывая агрегатные функции, можно вычислять эти агрегаты для разных групп строк, определяемых общими значениями в конкретных столбцах. Для этого добавим к предыдущему шаблону запроса заключительное предложение GROUP BY, за которым будет следовать разделенный запятыми список столбцов, используемых для формирования групп. Например, допустим, нам нужно посчитать количество продаж видеоигр каждого жанра (скажем, стратегии или экшена) отдельно. С помощью следующего запроса можно вернуть одну строку с агрегатами для каждой категории игр (обратите внимание, что мы также добавляем в предложение SELECT столбец genre, чтобы можно было связать числа с нужным жанром):
SELECT genre, name, eusales + japansales
FROM games
WHERE year = 2017 AND publisher = 'Activision'
GROUP BY genre
Краткого пояснения SQL, предоставленного в этом подразделе, явно недостаточно для написания собственных SQL-запросов, за исключением нескольких простых случаев. Однако нам на самом деле и не нужно писать эти запросы — ведь мы планируем, что за нас это будет делать языковая модель! Вводный материал должен дать вам общее представление о запросах, сгенерированных языковой моделью. А наблюдая за тем, как она преобразует вопросы в запросы, вы сможете легко сделать первые шаги в изучении SQL. В следующем подразделе мы начнем с создания простого переводчика, преобразующего вопросы в SQL-запросы к базе данных games.
Итак, вы получили базовое представление о SQL, достаточное для проверки результатов работы языковой модели. И теперь мы приступим к разработке переводчика текста в SQL-запросы. Работать с переводчиком будем через командную строку. Введем в качестве входных данных вопрос, и в идеале в результатах вывода хотелось бы получить перевод вопроса в SQL-запрос. Запустив полученный запрос в SQLite, мы должны получить ответ на свой исходный вопрос. Конечно, интерфейс подразумевает работу вручную и не очень удобен в использовании. В идеале хотелось бы, чтобы он автоматически выполнял запросы и показывал соответствующие результаты прямо в интерфейсе запросов. Мы создадим такой интерфейс в следующем разделе. Сейчас просто сосредоточимся на основной проблеме перевода вопросов в запросы. Кроме того, на данный момент наша единственная цель — переводить вопросы о компьютерных играх. Поэтому нужно строго прописать в коде структуру целевой базы данных. Опять же, мы обобщим этот подход в следующем проекте.
Внутренняя логика перевода входного вопроса такова: сначала сформируем промпт. В нем описывается задача перевода и содержатся все актуальные для перевода детали (например, структура целевой базы данных). После отправки этого промпта языковой модели в большинстве случаев должен получиться корректно переведенный SQL-запрос. Возможно, придется еще немного потрудиться, чтобы вычленить из длинного ответа языковой модели сам запрос. Давайте обсудим действия более подробно, начиная с промптов.
Какую информацию необходимо передать языковой модели, чтобы она выполнила перевод успешно? Очевидно, требуется добавить вопрос, который следует перевести. Кроме того, нужно указать целевую систему (SQLite) и описать структуру целевой базы данных. Пока мы строго прописываем в коде структуру базы. Можно просто предоставить языковой модели определение таблицы (create table...), использовавшееся в подразделе 5.2.1. Языковая модель поймет, как эта команда соотносится со структурой таблицы. Мы отправляем модели промпт с инструкциями по переводу, содержащий все упомянутые ранее типы информации. Получив его, языковая модель должна быть способна создать соответствующий SQL-запрос.
Используем следующий шаблон промпта:

В этом шаблоне содержатся все фрагменты данных, упомянутые ранее. Он описывает целевую базу данных ❶, предоставляя SQL-команды, используемые для создания связанных таблиц (в данном случае — одной таблицы). Обратите внимание, что это не метка-заполнитель, так как здесь интерфейс запросов должен работать только с одной базой данных (структуру которой мы строго прописываем в коде в шаблоне). Далее, шаблон промпта содержит описание задачи ❷: цель — переводить вопросы в SQL-запросы. Наконец, в шаблоне есть вопрос для перевода ❸. Здесь применяется метка-заполнитель (в квадратных скобках). Она позволяет использовать один и тот же шаблон промпта для различных вопросов, которые начальник может задать о данных.
Этот код генерирует промпты в соответствии с шаблоном, приведенным выше:

Получив в качестве входных данных вопрос для перевода, код добавляет описание базы данных ❶, затем инструкции для перевода ❷ и, наконец, вопрос для перевода ❸. Результатом является объединение всех частей промпта ❹.
В листинге 5.1 содержится полный код для нашего интерфейса запросов на естественном языке. В нем используется функция генерации промпта ❶, о которой говорилось выше, а также функция вызова языковой модели ❷, уже знакомая вам по предыдущим главам.
Листинг 5.1. Перевод вопросов о видеоиграх в SQL-запросы
import argparse
import openai
import re
import time
client = openai.OpenAI()
def create_prompt(question): ❶ 
"""Сгенерировать промпт для перевода вопроса в SQL-запрос:
Аргументы:
question: вопрос о данных на естественном языке.
Выводит:
Промпт для перевода вопроса.
"""
parts = []
parts += ['База данных:']
parts += ['create table games(rank int, name text, platform text,']
parts += ['year int, genre text, publisher text, americasales numeric,']
parts += ['eusales numeric, japansales numeric, othersales numeric,']
parts += ['globalsales numeric);']
parts += ['Перевести вопрос в SQL-запрос:']
parts += [question]
return '\n'.join(parts)
def call_llm(prompt): ❷ 
"""Отправить запрос языковой модели и вывести ответ.
Аргументы:
prompt: ввести запрос к языковой модели.
Выводит:
Ответ языковой модели.
"""
for nr_retries in range(1, 4):
try:
response = client.chat.completions.create(
model='gpt-4o',
messages=[
{'role':'user', 'content':prompt}
]
)
return response.choices[0].message.content
except:
time.sleep(nr_retries * 2)
raise Exception('Не удается выполнить запрос к модели OpenAI!')
if __name__ == '__main__': ❸ 
parser = argparse.ArgumentParser()
parser.add_argument('question', type=str, help='Вопрос об играх')
args = parser.parse_args()
prompt = create_prompt(args.question) ❹ 
answer = call_llm(prompt) ❺ 
❻ 
query = re.findall('```sql(.*)```', answer, re.DOTALL)[0]
print(f'SQL: {query}')
В этом фрагменте кода мы получаем вопрос о видеоиграх в качестве аргумента командной строки ❸. Используя введенный вопрос, он генерирует промпт ❹, который инструктирует языковую модель перевести вопрос в SQL-запрос. Он отправляет промпт в языковую модель и получает ее ответ ❺.
Необработанный ответ от языковой модели GPT-4o, помимо SQL-запроса, который нас интересует, обычно содержит еще и пояснения. Чтобы получить только запрос, нужно извлечь его из необработанного ответа ❻. Здесь воспользуемся тем, что GPT-4o помещает SQL-запросы между маркерами ```sql и ``` (если вы взаимодействуете с моделями GPT через веб-интерфейс ChatGPT, то все, что находится между этими маркерами, отображается как блок кода). Регулярное выражение ```sql(.*)``` находит SQL-запрос между маркерами, используя функцию Python re.findall для возврата списка совпадений с этим регулярным выражением (параметр re.DOTALL необходим, чтобы гарантировать, что точка соответствует всем символам, в том числе новой строки, которые могут появляться в SQL-запросах). Мы используем в качестве запроса первое совпадение (то есть мы допускаем, что функция вернет хотя бы одно совпадение и что первое из них будет корректным).
Итак, пора протестировать наш переводчик из текста в SQL! В терминале перейдите в каталог, содержащий код на Python. Предположим, что код сохранен в файле listing1.py (доступен по ссылке Listing 1, размещенной на сайте книги в разделе дополнительных материалов к этой главе). Выполните следующую команду, чтобы спросить, сколько игр хранится в базе данных:
python listing1.py "Сколько игр хранится?"
В результате должен получиться следующий SQL-запрос:
SELECT COUNT(*) FROM games;
Корректен ли он? Выясним это: в терминале перейдите в репозиторий, содержащий файл базы данных SQLite (games.db). Затем откройте базу через интерфейс командной строки SQLite:
sqlite3 games.db
Теперь наконец можно протестировать запрос, сгенерированный нашим переводчиком из текста в SQL. Введите запрос и нажмите клавишу Enter. Вы должны получить количество видеоигр, сохраненных в базе данных: 16 599.
Полагаю, вы захотите протестировать и другие запросы. Например, выяснить, удастся ли подсчитать количество видеоигр, выпущенных конкретными издателями или относящихся к определенному жанру! Скорее всего, для большинства вопросов, касающихся этой простой базы данных, модель GPT-4o окажется вполне способна предоставить точный перевод. Ваш начальник будет доволен.
Интерфейс, который мы создали в этом разделе, все еще имеет несколько ограничений.
• Во-первых, программу приходится перезапускать для каждого нового вопроса.
• Во-вторых, необходимо вручную копировать и выполнять каждый переведенный запрос в интерфейсе системы баз данных.
• В-третьих, и это самое главное, если вы когда-нибудь захотите поработать с другим набором данных, то шаблон промпта придется менять вручную.
В следующем разделе вы узнаете, как преодолеть эти ограничения.
Ваш начальник доволен новым интерфейсом на естественном языке и регулярно делится с вами любопытными сведениями о продажах компьютерных игр. Однако вы поневоле задумываетесь: полностью ли раскрывается потенциал подхода в рамках лишь этого сценария использования? Например, отделу кадров в компании Banana регулярно приходится иметь дело со сложными задачами, касающимися таблиц, в которых хранится информация о сотрудниках. Нельзя ли обобщить наш интерфейс естественного языка, чтобы помочь и кадровикам? В этом разделе мы расширим интерфейс запросов на естественном языке, чтобы он мог работать с любой базой данных и при этом не приходилось вносить изменения в код. Более того, мы сделаем интерфейс более удобным, выполняя переведенные запросы напрямую и избегая перезапусков между разными вопросами по одним и тем же данным.
Для начала посмотрим, как можно выполнять переведенные запросы напрямую из Python. Это позволит избежать утомительного копирования запросов из одного интерфейса в другой. В Python запросы к базе данных SQLite можно выполнять с помощью библиотеки sqlite3.
Предположим, что путь к файлу базы данных хранится в переменной data_path. Чтобы выполнять запросы к этой базе данных, необходимо сначала создать соединение:
import sqlite3
with sqlite3.connect(data_path) as connection:
...
Теперь можно выполнять SQL-запросы к базе данных, используя объект connection. Предположим, что SQL-запрос, который необходимо выполнить, хранится в переменной query. После подключения к базе данных мы сначала создаем объект cursor (позволяющий выполнять запросы и получать результаты), а затем используем его для выполнения запроса:
import sqlite3
with sqlite3.connect(data_path) as connection:
cursor = connection.cursor()
cursor.execute(query)
После выполнения, чтобы получить все строки результата, мы вызываем метод cursor.fetchall(). Мы соберем все вместе в функцию, которая принимает в качестве входных данных два параметра — путь к базе данных и запрос для выполнения в ней, — возвращая результат запроса в виде строки:

После подключения к целевой базе данных ❶ функция создает объект cursor ❷, выполняет входящий запрос ❸ и извлекает результат запроса ❹. Завершив преобразование кортежей результата в строковое представление ❺, мы объединяем полученные строки, разделяя их символами новой строки ❻.
Нам требуется интерфейс, который будет работать с любыми базами SQLite, не вынуждая нас вносить изменения в код. Значит, нужно научиться автоматически извлекать структуру текущей базы данных (информацию о ее таблицах и столбцах).
Для SQLite структуру базы данных можно извлечь, выполнив SQL-запросы. Они обращаются к специальной таблице схемы (schema table). Она создается автоматически (то есть ее не нужно создавать вручную). Среди прочего она содержит SQL-команды, которые использовались для создания других таблиц в базе. Их можно использовать как краткое описание структуры базы, подходящее в качестве входных данных для языковой модели.
Доступ к таблице схемы можно получить по ее имени — sqlite_master. Она содержит столбец sql, в котором хранится информация о запросах, использованных для создания объектов внутри базы данных. В частности, нас интересуют SQL-команды, которые использовались для создания таблиц. Эти запросы содержат ключевую для перевода информацию, в том числе имена и типы столбцов таблицы, которую они создают. Этот запрос извлекает все SQL-инструкции, использованные для создания таблиц в текущей базе данных:
select sql from sqlite_master where type = 'table';
Следовательно, достаточно просто выполнить этот запрос из кода Python. К счастью, мы уже выяснили, как это сделать. Получив путь к базе данных, функция (см. ниже) возвращает текст с описанием запросов, использовавшихся для создания всех таблиц в базе:

И снова мы создаем соединение ❶ и соответствующий объект cursor ❷. Далее выполняем запрос к таблице схемы, чтобы получить все SQL-запросы, использованные для создания таблиц в текущей базе данных ❸. Мы извлекаем результаты ❹ и получаем тексты SQL-запросов из результата запроса ❺. Обратите внимание, что эта часть функции немного отличается от общей функции для выполнения запросов, о которой шла речь в предыдущем разделе. Извлекая значения первого (и для этого конкретного запроса — единственного) поля каждой строки, мы избавляемся от ненужных разделителей между строками, которые в противном случае появились бы в выводе (а потом — в запросах). Результатом является объединение всех строк вывода ❻.
В листинге 5.2 представлен полный код универсального интерфейса запросов на естественном языке (файл доступен по ссылке Listing 2, которую можно найти на сайте книги в разделе дополнительных материалов к этой главе).
Листинг 5.2. Универсальный интерфейс для преобразования текста в SQL-запрос
import argparse
import openai
import re
import sqlite3
import time
client = openai.OpenAI()
def get_structure(data_path): ❶ 
"""Извлечь структуру из базы данных SQLite.
Аргументы:
data_path: путь к файлу данных SQLite.
Выводит:
текстовое описание структуры базы данных.
"""
with sqlite3.connect(data_path) as connection:
cursor = connection.cursor()
cursor.execute("select sql from sqlite_master where type = 'table';")
table_rows = cursor.fetchall()
table_ddls = [r[0] for r in table_rows]
return '\n'.join(table_ddls)
def create_prompt(description, question): ❷ 
"""Сгенерировать промпт для перевода вопроса в SQL-запрос.
Аргументы:
description: текстовое описание структуры базы данных.
question: вопрос о данных на естественном языке.
Выводит:
промпт для перевода вопроса.
"""
parts = []
parts += ['База данных:']
parts += [description]
parts += ['Переведи вопрос в SQL-запрос']
parts += [question]
return '\n'.join(parts)
def call_llm(prompt): ❸ 
"""Отправить запрос языковой модели и вывести ответ.
Аргументы:
prompt: введите запрос к языковой модели.
Выводит:
Ответ языковой модели.
"""
for nr_retries in range(1, 4):
try:
response = client.chat.completions.create(
model='gpt-4o',
messages=[
{'role':'user', 'content':prompt}
]
)
return response.choices[0].message.content
except:
time.sleep(nr_retries * 2)
raise Exception('Не удается выполнить запрос к модели OpenAI!')
def process_query(data_path, query): ❹ 
"""Обрабатывает SQL-запрос и выводит результат.
Аргументы:
data_path: путь к файлу данных SQLite.
query: обрабатывает этот запрос к базе данных.
Выводит:
query result.
"""
with sqlite3.connect(data_path) as connection:
cursor = connection.cursor()
cursor.execute(query)
table_rows = cursor.fetchall()
table_strings = [str(r) for r in table_rows]
return '\n'.join(table_strings)
if __name__ == '__main__':
parser = argparse.ArgumentParser()
parser.add_argument('dbpath', type=str, help='Путь к данным SQLite')
args = parser.parse_args()
data_structure = get_structure(args.dbpath) ❺ 
while True: ❻ 
user_input = input('Введите вопрос:')
if user_input == 'quit':
break
prompt = create_prompt(data_structure, user_input)
answer = call_llm(prompt)
query = re.findall('```sql(.*)```', answer, re.DOTALL)[0]
print(f'SQL: {query}')
try: ❼ 
result = process_query(args.dbpath, query)
print(f'Результат: {result}')
except:
print('Ошибка обработки запроса! Попробуйте переформулировать')
В этом коде используется функция для извлечения структуры базы данных ❶, которую мы обсуждали ранее. Функция для создания запросов ❷ представляет собой немного модифицированную версию той, что использовалась в предыдущем интерфейсе для конкретной базы данных. В отличие от четко прописанной структуры базы данных теперь описание базы передается в виде входных данных и вставляется в промпт. Функция для вызова языковой модели ❸ не изменилась по сравнению с предыдущей версией интерфейса. С функцией process_query ❹ вы познакомились в подразделе 5.3.1.
После чтения аргументов командной строки интерфейс запросов на естественном языке извлекает структуру базы данных ❺. Затем запускается цикл ❻, который продолжается до тех пор, пока пользователь не завершит работу интерфейса. На каждой итерации сначала считывается ввод с клавиатуры (с выходом из цикла, если пользователь вводит команду quit), а затем создается промпт и выполняется вызов языковой модели.
Когда исходный вопрос будет переведен в запрос, этот запрос выполняется напрямую ❼. Конечно, перевод может быть неверным и привести к созданию запроса, который невозможно будет выполнить в целевой базе данных. В этом случае SQLite может выдать сообщение об ошибке, а нам необходимо убедиться, что выполнение программы не завершится аварийно. Именно поэтому мы заключаем вызов функции обработки запроса в блок try-except.
Самое время протестировать наш интерфейс запросов на естественном языке! Теперь он работает с произвольными базами данных, однако у нас всего одна база, поэтому снова используем базу данных видеоигр. В терминале перейдите в каталог, содержащий файл games.db, и выполните следующую команду (предположим, что код сохранен в файле listing3.py):
python listing3.py games.db
Откроется поле для ввода, в которое можно вводить вопросы о данных. Вот пример взаимодействия с интерфейсом запросов на естественном языке:
Введите вопрос: Сколько всего видеоигр хранится в базе?
SQL: SELECT COUNT(*) FROM games;
Ответ: (16599,)
Введите вопрос: Сколько видеоигр создала компания Activision?
SQL: SELECT COUNT(*) FROM games WHERE publisher = 'Activision'
Ответ: (975,)
Введите вопрос: Назови видеоигру, которая была выпущена в 2017 году.
SQL: SELECT name
FROM games
WHERE year = 2017
LIMIT 1
Ответ: (Phantasy Star Online 2 Эпизод 4: Подарочное издание)
Введите вопрос: Сколько видеоигр каждого жанра было выпущено?
SQL: SELECT genre, COUNT(*) as num_games
FROM games
GROUP BY genre
Ответ: ('Экшен', 3316)
('Приключения', 1286)
('Файтинг', 848)
('Жанр', 1)
('Прочее', 1739)
('Платформенная игра', 886)
('Головоломка', 582)
('Гонки', 1249)
('Ролевая игра', 1488)
('Шутер', 1310)
('Симулятор', 867)
('Спортивные игры', 2346)
('Стратегия', 681)
Введите вопрос: Какие три видеоигры продавались больше в Японии, чем в Европе?
SQL: SELECT name
FROM games
WHERE japansales > eusales
ORDER BY japansales DESC
LIMIT 3;
Ответ: ('Название',)
('Pokemon Red/Pokemon Blue',)
('Pokemon Gold/Pokemon Silver',)
Введите вопрос: Разбей продажи игр в Америке по платформам.
SQL: SELECT platform, sum(americasales) AS total_sales
FROM games
GROUP BY platform
Ответ: ('2600', 90.59999999999992)
('3DO', 0)
('3DS', 78.86999999999996)
('DC', 5.43)
('DS', 390.7099999999977)
('GB', 114.32000000000001)
('GBA', 187.54000000000033)
('GC', 133.46000000000004)
('GEN', 19.27)
('GG', 0)
('N64', 139.02000000000015)
('NES', 125.94000000000005)
('NG', 0)
('PC', 93.2800000000005)
('PCFX', 0)
('PS', 336.509999999998)
('PS2', 583.8399999999925)
('PS3', 392.2599999999998)
('PS4', 96.79999999999998)
('PSP', 108.98999999999975)
('PSV', 16.200000000000006)
('Platform', 0.0)
('SAT', 0.7200000000000001)
('SCD', 1)
('SNES', 61.22999999999998)
('TG16', 0)
('WS', 0)
('Wii', 507.7099999999991)
('WiiU', 38.31999999999999)
('X360', 601.0499999999992)
('XB', 186.6900000000008)
('XOne', 83.19000000000003)
Введите вопрос: quit
Как видите, можно задавать самые разные вопросы и получать адекватные ответы. Помимо ответа, система также выводит запрос. Обладая определенными знаниями в области SQL, мы можем проверить, точно ли запрос переводит вопрос.
Новость о вашем интерфейсе, преобразующем текст в SQL, разлетелась по компании Banana, и некоторые коллеги пользуются им для анализа табличных наборов данных. Ваш новый коллега работает с большими графами, моделируя связи между сотрудниками во внутренней социальной сети компании. Эти данные представлены не в виде таблицы, а в виде графа — формата данных, который отлично подходит для моделирования связей между сущностями (в данном случае людьми). Коллега спрашивает вас, можно ли расширить интерфейс так, чтобы он работал и с такими данными. Зная, что языковые модели в принципе должны уметь работать с различными формальными языками запросов, вы настроены оптимистично и соглашаетесь изучить этот вопрос.
Подобно реляционным данным, графы — это крайне популярный тип структурированных данных. Граф обычно состоит из набора узлов, соединенных ребрами. С узлами могут быть связаны свойства, а у ребер есть метки. Например, в виде графов часто представляют социальные сети. Здесь узлы обозначают людей, а ребра — дружеские связи и отношения. Кроме того, графы служат естественным представлением дорожных или метрополитеновских сетей. В этом случае узлы обозначают города или станции метро, а ребра — дороги или железнодорожные пути, соединяющие их.
На рис. 5.2 показан пример графа, представляющего сеть метро. Он изображает станции нью-йоркского метро как узлы (они помечены как Station). Ребра представляют прямые соединения и помечены названиями соответствующих линий метро. С узлами связано свойство name, присваивающее узлу имя станции.

Рис. 5.2. Пример графа, представляющего небольшой фрагмент нью-йоркского метро. Узлы, изображенные в виде кружков, обозначают станции метро и связаны со свойством, которое присваивает им имя представляемой станции метро. Ребра, изображенные стрелками, представляют прямые соединения по определенным линиям метро. Ребра помечены названием линии метро, соединяющей станции
Возможность использования графов для моделирования различных типов данных привела к созданию множества специализированных систем, называемых графовыми базами данных, предназначенных для обработки таких данных. Эти системы поддерживают специализированные языки запросов для графов (не SQL), позволяя пользователям формулировать сложные вопросы о хранимых данных. Графовые базы оптимизированы для эффективной обработки больших графов.
Далее с помощью языковых моделей мы будем переводить вопросы на естественном языке в запросы, сформулированные в системе управления графовой базой данных. Мы выясним, что, по сравнению методом перевода вопросов в SQL-запросы, этот сценарий требует лишь незначительных модификаций.
Мы будем работать с системой Neo4j — базой данных, специализированной для работы с графовыми данными. Вам даже не придется ничего устанавливать на локальный компьютер. У Neo4j есть демоверсия онлайн, которой мы и воспользуемся.
Сначала в браузере перейдите по адресу https://neo4j.com/sandbox/. Нажмите кнопку Get Started Free (Запустить бесплатную песочницу). Откроется форма входа, в которой можно создать новую учетную запись или использовать существующую (например, аккаунт Google). На рис. 5.3 показан экран, который вы должны увидеть далее.

Рис. 5.3. Выберите базу данных Movies и нажмите кнопку Create (Создать), чтобы создать соответствующий экземпляр
Здесь можно выбрать одну из нескольких демонстрационных баз данных, чтобы протестировать Neo4j в работе. Мы выберем базу данных Movies. Она содержит информацию о фильмах и актерах, которые в них играли (фильмы и актеры представлены в виде узлов). Щелкните на базе данных Movies, а затем нажмите кнопку Create (Создать), чтобы создать экземпляр базы и подготовить ее к выполнению запросов. Создание базы может занять несколько минут. После этого откройте базу данных Movies — и вы получите доступ к интерфейсу запросов.
На рис. 5.4 показан интерфейс запросов Neo4j. Щелкните на значке базы данных в левом верхнем углу интерфейса. Появится обзор базы. Она содержит несколько типов меток (то есть типов узлов), включая Person и Movie. Кроме того, в ней содержатся типы отношений, такие как ACTED_IN и DIRECTED, которые помечают ребра в графе. Например, эти два типа отношений позволяют отслеживать, кто в каком фильме сыграл роль (ACTED_IN) и кто какой фильм режиссировал (DIRECTED). Свойства связаны с узлами и сопоставляют ключи со значениями. Обзор базы данных показывает ключи свойств, такие как name (присваивает актеру имя) и title (присваивает фильмам названия).
Запросы можно вводить в текстовое поле в верхней части экрана и отправлять, нажимая кнопку справа от текстового поля.

Рис. 5.4. Щелкните на значке базы данных (в левом верхнем углу интерфейса), чтобы получить информацию о текущей графовой базе, в том числе о типах узлов и ребер
СУБД Neo4j поддерживает язык запросов Cypher. Подробное изучение этого языка выходит за рамки книги (вы можете изучить документацию Neo4j, доступную по адресу https://neo4j.com/docs/cypher-manual/current/introduction/), поэтому здесь мы рассмотрим лишь его основы. Цель такого краткого введения — научить вас понимать семантику запросов, генерируемых языковой моделью, по крайней мере в простых случаях.
Простой тип запроса Cypher использует оператор MATCH, описывающий подграф для поиска. Например, мы хотим найти все узлы определенного типа. Запрос для поиска имен всех людей в базе данных Movies выглядит так:

Оператор MATCH описывает шаблон для сопоставления ❶. В данном случае шаблон состоит из отдельных узлов Person. Оператор MATCH присваивает переменные узлам или ребрам, которые появляются в шаблоне. Здесь мы вводим переменную p и присваиваем ее узлам, соответствующим шаблону. Оператор RETURN ❷ описывает результат запроса на основе сопоставленных шаблонов. В данном случае мы указываем системе вернуть свойство name для каждого узла, соответствующего шаблону.
Шаблоны могут выходить за пределы одного узла. Например, нам может потребоваться найти названия всех фильмов с участием Тома Круза. В этом случае нужный нам шаблон будет состоять не из одного, а из двух соединенных узлов. Мы ищем узел типа Movie, соединенный через ребро типа ACTED_IN с узлом типа Person, для свойства name которого задано значение Tom Cruise. Это можно сделать с помощью такого запроса Cypher:

Выражение (p:Person {name: 'Tom Cruise'}) ❶ находит все узлы типа Person, свойство name которых имеет значение Tom Cruise. Выражение (m:Movie) находит все узлы типа Movie. Наконец, мы соединяем их с помощью выражения -[:ACTED_IN]->. Оно представляет направленное соединение (отсюда форма стрелки) между первым узлом (представляющим Тома Круза) и вторым узлом (представляющим какой-либо фильм). Тип соединения ограничен значением ACTED_IN (которое исключает, к примеру, фильмы, в которых Том Круз был режиссером, но не играл). Наконец, обратите внимание, что выражение MATCH снова присваивает частям этого шаблона переменные. Том Круз будет представлен переменной p, а фильмы, в которых он играл, — переменной m. Выражение RETURN ❷ извлекает свойство title узла фильма.
И напоследок посмотрим, как Neo4j вычисляет агрегаты (аналогично SQL):

Запрос похож на предыдущий и просто подсчитывает количество фильмов с участием Тома Круза в качестве актера ❶. Оператор RETURN ❷ содержит соответствующий агрегат. Введя данный запрос, вы должны получить результат 3 (очевидно, демонстрационная база данных неполная).
Мы будем использовать подход, аналогичный тому, с помощью которого мы переводили вопросы в SQL-запросы. В первую очередь необходимо изменить промпт для нашей языковой модели. Дадим ей указание переводить не на SQL, а на Cypher. К счастью, языковые модели, такие как GPT-4o, были предварительно обучены на большом и разнообразном наборе данных. Среди обучающих данных, вероятно, содержались и запросы на Cypher, так что языковые модели можно использовать для перевода. Как всегда, абсолютных гарантий нет, и запросы на Cypher, сгенерированные языковой моделью, могут не совсем точно отражать наши вопросы. Однако, по крайней мере для простых запросов, перевод, как правило, корректен.
Для перевода вопросов в запросы Cypher необходимо добавить в промпт некоторую информацию.
• Во-первых, это сам вопрос, который требуется перевести.
• Во-вторых, описание структуры базы данных. В случае с SQL структура базы определяется, например, именами таблиц и столбцов. В случае базы данных Neo4j необходимо добавить информацию о типах узлов и ребер, а также имена наиболее релевантных свойств. По сути, вся эта информация показана в левой части рис. 5.4.
Для простоты мы сосредоточимся на представленной ранее демонстрационной базе данных, в которой содержится информация о фильмах. Значит, нам нужно будет строго прописать структуру базы. Конечно, как и в случае с интерфейсом преобразования текста в SQL, можно расширить интерфейс так, чтобы он работал с произвольными графовыми базами данных.
Мы будем использовать следующий шаблон промпта:

Шаблон содержит описание базы данных ❶. Оно включает в себя спецификацию типа базы (Neo4j), а также список меток узлов, типов отношений и свойств. Обратите внимание, что структура базы строго прописана в шаблоне промпта. При использовании интерфейса с другой базой эту часть промпта придется заменить. Далее в шаблоне указан вопрос для перевода ❷. Это метка-заполнитель, ведь пользователи должны иметь возможность задавать разные вопросы о данных. Промпт завершается указанием целевого языка для его перевода ❸, что служит для языковой модели инструкцией перевести вопрос в запрос на языке Cypher.
В этом фрагменте кода создается экземпляр шаблона для заданного вопроса:
def create_prompt(question):
parts = []
parts += ['База данных Neo4j:']
parts += ['Метки узла: Movie, Person']
parts += ['Типы отношений: ACTED_IN, DIRECTED,']
parts += ['FOLLOWS, PRODUCED, REVIEWED, WROTE']
parts += ['Ключи свойств: born, name, rating, released']
parts += ['roles, summary, tagline, title']
parts += [question]
parts += ['Запрос Cypher:']
return '\n'.join(parts)
В листинге 5.3 используется ранее рассмотренная функция для генерации промптов ❶ и повторно применяется функция для вызова языковой модели GPT-4o ❷ (допускающая при необходимости выполнение нескольких попыток).
Листинг 5.3. Перевод текстовых вопросов в запросы Cypher для Neo4j
import argparse
import openai
import re
import time
client = openai.OpenAI()
def create_prompt(question): ❶ 
"""Сгенерировать промпт для перевода вопроса в запрос Cypher.
Аргументы:
question: вопрос о данных на естественном языке.
Выводит:
промпт для перевода вопроса.
"""
parts = []
parts += ['База данных Neo4j:']
parts += ['Метки узла: Movie, Person']
parts += ['Типы отношений: ACTED_IN, DIRECTED,']
parts += ['FOLLOWS, PRODUCED, REVIEWED, WROTE']
parts += ['Ключи свойств: born, name, rating, released']
parts += ['roles, summary, tagline, title']
parts += [question]
parts += ['Запрос Cypher:']
return '\n'.join(parts)
def call_llm(prompt): ❷ 
"""Отправить запрос большой языковой модели и вывести ответ.
Аргументы:
prompt: введите запрос к языковой модели.
Выводит:
Ответ языковой модели.
"""
for nr_retries in range(1, 4):
try:
response = client.chat.completions.create(
model='gpt-4o',
messages=[
{'role':'user', 'content':prompt}
]
)
return response.choices[0].message.content
except:
time.sleep(nr_retries * 2)
raise Exception('Не удается выполнить запрос к модели OpenAI!')
if __name__ == '__main__':
parser = argparse.ArgumentParser()
parser.add_argument('question', type=str, help='Вопрос о фильмах')
args = parser.parse_args()
prompt = create_prompt(args.question) ❸ 
answer = call_llm(prompt) ❹ 
❺ 
query = re.findall('```cypher(.*)```', answer, re.DOTALL)[0]
print(f'Запрос Cyper: {query}')
Получив во входных данных вопрос о базе данных, код генерирует соответствующий промпт ❸, получает ответ от языковой модели ❹ и, наконец, извлекает из этого ответа запрос на языке Cypher ❺. Регулярное выражение, используемое для извлечения, немного различается, поскольку GPT заключает запросы Cypher в шаблон '''cypher ... '''. Наконец, мы выводим извлеченный запрос.
Файл с кодом из листинга 5.3 доступен по ссылке Listing 3, размещенной на сайте книги в разделе дополнительных материалов к главе. Скачайте код и запустите из командной строки, введя команду, содержащую вопрос о количестве фильмов в базе данных:
python listing3.py "Сколько фильмов здесь хранится?"
Должен быть выведен примерно такой запрос:
MATCH (m:Movie)
RETURN COUNT(m) AS numberOfMovies
Теперь этот запрос можно ввести в интерфейс Neo4j и получить соответствующий результат (38). Попробуйте ввести еще несколько запросов, чтобы лучше понять возможности языковой модели. Как видите, внеся всего несколько изменений в шаблон промпта, мы превратили интерфейс преобразования текста в SQL в интерфейс преобразования текста в Cypher, который в большинстве случаев работает корректно.
• Структурированные данные имеют стандартный формат, что упрощает их синтаксический анализ. Примеры — табличные данные и графы.
• Структурированные данные часто обрабатываются с помощью специализированных инструментов.
• Реляционные системы управления базами данных обрабатывают табличные данные и обычно поддерживают SQL-запросы.
• Системы управления графовыми данными обрабатывают данные, представляющие графы.
• Языковые модели выполняют перевод с естественного языка на многие формальные языки запросов.
• Помимо вопроса, в промптах, составляемых для перевода, указывается структура базы данных.
• Делайте резервную копию базы данных, прежде чем выполнять запросы, сгенерированные языковыми моделями.
• Не доверяйте слепо языковой модели при генерации точных запросов.