Задавайте вопросы о своих структурированных данных; Получите обоснованные ответы

Введение

Одна из проблем использования LLM (больших языковых моделей) в бизнес-контексте — заставить модель давать фактические и точные ответы о данных вашей компании. Одним из возможных решений является поисковая дополненная генерация (RAG) с использованием векторной базы данных для заполнения контекста подсказки (см. мой пост: Вопросы и ответы с вашими документами: нежное введение в механизм сопоставления + PaLM). Это хорошо работает для полуструктурированных данных, таких как текстовые файлы и PDF-файлы. Но что, если вы хотите получить данные из структурированного источника данных? Что, если бы наш LLM использовал результаты аналитического запроса к базе данных? Это то, что мы собираемся изучить в этом посте.

Используя новые API-интерфейсы Google Codey, анонсированные на Google I/O ранее в этом году, мы создадим систему, которая:

  1. Преобразует вопрос пользователя на естественном языке в оператор SQL.
  2. Запускает этот оператор SQL для аналитической базы данных.
  3. Использует результат запроса для ответа на исходный вопрос пользователя.

Мы также обсудим быструю настройку, а также некоторые недостатки и ограничения такой системы.

В этом практическом руководстве я буду запрашивать публичный набор данных NYC Citibike.

Хотя я объясню каждый модуль кода по порядку, полный код представлен в конце этого руководства.

Шаг 1. Включите необходимые облачные API

Запустите gcloud init для аутентификации вашего пользователя и проекта GCP.

Включите необходимые API для вашего проекта:

gcloud services enable aiplatform.googleapis.com --async

Шаг 2. Установите пакеты Python

При необходимости установите необходимые пакеты Python в среду Python с помощью Pip:

pip install google-cloud-bigquery pandas google-cloud-aiplatform db-dtypes

Шаг 3. Получите примеры данных и схем

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

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

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

Следующий код создаст файлы data.txt и schemas.txt локально, если они еще не существуют.

pd.options.display.max_columns = 500
pd.options.display.max_rows = 500   

def get_data(tables):
    try:
        with open("./data.txt", "r") as d:
            data = d.read()
        return data
    except:
        data = ""
        for table in tables:
            if table == 'bigquery-public-data.new_york.citibike_trips':
                querystring = f"""
                SELECT
                *
                FROM
                `{table}`
                WHERE gender != ""
                LIMIT
                5
                """
            else: # 'bigquery-public-data.new_york.citibike_stations'
                querystring = f"""
                SELECT
                *
                FROM
                `{table}`
                WHERE station_id IS NOT NULL
                LIMIT
                5
                """
            data += f"\n\nData for table: {table}:\n\n"
            data += str(client.query(querystring).result().to_dataframe())
        
        with open("./data.txt", "w") as d:
            d.write(data)

        return data

def get_schemas(tables):
  
    try:
        with open("./schemas.txt", "r") as s:
            schemas = s.read()
        return schemas
    except:
        schemas = ""
        for table in tables:
            querystring = f"""
            SELECT
            column_name,
            data_type
            FROM
            `bigquery-public-data.new_york`.INFORMATION_SCHEMA.COLUMNS
            WHERE
            table_name = "{table.split(".")[-1]}";
            """
            schemas += f"\n\nSchema for table: {table}:\n\n"
            schemas += str(client.query(querystring).result().to_dataframe())

        with open("./schemas.txt", "w") as s:
            s.write(schemas)

        return schemas

Шаг 4. Сгенерируйте SQL с помощью Codey

Теперь мы сгенерируем SQL-запрос в ответ на вопрос пользователя.

Несколько вещей, на которые следует обратить внимание с этим кодом:

  1. Фактическое приглашение, которое получает Коди, представляет собой комбинацию схем и данных доступных таблиц и вопроса пользователя, обернутых дополнительным контекстом и правилами для Коди.
  2. Правила являются результатом устранения неполадок и принуждения системы к предоставлению полезных и точных ответов.
  3. В операторе возврата мы отфильтровываем ```sql и ```; это потому, что Коди отвечает запросом в формате Markdown. Нам просто нужен необработанный текст запроса.
def get_proposed_query(question, schemas, data):

    parameters = {
        "temperature": 0.2,
        "max_output_tokens": 1024
    }

    response = codey.predict(
        prefix = f"""

    {schemas}

    {data}

    As a senior analyst, given the above schemas and data of bicycle trips in New York City, write a BigQuery SQL query to answer the following question:

    {question}

    When constructing SQL statements, follow these rules:
    - There is no `MONTH` function; if you want the month, instead use `EXTRACT(month FROM starttime) AS month`
   
    """,
        **parameters
    )

    return response.text.replace("```sql", "").replace("```", "")

