技術ノート・セキュリティ

検証方法(E2E)

  1. GET /health で全コンポーネント(DB・ChromaDB・Ollama)が ok を返すことを確認
  2. Ollama が起動し日本語で回答できることを確認
  3. PDF(就業規則サンプル)をアップロード → Ingestion ステータスが completed になる
  4. content_tsv カラムにトークンが格納されていることを確認
  5. 社員チャット画面から「育児休暇の手続きは?」と質問
  6. テキスト回答 + Mermaid フロー図(手順フロー)がブラウザに表示される
  7. Mermaid.js の securityLevel: 'strict' が有効で、XSSペイロードが無害化されることを確認
  8. 管理者が規約を更新 → バージョン履歴に旧版が残る → 復元操作が可能
  9. audit_logs に各操作が記録されていることを確認
  10. must_change_password=true のユーザーで初回ログイン → パスワード変更画面に強制リダイレクト

技術的検討・注意点

MacのDockerコンテナ内GPUについて(重要)

Mac上のDockerはLinux VMを経由するため、Metal GPU・Neural EngineにはDockerコンテナからアクセスできない。 この制約により、AIコンポーネントをDockerコンテナ内で動かすと以下のパフォーマンス劣化が生じる:

コンポーネントDocker内(CPU推論)ホスト直接(Mac Studio M4 Max / Metal GPU)
Ollama(gemma3:27b)3〜5 tokens/sec30〜40 tokens/sec
multilingual-e5-large200〜400ms/doc~30ms/doc(546 GB/s 帯域・MPS)
japanese-reranker100〜150ms/pair × 10件 = 1〜1.5秒~15ms/pair(MPS)

結論: Ollama・FastAPI(Embedding/Reranker含む)はホスト直接起動が必須。 Mac Studio M4 Max はメモリ帯域 546 GB/s により、LLM推論・Embedding ともに M4 Pro(273 GB/s)の約2倍の速度を発揮する。

MPS(Metal Performance Shaders)利用時の注意

  • PyTorchのMPS対応は進んでいるが、一部演算は未対応
  • PYTORCH_ENABLE_MPS_FALLBACK=1 を設定することで、未対応演算を自動的にCPUで補完する
  • この設定がないとMPS非対応演算でエラーが発生する可能性がある

DockerボリュームのI/O性能

  • bind mount(./path:/container/path)はVirtioFS経由で3〜30倍遅化する → 絶対に使用しない
  • named volumeを必ず使用するpostgres_datachromadb_data
  • PostgreSQL・ChromaDBのデータはNASに置かない(NFS越しのI/Oがボトルネックになる)
  • NASはPDF等の元ファイルのみ格納する

macOSサーバーとしての運用設定

  • スリープ無効化必須(社内サーバーとして常時稼働させるため)
    sudo systemsetup -setsleepdisable on
  • NASのマウント方式はNFSを推奨(SMBは長時間接続で不安定になる場合がある)
  • Mac Studioを社内LAN固定IPに設定する

開発・テスト環境の分離

テスト実行時に開発DBを汚染しないよう、環境を完全に分離する。

ChromaDBクライアントの環境別切替(rag/chroma_client.py):

採用方針: Docker + HttpClient(本番に近い構成でテスト) テスト環境でも docker-compose.test.ymlchromadb-test(ポート8002)を起動し、 本番と同じ HttpClient 経由で接続する。.env.testCHROMA_PORT=8002 で切り替え。

import chromadb
from app.config import settings

def get_chroma_client():
    return chromadb.HttpClient(host=settings.chroma_host, port=settings.chroma_port)

テストフィクスチャの構成(tests/conftest.py):

import pytest
from unittest.mock import MagicMock, patch
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker

# AIモデルはすべてモック(2〜4GB のモデルロードをテストで行わない)
@pytest.fixture(scope="session")
def mock_embedding_model():
    with patch("app.rag.ingestion.get_embedding_model") as m:
        model = MagicMock()
        model.encode.return_value = [[0.1] * 1024]  # multilingual-e5-largeの次元数
        m.return_value = model
        yield model

@pytest.fixture(scope="session")
def mock_ollama():
    with patch("app.rag.generation.OllamaLLM") as m:
        llm = MagicMock()
        llm.complete.return_value = MagicMock(text="テスト回答です。")
        m.return_value = llm
        yield llm

