💪

LangExtractで食べ物の口コミを分析してみる

に公開

概要

今回はLangExtractで外食の非構造化テキスト文書から構造化された情報を抽出します。

langextractとは

LangExtractは、ユーザーの指示に基づいて、非構造化テキスト文書から構造化された情報を抽出するために設計されたGoogle製のPythonライブラリです。臨床記録や報告書などの資料を処理し、重要な詳細を特定・整理しながら、抽出されたデータが元のテキストと一致するように保証します。

https://github.com/google/langextract

特徴としては下記のようになっています。

  1. 抽出箇所の特定:抽出結果を元テキスト内の正確な位置に対応付け、ハイライト表示で追跡・検証可能。
  2. 構造化された出力:Few-shot例に基づく一貫したスキーマを強制し、Geminiなど対応モデルで堅牢な構造化結果を保証。
  3. 長文対応:テキスト分割・並列処理・複数回パスで「干し草の山から針を探す」課題を克服し、高い再現率を実現。
  4. 可視化機能:抽出結果を元文脈付きで確認できる自己完結型HTMLを即時生成。
  5. 幅広いLLM対応:Google GeminiなどのクラウドLLMから、Ollama経由のローカルモデルまで利用可能。
  6. あらゆるドメイン適応:数例の定義だけで、追加学習なしに任意ドメインの抽出タスクに対応。
  7. LLMの知識活用:精密なプロンプトと例示で抽出精度を高めるが、精度や仕様遵守はモデル性能やプロンプト設計に依存。

関連ライブラリとの違い

関連したGoogle製の代表的なツールだとGoogle Gen AI Python SDKがあると思います。これは、開発者がGoogleの生成モデルをGemini Developer APIおよびVertex AI APIを経由して呼び出すために使用されます。

https://github.com/googleapis/python-genai

Gemini APIはLLM推論そのものを指しますが、LangExtractは抽出ワークフロー形式です。設計や実行、検証、可視化を可能にしており、内部でLLMモデルを呼び出しています。それぞれ差分を説明していきます。

出力スキーマの強制

例えばですが、出力スキーマの矯正ができ、下記のような形で事例を提示したfew-shotや制約生成で厳密なjson結果を得ることが可能です。

examples = [
    lx.data.ExampleData(
        text="""チーズ入りトンテキを注文しました!
表面は香ばしく焼かれた豚肉に、とろーりと溶けたチーズがたっぷり絡んでいて、噛むたびに旨味が広がります。
チーズのコクと豚肉のジューシーさのバランスが絶妙で、これはクセになるおいしさ。
ご飯が止まらなくなる系のメニューでした!""",
        extractions=[
            lx.data.Extraction(
                extraction_class="food",
                extraction_text="豚肉"
            ),
            lx.data.Extraction(
                extraction_class="food",
                extraction_text="チーズ"
            ),
            lx.data.Extraction(
                extraction_class="menu",
                extraction_text="チーズ入りトンテキ"
            ),
            lx.data.Extraction(
                extraction_class="sizzle_word",
                extraction_text="とろーり",
                attributes={
                    "category": "食感系",
                    "emotion": "positive"
                }
            ),
            lx.data.Extraction(
                extraction_class="sizzle_word",
                extraction_text="香ばしい",
                attributes={
                    "category": "味覚系",
                    "emotion": "positive"
                }
            ),
            lx.data.Extraction(
                extraction_class="sizzle_word",
                extraction_text="ジューシー",
                attributes={
                    "category": "食感系",
                    "emotion": "positive"
                }
            ),
            lx.data.Extraction(
                extraction_class="sizzle_word",
                extraction_text="旨味が広がる",
                attributes={
                    "category": "味覚系",
                    "emotion": "positive"
                }
            ),
            lx.data.Extraction(
                extraction_class="sizzle_word",
                extraction_text="クセになる",
                attributes={
                    "category": "情報系",
                    "emotion": "positive"
                }
            ),
            lx.data.Extraction(
                extraction_class="relationship",
                extraction_text="絡む",
                attributes={
                    "source": "チーズ",
                    "target": "豚肉"
                }
            ),
            lx.data.Extraction(
                extraction_class="relationship",
                extraction_text="バランスが絶妙",
                attributes={
                    "source": "チーズ",
                    "target": "豚肉"
                }
            ),
        ]
    )
]

