LlamaIndex + pgvector:日本語・タイ語ビジネス文書に対応したRAGの本番運用

RAGのデモはたいていうまく動く。しかし、本番環境に展開されたRAGシステムの多くは失敗する——静かに、高コストで、デバッグの難しい形で。

simpliDoc(Simplicoが開発した多言語対応AIドキュメントインテリジェンス基盤)の構築を通じ、私たちはプロトタイプと、タイ語・日本語・英語の企業文書を同時処理できる本番システムとの違いを体験しました。本記事では、実際にデプロイした内容を共有します:技術スタック、テストを経て採用した設定値、遭遇した障害パターン、そしてその解決策です。


このスタックを選んだ理由

SimplicoではLlamaIndexをオーケストレーション層、pgvector(PostgreSQL拡張)をベクトルストアとして採用しています。他の選択肢ではなく、この2つを組み合わせた理由は以下のとおりです。

  • LlamaIndexは多言語埋め込みをネイティブサポートし、トークン化の挙動が根本的に異なるタイ語・日本語・英語を横断するチャンキング戦略を適切に処理できます。
  • pgvectorはPostgreSQLの内部で動作するため、ベクトルデータをビジネスデータと同じデータベースに格納できます。追加インフラ不要、同期の複雑さなし、別サービスの運用負担なし。個人情報保護法(PIPL)METIのAIガバナンスガイドラインに基づき厳格なデータレジデンシーを求める日本企業にとって、すべてをオンプレミスの単一Postgresインスタンスに収めることは、コンプライアンス上の大きな優位性です。

システムアーキテクチャ

flowchart TD
    A["ドキュメントアップロード\n(PDF / DOCX / TXT)"] --> B["LlamaIndex\nDocument parser"]
    B --> C["言語検出\n(langdetect)"]
    C --> D["multilingual-e5-large\n埋め込みモデル"]
    D --> E["pgvector\n(PostgreSQL)"]
    F["ユーザーの質問"] --> G["FastAPI\nRAGエンドポイント"]
    G --> D
    G --> E
    E --> H["Top-k検索\n(コサイン類似度)"]
    H --> I["Claude API\n回答生成"]
    I --> J["ストリーミングレスポンス\n(SSE)"]

ステップ1:PostgreSQL + pgvector のセットアップ

-- pgvector拡張を有効化
CREATE EXTENSION IF NOT EXISTS vector;

-- ドキュメントチャンクテーブル
CREATE TABLE document_chunks (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    doc_id      UUID NOT NULL,
    chunk_index INTEGER NOT NULL,
    content     TEXT NOT NULL,
    language    VARCHAR(10),           -- 'th', 'ja', 'zh', 'en'
    embedding   VECTOR(1024),          -- multilingual-e5-largeの出力次元数
    metadata    JSONB,
    created_at  TIMESTAMPTZ DEFAULT NOW()
);

-- 高速近似最近傍探索のためのHNSWインデックス
-- 50k件超では必須:なければクエリが~8msから4秒超に
CREATE INDEX ON document_chunks
    USING hnsw (embedding vector_cosine_ops)
    WITH (m = 16, ef_construction = 64);

本番運用のヒント: HNSWインデックスはリリース前に作成してください。後から追加するのは避けるべきです。私たちは80kチャンクの状態でリリース後に追加するという失敗をしました。インデックス構築に4時間かかり、その間クエリのレイテンシが増大しました。


ステップ2:埋め込みモデル

HuggingFaceのmultilingual-e5-large(1024次元)を使用しています。タイ語・日本語・簡体字中国語・英語を単一モデルで処理でき、言語ごとのモデル管理が不要です。

from llama_index.embeddings.huggingface import HuggingFaceEmbedding

embed_model = HuggingFaceEmbedding(
    model_name="intfloat/multilingual-e5-large",
    max_length=512,
    device="cpu",        # GPUがあれば利用可。本番はCPU 4コアVMで運用
)

本番環境でのスループット: 4コアCPU VMで約60チャンク/秒。200ページのPDFを約600チャンクに分割した場合、取り込み処理は約10秒で完了します。


ステップ3:チャンキング戦略

ほとんどのRAGプロジェクトが失敗するのはここです。日本語とタイ語には単語境界のスペースがないため、文字数ベースのチャンキングは英語の場合とまったく異なる結果をもたらします。

from llama_index.core.node_parser import SentenceSplitter

splitter = SentenceSplitter(
    chunk_size=400,       # 文字数(トークン数ではない)
    chunk_overlap=80,     # 20%のオーバーラップ
    paragraph_separator="\n\n",
)

検証結果と400/80を採用した理由:

チャンクサイズ オーバーラップ 結果
256 / 50 小さすぎ タイ語文が途中で分断、コンテキスト欠落
512 / 100 中程度 英語は良好、タイ語/日本語は断片化が残る
400 / 80 採用値 3言語すべてで最良の検索品質
800 / 160 大きすぎ 検索品質は良好、pgvectorのコサイン類似度の識別力が低下

調整前(チャンクサイズ256、日本語契約書テキスト):

取得チャンク: "...利率は年率15パーセントとし、借主が返済を怠った場合に..."
回答: 利率は年15%です。
欠落: 延滞条件が次のチャンクにあり取得されなかった

