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?

[アーキテクチャ] 精度重視のオフラインRAGを設計した話 ― ハッカソンを辞退した理由

0
Posted at

Note to Readers:
私は日本語を勉強中のため、この記事の執筆にはAI翻訳支援ツールを使用しています。不自然な表現があるかもしれませんが、ご容赦ください。

はじめに

はじめまして。インドの大学に通う、CS専攻の4年生です。
卒業後は日本でデータエンジニアとしてのキャリアを築きたいと強く考えており、現在日本語を勉強中です。
今回は初めてのQiita投稿として、技術共有のためにこの記事を書きました。

この記事では、6ヶ月前に「文書検索ハッカソン」に向けて構築した オフラインRAGシステム について紹介します。

結論から言うと、私はこのハッカソンを辞退しました。
大会の要件は、提供されたリンクから文書をダウンロードし、解析・Embedding生成・クエリ回答までを含めて「30秒以内」に完了することでした。

しかし私のシステムでは、文書の解析(Parsing)だけで5〜15分かかります(Embedding生成自体は高速です)。
これは単なる性能不足ではなく、意図的な設計判断 です。
法務・医療といったドメインでは、「精度を犠牲にしてまで速度を優先するべきではない」 と考えたためです。

この記事では、処理時間のトレードオフを受け入れつつ、精度を徹底的に追求するために設計した 「2.5層キャッシュアーキテクチャ」 について解説します。

この記事は誰のため?
RAGシステムの精度向上やキャッシング戦略に興味がある方、あるいは実装時のトレードオフについて知りたい方向けです。私自身の経験をシェアしているだけなので、参考程度にご覧ください。


Architecture: 2.5 Layer Caching

多くのRAGシステムは、LLMが生成した「最終回答(Q&Aペア)」をキャッシュします。しかし、私のシステムでは 「検索(Retrieval)結果」のレベル でキャッシュを行います。これにより、検索結果を再利用しつつ、LLMは毎回新しいコンテキストで回答を生成できます。

Architecture Diagram

なぜ「2.5層」なのか?

Layer 1: Exact Match Cache (xxHash)
  • 同一クエリに対する即時ルックアップ
  • ハッシュベースのため、FAQのような定型的な質問に最適
  • 完全一致(Exact Match)なので信頼度は100%
def get_exact_match(self, query: str) -> Optional[str]:
    cache_key = self._generate_cache_key(query)  # xxHash
    if cache_key in self.exact_cache:
        entry = self.exact_cache[cache_key]
        if not self._is_expired(entry) and self._is_kb_version_valid(entry):
            return entry.llm_response
    return None
Layer 2: Semantic Similarity Cache (FAISS)
  • ベクトル化による意味的な類似性比較
  • 「十分に似ている」クエリを見つける
  • ドメインに応じて閾値を設定可能(95〜99%)
# FAISS search for similar cached queries
query_embedding_f32 = normalized_query.astype('float32').reshape(1, -1)
scores, faiss_ids = self.cached_embeddings_index.search(query_embedding_f32, k)

if cosine_sim >= self.semantic_threshold:
    candidates.append((entry, cosine_sim))
Layer 2.5: Cross-Encoder Validation

これこそが、このシステムをユニークにしている「0.5層」の部分です。Layer 2の結果を、別のモデル(Cross-Encoder)を使って検証し、偽陽性(False Positives)を防ぎます。

# Dual verification: semantic similarity + cross-encoder
cross_score = self.semantic_validator.predict([(query, entry.query_original)])[0]
cross_score = np.tanh(cross_score)

if cross_score >= self.cross_encoder_threshold:
    # Safe to return cached result
    return best_entry.retrieved_chunks

なぜ2つのモデルを使うのか?
Embeddingの類似度(Cosine Similarity)だけでは、意味が異なっていてもベクトルが近い「偽陽性」が発生することがあります。

例えば:

  • "You are liable to pay benefits" と "You are NOT liable to pay benefits" は、否定語1つの違いですが、ベクトル空間では非常に近くに配置されます
  • "approved claims" と "rejected claims" なども同様の問題が起きます

ドメインごとの閾値設定

システムは、対象ドメインに応じて信頼度(Confidence)の要件を動的に変更します。

self.domain_configs = {
    "medical":   {"semantic_threshold": 0.99, "cross_encoder_threshold": 0.98},
    "financial": {"semantic_threshold": 0.99, "cross_encoder_threshold": 0.98}, 
    "legal":     {"semantic_threshold": 0.99, "cross_encoder_threshold": 0.98},
    "general":   {"semantic_threshold": 0.95, "cross_encoder_threshold": 0.95}
}

医療・法務ユースケースでは、意味的類似度99%、Cross-Encoder信頼度98%という厳しい基準を設けています。一方で一般的なクエリでは95%でも許容します。これらの値は実験的に設定したもので、ドメインやユースケースに応じて調整可能な設計にしています。


設計上の意思決定 (Design Decisions)

Chunk Size: 200 Tokens (Overlapなし)

