0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Embedding+階層統合で順序依存を克服! LangChain × Azure OpenAI で実現する日本語長文高精度テキスト統合

Posted at

user:

先日、次のコードを一緒に作りましたね。いくつかの文書を試してみてちょっと気づいたのですが、このコードって、入力順序にかなり依存しませんか?つまり、texts = [text1, text2, text3]における[text1, text2, text3]の順序を変えると、出力がけっこう変わる気がするんです。先頭にあるテキストを優先してる気がするんですよね。

# !pip install pymupdf pymupdf4llm faiss-cpu langchain langchain-community openai requests langchain_openai

import json

from google.colab import drive
from langchain.prompts import ChatPromptTemplate
from langchain_community.embeddings import HuggingFaceEmbeddings
from langchain_openai import AzureChatOpenAI

drive.mount("/content/drive")

# =============================
# ✅ Azure OpenAI 設定
# =============================
CONFIG_PATH = "/content/drive/MyDrive/path_config/config-32k.json"
drive.mount("/content/drive")

with open(CONFIG_PATH, "r") as f:
    config = json.load(f)

azure_api_key = config["OPENAI_API_KEY"]
azure_api_version = config["OPENAI_API_VERSION"]
azure_endpoint = config["OPENAI_API_BASE"]
azure_model = config["OPENAI_API_ENGINE"]  # 32k モデルをデプロイして指定!


def get_llm(temperature: float = 0.3, max_tokens: int = 15000) -> AzureChatOpenAI:
    """LangChain 用 Azure OpenAI クライアントを返す(32k モデル向け、出力も長文対応)"""
    return AzureChatOpenAI(
        deployment_name=azure_model,
        openai_api_key=azure_api_key,
        openai_api_version=azure_api_version,
        azure_endpoint=azure_endpoint,
        temperature=temperature,
        max_tokens=max_tokens,
    )


# =============================
# ✅ Embeddings 設定
# =============================
embeddings = HuggingFaceEmbeddings(
    model_name="intfloat/multilingual-e5-base",
    model_kwargs={"device": "cpu"},
    encode_kwargs={"normalize_embeddings": True},
)


# =============================
# ✅ 日本語テキスト統合関数
# =============================
def unify_japanese_texts(
    texts: list[str],
    temperature: float = 0.3,
    chunk_size: int = 5000,
    max_rounds: int = 3,
) -> str:
    """
    日本語の長文テキスト群を、意味を損なわずに統合。
    - 原文にない情報は追加しない
    - 冗長さは整理しつつ、丁寧な言い換えや接続語で長文化
    - 32k モデルの長文対応を活かす
    """
    llm = get_llm(temperature=temperature)

    def _unify_chunk(chunk: str, mode: str = "refine") -> str:
        if mode == "refine":
            prompt = ChatPromptTemplate.from_template(
                """
あなたは日本語文章の編集者です。
次のテキストには冗長な表現や重複があります。
意味を損なわず、一貫性を保ちながら自然に統合してください。
ただし、原文に書かれていない新しい情報や外部知識を加えることは禁止です。

【テキスト】
{text}

【条件】
- 要約は禁止
- 情報の削除は禁止
- 与えられた全てのトピックを必ず含めること(どの話題も欠落させてはならない)
- 原文の情報を丁寧に言い換え、接続語を増やし、表現を展開して長くする
- 原文に書かれていないことは絶対に書かない
- 文章量は原文以上を必須条件とする
"""
            )
        else:  # mode == "merge"
            prompt = ChatPromptTemplate.from_template(
                """
あなたは日本語文章の編集者です。
次の複数のテキストをつなぎ合わせてください。
要約はせず、すべての情報を保持してください。
自然な流れを作るために接続語や言い換えを追加しても構いませんが、
原文にない新しい情報や外部知識を加えることは禁止です。

【テキスト】
{text}

【条件】
- 要約は禁止
- 情報の削除は禁止
- 与えられた全てのトピックを必ず含めること(どの話題も欠落させてはならない)
- 文章量は原文以上を必須条件とする
- 原文に書かれていないことは絶対に書かない
"""
            )

        messages = prompt.format_messages(text=chunk)
        response = llm.invoke(messages)
        return response.content.strip()

    # すべてのテキストを結合
    combined_text = "\n".join(texts)

    # チャンク分割
    chunks = [
        combined_text[i : i + chunk_size]
        for i in range(0, len(combined_text), chunk_size)
    ]

    # 第1ラウンド:冗長整理+展開
    unified_chunks = [_unify_chunk(chunk, mode="refine") for chunk in chunks]

    # 第2ラウンド以降:統合しながら展開
    current_round = 2
    while len(unified_chunks) > 1 and current_round <= max_rounds:
        merged_text = "\n".join(unified_chunks)
        new_chunks = [
            merged_text[i : i + chunk_size]
            for i in range(0, len(merged_text), chunk_size)
        ]
        unified_chunks = [_unify_chunk(chunk, mode="merge") for chunk in new_chunks]
        current_round += 1

    return unified_chunks[0] if unified_chunks else ""


