【徹底解説】Document Intelligenceを利用してRAGを構築する【セマンティックチャンキング】

こんにちは、サイオステクノロジー の佐藤 陽です。
今日は Azure の AI サービスである Document Intelligence を利用した RAG システムを構築してみたいと思います。

  • PDF データを独自データとした RAG を構築したい!
  • Document Intelligence とか Azure AI Search ってどうやって使うの?
  • セマンティックチャンキングって何?

といった方は最後までご覧ください!

はじめに

こちらのサンプルに従って実装の方を行っていきます。

日本語の説明も無いですし、RAG初心者&python 不慣れな自分には分かりづらい部分もあったため、そのあたり丁寧に解説していきたいと思います。
ただの公式サンプル解説記事にはなりますが、同じような境遇の誰かの役に立てば幸いです。

要素の紹介

今回のサンプルで登場する技術要素、サービスとしては以下のようなものがあります。

  • Azure AI Document Intelligence
  • Azure AI Search
  • Azure OpenAI Service
  • LangChain

Azure AI Document Intelligence

Document Intelligence の機能を使って PDF からテキストデータを抽出します。
Document Intelligence の layout モデルを利用することで、文章の構造を保ったまま Markdown の形式で抽出することが可能です。

Markdown の形式で抽出することで、今回の記事の肝であるセマンティックチャンキング(後述)が可能となります。

また、Document Intelligenceに関してはこちらの記事でも以前解説しています。

Azure AI Document Intelligence入門【Resultパラメータ解説付き!】

RAG を構築する際に利用するベクトルストアです。
チャンク化されたデータをベクトル化し、保存しておきます。
またユーザーからのリクエストに対して、関連のある情報を検索し、抽出します。

Azure OpenAI Service

Chat や Embedding の LLM の機能を持ちます。
チャットのやり取りや、チャンキングした情報のベクトル化などを行います。

LangChain

LangChain は LLM を手軽に使えるようにしてくれるライブラリです。
様々な機能が提供されていますが、今回は主にチャンクキングの処理や、Azure OpenAI Service を実行するためのラッパー、RAGシステムの構築のために利用します。

システム構成

参考サイトのものをそのまま張っておきます。 