@pytest.fixture(scope="session")
def mock_reranker():
    with patch("app.rag.retrieval.CrossEncoder") as m:
        reranker = MagicMock()
        reranker.predict.return_value = [0.9, 0.7, 0.5]
        m.return_value = reranker
        yield reranker

# テストDB:各テスト後にロールバック(テスト間の独立性確保)
@pytest.fixture(scope="function")
def db_session(test_engine):
    connection = test_engine.connect()
    transaction = connection.begin()
    Session = sessionmaker(bind=connection)
    session = Session()
    yield session
    session.close()
    transaction.rollback()
    connection.close()

pyproject.toml のpytest設定:

[tool.pytest.ini_options]
testpaths = ["tests"]
env_files = [".env.test"]   # pytest-dotenvで.env.testを自動読み込み
asyncio_mode = "auto"       # FastAPI非同期テスト対応

[tool.coverage.run]
source = ["app"]
omit = ["tests/*"]

vite.config.ts のtest設定:

import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [react()],
  test: {
    environment: 'jsdom',
    setupFiles: ['./src/__tests__/setup.ts'],
    globals: true,
    coverage: {
      reporter: ['text', 'html'],
    },
  },
})

MSW セットアップ(frontend/src/__tests__/setup.ts):

import { setupServer } from 'msw/node'
import { handlers } from './mocks/handlers'

const server = setupServer(...handlers)
beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())

セキュリティ設計

ローカル動作の担保

  • multilingual-e5-large は初回起動時にHugging Faceからダウンロード、以後はローカルキャッシュを使用(HF_HOME=~/.cache/huggingface
  • モデルのrevisionをconfigで固定し、更新によるベクトル空間のズレを防ぐ
  • Ollama モデルは ollama pull gemma3:27b で事前ダウンロード
  • ChromaDB はサーバーモードでDockerのnamed volumeに永続化

日本語対応(重要度:最高)

  • multilingual-e5-largequery: / passage: prefix の付与が 必須(なしでは精度が大幅低下)
  • BM25相当はPostgreSQL全文検索(tsvector + GIN)+ SudachiPy で実現(ChromaDBのBM25非対応を回避)
  • Rerankerは日本語対応モデル (hotchpotch/japanese-reranker-cross-encoder-large-v1) を使用
  • チャンクサイズ512tokenは日本語で約300〜400文字相当に注意

セキュリティ

  • パスワードは bcrypt でハッシュ化
  • JWT は HttpOnly Cookie で管理(LocalStorage禁止・XSSによるトークン窃取防止)
  • Mermaid.js は securityLevel: 'strict' + DOMPurify でXSSリスクを軽減
  • バックエンドでMermaid構文の許可リスト検証を実施
  • ファイルアップロードは python-magic でバイナリシグネチャ検証(拡張子偽装対策)
  • レート制限(slowapi)で POST /chat のDoS対策
  • プロンプトインジェクション対策:入力500文字上限・区切り文字の強化・ロギング
  • SECRET_KEY は起動時に強度チェック(空・32文字未満・デフォルト値で起動拒否)

スケーラビリティ・運用性

  • v1.0はシングルテナント(企業ごとに独立インスタンス)
  • ChromaDB は単一コレクション + メタデータフィルタ方式(カテゴリ別複数コレクション方式は管理コスト大のため不採用)
  • Embeddingモデルのバージョンを固定してChromaDBとの整合性を保つ
  • Ingestion非同期処理はPhase 1〜6ではFastAPI BackgroundTasks、本番スケール時はCelery + Redisへの移行を検討
  • PostgreSQL と ChromaDB のバックアップは同一タイムスタンプで取得(両DBの整合性確保)
  • 監査ログ(audit_logs)で法務・コンプライアンス要件に対応
  • 将来のロードマップ:マルチテナント対応・カテゴリ単位アクセス制御・管理者承認型FAQ機能(v1.1〜)・セマンティックキャッシュ(v1.5〜)

Ingestion整合性

  • ChromaDB削除失敗時はDBロールバックし、整合性を優先
  • Ingestion中のチャットは旧インデックスで回答(可用性優先)
  • 管理画面からIngestion再実行ボタンで手動リトライ可能
  • バージョン復元時は旧チャンク削除 → 新チャンク登録をセットで実行