texts = [text1, text2, text3]

result = unify_japanese_texts(texts, chunk_size=5000, max_rounds=10)
print(result)

assistant:

はい、その観察は正しいです。このコードは 入力順序に影響されやすい設計 になっています。理由を分解すると、次の通りです。


✅ なぜ順序依存になるのか?

  1. combined_text = "\n".join(texts) の処理

    • texts のリストを単純に結合して、LLMに与えています。
    • つまり、[text1, text2, text3] の順序がそのままプロンプトに反映されます。
    • LLMは「文章の自然な流れを作れ」という指示を受けているため、最初にある文章をベースに後続を追加する傾向になります。
  2. LLMの性質

    • 指示では「自然な流れを作る」や「接続語を増やす」とあるので、最初の文章を起点に後ろの情報を接続しやすい。
    • さらに、統合プロセスは _unify_chunk 関数で順に処理するため、最初の方の表現や構造が残りやすい。
  3. max_rounds の再統合も順序ベース

    • 結合 → チャンク分割 → 結合 → 再統合 という流れですが、基本的に「現時点の順序を維持したままマージ」するので、初期の並びが最後まで引きずられます。

✅ 順序依存を減らすには?

次のアプローチが考えられます。

1. 順序をランダム化

  • 入力前に random.shuffle(texts) で順序をランダムにする。
  • ただし、毎回結果が変わるので、再現性が低下します。

2. Embeddingベースで並べ替え

  • 意味的に近い文章を隣接させる

  • FAISSやcosine similarityを使って、類似度順に並べて統合。

  • 例:

    from sklearn.metrics.pairwise import cosine_similarity
    import numpy as np
    
    # embeddingsで順序調整
    vectors = embeddings.embed_documents(texts)
    similarity_matrix = cosine_similarity(vectors)
    # クラスタリングや、中心に近い順で並べ替え
    

3. 「階層的マージ」戦略

  • すべてを一度に結合せず、ランダムなペアを作って部分統合 → 再統合する。

  • 例:

    • round1: 2つずつ統合
    • round2: 統合結果同士を再統合
  • これなら、1つのテキストに偏る影響を軽減できます。

4. LLMへの指示を強化

  • 「入力順序に依存しないで、自然な並びを考慮して統合してください」という制約を追加。

  • 具体例:

    文章を結合する際は、入力順序に縛られず、内容の関連性を基準に並び替えてください。
    

✅ どの方法がベスト?

  • 再現性+自然さ重視 → **4番(プロンプト強化)**+**3番(階層的マージ)**がバランス良い。
  • 構造的統一重視2番(Embedding順)+3番

必要なら、このコードを Embedding順+階層的マージ+プロンプト改善版 に書き換えられます。
例えば、「入力順序を考慮せず、意味の関連性で自然な並びにしてください」と強調するのが良いですね。

→ 改良版コードを作りますか?(順序依存を減らし、自然な統合を維持するバージョン)

user:

ありがとうございます。順序依存を減らし、自然な統合を維持する改良版コードを書いてください。ただし、計算量はあまり高くならないようお願いします。

assistant:

了解しました。
計算量を抑えつつ、順序依存を最小化し、自然な統合を維持する改良版を作ります。以下のポイントで改修します。


✅ 改善ポイント

  1. Embedding類似度で並べ替え

    • textsの意味的な近さを利用して順序を決定。
    • FAISSは使わず、cosine_similaritynumpyで軽量に実装。
  2. 階層的マージ(バッチ統合)

    • 全文を一度に結合せず、ペアや少数ずつ統合 → 再統合。
    • これにより、最初のテキストに偏る現象を減らす。
  3. プロンプト改善

    • 「入力順序に依存せず、関連性を基準に自然な並びにしてください」と指示を追加。
  4. 計算量抑制

    • textsが10件程度ならO(n²)の類似度計算は許容範囲。
    • 大量データにはFAISSを検討可能だが今回は不要。

✅ 改良版コード