調整後(チャンクサイズ400、同一文書):

取得チャンク: "...利率は年率15パーセントとし、借主が30日を超えて返済を怠った場合に適用されます..."
回答: 利率は年15%で、返済が30日超延滞した場合に適用されます。

ステップ4:インジェストパイプライン

import asyncio
from llama_index.core import Document, VectorStoreIndex
from llama_index.vector_stores.postgres import PGVectorStore

async def ingest_document(file_path: str, doc_id: str, language: str):
    with open(file_path, "rb") as f:
        raw_text = extract_text(f)  # PDF/DOCXパーサー

    doc = Document(
        text=raw_text,
        metadata={"doc_id": doc_id, "language": language}
    )

    vector_store = PGVectorStore.from_params(
        database="simplidoc",
        host="localhost",
        port=5432,
        user="simplidoc_user",
        password=os.environ["DB_PASSWORD"],
        table_name="document_chunks",
        embed_dim=1024,
    )

    index = VectorStoreIndex.from_documents(
        [doc],
        embed_model=embed_model,
        transformations=[splitter],
        vector_store=vector_store,
    )

    return index

ステップ5:FastAPI RAGエンドポイント

from fastapi import FastAPI
from fastapi.responses import StreamingResponse
from anthropic import Anthropic

app = FastAPI()
client = Anthropic()

@app.post("/query")
async def query_documents(request: QueryRequest):
    query_engine = index.as_query_engine(
        similarity_top_k=5,
        streaming=True,
    )

    retrieval = await query_engine.aretrieve(request.query)
    context = "\n\n---\n\n".join([node.text for node in retrieval])

    system_prompt = """あなたは企業向けビジネス文書のドキュメントアシスタントです。
提供されたコンテキストのみに基づいて質問に回答してください。
コンテキストに答えが含まれていない場合は、明確にその旨を伝えてください。
質問と同じ言語で回答してください。"""

    async def stream_response():
        with client.messages.stream(
            model="claude-sonnet-4-20250514",
            max_tokens=1000,
            system=system_prompt,
            messages=[{
                "role": "user",
                "content": f"コンテキスト:\n{context}\n\n質問: {request.query}"
            }]
        ) as stream:
            for text in stream.text_stream:
                yield f"data: {text}\n\n"

    return StreamingResponse(stream_response(), media_type="text/event-stream")

本番環境で遭遇した障害パターン

1. 埋め込みモデルのコールドスタート(初回クエリで12秒の遅延)

multilingual-e5-largeモデルは初回使用時にRAMへロードされます。コールドVMでは、各セッションの最初のクエリで12秒の遅延が発生しました。

解決策:起動時にモデルをウォームアップします。

@app.on_event("startup")
async def warm_embedding_model():
    _ = embed_model.get_text_embedding("warmup")

2. 日本語PDFのテキスト抽出が文字化け

一部の日本語PDFは非標準のフォントエンコーディングを使用しており、PyPDF2では文字化けが発生しました。日本語文書にはpdfplumberを使用してください。

import pdfplumber

def extract_pdf(path: str) -> str:
    with pdfplumber.open(path) as pdf:
        return "\n".join(page.extract_text() or "" for page in pdf.pages)

3. 低しきい値でpgvectorが無関係なチャンクを返す

当初はコサイン類似度0.5超のチャンクをすべて返していました。曖昧な質問に対して0.55〜0.65のスコアで無関係なチャンクが含まれることがありました。

解決策:しきい値を引き上げ、リランキングステップを追加します。

retrieval = await query_engine.aretrieve(request.query)
filtered = [n for n in retrieval if n.score >= 0.72]

コストとパフォーマンス

4コア / 8GB RAM の単一VM(タイのクラウドプロバイダーで月額約¥4,500相当)での実績:

指標
平均クエリレイテンシ(ウォーム時) 1.8秒(エンドツーエンド)
埋め込みスループット 約60チャンク/秒
pgvector検索(HNSW、200kチャンク) 約8ms
クエリあたりのClaude APIコスト 約¥0.18(当社利用量水準)
インデックス済みドキュメント数 タイ語/日本語/英語で12,000件超

データレジデンシーについて

個人情報保護法に基づく日本企業のデプロイメントでは:すべての埋め込みとドキュメントコンテンツはPostgreSQLインスタンス内に留まります。multilingual-e5-largeはローカルで動作するため、外部の埋め込みAPIにはドキュメントコンテンツは送信されません。Claude APIに送信されるのは、回答生成のために検索されたコンテキスト(文書全体ではなく)のみです。

METI AIガバナンスガイドラインへの対応として:すべてのAI処理ステップを文書化し、回答生成に使用されたコンテキストをログとして保存することを推奨します。これにより説明可能性の要件を満たすことができます。

より厳格なデータレジデンシーが必要な場合は、回答生成ステップのClaude APIをローカルホストのモデルに置き換えることも可能です。


関連記事

企業ドキュメント向けの多言語RAGシステムが必要ですか?Simplicoへお問い合わせください——タイ・日本・グローバルのお客様向けに構築実績があります。


Get in Touch with us

Chat with Us on LINE

iiitum1984

Speak to Us or Whatsapp

(+66) 83001 0222

Related Posts

Our Products