ハイライト

ソースへの位置対応も可能で、直接LLMを呼び出す方法だと自作する必要がありますが、langextractでは提供されています。

ただし、現状だとリリースブランチで日本語における位置対応が実装されてません。こちらについては下記のissueで指摘されてます。

https://github.com/google/langextract/issues/13

現状としては、下記の形でインストールすれば日本語にも対応できます。

pip install git+https://github.com/google/langextract.git@feature/multi-language-tokenizer

長文に対する最適化

分割や並列などの最適化が組み込まれています。

失敗時の再試行・堅牢化

ワークフロー内で実装されているので、リトライや冪等性を自作する必要がありません。

可視化

ダッシュボードを使う必要がなく可視化が可能ですので、トレーサビリティが欲しい時に有用です。後で実際に可視化してみます。

モデルの切替

もちろんGeminiだけではなく、Open AIやllamaのローカルLLMで実行することが可能です。

まとめると、推論エンジンというよりは情報抽出のための実務フレームワークという位置付けになると思います。

実装例

最初に、インストールします。今回は日本語で抽出するので、特定のブランチをインストールします。

pip install git+https://github.com/google/langextract.git@feature/multi-language-tokenizer

まずは、プロンプトとfew-shot用の事例を用意します。食品名・メニュー名・食材名を取り出すだけではなく、感情成分や感情を動機付けするシズルワードと呼ばれるものを取り出していきます。

import langextract as lx
import textwrap

# 1. Define the prompt and extraction rules
prompt = textwrap.dedent("""\
## 目的
食に関する口コミ文から、以下の情報を抽出してください。

## 抽出要素

1. 食品名・メニュー名・食材名

明示されている食べ物や料理、個別の食材に関する名詞を抽出してください。

2. シズルワードとそのカテゴリ(味覚系 / 食感系 / 情報系)

「おいしそう」「食べたい」などの感情を喚起する言葉(=シズルワード)を抽出し、下記の3カテゴリのいずれに属するか分類してください。

- 味覚系:味・風味・香りなど(例:甘い、香ばしい、ピリ辛)

- 食感系:食感や音による印象(例:もちもち、サクサク、ジューシー)

- 情報系:鮮度・調理状況・評判に関する表現(例:炊き立て、旬、絶品)

3. シズルワードに対する感情・印象(オプション)

そのシズルワードが口コミ文中でどういうポジティブ・ネガティブな感情と結びついているか(例:「サクサクで最高!」→ポジティブ)

4. 食材や食品同士の関係性(attributeとして記述)

例:「豚肉にとろーりチーズが絡む」→「豚肉」と「チーズ」の関係は「絡む」

抽出された食品同士が、どういう調理・盛り付け・食感的な関係にあるかを、自然言語またはタグで記述してください。

### 補足:
- 「食べたい」「おいしそう」などの抽象表現も、他の具体的な語とセットで記述されている場合は含めてください。
- 曖昧な表現(例:「いい感じの歯ごたえ」)も可能な限り分類してください。
""")