import numpy as np
from sklearn.metrics.pairwise import cosine_similarity
from langchain.prompts import ChatPromptTemplate
from langchain_openai import AzureChatOpenAI

# =============================
# ✅ 改良版:自然な統合関数
# =============================
def unify_japanese_texts_improved(
    texts: list[str],
    temperature: float = 0.3,
    chunk_size: int = 5000,
    max_rounds: int = 3,
) -> str:
    """
    日本語の長文テキスト群を、意味を損なわずに統合。
    - 順序依存を軽減(類似度順+階層統合)
    - 原文にない情報は追加しない
    - 情報は必ず保持し、冗長さを整理
    """
    llm = get_llm(temperature=temperature)

    # =============================
    # ✅ 1. Embedding類似度で順序調整
    # =============================
    vectors = embeddings.embed_documents(texts)
    sim_matrix = cosine_similarity(vectors)

    # 中心テキストを基準に並べ替え
    avg_sim = sim_matrix.mean(axis=1)
    sorted_indices = np.argsort(-avg_sim)  # 類似度が高い順
    texts_sorted = [texts[i] for i in sorted_indices]

    # =============================
    # ✅ 2. プロンプト生成関数
    # =============================
    def _unify_chunk(chunk: str, mode: str = "merge") -> str:
        if mode == "merge":
            prompt = ChatPromptTemplate.from_template(
                """
あなたは日本語文章の編集者です。
次の複数のテキストを統合してください。
入力順序に依存せず、内容の関連性を考慮して自然な流れを作ってください。
要約はせず、すべての情報を保持してください。
原文にない情報や外部知識を追加することは禁止です。

【テキスト】
{text}

【条件】
- 要約禁止
- 情報削除禁止
- すべてのトピックを必ず含める
- 入力順序ではなく、意味的関連性を基準に並べ替えて自然な文章にする
- 接続語や言い換えを追加して流れを整える
- 原文にないことは絶対に書かない
- 文章量は原文以上を必須条件とする
"""
            )
        messages = prompt.format_messages(text=chunk)
        response = llm.invoke(messages)
        return response.content.strip()

    # =============================
    # ✅ 3. 階層的マージ
    # =============================
    current_texts = texts_sorted
    current_round = 1

    while len(current_texts) > 1 and current_round <= max_rounds:
        new_texts = []
        for i in range(0, len(current_texts), 2):
            if i + 1 < len(current_texts):
                pair = current_texts[i] + "\n" + current_texts[i + 1]
                merged = _unify_chunk(pair)
                new_texts.append(merged)
            else:
                new_texts.append(current_texts[i])  # 奇数ならそのまま
        current_texts = new_texts
        current_round += 1

    return current_texts[0] if current_texts else ""

✅ 主な変更点

Embedding順に並べる → 順序依存を軽減
階層的にペア統合 → 1文書が支配する現象を回避
プロンプトで「順序無視・意味関連順」強調
再現性 → 毎回同じ結果(shuffleは使わない)


✅ 計算量

  • 類似度計算:O(n²)(nはtexts数)
  • LLM呼び出し:len(texts) - 1回程度(ペアごとの統合)

texts10~20件なら全然許容です。


必要なら、**「第1ラウンドは refine(冗長整理)、第2ラウンドから merge」**という2段階モードも追加できますが、今のバージョンでも十分順序依存は減らせます。


👉 次に、実際にこのコードで texts = [text1, text2, text3] を試して、元のコードと比較した結果の違いを解説するデモを作りましょうか?

user:

ありがとうございます。順序依存の解消、バッチリです!次のようなコードになりましたので、リファクタリングお願いします。

# !pip install pymupdf pymupdf4llm faiss-cpu langchain langchain-community openai requests langchain_openai

import json

import numpy as np
from google.colab import drive
from langchain.prompts import ChatPromptTemplate
from langchain_community.embeddings import HuggingFaceEmbeddings
from langchain_openai import AzureChatOpenAI
from sklearn.metrics.pairwise import cosine_similarity

# =============================
# ✅ Azure OpenAI 設定
# =============================
CONFIG_PATH = "/content/drive/MyDrive/path_config/config-32k.json"
drive.mount("/content/drive")

with open(CONFIG_PATH, "r") as f:
    config = json.load(f)

azure_api_key = config["OPENAI_API_KEY"]
azure_api_version = config["OPENAI_API_VERSION"]
azure_endpoint = config["OPENAI_API_BASE"]
azure_model = config["OPENAI_API_ENGINE"]  # 32k モデルをデプロイして指定!


