с самостоятельным запросом LangChain на основе настроенного загрузчика CSV

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

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

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

Поиск по набору данных о заболеваемости

Мы хотели бы запросить следующий синтетический набор данных SIR, созданный автором: мы моделируем три разные группы населения в течение 90 дней болезни в 10 городах на основе простой модели SIR. Для простоты предположим, что население каждого города колеблется от 5e3 до 2e4, и перемещение населения между городами отсутствует. Более того, мы генерируем десять случайных целых чисел от 500 до 2000 в качестве исходного числа заразных людей.

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

time susceptible infectious removed city
0 2018-01-01 8639 8639 0 city0
1 2018-01-02 3857 12338 1081 city0
2 2018-01-03 1458 13414 2405 city0
3 2018-01-04 545 12983 3749 city0
4 2018-01-05 214 12046 5017 city0

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

  1. Используя CSVLoader для загрузки данных,
  2. Создайте векторное хранилище (здесь мы используем Chroma) для хранения данных встраивания с вложениями OpenAI,
  3. Используйте ретриверы для возврата документов, относящихся к заданному неструктурированному запросу.

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

# load data fron local path 
loader = CSVLoader(file_path=LOCAL_PATH)
data = loader.load()

# Create embedding
embeddings = OpenAIEmbeddings()
vectorstore = Chroma.from_documents(data, embeddings)

# Create retriever 
retriever=vectorstore.as_retriever(search_kwargs={"k": 20})

Теперь мы можем определить ConversationalRetriverChain для запроса набора данных SIR.

llm=ChatOpenAI(model_name="gpt-4",temperature=0)

# Define the system message template
system_template = """The provided {context} is a tabular dataset containing Suspectible, infectious and removed population during 90 days in 10 cities.
The dataset includes the following columns:
'time': time the population was meseaured,
'city': city in which the popoluation was measured,
"susceptible": the susceptible population of the disease, 
"infectious": the infectious population of the disease, 
"removed": the removed popolation of the disease. 
----------------
{context}"""

# Create the chat prompt templates
messages = [
    SystemMessagePromptTemplate.from_template(system_template),
    HumanMessagePromptTemplate.from_template("{question}")
]
qa_prompt = ChatPromptTemplate.from_messages(messages)
memory = ConversationBufferMemory(memory_key="chat_history", return_messages=True)
qa = ConversationalRetrievalChain.from_llm(llm=llm, retriever=vectorstore.as_retriever(), return_source_documents=False,combine_docs_chain_kwargs={"prompt": qa_prompt},memory=memory,verbose=True)

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

Давайте теперь зададим один простой вопрос: «В каком городе больше всего заразных людей на 2018-02-03?»

Удивительно, но наш чат-бот сказал: «Предоставленный набор данных не включает данные на дату 2018–02–03».

Как это возможно?

Почему поиск не удался?

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