# 2. Provide a high-quality example to guide the model
examples = [
    lx.data.ExampleData(
        text="""チーズ入りトンテキを注文しました!
表面は香ばしく焼かれた豚肉に、とろーりと溶けたチーズがたっぷり絡んでいて、噛むたびに旨味が広がります。
チーズのコクと豚肉のジューシーさのバランスが絶妙で、これはクセになるおいしさ。
ご飯が止まらなくなる系のメニューでした!""",
        extractions=[
            lx.data.Extraction(
                extraction_class="food",
                extraction_text="豚肉"
            ),
            lx.data.Extraction(
                extraction_class="food",
                extraction_text="チーズ"
            ),
            lx.data.Extraction(
                extraction_class="menu",
                extraction_text="チーズ入りトンテキ"
            ),
            lx.data.Extraction(
                extraction_class="sizzle_word",
                extraction_text="とろーり",
                attributes={
                    "category": "食感系",
                    "emotion": "positive"
                }
            ),
            lx.data.Extraction(
                extraction_class="sizzle_word",
                extraction_text="香ばしい",
                attributes={
                    "category": "味覚系",
                    "emotion": "positive"
                }
            ),
            lx.data.Extraction(
                extraction_class="sizzle_word",
                extraction_text="ジューシー",
                attributes={
                    "category": "食感系",
                    "emotion": "positive"
                }
            ),
            lx.data.Extraction(
                extraction_class="sizzle_word",
                extraction_text="旨味が広がる",
                attributes={
                    "category": "味覚系",
                    "emotion": "positive"
                }
            ),
            lx.data.Extraction(
                extraction_class="sizzle_word",
                extraction_text="クセになる",
                attributes={
                    "category": "情報系",
                    "emotion": "positive"
                }
            ),
            lx.data.Extraction(
                extraction_class="relationship",
                extraction_text="絡む",
                attributes={
                    "source": "チーズ",
                    "target": "豚肉"
                }
            ),
            lx.data.Extraction(
                extraction_class="relationship",
                extraction_text="バランスが絶妙",
                attributes={
                    "source": "チーズ",
                    "target": "豚肉"
                }
            ),
        ]
    )
]

シズルワードについて知りたい方は下記を参考にしてください。

https://www.kiyota-s.com/kiyota-diary/2796

では、実際に口コミから抽出してみます。今回はgeminiを使うので、Google AI StudioのAPI KEYをapi_keyに埋め込むことで実行できます。

input_text = "好評と噂の鰻は独特な焼き方をしていて、皮はパリパリで中はフワフワでとても美味でした。手打ちうどんは手作りでコシがあって美味しい。天ぷらもサクサク。煮物も出汁が効いてて美味しかった。"

result = lx.extract(
    text_or_documents=input_text,
    prompt_description=prompt,
    examples=examples,
    model_id="gemini-2.5-flash",
    api_key=<Google AI StudioのAPI KEY>,
    debug=True
)

結果として下記のようになります。本来ならもっと細かく情報を抽出したりなどの調整が必要ですが、テキトーにプロンプトを作った割には必要な情報を抽出できていました。