def get_llm(temperature: float = 0.3, max_tokens: int = 15000) -> AzureChatOpenAI:
    """LangChain 用 Azure OpenAI クライアントを返す(32k モデル向け、出力も長文対応)"""
    return AzureChatOpenAI(
        deployment_name=azure_model,
        openai_api_key=azure_api_key,
        openai_api_version=azure_api_version,
        azure_endpoint=azure_endpoint,
        temperature=temperature,
        max_tokens=max_tokens,
    )


# =============================
# ✅ Embeddings 設定
# =============================
embeddings = HuggingFaceEmbeddings(
    model_name="intfloat/multilingual-e5-base",
    model_kwargs={"device": "cpu"},
    encode_kwargs={"normalize_embeddings": True},
)


# =============================
# ✅ 改良版:自然な統合関数
# =============================
def unify_japanese_texts_improved(
    texts: list[str],
    temperature: float = 0.3,
    chunk_size: int = 5000,
    max_rounds: int = 3,
) -> str:
    """
    日本語の長文テキスト群を、意味を損なわずに統合。
    - 順序依存を軽減(類似度順+階層統合)
    - 原文にない情報は追加しない
    - 情報は必ず保持し、冗長さを整理
    """
    llm = get_llm(temperature=temperature)

    # =============================
    # ✅ 1. Embedding類似度で順序調整
    # =============================
    vectors = embeddings.embed_documents(texts)
    sim_matrix = cosine_similarity(vectors)

    # 中心テキストを基準に並べ替え
    avg_sim = sim_matrix.mean(axis=1)
    sorted_indices = np.argsort(-avg_sim)  # 類似度が高い順
    texts_sorted = [texts[i] for i in sorted_indices]

    # =============================
    # ✅ 2. プロンプト生成関数
    # =============================
    def _unify_chunk(chunk: str, mode: str = "merge") -> str:
        if mode == "merge":
            prompt = ChatPromptTemplate.from_template(
                """
あなたは日本語文章の編集者です。
次の複数のテキストを統合してください。
入力順序に依存せず、内容の関連性を考慮して自然な流れを作ってください。
要約はせず、すべての情報を保持してください。
原文にない情報や外部知識を追加することは禁止です。

【テキスト】
{text}

【条件】
- 要約禁止
- 情報削除禁止
- すべてのトピックを必ず含める
- 入力順序ではなく、意味的関連性を基準に並べ替えて自然な文章にする
- 接続語や言い換えを追加して流れを整える
- 原文にないことは絶対に書かない
- 文章量は原文以上を必須条件とする
"""
            )
        messages = prompt.format_messages(text=chunk)
        response = llm.invoke(messages)
        return response.content.strip()

    # =============================
    # ✅ 3. 階層的マージ
    # =============================
    current_texts = texts_sorted
    current_round = 1

    while len(current_texts) > 1 and current_round <= max_rounds:
        new_texts = []
        for i in range(0, len(current_texts), 2):
            if i + 1 < len(current_texts):
                pair = current_texts[i] + "\n" + current_texts[i + 1]
                merged = _unify_chunk(pair)
                new_texts.append(merged)
            else:
                new_texts.append(current_texts[i])  # 奇数ならそのまま
        current_texts = new_texts
        current_round += 1

    return current_texts[0] if current_texts else ""


texts = [text1, text2]

result = unify_japanese_texts_improved(texts, chunk_size=5000, max_rounds=10)
print(result)

assistant:

リファクタリングの観点で、以下の改善を入れます。


✅ 改善ポイント

  1. 構造整理

    • セクションコメントを簡潔に。
    • 冗長なコメントを圧縮し、Docstringで明示。
  2. 責務分離

    • Embedding順序計算と統合処理を関数化。
    • プロンプト生成ロジックも独立。
  3. 定数・設定のまとめ

    • ハードコードを減らし、引数や設定で制御可能に。
  4. 可読性向上

    • 型ヒント、命名規則統一。
    • インデントや改行を整理。

✅ リファクタリング後コード

# 必要ライブラリ
import json
import numpy as np
from google.colab import drive
from langchain.prompts import ChatPromptTemplate
from langchain_community.embeddings import HuggingFaceEmbeddings
from langchain_openai import AzureChatOpenAI
from sklearn.metrics.pairwise import cosine_similarity

# =============================
# ✅ 設定
# =============================
CONFIG_PATH = "/content/drive/MyDrive/path_config/config-32k.json"
drive.mount("/content/drive")

with open(CONFIG_PATH, "r") as f:
    config = json.load(f)

