🚀

Ollama + PostgreSQL + pg_vectorで作るプライベートRAGシステム

に公開

Ollama + PostgreSQL + pg_vectorで作るプライベートRAGシステム

はじめに - なぜローカルRAGシステムなのか

企業の文書管理において、ChatGPTやClaudeのようなクラウドAIサービスは確かに強力ですが、機密性の高い文書を外部APIに送信することには大きなリスクが伴います。また、大量の文書処理時のAPI費用や、インターネット接続に依存する制約も無視できません。

そこで注目されているのが、完全ローカル環境で動作するRAG(Retrieval-Augmented Generation)システムです。本記事では、Ollama、PostgreSQL、pg_vectorを組み合わせて構築した、プライバシーファーストな日本語対応RAGシステムの技術的詳細を解説します。

このシステムの特徴は、30GB RAMで20億パラメータのLLMモデルを動かしながら、LibreOfficeとTesseractを活用した高精度な日本語文書処理を実現している点です。

ソースコードはrag-systemとして公開しています。

画面イメージ

画面イメージ

システムアーキテクチャの設計思想

マイクロサービス構成による疎結合設計

本システムは、責務を明確に分離した5つのコアサービスで構成されています:

各サービスが独立したコンテナとして動作することで、スケーラビリティと保守性を確保しています。特に重要なのは、Document ProcessorRAG Serviceの分離です。これにより、重い文書処理タスクが検索・応答システムのパフォーマンスに影響することを防いでいます。

Docker Composeによるリソース管理

大型LLMモデルを扱うため、メモリ使用量の制御が重要です。

ollama:
  deploy:
    resources:
      limits:
        memory: 24G
      reservations:
        memory: 8G
  environment:
    - OLLAMA_MAX_LOADED_MODELS=2
    - OLLAMA_KEEP_ALIVE=5m

この設定により、Ollamaが24GB以上のメモリを使用することを防ぎ、他のサービス用に6GBの余裕を確保しています。

Ollamaを活用したLLMとエンベディングの実装

非同期HTTPクライアントによるOllama連携

OllamaとのAPIコミュニケーションには、httpxを使った非同期処理を採用しています:

async def get_embeddings_ollama(text: str) -> List[float]:
    """Ollamaで埋め込みベクトルを生成"""
    try:
        async with httpx.AsyncClient() as client:
            response = await client.post(
                f"{OLLAMA_URL}/api/embeddings",
                json={"model": EMBEDDING_MODEL, "prompt": text},
                timeout=30.0
            )
            if response.status_code == 200:
                result = response.json()
                return result.get("embedding", [])
            else:
                logger.error(f"Ollama埋め込み生成エラー: {response.status_code}")
                return [0.0] * 768  # フォールバック
    except Exception as e:
        logger.error(f"Ollama接続エラー: {e}")
        return [0.0] * 768

gpt-oss:20bとnomic-embed-textの使い分け

システムでは2種類のモデルを用途別に使い分けています。

  • gpt-oss:20b: 質問応答用のLLMモデル(約13GB RAM使用)
  • nomic-embed-text:latest: 文書ベクトル化用の埋め込みモデル(約270MB RAM使用)

LLM応答生成では、コンテキストオーバーフローを防ぐため、プロンプトエンジニアリングによる厳密な制御を行っています:

async def generate_llm_response_ollama(query: str, context: str) -> str:
    prompt = f"""以下のコンテキストに基づいて質問に回答してください。

【重要な制約】
1. 自信度が80%以上の場合のみ回答してください
2. 正答には1点、誤答には-3点、無回答には0点が与えられます
3. コンテキストに明確な情報がない場合は「提供された情報では回答できません」と述べてください

コンテキスト:
{context}

質問: {query}"""

この制約により、ハルシネーションを最小限に抑えています。

pg_vectorによる高速ベクトル検索の実装

PostgreSQL拡張とHNSWインデックス

pg_vectorを使ったベクトル検索の核となるのは、適切なデータベーススキーマ設計です:

-- pg_vector拡張機能を有効化
CREATE EXTENSION IF NOT EXISTS vector;

-- ドキュメントチャンクテーブル
CREATE TABLE document_chunks (
    id SERIAL PRIMARY KEY,
    document_id INTEGER REFERENCES documents(id) ON DELETE CASCADE,
    chunk_index INTEGER NOT NULL,
    content TEXT NOT NULL,
    embedding vector(768), -- nomic-embed-textの次元数
    created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);

-- HNSWアルゴリズムによる高速ベクトル検索インデックス
CREATE INDEX idx_document_chunks_embedding 
ON document_chunks USING hnsw (embedding vector_cosine_ops);

HNSWインデックスにより、768次元ベクトルの近似最近傍探索を高速化しています。

コサイン類似度を使った検索クエリ

実際のベクトル検索は、pg_vectorの演算子を使って実装されています。