Шаг 5. Организуйте вызовы функций в инструкции main()

Наконец, мы можем объединить эти вызовы функций в один оператор main(). Некоторые особенности этого кода, на которые следует обратить внимание:

  1. Он включает анализ аргументов для использования в командной строке; примеры использования см. ниже.
  2. Результат запроса SQL не возвращается непосредственно пользователю. Вместо этого модель генерации текста (модель PaLM text-bison@001) интерпретирует этот результат как часть окончательного ответа системы.
  3. Температура модели генерации текста установлена ​​на 0. Эту температуру можно рассматривать как «креативность» ответа модели. Поскольку мы хотим, чтобы модель отвечала только с использованием данных, доступных в ответе на запрос SQL, мы устанавливаем для нее значение 0 (самое низкое значение креативности). Не стесняйтесь экспериментировать с разными значениями.
def main():
    parser = argparse.ArgumentParser(description='A program to respond to analytic questions with SQL context')
    parser.add_argument('-q', '--question', help='The users question')
    parser.add_argument('-v', '--verbose', action='store_true', help='Whether to print more detail')
    args = parser.parse_args()

    if not args.question:
        print("You must enter a question")
        return
    
    user_question = args.question
    
    tables = ['bigquery-public-data.new_york.citibike_stations', 'bigquery-public-data.new_york.citibike_trips']
    schemas = get_schemas(tables)
    data = get_data(tables)
    
    proposed_query = get_proposed_query(user_question, schemas, data)
    if args.verbose:
        print("\nProposed Query: \n", proposed_query)
    query_result = client.query(proposed_query).result().to_dataframe()
    if args.verbose:
        print("BQ Query Result: \n\n", query_result, "\n")

    prompt = f"""
    Context: You are a senior business intelligence analyst.
    Use the following query result to give a detailed answer to any questions you receive: {query_result}
    If the column header is something like: "f0_" that means that you have number for your answer.
    Do not use any other information to answer the question. Only use information from the query result. Do not make up information. If you don't have enough information, say "I don't have enough information."
    Question: {user_question}
        """

    print("Answer:", generation_model.predict(prompt, temperature = 0, max_output_tokens = 1024), "\n")

Шаг 8. Тестируйте и наблюдайте

Вот и все! Использование следующее:

python cli_analyst.py -v -q <USER_QUESTION>

Флаг -v печатает дополнительную информацию во время вызова функции. Флаг -q предшествует задаваемому вопросу.

Полный код Python выглядит следующим образом:

import argparse
from google.cloud import bigquery
import pandas as pd
from vertexai.language_models import CodeGenerationModel
from vertexai.preview.language_models import TextGenerationModel

generation_model = TextGenerationModel.from_pretrained("text-bison@001")
codey = CodeGenerationModel.from_pretrained("code-bison@001")
client = bigquery.Client()

pd.options.display.max_columns = 500
pd.options.display.max_rows = 500   

def get_data(tables):
    try:
        with open("./data.txt", "r") as d:
            data = d.read()
        return data
    except:
        data = ""
        for table in tables:
            if table == 'bigquery-public-data.new_york.citibike_trips':
                querystring = f"""
                SELECT
                *
                FROM
                `{table}`
                WHERE gender != ""
                LIMIT
                5
                """
            else: # 'bigquery-public-data.new_york.citibike_stations'
                querystring = f"""
                SELECT
                *
                FROM
                `{table}`
                WHERE station_id IS NOT NULL
                LIMIT
                5
                """
            data += f"\n\nData for table: {table}:\n\n"
            data += str(client.query(querystring).result().to_dataframe())
        
        with open("./data.txt", "w") as d:
            d.write(data)

        return data

def get_schemas(tables):
  
    try:
        with open("./schemas.txt", "r") as s:
            schemas = s.read()
        return schemas
    except:
        schemas = ""
        for table in tables:
            querystring = f"""
            SELECT
            column_name,
            data_type
            FROM
            `bigquery-public-data.new_york`.INFORMATION_SCHEMA.COLUMNS
            WHERE
            table_name = "{table.split(".")[-1]}";
            """
            schemas += f"\n\nSchema for table: {table}:\n\n"
            schemas += str(client.query(querystring).result().to_dataframe())

        with open("./schemas.txt", "w") as s:
            s.write(schemas)

        return schemas