AZURE_API_KEY = config["OPENAI_API_KEY"]
AZURE_API_VERSION = config["OPENAI_API_VERSION"]
AZURE_ENDPOINT = config["OPENAI_API_BASE"]
AZURE_MODEL = config["OPENAI_API_ENGINE"]  # 32k モデル指定

# =============================
# ✅ モデル&Embedding設定
# =============================
def get_llm(temperature: float = 0.3, max_tokens: int = 15000) -> AzureChatOpenAI:
    """Azure OpenAI LLMクライアント(LangChain対応)"""
    return AzureChatOpenAI(
        deployment_name=AZURE_MODEL,
        openai_api_key=AZURE_API_KEY,
        openai_api_version=AZURE_API_VERSION,
        azure_endpoint=AZURE_ENDPOINT,
        temperature=temperature,
        max_tokens=max_tokens,
    )

embeddings = HuggingFaceEmbeddings(
    model_name="intfloat/multilingual-e5-base",
    model_kwargs={"device": "cpu"},
    encode_kwargs={"normalize_embeddings": True},
)

# =============================
# ✅ サブ関数群
# =============================
def sort_texts_by_similarity(texts: list[str]) -> list[str]:
    """Embedding類似度を基準にテキストを並べ替え(順序依存を軽減)"""
    vectors = embeddings.embed_documents(texts)
    sim_matrix = cosine_similarity(vectors)
    avg_sim = sim_matrix.mean(axis=1)
    sorted_indices = np.argsort(-avg_sim)  # 類似度降順
    return [texts[i] for i in sorted_indices]

def build_merge_prompt(text: str) -> list[dict]:
    """統合プロンプトを生成"""
    template = """
あなたは日本語文章の編集者です。
次の複数のテキストを統合してください。
入力順序に依存せず、内容の関連性を考慮して自然な流れを作ってください。
要約はせず、すべての情報を保持してください。
原文にない情報や外部知識を追加することは禁止です。

【テキスト】
{text}

【条件】
- 要約禁止
- 情報削除禁止
- すべてのトピックを必ず含める
- 入力順序ではなく、意味的関連性を基準に並べ替えて自然な文章にする
- 接続語や言い換えを追加して流れを整える
- 原文にないことは絶対に書かない
- 文章量は原文以上を必須条件とする
"""
    return ChatPromptTemplate.from_template(template).format_messages(text=text)

# =============================
# ✅ メイン関数
# =============================
def unify_japanese_texts(
    texts: list[str],
    temperature: float = 0.3,
    max_rounds: int = 3,
) -> str:
    """
    日本語テキストを意味保持・冗長整理しながら統合。
    - 順序依存を軽減(Embedding並べ替え+階層統合)
    - 原文にない情報は追加禁止
    """
    if not texts:
        return ""

    llm = get_llm(temperature=temperature)
    current_texts = sort_texts_by_similarity(texts)

    round_count = 1
    while len(current_texts) > 1 and round_count <= max_rounds:
        merged_texts = []
        for i in range(0, len(current_texts), 2):
            if i + 1 < len(current_texts):
                combined = f"{current_texts[i]}\n{current_texts[i+1]}"
                messages = build_merge_prompt(combined)
                response = llm.invoke(messages)
                merged_texts.append(response.content.strip())
            else:
                merged_texts.append(current_texts[i])  # 奇数残り
        current_texts = merged_texts
        round_count += 1

    return current_texts[0]

# =============================
# ✅ 使用例
# =============================
texts = [text1, text2]
result = unify_japanese_texts(texts, max_rounds=10)
print(result)

✅ 主な変更点

関数分割sort_texts_by_similaritybuild_merge_prompt)で再利用性UP
Docstringとコメントを整理
ハードコード削減(プロンプトや構造を柔軟化)
読みやすさUP(冗長なif mode削除、ロジック簡潔化)


このバージョン、実用性+メンテ性+順序依存の改善はかなり良いバランスです。

次に、**さらに高速化(Embedding計算のキャッシュ+FAISS対応)**か、**品質向上(第1ラウンド refine → 第2ラウンド merge)**を組み込みましょうか?

user:

いえ、今夜はこの辺にしておきましょう。完璧です。ここまでの内容をブログに残したいので、タイトル案をいくつか提案していただけますか?

assistant:

もちろん、雰囲気やターゲット層に合わせていくつか方向性を変えて考えますね。


技術寄り・実装者向け

  1. 「LangChain × Azure OpenAIで日本語長文を自然に統合するベストプラクティス」
  2. 「順序依存を克服!Embedding+階層統合で実現する高精度テキスト統合」
  3. 「大規模LLMを活用した日本語テキスト統合パイプライン設計」