async def search_similar_chunks(query_embedding: List[float], max_chunks: int = 5, similarity_threshold: float = 0.7):
    # ベクトルを文字列形式に変換
    embedding_str = '[' + ','.join(map(str, query_embedding)) + ']'
    
    sql_query = f"""
        SELECT 
            dc.id as chunk_id,
            dc.document_id,
            d.filename,
            dc.content,
            1 - (dc.embedding <=> '{embedding_str}'::vector) as similarity
        FROM document_chunks dc
        JOIN documents d ON dc.document_id = d.id
        WHERE 1 - (dc.embedding <=> '{embedding_str}'::vector) > :similarity_threshold
        ORDER BY dc.embedding <=> '{embedding_str}'::vector
        LIMIT :max_chunks
    """

<=>演算子がコサイン距離を計算し、1 - コサイン距離で類似度を算出しています。この方法により、一般的なユークリッド距離よりも意味的類似性を適切に評価できます。

ドキュメント処理パイプライン - LibreOffice・Tesseract・OCR連携

多段階フォールバック戦略による堅牢な文書処理

Office文書の処理では、直接ライブラリ抽出 → LibreOffice変換 → OCR処理の三段階フォールバック戦略を採用し、あらゆる文書形式に対応しています。

DOCX処理の実装例

def extract_text_from_docx(file_path: Path) -> str:
    """DOCXからテキストを抽出(LibreOfficeフォールバック)"""
    try:
        # まずpython-docxで直接テキスト抽出を試行
        doc = Document(file_path)
        direct_text = ""
        for paragraph in doc.paragraphs:
            direct_text += paragraph.text + "\n"
        
        # テーブルのテキストも抽出
        for table in doc.tables:
            for row in table.rows:
                row_text = []
                for cell in row.cells:
                    if cell.text.strip():
                        row_text.append(cell.text.strip())
                if row_text:
                    direct_text += " | ".join(row_text) + "\n"
        
        # 直接抽出で十分なテキストが取得できた場合
        if len(direct_text.strip()) > 50:
            return direct_text.strip()
        
        # LibreOfficeで変換を試行
        return extract_text_with_libreoffice(file_path, direct_text)

LibreOfficeによる変換とOCRフォールバック

def extract_text_with_libreoffice(file_path: Path, fallback_text: str = "") -> str:
    """LibreOfficeを使用してファイルをPDFに変換し、テキストを抽出"""
    try:
        with tempfile.TemporaryDirectory() as temp_dir:
            # LibreOfficeでPDFに変換(120秒タイムアウト)
            result = subprocess.run([
                'libreoffice', '--headless', '--convert-to', 'pdf',
                '--outdir', str(temp_dir), str(file_path)
            ], capture_output=True, text=True, timeout=120)
            
            if result.returncode != 0:
                return fallback_text
            
            # PDFからテキスト抽出
            pdf_path = list(Path(temp_dir).glob("*.pdf"))[0]
            pdf_text = extract_text_from_pdf(pdf_path)
            
            # LibreOfficeでも改善がない場合、OCRを試行
            if len(pdf_text.strip()) <= max(len(fallback_text.strip()), 50):
                return extract_text_with_ocr_from_pdf(pdf_path, fallback_text)
            
            return pdf_text

この戦略により、パスワード保護、複雑なレイアウト、スキャン画像を含む文書でも高い成功率でテキスト抽出が可能です。

高度なOCR処理 - OpenCVによる画像前処理とTesseract最適化

画像やスキャンされたPDFの処理では、OpenCVによる画像前処理とEasyOCR、Tesseractの三段構えでOCR精度を最大化しています。

画像前処理による品質向上

