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 ProcessorとRAG 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文字のオーバーラップにより、以下の利点を実現しています:
- 精密な検索: 小さなチャンクで関連文脈をより正確に特定
- メモリ効率: 30GB RAM制約下での安定動作
- 文脈保持: 200文字のオーバーラップで文脈の連続性を保持
- 処理速度: 埋め込みベクトル生成時間の短縮
メモリ効率とパフォーマンス最適化
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