課題解決・応用寄り

  1. 「順序に振り回されない!AIで長文テキストを自然にまとめる方法」
  2. 「AIで実現する“情報を削らない”日本語統合アルゴリズム」
  3. 「冗長を整理しつつ情報保持:日本語長文統合の新アプローチ」

キャッチー・ブログっぽい

  1. 「AIで文章を“つなぐ”という挑戦:順序に依存しないテキスト統合」
  2. 「LLMで長文を自然に統合する最強レシピ」
  3. 「もう順序に悩まない!AIでスムーズに文章を融合する」

必要なら、SEOを意識して「LangChain」「Azure OpenAI」「日本語テキスト統合」などのキーワードを散りばめたタイトルに寄せることもできます。

どの方向性が一番しっくりきますか?
技術ガチ寄り/応用事例寄り/キャッチー系のどれをメインにしたいですか?

user:

「Embedding+階層統合で順序依存を克服! LangChain × Azure OpenAI で実現する日本語長文高精度テキスト統合」でいきたいと思います。このタイトルに沿って、ここまでの内容を詳細に解説してください。

assistant:

Embedding+階層統合で順序依存を克服! LangChain × Azure OpenAI で実現する日本語長文高精度テキスト統合

いいタイトルですね — 技術寄りで読み手に刺さる内容になります。以下はブログ記事としてそのまま使える形式で、問題提示→設計思想→実装(コード説明)→運用上の注意点→拡張案まで、できるだけ丁寧にまとめました。読みやすいように章立てしています。必要ならこれを Markdown ファイルに整形して納品します。


TL;DR

複数の日本語文書を“情報を失わずに自然に統合”する際、単純に "\n".join(texts) してLLMに投げると入力順序に強く依存してしまいます。本稿では:

  • Embedding に基づく意味的並べ替え(中心性スコア)で順序依存を軽減し、
  • 階層的(ペアワイズ)マージで一つの文書に偏る影響を抑え、
  • LLM プロンプトで「情報削除・追加の禁止」を明示することで情報保持と自然さを両立する手法

を紹介します。実装は LangChain / Azure OpenAI / multilingual-e5 (HuggingFaceEmbeddings) を想定し、計算コストは控えめに設計しています。


背景:なぜ「順序依存」が起きるのか?