ChromaDBのチャンキングに関する調査(Evaluating Chunking)に基づいています。オーバーラップなしの200トークンチャンクが、ベンチマーク全体で最も安定したパフォーマンスを示しました。

text_splitter = RecursiveCharacterTextSplitter.from_huggingface_tokenizer(
    tokenizer=self.tokenizer,
    chunk_size=chunk_size,
    chunk_overlap=chunk_overlap,
)

ナレッジベースのバージョニング

ドキュメントが変更された場合、キャッシュを自動的に無効化(Invalidate)します。

def _generate_kb_version(self, document_paths, total_chunks, 
                          embedding_model, chunk_params):
    document_hashes = {}
    for path in document_paths:
        document_hashes[basename(path)] = self._generate_document_hash(path)  # xxHash
    
    version_data = json.dumps({
        "docs": document_hashes,
        "chunks": total_chunks,
        "model": embedding_model,
        "params": chunk_params
    }, sort_keys=True)
    
    version_id = xxhash.xxh64(version_data.encode()).hexdigest()[:16]
    return KnowledgeBaseVersion(...)

ドキュメントの内容、チャンクパラメータ、あるいはEmbeddingモデルのいずれかが変更されると、新しいバージョンIDが発行され、古いキャッシュは無効になります。

Matryoshka Embeddingのサポート

高速な検索のために、次元のスライシング(768 → 512 → 256)をサポートしています。

# Index built with full dimensions
full_embedding = self.embed_model.encode(prefixed_query, convert_to_tensor=True)

# Can truncate during retrieval
truncated_embedding = normalized_embedding_np[:matryoshka_dim]

ただし、取り込み(Ingestion)の際は精度を優先するため、フルの768次元を使用することを推奨しています。


GCP Deployment での教訓

200GBのストレージ肥大化問題

Google Cloud RunはDockerイメージをArtifact Registryに保存しますが、古いレイヤーを保持し続けます。頻繁なデプロイの結果、気づかないうちに約200GBものストレージを消費していました。

以下のコマンドで発見しました:

gcloud artifacts repositories list --location=all

Artifact RegistryのUIから古いイメージを削除することで解決しましたが、個人開発では注意が必要です。


Performance

Document Processing: 5-15 Minutes

DoclingによるPDF解析は低速ですが、非常に正確です。一度解析されればキャッシュが効くため、同じドキュメントに対する後続のクエリはほぼ瞬時に処理されます。

Retrieval: キャッシュの効果

以下は、本番環境での実際のログです。キャッシュシステムがどのように機能しているかが分かります。

Query 1: "when are you not liable to pay under benefits?" (給付金の下で支払い義務がないのはいつですか?)

✗ CACHE MISS (Layer 1)
EXECUTING RETRIEVAL PIPELINE
  - Query processing: 0.155s
  - FAISS search: 0.000s
  - Reranking: 0.786s
✓ RETRIEVAL PIPELINE COMPLETE in 1.030s
✓ CACHED (Layer 1): Exact match response
✓ CACHED (Layer 2): High-confidence embedding result
  - Semantic confidence: 1.0000

Query 2: "when is someone not liable to pay under benefits" (誰かが給付金の下で支払い義務を負わないのはいつですか)
※ Query 1と意味は同じだが、文字列が異なるケース

✗ CACHE MISS (Layer 1)  // 文字列が異なるためミス
Checking Layer 2 cache using FAISS index (entries: 1)
Found 1 candidates via FAISS search above threshold
  Candidate: cosine=0.9280, cross_encoder=0.9800
    Cached: 'when are you not liable to pay under benefits?'
    Current: 'when is someone not liable to pay under benefits'
✓ CACHE HIT (Layer 2): High-confidence semantic match
  - CrossEncoder score: 0.9800
  - Validation time: 0.027s
  - Returning 5 cached chunks

速度比較:

  • 通常のパイプライン: 1.030秒
  • Layer 2 キャッシュヒット: 0.027秒 (約38倍高速)

まとめ

このシステムは、速度よりも精度(Precision)が重要視される医療や法務ドキュメントのために設計されました。
ドキュメント解析には5〜15分かかりますが、一度インデックス化されれば検索は高速です。そして、2.5層キャッシュとバージョニング管理により、精度を落とすことなく無駄な再計算を防ぐことができます。

ハッカソンの「30秒制限」には適合しませんでしたが、実世界の問題解決には適したアーキテクチャだと考えています。


Tech Stack Summary

  • Embedding Model: nomic-ai/nomic-embed-text-v1.5 (768 dimensions)
  • Reranker: cross-encoder/ms-marco-MiniLM-L-6-v2
  • PDF Parser: Docling (IBM)
  • Vector Store: FAISS (exact L2 search)
  • Text Splitter: LangChain RecursiveCharacterTextSplitter
  • Cache: Custom 2.5-layer system (xxHash + FAISS + CrossEncoder)
  • Backend: FastAPI
  • Frontend: Streamlit
  • Infrastructure: Google Cloud Run (Docker)
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?