ChatGPTなどの生成AIに社内文書などの外部情報を与え、チャットBotに回答させるといった、RAG(Retrieval-Augmented Generation)の活用が進んでいます。弊社でも、同様のシステム開発を行いましたが、既存のライブラリやサービスではWordファイルのテキストが上手く読み取れなかったり、Wordファイルに付いたコメントが読み取れないなどといった課題がありました。
Web上で検索しても同様の記事は見当たらなかったため、本記事では試行錯誤の末に見つけた、Wordファイルからテキストとコメントを抜け漏れなく抽出する方法について紹介します。
既存のライブラリの課題
pythonでWordファイルを読み込む方法として、python-docx
ライブラリがよく使われています。例えば、LangChainと組み合わせて使われるUnstructuredでも、裏側でpython-docx
が使われていますが、実際に社内文書などを読み取ってみると、抜け漏れしているテキストが多々あることに気づくでしょう。
python-docx
は、Wordファイル(.docx)のXMLから情報を取得しており、XMLタグの特定の構造を前提としているようです。例えば、弊社で試したところ、タイトルやヘッダー文、ネストが深いリストなどが取得できていませんでした。
一方で、Azure AI Document IntelligenceのようなOCRベースのサービスを利用すると文字は抜け漏れなく読み取ることができました。しかしながら、今回の用途では、Wordファイルに記入されたコメントを読み取る必要があったため、こちらの要件が満たせませんでした。また、Azureのサービスであるため利用コストが発生する点も、大規模化する時に気になる点でした。
そのため、XMLファイルからテキストとコメントを読み取る方法を試すことにしました。
Word(.docx)ファイルの構成
前提として、.docx
ファイルは複数のXMLファイルがzipで固められたファイルです。そのため、unzipコマンドで展開することができます。
展開するとword/
の下にdocument.xml
とcomments.xml
が存在し、これらがWordファイルから文字を抽出するのに使うファイルです。コメントがついていない場合は、comments.xml
はありません。
サンプルのWordファイル
Wordファイルは何でも良いので、夏目漱石のこころの一部をジェネレーターで取得しました。
「私はその人を常に先生と読んでいた。」の部分にコメントをつけています。
コメントを抽出する
最初にコメントを抽出します。 Wordファイル内のコメントは、全てword/comments.xml
に記載があります。
各コメントにはIDが振られているので、comments.xml
からコメントのIDと内容を抽出し、Dictで保存します。
このDictは後ほど、document.xml
と突き合わせて、コメントがつけられた位置の直後にコメント文を挿入するために使用します。
それでは、コメントのXML表現を見てみましょう。
<w:comment w:initials="KO" w:author="Kaito Osugi" w:date="2024-04-22T12:41:25" w:id="314251227">
<w:p w:rsidR="63FDECCA" w:rsidRDefault="63FDECCA" w14:paraId="126FF5E0"
w14:textId="14CD8828">
<w:pPr>
<w:pStyle w:val="CommentText" />
</w:pPr>
<w:r w:rsidR="63FDECCA">
<w:rPr />
<w:t>これはコメントです。</w:t>
</w:r>
<w:r>
<w:rPr>
<w:rStyle w:val="CommentReference" />
</w:rPr>
<w:annotationRef />
</w:r>
</w:p>
</w:comment>
ポイントは、w:id="314251227"
とw:t
で囲まれた部分です。前者がコメントのIDであり、後者はコメントの内容です。
Wordファイル内では、ユーザーが実際に見るテキストは<w:t>
で囲まれて表現されています。
それでは、IDとコメント内容を抽出するPythonコードを見ていきましょう。
import zipfile
from lxml import etree as ET
from typing import Any, List, Optional
namespaces = {"w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main"}
ns = "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}"
def create_comment_id_pair(docx_path: str) -> Optional[dict[str, str]]:
with zipfile.ZipFile(docx_path, "r") as docx:
comment_file = "word/comments.xml"
comment_id = ""
if comment_file in docx.namelist():
with docx.open(comment_file) as comments_xml:
comments_tree = ET.parse(comments_xml, None)
comments_root = comments_tree.getroot()
comments_dict = {}
for comment in comments_root.findall(".//w:comment", namespaces):
comment_id = comment.attrib[ns + "id"]
comment_text = "".join(
elem.text
for elem in comment.findall(".//w:t", namespaces)
if elem.text
)
comments_dict[comment_id] = comment_text
return comments_dict
else:
return None
XMLを解析するためにlxml
パッケージを利用します。pip install lxml
でインストールできます。
最初に、WordファイルをZipファイルとして展開し、word/comments.xml
を読み込みます。読み込まれたXMLファイルは木構造で表現され、木の根(root)からfindall
で子ノードを探すことができます。
次に、コメントの内容が含まれる/w:comment
をfindall
で検索し、コメントのIDとテキストを抽出しています。
このXMLでは、w
は"http://schemas.openxmlformats.org/wordprocessingml/2006/main"
を表すため、一番最初に定義しています。
この関数を実行することで、{comment_id: comment}
の形式である{"314251227": "これはコメントです。"}
のDictが得られます。
本文を抽出する
次はWordの本文のXML表現を見ていきましょう。
<w:p wp14:paraId="2C078E63"
xmlns:wp14="http://schemas.microsoft.com/office/word/2010/wordml" wp14:textId="2F3467A2">
<w:commentRangeStart w:id="314251227" />
<w:r w:rsidR="150C9252">
<w:rPr />
<w:t>私はその人を常に先生と呼んでいた。</w:t>
</w:r>
<w:commentRangeEnd w:id="314251227" />
<w:r>
<w:rPr>
<w:rStyle w:val="CommentReference" />
</w:rPr>
<w:commentReference w:id="314251227" />
</w:r>
<w:r w:rsidR="150C9252">
<w:rPr />
<w:t>
だからここでもただ先生と書くだけで本名は打ち明けない。これは世間を憚かる遠慮というよりも、その方が私にとって自然だからである。私はその人の記憶を呼び起すごとに、すぐ「先生」といいたくなる。筆を執っても心持は同じ事である。よそよそしい頭文字などはとても使う気にならない。私が先生と知り合いになったのは鎌倉である。その時私はまだ若々しい書生であった。</w:t>
</w:r>
</w:p>
<w:t>
に実際のテキストが記載されています。
また、コメントの範囲は<w:commentRangeStart>
と<w:commentRangeEnd>
で示されていることが分かります。これらのタグに記載のあるw:id="314251227"
が対応するコメントのIDです。
それでは、本文を抽出しつつ、コメントを発見したら本文の直後にコメントを追加するコードを見ていきましょう。
def read_docx_from_xml(
docx_path: str,
read_comment: bool = False,
read_insertion: bool = False,
read_delete: bool = False,
):
comment_id_pair = create_comment_id_pair(docx_path)
with zipfile.ZipFile(docx_path, "r") as docx:
with docx.open("word/document.xml") as document_xml:
document_tree = ET.parse(document_xml, None)
document_root = document_tree.getroot()
comment_id = ""
texts: list[str] = []
for paragraph in document_root.xpath(".//w:p", namespaces=namespaces):
paragraph_texts: list[Any] = []
inside_ins = False
for event, elem in ET.iterwalk(paragraph, events=("start", "end")):
if read_insertion and event == "start" and elem.tag == ns + "ins":
inside_ins = True
elif read_insertion and event == "end" and elem.tag == ns + "ins":
inside_ins = False
elif event == "end" and elem.tag == ns + "t":
if inside_ins:
paragraph_texts.append(f"<++{elem.text}++>")
else:
paragraph_texts.append(elem.text)
elif read_delete and event == "end" and elem.tag == ns + "delText":
paragraph_texts.append(f"<--{elem.text}-->")
elif (
read_comment
and event == "start"
and elem.tag == ns + "commentRangeStart"
):
comment_id = elem.attrib.get(ns + "id")
elif (
read_comment
and event == "start"
and elem.tag == ns + "commentRangeEnd"
and elem.attrib.get(ns + "id") == comment_id
):
if comment_id_pair:
comment_text = comment_id_pair.get(comment_id, "")
if comment_text:
paragraph_texts.append(f"<##{comment_text}##>")
texts.append("".join(paragraph_texts))
pretty_texts = []
for text in texts:
new_text = (
text.replace("++><++", "").replace("--><--", "").replace("##><##", "")
)
pretty_texts.append(new_text)
return pretty_texts
このコードでは、引数として、次の真偽値を取ります。
- read_comment: コメントを考慮するか。Trueの場合、コメントを挿入します。コメントは、コメントであることを明示するために
<## ##>
で囲っています。 Falseの場合、コメントは無視します。 - read_insertion: Wordファイルで挿入されたテキストは、
<w:ins>
で囲まれており、判別可能です。このフラグがTrueの時は、挿入されたテキストを<++ ++>
で囲むことで判別できるようにしています。このフラグがFalseの時は、通常のテキストとして扱います。挿入されたテキストは、<w:ins>
で囲まれた<w:t>
に記載されています。 - read_delete: Wordファイルで削除されたテキストは、
<w:delText>
で囲まれており、判別可能です。このフラグがTrueの時は、削除されたテキストを<-- -->
で囲むことで判別できるようにしています。Falseの時は、削除されたテキストは無視します。
document.xml
では、パラグラフが<w:p>
で表現されており、上記コードではパラグラフ単位で処理を行っています。 Wordファイルは、ファイルを編集するたびに文章が別の<w:t>
に囲まれる仕様(断片化と呼ばれています)になっているため、texts.append("".join(paragraph_texts))
でテキストを結合し、パラグラフ単位でList(texts)に入るようにしています。
ET.iterwalk
でXMLを1行ずつ読み取り、<w:t>
の内容を読み取りつつ、上記の引数に応じて処理を行います。
最後に、次の処理で連続するコメント文/挿入文/削除文を結合しています。上述した断片化により、一つの挿入文やコメント文がバラバラになることがあるため、この処理を追加しました。
pretty_texts = []
for text in texts:
new_text = (
text.replace("++><++", "").replace("--><--", "").replace("##><##", "")
)
pretty_texts.append(new_text)
まとめ
本記事では、Wordファイル(.docx)からPythonでテキストとコメントを読み取る方法について紹介しました。
生成AIの活用方法の1つとして、Q&Aや社内ドキュメントの内容に基づいて回答させるRAG(Retrieval-Augmented Generation)が流行っておりますが、文書の内容を正確に読み取れているかどうかは意外と見落とされている観点かと思います。
弊社と同様に、Wordファイルからテキストが読み取れず、困っている方に本記事の内容が役に立てば幸いです。