全体の処理の流れとしては

  1. AI Document Intelligence で PDF を読み取る
  2. PDF の内容を分析し、Markdown として出力する
  3. LangChain の機能を利用し、Markdown の内容を見出し位置(#,##,###)でチャンク化する
  4. チャンク化した内容をベクトル化し、ベクトルストアである AI Search に登録する
  5. ユーザーからの質問の内容に関連する情報を AI Search から取り出す
  6. Chat に投げる Prompt を構築する
  7. Chat から回答を得る

といった形となります。

セマンティックチャンキングとは

実装を行う前に、今回の記事のタイトルにもなっている「セマンティックチャンキング」について解説します。
まず、セマンティック(Semantic)とは?ということなのですが、以下のように定義されていました。

《言語学》意味の、語義の

次にチャンキングについては、Wikipedia によると

あるものをより小さな断片に分割したり(チャンキング・ダウン)、逆により大きな断片にまとめたり(チャンキング・アップ)すること。

とあります。

一般的にRAGの話におけるチャンキングというと、Chat AIが参照するドキュメントの単位に分割することを指します。
分割するサイズなどによってもRAGの性能が変わってくることが言われており、チャンキング戦略はRAGにおける重要なポイントとされています。

これを踏まえると

セマンティックチャンキング=意味の塊部分で区切って小さく分割する

と捉えることができます。

ちなみに一般的なチャンキングの方法はというと
事前にチャンクサイズ(分割するサイズ)が固定で決まっており、 文章の意味や区切りなどはお構いなしに、決まったサイズで文章を区切るような方法が多いです。

メリット

ではセマンティックチャンキングを使うメリットは何でしょうか?
こちらも先程のサンプルのページにおいて言及されており、メリットとして以下の 3 つが挙げられています。

  1. 効率的な保管と検索(Efficient storage and retrieval)
  2. 関連性の向上(Improved relevance)
  3. 解釈可能性の向上(Enhanced interpretability)

つまり

ベクトルストアに対して効率的に保存ができ、関連の強いドキュメントの検索が可能になり、LLM が独立した情報として理解できるため、明確な回答が行えるようになる。

ということがいえます。

参考:セマンティックチャンキング – Microsoft

Document Intelligence を活用したセマンティックチャンキング

今回は Document Intelligence を活用して、セマンティックチャンキングを行います。

Document Intelligence は PDF などのデータを、文章の構造を保ったまま Markdown 形式で出力することが可能です。
Markdown 形式で出力することで、見出し(#, ## etc.)の位置でチャンク化することが可能となり、セマンティックチャンキングを実現できます。

実装

では実際にステップを踏んで実行していきます。
ソースコードは基本的にサンプルのコピペです。

環境構築

必要なパッケージのインストールを行います。

! pip install python-dotenv langchain langchain-community langchain-openai langchainhub openai tiktoken azure-ai-documentintelligence azure-identity azure-search-documents==11.6.0b3

次に環境変数(API キーや、Azure OpenAI Service のエンドポイント等)の設定をします。
.env ファイルを作成して、各パラメータを設定してください。

いつものお約束ですが、KEYの情報などはGitHubなどに公開しないよう注意して下さい。

"""
This code loads environment variables using the `dotenv` library and sets the necessary environment variables for Azure services.
The environment variables are loaded from the `.env` file in the same directory as this notebook.
"""
import os
from dotenv import load_dotenv

load_dotenv()

os.environ["AZURE_OPENAI_ENDPOINT"] = os.getenv("AZURE_OPENAI_ENDPOINT")
os.environ["AZURE_OPENAI_API_KEY"] = os.getenv("AZURE_OPENAI_API_KEY")
doc_intelligence_endpoint = os.getenv("AZURE_DOCUMENT_INTELLIGENCE_ENDPOINT")
doc_intelligence_key = os.getenv("AZURE_DOCUMENT_INTELLIGENCE_KEY")

.envファイル

AZURE_OPENAI_ENDPOINT = "https://{resouce-name}.openai.azure.com/"
AZURE_OPENAI_API_KEY = "{openapi_key_from_azure_portal}"
AZURE_SEARCH_ENDPOINT = "https://{resource-name}.search.windows.net"
AZURE_SEARCH_ADMIN_KEY = "{ai_search_key_from_azure_portal}"
AZURE_DOCUMENT_INTELLIGENCE_ENDPOINT= "https://{resource-name}.cognitiveservices.azure.com/"
AZURE_DOCUMENT_INTELLIGENCE_KEY = "{document_intelligence_key_from_azure_portal}"

実装で活用する LangChainの機能を import します

from langchain import hub
from langchain_openai import AzureChatOpenAI
from langchain_community.document_loaders import AzureAIDocumentIntelligenceLoader
from langchain_openai import AzureOpenAIEmbeddings
from langchain.schema import StrOutputParser
from langchain.schema.runnable import RunnablePassthrough
from langchain.text_splitter import MarkdownHeaderTextSplitter
from langchain.vectorstores.azuresearch import AzureSearch

Document Intelligence を使ったセマンティックチャンキング

実際に Document Intelligence を使って、PDF を読み込んでセマンティックチャンキングを行っていきます。

まずは、PDF を読み込んで Markdown 形式に変換します。
今回、対象とする PDF(it_servey.pdf)は前回の記事で扱ったものと同じものを用意します。
PDFは、ソースコードのファイルと同じ階層に置きました。

# Initiate Azure AI Document Intelligence to load the document. You can either specify file_path or url_path to load the document.
loader = AzureAIDocumentIntelligenceLoader(file_path="it_servey.pdf", api_key = doc_intelligence_key, api_endpoint = doc_intelligence_endpoint, api_model="prebuilt-layout")
docs = loader.load()

もちろん StorageAccount などのサーバー上に公開されている PDF でも読み込み可能であり、 その場合は file_path の引数を、url_path に変更して URL を設定します。

また Markdown への変換に関してですが、AzureAIDocumentIntelligenceLoaderOptional の引数として用意されています。
ただし、Default として markdown が設定されているため、引数として明示的に記載する必要はありません。

mode: Optional[str]
    The type of content representation of the generated Documents.
    Use either "single", "page", or "markdown". Default value is "markdown".
次に、Markdown の内容を LangChain の機能を使って分割します。
今回は、Markdown の#, ##, ###のタグでチャンキングします。
# Split the document into chunks base on markdown headers.
headers_to_split_on = [
    ("#", "Header 1"),
    ("##", "Header 2"),
    ("###", "Header 3"),
]
text_splitter = MarkdownHeaderTextSplitter(headers_to_split_on=headers_to_split_on)

docs_string = docs[0].page_content #DocumentIntelligenceでMarkdown化したコンテンツ
splits = text_splitter.split_text(docs_string) #設定したsplitterで分割を行う

# ヘッダー情報を表すmetadataのみ出力
for split in splits:
    print(split.metadata)

LangChain の機能である、MarkdownHeaderTextSplitter で Markdown のチャンキングを行い、splits の配列に格納します。

セマンティックチャンキングの出力確認

セマンティックチャンキングの出力確認のため、分割されたsplitsの中身のヘッダー情報を示すmetadataのみを出力してみました。

{} # SIOS HOGEHOGE TECHNOLOGY 111-1111 東京都...の部分
{'Header 1': '調査概要 2024 年9月4日', 'Header 2': '調査目的'}
{'Header 1': '調査方法'}
{'Header 1': '市場概況'}
{'Header 1': '市場概況', 'Header 2': 'クラウドコンピューティング ♡'}
{'Header 1': '市場概況', 'Header 2': 'AIN'}
{'Header 1': '市場概況', 'Header 2': 'AIN', 'Header 3': 'ブロックチェーン'}

するとMarkdownの各ヘッダーの位置で文章が区切れてチャンキングされ、7つのデータに分割されていることが確認できます。
一応併せて元データの画像も載せておきます。

概ね良さそうに見えるのですが、いくつか誤っている部分も見られます。

split[2]の{'Header 1': '調査方法'}は、本来は{'Header 1': '調査概要 2024 年9月4日', 'Header 2': '調査方法'}が正しいように思います。

またsplit[6]に関しても、{'Header 1': '市場概況', 'Header 2': 'AIN', 'Header 3': 'ブロックチェーン'}とあり、ブロックチェーンAIの子要素であると認識されていたりします。

これらはいずれもDocument IntelligenceにおいてMarkdownとして出力する際に、誤った見出しが付けられていることが原因でした。
ここらへんはDocument Intelligenceの精度向上を祈るしかないかもしれないですね…。

一方で、split[3]の中に技術別市場シェア という見出しがありますが
こちらはDocument Intelligenceから出力されたMarkdownには 見出し2(##)として記録されていました。(↓)

## 技術別市場シェア

ただ、MarkdownHeaderTextSplitterの対象とならなかったようです。
このあたりの細かい振る舞いに関して、もう少し調査が必要そうです。

情報のベクトル化とベクトルストアへの格納

次にチャンク化した情報をベクトル化し、ベクトルストアに格納していきます。

ベクトル化を行う方法としては、Azure OpenAI Service の embedding model を活用し、
ベクトル化したデータは Azure AI Search に格納していきます。

Langchainの機能を使い、Azure OpenAI のEmbeddingモデルをラップして利用します。

# Embed the splitted documents and insert into Azure Search vector store

# AOAIのEmbeddings LLMをラップ
aoai_embeddings = AzureOpenAIEmbeddings(
    azure_deployment="<Azure OpenAI embeddings model>", e.g., "text-embedding-ada-002"
    openai_api_version="<Azure OpenAI API version>",  # e.g., "2023-12-01-preview"
)

vector_store_address: str = os.getenv("AZURE_SEARCH_ENDPOINT")
vector_store_password: str = os.getenv("AZURE_SEARCH_ADMIN_KEY")

index_name: str = "<your index name>" #Azure AI Serch上に作成するIndex名

# ベクトルストアから簡単にデータを取り出すためのvector_storeのインスタンスを構築.
# インスタンスを構築したタイミングでIndexが作成されます
# 今回は対象をAzure AI Searchとする.
vector_store: AzureSearch = AzureSearch(
    azure_search_endpoint=vector_store_address,
    azure_search_key=vector_store_password,
    index_name=index_name,
    embedding_function=aoai_embeddings.embed_query ## Azure OpenAI Serviceのembeddingsモデルを利用
)

# 先程チャンク化した情報を格納する
# 格納するタイミングでベクトル化も行われる
vector_store.add_documents(documents=splits)

なお、利用する Azure OpenAI Service のバージョンはこちらを参照してください。
今だと、”2024-02-01″がGAされてる最新バージョンかと思います。 

質問に関連する情報をベクトルストアから取り出す

次に実際にベクトルストアから、質問に対して関連した情報を抽出します。

この時 Retriever というインスタンスを作成して情報を取り出していきます。
この Retriever の振る舞いに関してはこちらの記事で分かりやすくまとまっていたので参照ください。

サンプルコードでは SearchType を similarity としているため類似検索が行われ、
search_kwargs を k:3 としているため 3 件の情報が返ってくるよう設定されています。

その後、実際に Retriever に質問を与え、関連する情報を retrieved_docs に格納し表示しています。

# Retrieve relevant chunks based on the question
# 先ほど作成したベクトルストアをRetrieverとして定義
retriever = vector_store.as_retriever(search_type="similarity", search_kwargs={"k": 3})

retrieved_docs = retriever.get_relevant_documents(
    "この調査の目的は何ですか?"
)

print(retrieved_docs) #サンプルコードでは[0].page_contentだけ出力していますが、内容理解のため全部出力します。

出力結果

出力された内容としては以下のようなものです。(分かりやすさのため改行しています。)

[Document(page_content='本市場調査の目的は、最新の IT 技術動向を詳細に把握し、各技術の市場シェア、成長予測、およ び競争状況を包括的に明らかにすることであり、特にクラウドコンピューティング、AI(人工知能)、loT (モノのインターネット)、およびブロックチェーン技術の各分野に焦点を当てるものである。', metadata={'Header 1': '調査概要 2024 年9月4日', 'Header 2': '調査目的'}),
Document(page_content='1\\. プライマリーデータはアンケート調査およびインタビューによって収集し、直接的かつ現場の 視点から得られる情報を重視する一方で、セカンダリーデータは公開データベースおよび業 界レポートから取得し、信頼性の高い既存の情報源を利用して補完的なデータ収集を行う。  \n2\\. データ分析に関しては、定量分析として統計解析ソフトウェアを駆使して大量の数値データを 精緻に解析し、定性分析として内容分析およびテーマ別分析を行い、収集されたデータの質 的側面を多角的に検討する。', metadata={'Header 1': '調査方法'}),
Document(page_content='AI 技術に関しては、IBM が市場シェアの 25%を占め、Google が 20%、Microsoft が 18%、その他が 37%を占めている。', metadata={'Header 1': '市場概況', 'Header 2': 'AIN'}),
Document(page_content='クラウドコンピューティング技術に関しては、AWS が市場シェアの 32%を占め、Microsoft Azure が 22%、Google Cloud が18%、その他が28%を占めている。', metadata={'Header 1': '市場概況', 'Header 2': 'クラウドコンピューティング ♡'})]

今回の質問に対して、関連するドキュメントが4件抽出されてました。
ただ、search_kwargs の値として "k":3を設定しており、3件返ってくるのかなと思っていました。

retrieverの値をデバッグしてみたところ、k=4が設定されていました。
kのDefault値が4みたいなので、うまくパラメータが設定できていないみたいですね。

このあたりlangchainをうまく使いこなせてない可能性があるので、また再調査します。

件数は一旦さておき、出力の中身としては 以下2 つの内容を含んでいることが分かります。

page_content 参考にした文章内容
metadata Section情報(今回でいえばMarkdown における見出し位置)

一番上の結果のpage_contentは「本市場調査の目的は~」から始まっており、まさに質問に関連する部分ですね。
AI Searchによって、質問に関連するドキュメントが抽出できていることが分かります。

プロンプトを構築する

次にプロンプトを構築していきます。

「え?”このドキュメントの想定される読者は誰ですか?”がプロンプトじゃないんですか?」

と思われた方もいるんじゃないでしょうか? 自分も最初はそうでした。

ただ、LLM に投げかけるプロンプトの内容は非常に重要であり、プロンプトエンジニアリングとしても技術が確立されています。
プロンプトエンジニアリングの知識がないユーザーからの質問文をそのままプロンプトとして投げた場合、求める品質の回答が得られない場合があります。

そのため、システム側である程度プロンプトのテンプレートを構築しておき、そこにユーザーからの質問を組み込む形を取ります。
この時に利用するのが LangChain のPrompt Templateの機能になります。

では実際にソースコードを見てみます。 なお、今回は既に定義された Prompt Template を利用します。

# Use a prompt for RAG that is checked into the LangChain prompt hub (https://smith.langchain.com/hub/rlm/rag-prompt?organizationId=989ad331-949f-4bac-9694-660074a208a7)
prompt = hub.pull("rlm/rag-prompt")

hub というのは GitHub とか、DockerHub のようにこう言った Template などが確立されている場所だと思ってください。
この hub から今回は rlm/rag-promptというテンプレートを pull してきます。

参照リンクへ飛んで、テンプレートの中身を見てみると以下のように定義されています。

You are an assistant for question-answering tasks. Use the following pieces of retrieved context to answer the question. If you don't know the answer, just say that you don't know. Use three sentences maximum and keep the answer concise.

Question: {question}

Context: {context}

Answer:

なおこの時、変数{ }として定義されている値としては以下のような内容になります。

question ユーザーからの質問文そのまま
contex ベクトルストアから抽出した関連情報

このようなテンプレートを介すことで、ユーザーから単純な質問を投げられたとしても
LLM にとって分かりやすい形のプロンプトを構築することが可能になります。

この後のステップとして、このテンプレートに入れるための{question}{context}を構築していきます。

余談

今回日本語のシステムを構築しようとしているので、日本語でテンプレート定義しなおした方がいいかもしれないです。
ただ逆に LLM は英語の方が理解しやすいかとおもうので、ぎりぎりまで英語でやり取りした方がいいかもしれないです。
このあたり日本語の扱いが難しいですね…

RAG の Chain 構築

最後に、実際に RAG の Chain を構築していきます。
Chain とはその名の通り、LLM や Prompt などのコンポーネントを繋げて、システムを作り上げる機能になります。

なお Chain を実装するにあたり、LangChain Expression Language (LCEL)といった記述方法を利用しています。 

LLM や Prompt 等のコンポーネントを | で繋げるだけで簡単に Chain を構築することが可能です。
Linux のパイプみたいな感じですね。

llm = AzureChatOpenAI(
    openai_api_version="<Azure OpenAI API version>",  # e.g., "2023-12-01-preview"
    azure_deployment="<your chat model deployment name>",
    temperature=0,
)

# ベクトルストアから取り出したdocumentからpage_contentの内容だけを抽出し、連結します。
def format_docs(docs):
    return "\n\n".join(doc.page_content for doc in docs)

rag_chain = (
    {"context": retriever | format_docs, "question": RunnablePassthrough()} # step1
    | prompt # step2
    | llm # step 3
    | StrOutputParser() # step4
)

llm の変数に関しては、Azure OpenAI Service の Chat の LLM モデルを構築します。
その後、処理の chain をrag_chain変数として定義します。

chain のステップとしては以下の通りです。

  1. テンプレートに与える変数であるcontextと、questionを構築する
    1. 質問文が retriever に渡され、関連情報が得られた後にcontextの中に代入される
    2. questionに関しては、RunnablePassthrough()となっているので、そのまま文字列が渡される
  2. テンプレートを利用してプロンプトを構築する
  3. プロンプトを Chat LLM に投げる
  4. 得られた回答から必要な部分のみ抽出

1, 2, 3 はこれまで言及してきた部分を繋げただけなので省略します。

4 の StrOutputParser()に関しては、LangChain の Output parsers の機能になります。
Chat LLM から得られた回答のうち、最も関連性が高いものを文字列として出力します。

RAG の chain 実行

あとは先程組んだ Chain に質問文を与えて実行するだけです。

rag_chain.invoke("この調査の目的は何ですか?")

以下の回答が得られました。

本調査の目的は、最新の IT 技術動向を詳細に把握し、各技術の市場シェア、成長予測、および競争状況を包括的に明らかにすることです。

ドキュメント参照する RAG システム

サンプルのドキュメントにはもう一つ例が載っていました。
特に難しい事はしていないですが、少し複雑な内容になっているので解説していきたいと思います。

まずこのコードの目的ですが、「回答に加えて、参照したドキュメントの情報を返すこと」を目的としています。
どんな情報を参照したうえで回答したかをユーザーに教えることができるので、よりユーザーフレンドリーですね。

ソースコードの解説をする前に、全体の処理の流れを図に起こしたので載っけておきます。
コード見ていて分からなくなったらこの図を見てみてください。

では実際にソースコードを見ていきます。

# Return the retrieved documents or certain source metadata from the documents

from operator import itemgetter
from langchain.schema.runnable import RunnableMap

rag_chain_from_docs = (
    {
        "context": lambda input: format_docs(input["documents"]),
        "question": itemgetter("question"),
    }
    | prompt
    | llm
    | StrOutputParser()
)

rag_chain_with_source = RunnableMap(
    {"documents": retriever, "question": RunnablePassthrough()}
) | {
    "documents": lambda input: [doc.metadata for doc in input["documents"]],
    "answer": rag_chain_from_docs,
}

rag_chain_with_source.invoke("<your question>")

少し長いので分割してみていきます。
まず後半部分から

rag_chain_with_source = RunnableMap(
    {"documents": retriever, "question": RunnablePassthrough()}
) | {
    "documents": lambda input: [doc.metadata for doc in input["documents"]],
    "answer": rag_chain_from_docs,
}

rag_chain_with_source.invoke("<your question>")

rag_chain_with_sourceという chain が構築されており、最後にその chain が実行されています。
chain の中身を見ていきます。

まずは入力に対して retriever の処理を加えたものをdocumentsへ、何も処理を加えないものをquestionsへ代入します。

ただ、先程の例では RunnableMap を利用しておらず、今回の例で RunnableMap を利用してる点が理解できませんでした。
この点分かる方いらしたらコメントお願いします。

次に、documentsquestionsを入力として

{
    "documents": lambda input: [doc.metadata for doc in input["documents"]],
    "answer": rag_chain_from_docs,
}

の中身を定義していきます。

まずdocumentsの値に関しては、入力として与えられたdocuments の metadata 情報を、今回の定義のdocumentsに代入します。

answerに関しては、入力(documents, questions)に対して rag_chain_from_docs の処理を行ったものを代入します。

rag_chain_from_docs = (
    {
        "context": lambda input: format_docs(input["documents"]),
        "question": itemgetter("question"),
    }
    | prompt
    | llm
    | StrOutputParser()
)

rang_chain_from_docs において、まずcontextquestionを定義します。

contextに関しては、入力として与えられたdocuments の値に対して、format_docs の処理(連結処理)を行ってからcontextに代入します。
questionに関しては、入力のquestionの値をそのまま入れます。(つまりユーザーからのリクエストそのままですね。)

こうして作られた 値 に対して prompt, llm, StrOutputParser のコンポーネントの処理を通して出力します。
そして出力したものが、answerの中に入れることになります。。

ということで、最終的に得られた出力はこちらです。

{'documents': [{'Header 1': '調査概要 2024 年9月4日', 'Header 2': '調査目的'},
{'Header 1': '調査方法'},
{'Header 1': '市場概況', 'Header 2': 'AIN'},
{'Header 1': '市場概況', 'Header 2': 'クラウドコンピューティング ♡'}],
'answer': '本調査の目的は、最新の IT 技術動向を詳細に把握し、各技術の市場シェア、成長予測、および競争状況を包括的に明らかにすることです。'}

documentの中には参照したドキュメントの Metadata が入っており
answerの中には Chat LLM から得られた回答が入っていることが分かります。

まとめ

Document Intelligence を利用し、セマンティックチャンキングを行う RAG システムを構築しました。
サンプルでも丁寧に解説してくれているのですが、初学者の自分からすると分からないことも結構あったので整理してみました。
Document Intelligenceというよりは、langchainのRAG構築部分が少し複雑でしたね。

今回は「やってみた」で終わってしまったので、また機会があれば評価ツールなどを使いながら
マンティックチャンキングの効果を測定してみたりしたいと思います。

ではまた!

ご覧いただきありがとうございます! この投稿はお役に立ちましたか?

役に立った 役に立たなかった

0人がこの投稿は役に立ったと言っています。

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です