AnnotatedDocument(extractions=[Extraction(extraction_class='food', extraction_text='鰻', char_interval=CharInterval(start_pos=5, end_pos=6), alignment_status=<AlignmentStatus.MATCH_FUZZY: 'match_fuzzy'>, extraction_index=1, group_index=0, description=None, attributes={}), Extraction(extraction_class='food', extraction_text='手打ちうどん', char_interval=CharInterval(start_pos=42, end_pos=48), alignment_status=<AlignmentStatus.MATCH_FUZZY: 'match_fuzzy'>, extraction_index=2, group_index=1, description=None, attributes={}), Extraction(extraction_class='food', extraction_text='天ぷら', char_interval=CharInterval(start_pos=64, end_pos=67), alignment_status=<AlignmentStatus.MATCH_FUZZY: 'match_fuzzy'>, extraction_index=3, group_index=2, description=None, attributes={}), Extraction(extraction_class='food', extraction_text='煮物', char_interval=CharInterval(start_pos=73, end_pos=75), alignment_status=<AlignmentStatus.MATCH_FUZZY: 'match_fuzzy'>, extraction_index=4, group_index=3, description=None, attributes={}), Extraction(extraction_class='sizzle_word', extraction_text='好評と噂の', char_interval=CharInterval(start_pos=0, end_pos=5), alignment_status=<AlignmentStatus.MATCH_EXACT: 'match_exact'>, extraction_index=5, group_index=4, description=None, attributes={'category': '情報系', 'emotion': 'positive'}), Extraction(extraction_class='sizzle_word', extraction_text='独特な焼き方', char_interval=CharInterval(start_pos=7, end_pos=13), alignment_status=<AlignmentStatus.MATCH_EXACT: 'match_exact'>, extraction_index=6, group_index=5, description=None, attributes={'category': '情報系', 'emotion': 'positive'}), Extraction(extraction_class='sizzle_word', extraction_text='パリパリ', char_interval=CharInterval(start_pos=21, end_pos=25), alignment_status=<AlignmentStatus.MATCH_EXACT: 'match_exact'>, extraction_index=7, group_index=6, description=None, attributes={'category': '食感系', 'emotion': 'positive'}), Extraction(extraction_class='sizzle_word', extraction_text='フワフワ', char_interval=CharInterval(start_pos=28, end_pos=32), alignment_status=<AlignmentStatus.MATCH_EXACT: 'match_exact'>, extraction_index=8, group_index=7, description=None, attributes={'category': '食感系', 'emotion': 'positive'}), Extraction(extraction_class='sizzle_word', extraction_text='とても美味', char_interval=CharInterval(start_pos=33, end_pos=38), alignment_status=<AlignmentStatus.MATCH_EXACT: 'match_exact'>, extraction_index=9, group_index=8, description=None, attributes={'category': '味覚系', 'emotion': 'positive'}), Extraction(extraction_class='sizzle_word', extraction_text='手作り', char_interval=CharInterval(start_pos=49, end_pos=52), alignment_status=<AlignmentStatus.MATCH_EXACT: 'match_exact'>, extraction_index=10, group_index=9, description=None, attributes={'category': '情報系', 'emotion': 'positive'}), Extraction(extraction_class='sizzle_word', extraction_text='コシがあって', char_interval=CharInterval(start_pos=53, end_pos=59), alignment_status=<AlignmentStatus.MATCH_EXACT: 'match_exact'>, extraction_index=11, group_index=10, description=None, attributes={'category': '食感系', 'emotion': 'positive'}), Extraction(extraction_class='sizzle_word', extraction_text='美味しい', char_interval=CharInterval(start_pos=59, end_pos=63), alignment_status=<AlignmentStatus.MATCH_EXACT: 'match_exact'>, extraction_index=12, group_index=11, description=None, attributes={'category': '味覚系', 'emotion': 'positive'}), Extraction(extraction_class='sizzle_word', extraction_text='サクサク', char_interval=CharInterval(start_pos=68, end_pos=72), alignment_status=<AlignmentStatus.MATCH_EXACT: 'match_exact'>, extraction_index=13, group_index=12, description=None, attributes={'category': '食感系', 'emotion': 'positive'}), Extraction(extraction_class='sizzle_word', extraction_text='出汁が効いてて', char_interval=CharInterval(start_pos=76, end_pos=83), alignment_status=<AlignmentStatus.MATCH_EXACT: 'match_exact'>, extraction_index=14, group_index=13, description=None, attributes={'category': '味覚系', 'emotion': 'positive'}), Extraction(extraction_class='sizzle_word', extraction_text='美味しかった', char_interval=CharInterval(start_pos=83, end_pos=89), alignment_status=<AlignmentStatus.MATCH_EXACT: 'match_exact'>, extraction_index=15, group_index=14, description=None, attributes={'category': '味覚系', 'emotion': 'positive'})], text='好評と噂の鰻は独特な焼き方をしていて、皮はパリパリで中はフワフワでとても美味でした。手打ちうどんは手作りでコシがあって美味しい。天ぷらもサクサク。煮物も出汁が効いてて美味しかった。')

最後に可視化していきます。

lx.io.save_annotated_documents([result], output_name="extraction_results.jsonl", output_dir=".")

html_content = lx.visualize("extraction_results.jsonl")
html_content

Discussion