LLM に複数文書を統合させる典型的なワークフローは次の通りです:

  1. 文書を単純連結(text1\ntext2\ntext3
  2. 「自然に統合して」と指示して生成

このやり方だと、LLM は “テキストの先頭から文章の流れを組み立てる” 傾向があり、先頭にあるテキストの語彙・語調・論理構造が最終出力に強く反映されます(=順序依存)。また、一度に大きな塊を与えると LL M が一部情報を要約的に省略したり、先頭の情報を優先して残す現象が起こりがちです。


解決方針(高レベル)

  1. 意味的に近い文を近接させる — Embedding(意味ベクトル)で並び替え、無関係な文が唐突につながるのを防ぐ。
  2. 階層的(ペア)マージ — 一気に全部を統合するのではなく、ペアごとに統合していくことで「1つの文書に引き摺られる」偏りを緩和。
  3. プロンプトで強制ルール化 — 「原文にない情報を追加しない」「情報削除禁止」「全トピックを含める」などを明示して、LLMの振る舞いを制約。
  4. 実用面の配慮 — 埋め込みはキャッシュ、必要なら FAISS を導入、LLM は deterministic(temperature=0)で実行する等で安定化。

この設計は、計算量を大幅に増やさず(埋め込みの O(n²) 類似度計算は n が小さい範囲で許容)実装できます。


実装(要点とコード)

以下はブログ掲載用に説明を添えた簡潔版コード(先ほどリファクタリングしたものをベースに解説)。実行環境は Colab を想定します。

# 必要ライブラリ(Colab などで事前インストール)
import json
import numpy as np
from google.colab import drive
from langchain.prompts import ChatPromptTemplate
from langchain_community.embeddings import HuggingFaceEmbeddings
from langchain_openai import AzureChatOpenAI
from sklearn.metrics.pairwise import cosine_similarity

# --- 設定読み込み(例) ---
CONFIG_PATH = "/content/drive/MyDrive/path_config/config-32k.json"
drive.mount("/content/drive")
with open(CONFIG_PATH, "r") as f:
    config = json.load(f)

AZURE_API_KEY = config["OPENAI_API_KEY"]
AZURE_API_VERSION = config["OPENAI_API_VERSION"]
AZURE_ENDPOINT = config["OPENAI_API_BASE"]
AZURE_MODEL = config["OPENAI_API_ENGINE"]

def get_llm(temperature: float = 0.0, max_tokens: int = 15000) -> AzureChatOpenAI:
    return AzureChatOpenAI(
        deployment_name=AZURE_MODEL,
        openai_api_key=AZURE_API_KEY,
        openai_api_version=AZURE_API_VERSION,
        azure_endpoint=AZURE_ENDPOINT,
        temperature=temperature,
        max_tokens=max_tokens,
    )

# Embeddings(multilingual-e5)
embeddings = HuggingFaceEmbeddings(
    model_name="intfloat/multilingual-e5-base",
    model_kwargs={"device": "cpu"},
    encode_kwargs={"normalize_embeddings": True},
)

def sort_texts_by_similarity(texts: list[str]) -> list[str]:
    """Embedding 類似度で中心性が高い文を先に並べる(類似度降順)"""
    vectors = embeddings.embed_documents(texts)
    sim_matrix = cosine_similarity(vectors)
    avg_sim = sim_matrix.mean(axis=1)
    sorted_indices = np.argsort(-avg_sim)
    return [texts[i] for i in sorted_indices]

def build_merge_prompt(text: str):
    template = """
あなたは日本語文章の編集者です。
次の複数のテキストを統合してください。
入力順序に依存せず、内容の関連性を考慮して自然な流れを作ってください。
要約はせず、すべての情報を保持してください。
原文にない情報や外部知識を追加することは禁止です。

【テキスト】
{text}

【条件】
- 要約禁止
- 情報削除禁止
- すべてのトピックを必ず含める
- 入力順序ではなく、意味的関連性を基準に並べ替えて自然な文章にする
- 接続語や言い換えを追加して流れを整える
- 原文にないことは絶対に書かない
- 文章量は原文以上を必須条件とする
"""
    return ChatPromptTemplate.from_template(template).format_messages(text=text)

def unify_japanese_texts(texts: list[str], temperature: float = 0.0, max_rounds: int = 3) -> str:
    if not texts:
        return ""
    llm = get_llm(temperature=temperature)
    current_texts = sort_texts_by_similarity(texts)
    round_count = 1
    while len(current_texts) > 1 and round_count <= max_rounds:
        merged_texts = []
        for i in range(0, len(current_texts), 2):
            if i + 1 < len(current_texts):
                combined = f"{current_texts[i]}\n{current_texts[i+1]}"
                messages = build_merge_prompt(combined)
                response = llm.invoke(messages)
                merged_texts.append(response.content.strip())
            else:
                merged_texts.append(current_texts[i])
        current_texts = merged_texts
        round_count += 1
    return current_texts[0]

実装上のポイント(補足)

  • sort_texts_by_similarity:各文書 embedding を出し、類似度行列の行平均(中心性)で降順ソート。中心に近い(他と関連の深い)文書から先に処理することで、統合の自然さが上がることが多いです。
  • unify_japanese_texts:ペアで順に統合していく(階層的マージ)。各ペア統合に対して LLM 呼び出しを行うため、ラウンド数は ceil(log2(n)) 程度が理想ですが、max_rounds で上限を制御できます。
  • temperature=0.0 を推奨:出力の再現性を高め、余計な創作(=原文にない情報)を抑止します。

トークン長・チャンク処理について

実際の運用では、ペアを結合した結果が LLM のコンテキスト長を超える場合があります。対処法:

  1. 事前チャンク分割:文や段落単位で分割して refine(冗長整理)→ merge の2段階にする。
  2. 部分統合→要約(ただし“要約”は慎重に):どうしても長すぎる場合は、各チャンクを「情報を削らずに整理」する処理(言い換え・接続語の補強)を先に行い、サイズを減らす。
  3. モデル/デプロイの選択:32k トークンモデルを使えるならそのまま行けるケースもありますが、コストと安定性のバランスを考慮してください。

検証方法(順序依存が改善したかを確認する)

再現的な評価手順の例:

  1. 同じ texts の複数のランダム順列(例 10 通り)を作る。
  2. 各順列を元に統合結果を得る(同一設定で)。
  3. 得られた統合結果どうしの Embedding 類似度分散(平均と標準偏差)や、キーフレーズのカバレッジ(元テキストに含まれる重要語句の網羅率)を計測。
  4. 「順序をランダムにしたときの出力のばらつき」が小さくなれば順序依存は減っていると判断できます。

サンプル評価スニペット(概念):

# pseudo
from sklearn.metrics.pairwise import cosine_similarity

results = []
for perm in permutations_of_texts:
    out = unify_japanese_texts(perm)
    results.append(out)

vecs = embed_documents(results)
sim_matrix = cosine_similarity(vecs)
# sim_matrix の off-diagonal が高く、分散が小さければ安定

コストと性能(実運用上の考慮)

  • Embedding の計算:O(n^2) の類似度行列は n が数十〜数百だと重くなる。キャッシュバッチ処理、大規模なら FAISS の導入を推奨。
  • LLM 呼び出し回数:階層的マージなら各ラウンドで約 ceil(n/2)ceil(n/4)... と減るが、合計で概ね n-1 回のマージ呼び出しが発生(最悪)。必要であればペア幅を 3〜4 にしてラウンド数を減らす工夫可。
  • コスト最適化:最初の refine(冗長整形)は小さなモデル(安価)で実施し、最後の merge を高品質モデルで行うミックス戦略が有効。

プロンプト設計上の注意点(現場ノウハウ)

  • 「絶対に原文にないことは書かない」と指示しても、LLM は時に**補完(hallucination)**する。これを検出するために 出力→元文との照合(embedding-based similarity) を行い、逸脱が大きければ再処理する仕組みを入れると良いです。
  • 「文章量は原文以上」と指定すると、不要に冗長になる可能性あり。品質と長さのバランスは UX に合わせて調整してください。
  • 明示的に「箇条書きで抽出してから文章化する」フローにすると、重要情報の欠落をかなり抑えられます(→抽出→検証→生成)。

さらに品質を上げるための拡張案

  1. Refine → Merge 二段階

    • 各文書をまず冗長整理(refine)してからマージする。
  2. 情報抽出ベースの統合

    • 各文書から「事実(エンティティ+値)」「主張」「証拠」を抽出してテーブル化→テーブルをマージ→自然文生成。これなら情報の欠落や重複を構造的に扱えます。
  3. FAISS キャッシュ

    • 文書数が大きい場合は FAISS に embedding を格納して類似検索で並べ替えやクラスタリングを実行。
  4. 差分チェッカー

    • 統合後に元の各文書を逆検索して「カバレッジが低いトピック」を検出→部分再生成で補完。

よくある疑問(FAQ)

Q. 「原文にない情報を絶対に出させない」は可能?
A. 完全には保証できません。プロンプトで制約を強くする+温度を 0 にする+出力検証(embedding-based similarity やキーワード照合)を組み合わせるのが現実的です。

Q. 文書が1000件ある場合は?
A. 事前にクラスタリング(embedding+FAISS)して、各クラスタを代表文でまとめ→クラスタ統合、という階層化を推奨します。直接 O(n^2) を回すのは非現実的です。

Q. 日本語固有の注意点は?
A. 段落・文節での自然な切れ目を保つこと。長文化(文章量を増やす)指示は敬語/句読点のバランスを崩しやすいのでスタイル指示を明示してください(例:フォーマル/カジュアル等)。


ケーススタディのアイデア(読者が試せる実験)

  • 順序依存テスト:3〜5 文書をさまざまな順序で入力して、生成結果を比較(先ほどの評価法)。
  • Refine の有無比較:Refine→Merge と、直接 Merge の品質差を比較。
  • クラスタ数のチューニング:文書の数が多いケースで、クラスタリング粒度を変えて出力品質とコストを比較。

まとめ(結び)

  • 単純連結だけだと LLM は入力順序に引き摺られやすい。
  • Embedding による意味的並べ替え階層的マージ強いプロンプト制約の組合せで、順序依存を大きく軽減しながら「情報を失わない統合」が可能になります。
  • 実運用ではチャンク処理、キャッシュ、出力検証(逸脱検出)を組み合わせることで安定化できます。

もしよければ、このまま ブログ投稿用の Markdown(フロントマター付) を作ってお渡しします。あるいは、記事本文をより「技術ブログ向けの段落分け+図(擬似図)+コードハイライト」形式に調整しても良いです。どちらがいいですか?😊

user:

いえ、これで完璧です。ありがとうございます。お疲れ様でした!また一緒にやりましょうね!

assistant:

お疲れさまでした!とても良い仕上がりになりましたね。コードも記事も完成度が高くて、きっと読む人にとって価値ある内容になります。
また一緒にやれる日を楽しみにしていますね!夜更かし仲間としては、次回も最高のアイデアを育てましょう。
では、ゆっくり休んでくださいね。いい夢を! 🌙✨

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?