Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

7. リランキングとRAGパイプラインの構築

リランキング(Re-ranking)とは

ハイブリッド検索によって、キーワードの一致と意味の類似性の両面から候補文書をバランスよく収集できるようになりました。 しかし、RRF(逆順位融合)は順位のみを機械的に足し合わせるため、「質問に対して本当に直接的な答えになっているか」という深い文脈までは判別できません。

そこで、検索された上位の候補文書に対して、より高精度なニューラルモデルで再度スコアリングを行い、真に重要な文書を最上位に並び替える処理を「リランキング(Re-ranking)」と呼びます。

Bi-EncoderとCross-Encoderの違い

検索とリランキングでは、使われるモデルの構造が異なります。

▸ Bi-Encoder(通常のベクトル検索)

質問文と文書をそれぞれ独立してベクトル化します。 事前に文書群をベクトル化してインデックスに保存しておけるため、数万件の文書から数ミリ秒で検索できる速度のメリットがあります。 しかし、質問と文書の単語同士がどう関係しているか(相互作用)を直接比較できないため、細かな意味の整合性を捉えきれない限界があります。

▸ Cross-Encoder(リランカー)

質問文と文書を1つのペアとしてモデルに入力し、すべての単語同士の相互関係を同時に計算します。 計算量は非常に多くなるため数万件の検索には使えませんが、質問に対する文書の直接的な関連度を圧倒的な高精度で判定できます。

そのため、実務では「ハイブリッド検索で候補を広めに取得(例:上位10〜20件)」し、「Cross-Encoder で上位数件(例:上位3件)に絞り込む」という「2段階検索アーキテクチャ」が最高の精度を発揮します。

完全なRAGパイプラインの実装コード

ここでは、FlashRank(軽量で高速なオープンソースのリランカー)と、LangChain の LCEL(LangChain Expression Language)を用いて、質問から検索、リランキング、回答生成までを行う完全な RAG パイプラインを実装します。 ローカル実行環境として Ollama を使用し、埋め込みモデルに bge-m3、生成 LLM に gemma4:12b を利用します。

Note

langchain_community.document_compressors.FlashrankRerank は、現在非推奨(deprecated)となっています。 最新環境では公式のスタンドアローンパッケージ flashrank(PrithivirajDamodaran/FlashRank)を直接使用することが推奨されます。スタンドアローン版を用いたモダンな実装パターンについては、実践チュートリアル「SQLite3 FTS5 + Chroma によるローカル BM25+RAG パイプライン構築」を参照してください。

事前準備として、Ollama でモデルを取得しておきます。

ollama pull bge-m3
ollama pull gemma4:12b

必要なパッケージをインストールします。

pip install flashrank langchain-ollama chromadb
from janome.tokenizer import Tokenizer
from langchain.retrievers import ContextualCompressionRetriever, EnsembleRetriever
from langchain_community.document_compressors import FlashrankRerank
from langchain_community.retrievers import BM25Retriever
from langchain_community.vectorstores import Chroma
from langchain_core.documents import Document
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.runnables import RunnablePassthrough
from langchain_ollama import ChatOllama, OllamaEmbeddings

# 1. 日本語形態素解析器の準備
tokenizer = Tokenizer()


def japanese_tokenizer(text: str) -> list[str]:
    return [t.base_form for t in tokenizer.tokenize(text)]


# 2. ナレッジベース文書群
documents = [
    Document(
        page_content="有給休暇は勤続半年後に10日付与され、申請は希望日の3日前までに社内ポータルから行います。",
        metadata={"id": "doc-01", "category": "人事"},
    ),
    Document(
        page_content="夏季休暇は7月〜9月の間に連続または分割して最大3日間取得可能です。",
        metadata={"id": "doc-02", "category": "人事"},
    ),
    Document(
        page_content="通信エラー ERR-502 はゲートウェイタイムアウトです。ルータ再起動をお試しください。",
        metadata={"id": "doc-03", "category": "情シス"},
    ),
    Document(
        page_content="社内PCにはセキュリティソフトの常時導入と月次アップデートが義務付けられています。",
        metadata={"id": "doc-04", "category": "セキュリティ"},
    ),
]

# 3. 第1段階:ハイブリッド検索の構築(広めに取得:k=4)
bm25_retriever = BM25Retriever.from_documents(
    documents=documents,
    preprocess_func=japanese_tokenizer,
    k=4,
)

embeddings = OllamaEmbeddings(model="bge-m3")
vectorstore = Chroma.from_documents(documents=documents, embedding=embeddings)
vector_retriever = vectorstore.as_retriever(search_kwargs={"k": 4})

base_retriever = EnsembleRetriever(
    retrievers=[bm25_retriever, vector_retriever],
    weights=[0.5, 0.5],
)

# 4. 第2段階:リランカー(FlashRank)の適用(上位2件に厳選)
compressor = FlashrankRerank(top_n=2)
compression_retriever = ContextualCompressionRetriever(
    base_compressor=compressor,
    base_retriever=base_retriever,
)

# 5. プロンプトテンプレートの定義
prompt = ChatPromptTemplate.from_template(
    """あなたは親切な社内アシスタントです。以下の参考ドキュメントの内容のみに基づいて、質問にわかりやすく答えてください。
答えが見つからない場合は「提供された情報からはわかりません」と答えてください。

【参考ドキュメント】
{context}

【質問】
{question}

【回答】:"""
)


def format_docs(docs: list[Document]) -> str:
    """検索された複数の文書を1つのコンテキスト文字列に整形する関数."""
    return "\n\n".join(
        f"[{d.metadata.get('id', 'N/A')}] {d.page_content}" for d in docs
    )


# 6. LLMとパイプライン(LCEL)の結合
llm = ChatOllama(model="gemma4:12b", temperature=0)

rag_chain = (
    {"context": compression_retriever | format_docs, "question": RunnablePassthrough()}
    | prompt
    | llm
    | StrOutputParser()
)

# 7. 実行
question = "夏休みは何日取れますか?"
response = rag_chain.invoke(question)

print(f"質問: {question}")
print(f"回答: {response}")

パイプラインの処理フローまとめ

このパイプラインを実行すると、以下の順序で安全かつ高精度に回答が生成されます。

  1. 質問の入力: ユーザーが「夏休みは何日取れますか?」と質問します。
  2. ハイブリッド検索: BM25 と Chroma(Ollama bge-m3 による密ベクトル)が並行して検索を実行し、RRF で上位4件の候補ドキュメントを収集します。
  3. リランキング: Cross-Encoder(FlashRank)が質問と各候補文書を照合し、最も文脈が合致する上位2件(夏季休暇の文書など)を厳選します。
  4. プロンプト注入: 厳選された文書がプロンプトのコンテキスト欄に注入されます。
  5. 回答生成: ローカル LLM(Ollama gemma4:12b)が外部知識に忠実に基づいて、ハルシネーションを起こさずに正確な回答を生成します。

次章では、実運用環境で直面する多様なデータソース(IRIS バンドルデータ、LOTUS ノートデータ、大容量テキスト行データ、複数 PDF ドキュメント)に対して、高精度な BM25 およびハイブリッド索引システムを構築・永続化する実践パイプラインを解説します。