[Document(page_content=': 31\ntime: 2018-02-01\nsusceptible: 0\ninfectious: 1729\nremoved: 35608\ncity: city3', metadata={'source': 'sir.csv', 'row': 301}),
 Document(page_content=': 1\ntime: 2018-01-02\nsusceptible: 3109\ninfectious: 9118\nremoved: 804\ncity: city8', metadata={'source': 'sir.csv', 'row': 721}),
 Document(page_content=': 15\ntime: 2018-01-16\nsusceptible: 1\ninfectious: 2035\nremoved: 6507\ncity: city7', metadata={'source': 'sir.csv', 'row': 645}),
 Document(page_content=': 1\ntime: 2018-01-02\nsusceptible: 3481\ninfectious: 10873\nremoved: 954\ncity: city5', metadata={'source': 'sir.csv', 'row': 451}),
 Document(page_content=': 23\ntime: 2018-01-24\nsusceptible: 0\ninfectious: 2828\nremoved: 24231\ncity: city9', metadata={'source': 'sir.csv', 'row': 833}),
 Document(page_content=': 1\ntime: 2018-01-02\nsusceptible: 8081\ninfectious: 25424\nremoved: 2231\ncity: city6', metadata={'source': 'sir.csv', 'row': 541}),
 Document(page_content=': 3\ntime: 2018-01-04\nsusceptible: 511\ninfectious: 9733\nremoved: 2787\ncity: city8', metadata={'source': 'sir.csv', 'row': 723}),
 Document(page_content=': 24\ntime: 2018-01-25\nsusceptible: 0\ninfectious: 3510\nremoved: 33826\ncity: city3', metadata={'source': 'sir.csv', 'row': 294}),
 Document(page_content=': 33\ntime: 2018-02-03\nsusceptible: 0\ninfectious: 1413\nremoved: 35924\ncity: city3', metadata={'source': 'sir.csv', 'row': 303}),
 Document(page_content=': 25\ntime: 2018-01-26\nsusceptible: 0\ninfectious: 3173\nremoved: 34164\ncity: city3', metadata={'source': 'sir.csv', 'row': 295}),
 Document(page_content=': 1\ntime: 2018-01-02\nsusceptible: 3857\ninfectious: 12338\nremoved: 1081\ncity: city0', metadata={'source': 'sir.csv', 'row': 1}),
 Document(page_content=': 23\ntime: 2018-01-24\nsusceptible: 0\ninfectious: 1365\nremoved: 11666\ncity: city8', metadata={'source': 'sir.csv', 'row': 743}),
 Document(page_content=': 16\ntime: 2018-01-17\nsusceptible: 0\ninfectious: 2770\nremoved: 10260\ncity: city8', metadata={'source': 'sir.csv', 'row': 736}),
 Document(page_content=': 3\ntime: 2018-01-04\nsusceptible: 487\ninfectious: 6280\nremoved: 1775\ncity: city7', metadata={'source': 'sir.csv', 'row': 633}),
 Document(page_content=': 14\ntime: 2018-01-15\nsusceptible: 0\ninfectious: 3391\nremoved: 9639\ncity: city8', metadata={'source': 'sir.csv', 'row': 734}),
 Document(page_content=': 20\ntime: 2018-01-21\nsusceptible: 0\ninfectious: 1849\nremoved: 11182\ncity: city8', metadata={'source': 'sir.csv', 'row': 740}),
 Document(page_content=': 28\ntime: 2018-01-29\nsusceptible: 0\ninfectious: 1705\nremoved: 25353\ncity: city9', metadata={'source': 'sir.csv', 'row': 838}),
 Document(page_content=': 23\ntime: 2018-01-24\nsusceptible: 0\ninfectious: 3884\nremoved: 33453\ncity: city3', metadata={'source': 'sir.csv', 'row': 293}),
 Document(page_content=': 16\ntime: 2018-01-17\nsusceptible: 1\ninfectious: 1839\nremoved: 6703\ncity: city7', metadata={'source': 'sir.csv', 'row': 646}),
 Document(page_content=': 15\ntime: 2018-01-16\nsusceptible: 1\ninfectious: 6350\nremoved: 20708\ncity: city9', metadata={'source': 'sir.csv', 'row': 825})]

Удивительно, хотя я указал, что хочу знать, что произошло в дату 2018–02–03, строка с этой датой не была возвращена. Поскольку никакой информации об этой дате в ChatGPT так и не было отправлено, нет сомнений, что он не может ответить на такой вопрос.

Погружаясь в исходный код ретривера, мы видим, что get_relevant_dcouments по умолчанию вызывает similarity_search. Метод возвращает верхние n фрагментов (по умолчанию 4, но я установил число 20 в своем коде) на основе вычисленной метрики расстояния (косинусное расстояние по умолчанию) в диапазоне от 0 до 1, который измеряет сходство между вектором запроса и вектор фрагментов документа.

Возвращаясь к набору данных SIR, мы замечаем, что каждая строка рассказывает почти одну и ту же историю: в какой день, в каком городе и сколько людей помечено как какая группа. Неудивительно, что векторы, представляющие эти линии, похожи друг на друга. Быстрая проверка оценки сходства дает нам тот факт, что многие строки получают оценку около 0,29.

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

CSVLoader с настраиваемыми метаданными

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

Пишем следующий код:

class MetaDataCSVLoader(BaseLoader):
    """Loads a CSV file into a list of documents.

    Each document represents one row of the CSV file. Every row is converted into a
    key/value pair and outputted to a new line in the document's page_content.

    The source for each document loaded from csv is set to the value of the
    `file_path` argument for all doucments by default.
    You can override this by setting the `source_column` argument to the
    name of a column in the CSV file.
    The source of each document will then be set to the value of the column
    with the name specified in `source_column`.

    Output Example:
        .. code-block:: txt

            column1: value1
            column2: value2
            column3: value3
    """

    def __init__(
        self,
        file_path: str,
        source_column: Optional[str] = None,
        metadata_columns: Optional[List[str]] = None,   
        content_columns: Optional[List[str]] =None ,  
        csv_args: Optional[Dict] = None,
        encoding: Optional[str] = None,
    ):
        #  omitted (save as original code)
        self.metadata_columns = metadata_columns        # < ADDED

    def load(self) -> List[Document]:
        """Load data into document objects."""

        docs = []
        with open(self.file_path, newline="", encoding=self.encoding) as csvfile:
           #  omitted (save as original code)
                # ADDED CODE 
                if self.metadata_columns:
                    for k, v in row.items():
                        if k in self.metadata_columns:
                            metadata[k] = v
                # END OF ADDED CODE
                doc = Document(page_content=content, metadata=metadata)
                docs.append(doc)
        return docs

