LangChainを使ったSPARQLクエリ生成

はじめに

fuku株式会社にてインターンをしています、鈴木です。本業は広島大学のゲノム情報科学研究室にて大学院生をしております。研究を進める中で「文献からの情報抽出」や「LLMの活用」に興味を持ち、関連の深いfuku株式会社で働かせていただいています。

本記事では、LLMを活用することで、自然言語を入力とし、適切なSPARQL(グラフデータベースへのクエリ文)を出力できるかどうかを試した過程を公開します。

背景・課題

グラフデータベース(RDF)にデータを問い合わせる際には、SPARQLと呼ばれるRDF専用の問い合わせ言語を使い、クエリを作る必要があります。

例えば、日本版DBpedia(wikipediaの一部)に対して、ラグビー日本代表選手一覧を取得するためのSPARQLクエリは下記のようになります。

PREFIX category: <http://ja.dbpedia.org/resource/Category:>
PREFIX dcterms: <http://purl.org/dc/terms/>

SELECT  ?rugger_man
WHERE
{
  ?rugger_man  dcterms:subject  category:ラグビー日本代表選手 .  
}

こちらのクエリを使い、日本版DBpediaのエンドポイント (https://ja.dbpedia.org/sparql) に対して問い合わせをすると、結果が表示されます。(参考:SPARQLクエリの基本@第4回RDF講習会)

このように、SPARQLクエリを使いこなすと様々な活用ができますが、普段グラフデータベースを使わないユーザーの方々からすると、SPARQLクエリの生成は学習障壁が高いものになる可能性があります。

上記のような課題を解決するために、LLMを活用することにより、日本語による質問文(日本語の自然言語)からグラフデータベースへ検索するためのSPARQL生成ができないか、という取り組みを行なっています。

まとめると下記が背景・課題となります。

  • RDFを検索するためのSPARQL生成が困難
  • 日本語の質問文 > LLMがSPARQL生成 > RDFへ問い合わせて適切なデータを取得、という流れを正確に行うための方法が未明

RAGを使ったクエリ生成

LLMにより検索クエリを生成する作業を「query construction」と呼ぶことが多いようです。「query construction」について調査してみたところ、RAG(Retrieval-Augmented Generation)と呼ばれる方法が使われることが多いことがわかりました。

RAGとは、LLMの事前学習に取り扱っていない文書や情報に基づいて回答を生成するための方法となっています。RAGはユーザが入力したクエリに対して、追加の知識を元に回答を生成します。大まかにクエリに関連した文書を取得するRetrieverと、取得した文書に基づいて回答を生成するGeneratorで構成されます。下記がRAGについての参考図になります。

(参考論文:https://arxiv.org/abs/2005.11401)

RAGの手順としては、大まかに下記が一般的です。

  1. 回答の根拠として利用させたい文書をベクトル化して、ベクトルデータベースに格納
  2. Retrieverがユーザのクエリをベクトル化し、ベクトルデータベースからスコアが高い文書を取得
  3. Generatorが取得した文書を元にユーザのクエリに対する回答を生成

今回は、SPARQLに関する情報を追加情報(回答の根拠として利用させたい文書)としてLLMに与えることで、LLMによるSPARQL生成の精度を上げることを試みます。

アプローチ方法

SPARQL生成における先行研究を調査した結果、現状で使えそうなRAG手法として、下記2種類のアプローチを選抜しました。

  1. 類似質問に関する正解SPARQLをLLMに与える方法 (few-shot)
  2. RDFスキーマをLLMに与える方法

これら2点について、試行した結果を共有したいと思います。

1. 類似質問に関する正解SPARQLをLLMに与える方法

1個目のアプローチは、下記の論文(Leveraging LLMs in Scholarly Knowledge Graph Question Answering)を参考に実験しました。

今回の方法では、日本語の質問文に類似した質問文+正解のSPARQLが、RAGにおいてLLMに与える追加の情報となります。よって、事前に質問文質問に回答するデータを得るための正解となるSPARQLをできる限り多く(100件以上程度)準備しておく必要があります。

さらに質問文はRAGの一般的な手順に従い、ベクトル化(embedding(埋め込み))しておきます。実際のユーザーの質問文と近い質問を3-5件ほど取得し(ベクトル検索を行う)、それらの質問をLLMへfew-shotとして与えます。実際のコードとしては、下記を使いました。

from sentence_transformers import SentenceTransformer
import numpy as np
import json
import polars as pl
import os 
from rdflib import Graph
from rdflib.namespace import RDF
from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate
from langchain.prompts.few_shot import FewShotPromptTemplate
from langchain.prompts.prompt import PromptTemplate
import config
from config import NARO_NAME, NARO_ID


def main():
    # embedding用のLLMモデルの読み込み。
    # SentenceTransformerをベースとしたモデルを使用しました。
    model = SentenceTransformer('all-MiniLM-L6-v2')

    # グラフデータベースのturtleファイルの読み込み。
    g = Graph()
    g.parse(YOUR_TURTLE_FILE_PATH, format="turtle")

    # llmインスタンス作成
    llm = ChatOpenAI(openai_api_key=os.environ["openai_apikey"],temperature=0, model="gpt-4")

    # Sentence Transformerで、fewshot用の質問を埋め込む
    source_embs, source_list = source_embeddings(model, source_df)

    # 問い合わせたい質問を設定
    query = "ルークスカイウォーカーの故郷はどこですか?"

    # 同様のLLMモデルで質問分も埋め込みます
    query_emb = query_embedding(model, query)

    # 類似質問3件を取得
    sim_questions = question_analysis(query_emb, source_embs, 3, source_list)
    sim_questions_str = ",".join(i["question"] for i in sim_questions)

    # プロンプトを構築
    prompt = construct_prompt(source_df, sim_questions, query, source_list)

    # モデルにプロンプトを投げる
    predicted_sparql = run_llm(llm, prompt)
    
    # SPARQLクエリを実行
    results = run_query(g, predicted_sparql)
    
    # 結果を確認
    answer = row_id["answer"][0]


def source_embeddings(model, source_df):
    source_list_for_emb = []
    source_list = []
    for i in range(source_df.height):
        row = source_df[i]
        question = row["question"][0]
        id = row["id"][0]
        source_list_for_emb.append(question)
        source_list.append({"id": id, "question": question})
    source_embs = model.encode(source_list_for_emb)
    return source_embs, source_list

def query_embedding(model, query):
    query_emb = model.encode(query)
    return query_emb


def question_analysis(query_emb, source_embs, n_for_sim_questions:int, source_list:list):
    """
    コサイン類似度を計算することで、クエリと類似する質問を取得する
    """
    cosine_similarities = np.dot(source_embs, query_emb.T) / (np.linalg.norm(source_embs, axis=1) * np.linalg.norm(query_emb))
    top_idx = np.argsort(cosine_similarities)[::-1][:n_for_sim_questions]
    sim_questions = []
    for index in top_idx:
        # print(f"Question: {source_list[index]}, Similarity: {cosine_similarities[index]}")
        sim_questions.append(source_list[index])
    return sim_questions


def construct_prompt(source_df, sim_questions, query, source_list):
    """
    promptを生成する
    """
    example = ''
    for i in sim_questions:
        id = i["id"]
        # check if the question is in the source_df
        row = source_df.filter(source_df["id"] == id)
        sparql = row["sparql"][0]
        example += (f"Question: {i}\n"
                    f"Sparql: {sparql}\n")
    return f"""
          Task: Generate SPARQL queries to query the ORKG knowledge graph based on the provided schema definition.
          Instructions:
          If you cannot generate a SPARQL query based on the provided examples, explain the reason to the user.
          {example}
          Question: {query}
          Sparql:
          Note: Do not include any explanations or apologies in your responses.
          Output only the Sparql query.
        """


def run_llm(llm, prompt):
    """
    モデルにプロンプトを投げる
    """
    result = llm.invoke(prompt)
    for i in result:
        if i[0] == "content":
            predicted_sparql = i[1]
    return predicted_sparql


def run_query(graph, predicted_sparql):
    """
    SPARQLクエリを実行する
    """
    qres = graph.query(predicted_sparql)
    results = []
    for row in qres:
        # print(row)
        results.append(row[0])
    results = ",".join(results)
    return results

    
if __name__ == "__main__":
    main()

使ったLLMは下記となります。

  • LLM model: gpt-4
  • Embedding model: SentenceTransformer('all-MiniLM-L6-v2')

まだ試行段階ということで、100件の質問文と正解SPARQLは集めれず、12件という少量のデータでテストを行いました。その結果の正解は8/12件でした。

今回は約3-7行程度の比較的簡単なSPARQLの生成を試みています。

類似質問を取得できている場合には、基本的に正確なSPARQLを生成することに成功しているようにみえました。

類似質問が取得できない原因としては、現在究明を行なっています。基本的には質問文の語尾(...はなんですか?, ...を教えてください,等)に影響を受けることや、IDや固有名詞などの扱いが重要となってきそうに感じています。

本件の詳しい具体例を見たい場合は、参考にした論文(Leveraging LLMs in Scholarly Knowledge Graph Question Answering)をご確認いただけると幸いです。

2. RDFスキーマをLLMに与える方法

こちらのアプローチは、ontotext graphdbと呼ばれる、RDF活用に関するオープンソースソフトウェアを開発している会社によるツールを使った方法(Ontotext GraphDB)となっています。

RDFを示しているttlやtrigファイルをdockerコンテナにコピーし、dockerコンテナのrun.shを動かすことで、ローカルホストにインスタンスが生成され、ブラウザでRDFデータを可視化することができます。

ontotextGraphDBGraphライブラリを使うと、そのインスタンスと連携し、下記作業を行うことで、自然言語の入力から、RDF検索結果を出力してくれます。

  • RDFスキーマ(全トリプル情報)をpromptに組み込む
  • LLMが生成したSPARQLでRDF検索を行う

こちらのアプローチは、Ontotext GraphDBに記載されているコードをそのまま使って実験しました。

Dockerfileにて、RDFデータ(ttlファイル)をDockerコンテナにコピーする処理があるので、そこを自分用のRDFデータに書き換えると、自分のグラフデータをローカルホストで可視化することができます。

使ったLLMは下記となります。

例えば、What is the climate on Luke Skywalker's home planet? (ルーク・スカイウォーカーの故郷の惑星の気候は?)に対して、システムは下記のような出力を出します。こちらの例題では、実際に正解データを出力しているようです。

※ 例題のRDFは英語の情報になっているので、英語で質問しないと正確な回答を得るのが難しいようです。日本語の情報を含んだRDFであれば、日本語の質問に対応した回答を得ることができました。

# 入力情報
chain.invoke({chain.input_key: "What is the climate on Luke Skywalker's home planet?"})[
    chain.output_key
]
# 出力結果
> Entering new OntotextGraphDBQAChain chain...
Generated SPARQL:
PREFIX : <https://swapi.co/vocabulary/>
PREFIX owl: <http://www.w3.org/2002/07/owl#>
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
PREFIX xsd: <http://www.w3.org/2001/XMLSchema#>

SELECT ?climate
WHERE {
  ?character rdfs:label "Luke Skywalker" .
  ?character :homeworld ?planet .
  ?planet :climate ?climate .
}

> Finished chain.
"The climate on Luke Skywalker's home planet is arid."

LangChainドキュメントに記載されているスターウォーズに関するSPARQL生成例と同様に、弊社のRDFデータを使い実験を行いました。アプローチ方法1と同様の12件のデータでテストをしたところ、正解は9/12件でした。

正解数は、アプローチ1よりも少々上がっています。不正解となった質問は比較的に行数が多く、難しいSPARQLでした。

しかしRDFの全トリプルをLLMに読み込ませているために、お金がかかります。gpt-4-1106-previewだと、一度の質問で約110円程度かかりました。RDFのサイズによってその金額はさらに上がることが考えられます。

※ 新たに発表されたgpt-4oを使ってみました。すると、金額は大幅に下がり、2円以下程度となりました。(小規模なデータでテストしたのみの金額なので、大規模に実施した時の金額は今後確認する予定です)

次回

1のアプローチについては、さらに多くの質問集(100件程度)を準備してから、もう一度実験を行う予定です。質問集が多くなれば、類似した質問とそのSPARQLをfew-shotとして得ることができる可能性も高まるので、精度が高まることが期待されます。

2のアプローチについては、別の形のスキーマを与える方法を考える予定です。今回はグラフ構造のデータをばらしたトリプルの情報をスキーマとして与えているために、文字数が多い割には重要な情報が失われている可能性が考えられます。RDFの全体の内容を示すようなスキーマをLLMへ与えられないか、検討中です。