def get_proposed_query(question, schemas, data):

    parameters = {
        "temperature": 0.2,
        "max_output_tokens": 1024
    }

    response = codey.predict(
        prefix = f"""

    {schemas}

    {data}

    As a senior analyst, given the above schemas and data of bicycle trips in New York City, write a BigQuery SQL query to answer the following question:

    {question}

    When constructing SQL statements, follow these rules:
    - There is no `MONTH` function; if you want the month, instead use `EXTRACT(month FROM starttime) AS month`
   
    """,
        **parameters
    )

    return response.text.replace("```sql", "").replace("```", "")

def main():
    parser = argparse.ArgumentParser(description='A program to respond to analytic questions with SQL context')
    parser.add_argument('-q', '--question', help='The users question')
    parser.add_argument('-v', '--verbose', action='store_true', help='Whether to print more detail')
    args = parser.parse_args()

    if not args.question:
        print("You must enter a question")
        return
    
    user_question = args.question
    
    tables = ['bigquery-public-data.new_york.citibike_stations', 'bigquery-public-data.new_york.citibike_trips']
    schemas = get_schemas(tables)
    data = get_data(tables)
    
    proposed_query = get_proposed_query(user_question, schemas, data)
    if args.verbose:
        print("\nProposed Query: \n", proposed_query)
    query_result = client.query(proposed_query).result().to_dataframe()
    if args.verbose:
        print("BQ Query Result: \n\n", query_result, "\n")

    prompt = f"""
    Context: You are a senior business intelligence analyst.
    Use the following query result to give a detailed answer to any questions you receive: {query_result}
    If the column header is something like: "f0_" that means that you have number for your answer.
    Do not use any other information to answer the question. Only use information from the query result. Do not make up information. If you don't have enough information, say "I don't have enough information."
    Question: {user_question}
        """

    print("Answer:", generation_model.predict(prompt, temperature = 0, max_output_tokens = 1024), "\n")

if __name__ == '__main__':
    main()

Давайте попробуем несколько примеров запросов. Как насчет:

python cli_analyst.py -v -q "Which 3 months are the most trips taken in?"

Обратите внимание, как система использует API Codey для предложения и возврата запроса SQL. Затем он выполняет этот запрос к таблице BQ и возвращает ответ на запрос. И, наконец, обратите внимание, как LLM генерации текста правильно преобразует обозначения месяцев из числовых в лексические (т. е. девятый месяц года — сентябрь). Довольно круто!

Давайте попробуем еще один:

python cli_analyst.py -v -q "When was the youngest rider you have on record born?"

Упс! Он возвращает самого старого зарегистрированного гонщика (по всей видимости, 138 лет…), а не самого молодого. Давайте исправим это, добавив дополнительное правило в контекст запроса генерации SQL. В функции get_proposed_query() добавьте в конец списка правил правило следующего содержания:

- The `birth_year` field tells the birth year of when the rider was born. Older riders have smaller birth years, and younger riders have larger birth years.

Теперь попробуем еще раз:

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

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

Обсуждение

Хотя может возникнуть соблазн передать такую ​​систему нетехническому пользователю, существует несколько ограничений. Во-первых, нет никакой гарантии, что Codey API сгенерирует действительный код SQL. Например, правило «Нет функции MONTH...» в функции get_proposed_query() существует, поскольку я обнаружил, что она пытается использовать функцию MONTH(), которой не существует. Несмотря на то, что мы можем смягчить такое поведение с помощью соответствующего правила, мы не знаем полного возможного пространства ошибок и, следовательно, не можем смягчить каждый возможный недопустимый запрос. Кроме того, даже когда Коди генерирует действительный код SQL, нет никакой гарантии, что он отвечает на задаваемый вопрос. LLM не могут читать мысли и поэтому могут только ссылаться на вопрос как заданный. Если вопрос расплывчатый или плохо сформулирован, API Codey может интерпретировать его иначе, чем предполагалось. В конечном счете, нет никакого способа гарантировать, что сгенерированный код SQL «пригоден для использования», кроме как предоставить квалифицированному аналитику оценку утверждения. В результате, хотя может возникнуть соблазн развернуть подобную систему непосредственно для нетехнических пользователей, для технических пользователей, знающих SQL, она, вероятно, будет более ценной в качестве вспомогательного средства и ускорения их работы.

Заключение

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

Ссылки и дополнительная литература

  1. Меня вдохновил пост Кена Ван Харена: Замена аналитика SQL на 26 рекурсивных приглашений GPT
  2. API Google Cloud Codey

Весь код в этом сообщении принадлежит Google LLC, 2023 г., под лицензией Apache, версия 2.0.