Чтобы сэкономить место, я опустил код, который совпадает с исходным API, и включил только несколько дополнительных строк, которые в основном используются для добавления определенных столбцов, требующих особого внимания к метаданным. Действительно, в печатных данных выше вы можете заметить две части: содержимое страницы и метаданные. Стандартный CSVLoader записывает все столбцы таблицы в содержимое страницы и только ресурсы данных и номера строк в метаданные. Определенный «MetaDataCSVLoader» позволяет нам записывать другие столбцы в метаданные.

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

# Load data and set embeddings 
loader = MetaDataCSVLoader(file_path="sir.csv",metadata_columns=['time','city']) #<= modified 
data = loader.load()

Самозапрос к векторному хранилищу, основанному на метаданных

Теперь мы готовы использовать SelfQuerying API LangChain:

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

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

llm=ChatOpenAI(model_name="gpt-4",temperature=0)
metadata_field_info=[
     AttributeInfo(
        name="time",
        description="time the population was meseaured", 
        type="datetime", 
    ),
    AttributeInfo(
        name="city",
        description="city in which the popoluation was measured", 
        type="string", 
    ),
]
document_content_description = "Suspectible, infectious and removed population during 90 days in 10 cities "
retriever = SelfQueryRetriever.from_llm(
    llm, vectorstore, document_content_description, metadata_field_info, search_kwargs={"k": 20},verbose=True
)

Теперь мы можем определить аналогичный ConversationalRetriverChain для запроса набора данных SIR, но на этот раз с SelfQueryRetriever. Давайте посмотрим, что произойдет теперь, когда мы зададим тот же вопрос: «В каком городе больше всего заразных людей на 03 февраля 2018 г.?»

Чат-бот сказал:: «Город с максимальным количеством заразных людей на 03 февраля 2018 года — это город 3 с 1413 заразными людьми».

Дамы и господа, это правильно! Чат-бот делает свою работу с лучшим ретривером!

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

[Document(page_content=': 33\ntime: 2018-02-03\nsusceptible: 0\ninfectious: 1413\nremoved: 35924\ncity: city3', metadata={'source': 'sir.csv', 'row': 303, 'time': '2018-02-03', 'city': 'city3'}),
 Document(page_content=': 33\ntime: 2018-02-03\nsusceptible: 0\ninfectious: 822\nremoved: 20895\ncity: city4', metadata={'source': 'sir.csv', 'row': 393, 'time': '2018-02-03', 'city': 'city4'}),
 Document(page_content=': 33\ntime: 2018-02-03\nsusceptible: 0\ninfectious: 581\nremoved: 14728\ncity: city5', metadata={'source': 'sir.csv', 'row': 483, 'time': '2018-02-03', 'city': 'city5'}),
 Document(page_content=': 33\ntime: 2018-02-03\nsusceptible: 0\ninfectious: 1355\nremoved: 34382\ncity: city6', metadata={'source': 'sir.csv', 'row': 573, 'time': '2018-02-03', 'city': 'city6'}),
 Document(page_content=': 33\ntime: 2018-02-03\nsusceptible: 0\ninfectious: 496\nremoved: 12535\ncity: city8', metadata={'source': 'sir.csv', 'row': 753, 'time': '2018-02-03', 'city': 'city8'}),
 Document(page_content=': 33\ntime: 2018-02-03\nsusceptible: 0\ninfectious: 1028\nremoved: 26030\ncity: city9', metadata={'source': 'sir.csv', 'row': 843, 'time': '2018-02-03', 'city': 'city9'}),
 Document(page_content=': 33\ntime: 2018-02-03\nsusceptible: 0\ninfectious: 330\nremoved: 8213\ncity: city7', metadata={'source': 'sir.csv', 'row': 663, 'time': '2018-02-03', 'city': 'city7'}),
 Document(page_content=': 33\ntime: 2018-02-03\nsusceptible: 0\ninfectious: 1320\nremoved: 33505\ncity: city2', metadata={'source': 'sir.csv', 'row': 213, 'time': '2018-02-03', 'city': 'city2'}),
 Document(page_content=': 33\ntime: 2018-02-03\nsusceptible: 0\ninfectious: 776\nremoved: 19753\ncity: city1', metadata={'source': 'sir.csv', 'row': 123, 'time': '2018-02-03', 'city': 'city1'}),
 Document(page_content=': 33\ntime: 2018-02-03\nsusceptible: 0\ninfectious: 654\nremoved: 16623\ncity: city0', metadata={'source': 'sir.csv', 'row': 33, 'time': '2018-02-03', 'city': 'city0'})]

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

Заключение

В этом сообщении блога я исследовал ограничения ChatGPT при запросе наборов данных в формате CSV, используя в качестве примера набор данных SIR из 10 городов за 90-дневный период. Чтобы устранить эти ограничения, я предложил новый подход: загрузчик данных CSV с поддержкой метаданных, который позволяет нам использовать API с автоматическим запросом, значительно повышая точность и производительность чат-бота.