def preprocess_image_for_ocr(image_path: str) -> str:
    """OCR用画像前処理 - 品質向上とノイズ除去"""
    try:
        import cv2
        import numpy as np
        from PIL import ImageEnhance, ImageFilter

        # PILで画像を開く
        img = Image.open(image_path)
        
        # グレースケール変換
        if img.mode != 'L':
            img = img.convert('L')
        
        # 解像度向上(2倍にリサイズ)
        width, height = img.size
        img = img.resize((width * 2, height * 2), Image.LANCZOS)
        
        # コントラスト強化
        enhancer = ImageEnhance.Contrast(img)
        img = enhancer.enhance(2.0)
        
        # OpenCVで更なる前処理
        img_array = np.array(img)
        
        # ガウシアンブラーでノイズ除去
        img_array = cv2.GaussianBlur(img_array, (1, 1), 0)
        
        # 二値化(OTSU手法)
        _, img_array = cv2.threshold(img_array, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
        
        # モルフォロジー処理でノイズ除去
        kernel = np.ones((2, 2), np.uint8)
        img_array = cv2.morphologyEx(img_array, cv2.MORPH_CLOSE, kernel)
        
        processed_img = Image.fromarray(img_array)
        processed_path = image_path.replace('.png', '_processed.png')
        processed_img.save(processed_path)
        
        return processed_path

複数OCRエンジンによる精度最適化

def extract_text_with_ocr(image_path: str) -> str:
    """OCRで画像からテキストを抽出(日本語対応・強化版)"""
    try:
        # 画像前処理を実行
        processed_path = preprocess_image_for_ocr(image_path)
        
        # EasyOCRを使用(前処理済み画像で)
        results = ocr_reader.readtext(processed_path)
        text = ""
        for (bbox, text_content, confidence) in results:
            if confidence > 0.3:  # 信頼度閾値を下げて、より多くのテキストを取得
                text += text_content + " "
        
        # EasyOCRで不十分な場合はTesseractで詳細処理
        if len(text.strip()) < 15:
            img = Image.open(processed_path)
            
            # 複数のPSMモードで試行
            for psm in [6, 7, 8, 13]:
                try:
                    config = f'--oem 3 --psm {psm}'
                    text_tesseract = pytesseract.image_to_string(img, lang='jpn+eng', config=config)
                    if len(text_tesseract.strip()) > len(text.strip()):
                        text = text_tesseract
                        break
                except Exception:
                    continue
        
        return text.strip()

この実装では、OpenCVによる画像前処理EasyOCRの機械学習ベース認識Tesseractの複数PSMモード試行の三重構造により、従来のOCRでは読み取り困難な低品質画像からも高精度でテキストを抽出できます。

日本語OCRの限界と代替手段

重要な注意事項: 日本語文書の場合、OCRの精度には限界があります。特に複雑な漢字、縦書きテキスト、特殊フォントを含む文書では、OCR処理による文字化けや誤認識が発生する可能性が高くなります。

PDFファイルの推奨処理方法:

1. PDFビューアーでテキストを全選択(Ctrl+A / Cmd+A)
2. コピー(Ctrl+C / Cmd+C)
3. テキストファイル(.txt)として保存
4. テキストファイルをRAGシステムにアップロード

この方法により、OCR処理を経由することなく、元のPDFに埋め込まれた正確なテキストデータを取得できるため、日本語文書の処理精度が大幅に向上します。OCRは、スキャンされた画像PDFや画像ファイルなど、他に選択肢がない場合の最終手段として位置づけることを推奨します。

Janomeによる日本語処理とチャンク分割戦略の最適化

抽出されたテキストは、Janome形態素解析器を使って適切にチャンク分割されます。実際のシステムでは、メモリ効率と検索精度のバランスを考慮した最適化されたチャンク戦略を採用しています。

# 日本語トークナイザー
tokenizer = Tokenizer()

# チャンク分割(最適化版)- より小さなチャンクで精度向上
chunks = [content[i:i+600] for i in range(0, len(content), 400)]

600文字のチャンクサイズと400文字のオーバーラップにより、以下の利点を実現しています:

  1. 精密な検索: 小さなチャンクで関連文脈をより正確に特定
  2. メモリ効率: 30GB RAM制約下での安定動作
  3. 文脈保持: 200文字のオーバーラップで文脈の連続性を保持
  4. 処理速度: 埋め込みベクトル生成時間の短縮

メモリ効率とパフォーマンス最適化

30GB RAM環境でのリソース配分

本システムの最大の技術的チャレンジは、30GB RAMでgpt-oss:20bモデルを安定動作させることです。実測値に基づくメモリ配分は以下の通りです:

コンポーネント アイドル時 処理中 設定値
Ollama (LLM) 13-15GB 16-20GB 24GB制限
PostgreSQL 200MB 500MB 制限なし
Document Processor 150MB 800MB 2GB制限
RAG Service 100MB 300MB 1GB制限
システム予約 2GB 4GB -

Ollamaのメモリ制御環境変数

Ollamaのメモリ使用量は、環境変数で細かく制御しています:

environment:
  - OLLAMA_MAX_LOADED_MODELS=2    # 同時読み込みモデル数制限
  - OLLAMA_KEEP_ALIVE=5m          # メモリ保持時間短縮
  - OLLAMA_NUM_PARALLEL=2         # 並列処理数制限

これらの設定により、メモリオーバーフローを防ぎつつ、必要十分なパフォーマンスを確保しています。

まとめと今後の展望

本システムは、30GB RAMという比較的限られたリソースで、企業レベルの日本語文書処理RAGシステムを実現しました。技術的なポイントは以下の通りです。

設計要素:

  • マイクロサービス構成による責務分離
  • LibreOffice + OCRによる堅牢な文書処理パイプライン
  • pg_vectorのHNSWインデックスによる高速ベクトル検索
  • Ollamaの環境変数による細密なメモリ制御

適用場面:

  • 企業の機密文書分析(法務、コンプライアンス、技術文書)
  • オフライン環境での文書検索システム
  • 個人利用での大量文書管理

今後の発展性:

  • より大型のLLMモデル(gpt-oss:120B)への対応
  • GPU活用による処理速度向上
  • 分散処理によるスケールアウト
  • 外部SaaS OCRサービスの活用: Azure Computer Vision、Google Cloud Vision APIなどの高精度日本語OCRサービスとの連携による認識精度の大幅向上

完全ローカル環境でのRAGシステムは、プライバシー保護とコスト削減の両面で大きな価値を提供します。オープンソースLLMエコシステムの急速な発展により、今後さらに高性能で実用的なシステムが構築可能になるでしょう。

Discussion