結論

ハイブリッド検索は、BM25(キーワード検索)とベクトル検索を並べて走らせ、2つの順位を RRF(Reciprocal Rank Fusion)などで1つにまとめる方法です。ただし RRF をいつも同じ重みで使えば良くなるわけではありません。RJC の技術記事3,917節・750問で測ると、BM25 が強くベクトル側が弱い組み合わせでは、RRF(k=60)で MRR@10 が0.636から0.533に下がりました。一方、上位100件の再現率は0.964から0.988に上がり、ベクトル側の重みを0.3に下げると上位10件の再現率も BM25 単独と同じ水準まで戻りました。

この記事でわかること

  • BM25 とベクトル検索がそれぞれ強い質問・弱い質問を、型番・エラー文・言い換えの実例と順位で比べた結果
  • RRF の k(1〜1000)・重み付き RRF・min-max/z-score の線形結合を、同じ評価セットで並べた比較表
  • PostgreSQL で BM25・pgvector・RRF を1つの SQL 関数にした実装と、hnsw.ef_search の落とし穴

2026年11月4日更新|Python 3.13.18・SudachiPy 0.7.0・PostgreSQL 16.15・pgvector 0.6.0・CPU 2コアで実行確認

この記事で実際に動かしたもの:RJC の技術記事247本を見出し単位の3,917節に分け、Sudachi と文字 bigram で分かち書きした BM25、TF-IDF+SVD(LSA)の密ベクトル検索、RRF・重み付き RRF・線形結合を Python で自作し、自分で initdb した PostgreSQL 16 に同じ仕組みを SQL 関数として載せて、750問の Recall@10・MRR@10 と応答時間を測りました。

RAG(検索で集めた文書を LLM に渡して答えさせる仕組み)では、検索で正解の文書を上位に持ってこられなければ、LLM はその文書を読めません。ハイブリッド検索は、その検索部分の取りこぼしを減らすための定番の方法です。この記事は「2つの検索をどう組み合わせるか」に絞ります。評価指標の詳しい解説とチャンク分割の比較は、同じコーパスを使う別の記事(公開準備中)で扱います。

flowchart TD
    Q[質問] --> T[分かち書き]
    Q --> E[埋め込み]
    T --> B[BM25<br>上位100件]
    E --> V[ベクトル検索<br>上位100件]
    B --> F[融合<br>RRF や線形結合]
    V --> F
    F --> R[必要ならリランク]
    R --> L[上位を LLM に渡す]

よく調べられることの答えを先にまとめます。

知りたいこと 答え 詳しい節
ハイブリッド検索とは BM25 とベクトル検索を並べて実行し、順位かスコアで1つの結果にまとめる方法。検索アルゴリズムの名前ではなく組み合わせ方の名前 ハイブリッド検索とは
BM25 とベクトル検索の違い BM25 は語の一致と珍しさで点を付け、型番・エラー文に強い。ベクトル検索は意味の近さで点を付け、言い換えに強い BM25とベクトル検索の違い
RRF(reciprocal rank fusion)とは 各リストの順位 r から 1/(k+r) を足し合わせて並べ直す方法。スコアの単位が違っても使える RRFとは
RRF の k=60 は変えるべきか 手元では k=1〜10 が少し良く、k≧60 はほぼ同じ。k より重みの方が効いた RRFのkを変えた実測
RRF で精度が下がることはあるか ある。片方の検索が弱いと、片方だけが1位にした正解が数十位まで沈む。手元では MRR@10 が0.636→0.533 RRFが順位を下げる場面
RRF と線形結合のどちらを使うか 重みを評価セットで決められるなら線形結合(正規化つき)、決められないなら重み付き RRF から 重み付きRRFと線形結合
RAG の検索精度を上げるには まず単独の BM25(日本語の分かち書き)とベクトル検索を別々に測り、弱い方を直してから融合する ハイブリッド検索の判断表
PostgreSQL でハイブリッド検索をするには BM25 の CTE と pgvector の CTE を FULL OUTER JOIN して RRF を計算する。hnsw.ef_search を件数以上にする PostgreSQLで1つのSQLにする

確認環境:Ubuntu 24.04.5 LTS(Intel Xeon 2.10GHz・2コア・メモリ8GB、ほかの作業と同時に動いている共有のマシン)で、Python 3.13.18・numpy 2.5.3(OpenBLAS、スレッド数1)・scipy 1.18.1・scikit-learn 1.9.1・SudachiPy 0.7.0・SudachiDict-core 20261015 を使いました。コーパスは RJC の技術記事247本の本文(HTML タグを外し、見出し単位で3,917節・5,561,447文字)です。学習済みの埋め込みモデルには接続できないため、ベクトル検索は TF-IDF+SVD(LSA)で手元で作った密ベクトルを「埋め込みの代わり」に使いました。実際の埋め込みモデルでは、ベクトル側の数字も、融合したときの数字も変わります。PostgreSQL は、ホストの設定を変えないよう Ubuntu の PostgreSQL 16.15 の実行ファイル一式を作業場所に複製し、apt-get download した postgresql-16-pgvector 0.6.0 の .deb をそこへ展開して、作業場所に initdb したクラスタを 127.0.0.1 だけで起動しました。各製品の既定値は公式ドキュメント、RRF の式は Cormack らの原論文、BM25 の式は Apache Lucene の公式ドキュメントで確かめました。図は mermaid 11.16.0 で描画を確かめています(確認日 2026年11月4日)。

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「ハイブリッド検索とRRF|BM25+ベクトルを750問で実測・SQL実装」

ハイブリッド検索とは(BM25とベクトル検索を組み合わせる理由)

この節では、ハイブリッド検索が何を組み合わせ、なぜ RAG の検索精度の話で必ず出てくるのかが分かります。

ハイブリッド検索は、性質の違う2つ以上の検索を同じ質問で実行し、結果を1つの順位にまとめる方法です。RAG の文脈では、ほとんどの場合「BM25 などのキーワード検索」と「埋め込みを使ったベクトル検索」の組み合わせを指します。RRF は、その「まとめ方」の1つです。

1BM25(キーワード検索)

質問の語が文書に何回出るか、その語がコーパス全体でどれだけ珍しいかで点を付ける。転置インデックスで速い。表記が一致しないと拾えない。

2ベクトル検索

質問と文書を同じ空間のベクトルにして、コサイン類似度などで近い順に並べる。語が違っても意味が近ければ拾えるが、型番の1文字違いは見分けにくい。

3融合(fusion)

2つのリストを1つにする。順位だけを使う RRF と、スコアを正規化して重みを掛けて足す線形結合が代表。後ろにリランカーを置くこともある。

組み合わせる理由は、2つの検索が取りこぼす質問が違うからです。手元の750問で、BM25 と LSA の上位10件が何件重なるかを数えると、平均6.1件でした。残りの約4件は片方にしか出てこない文書で、ここに相手が取りこぼした正解が入っていることがあります。上位50件ずつを合わせると、重複を除いて平均69.7件になりました。

一方で、2つを足せば必ず良くなるわけではありません。片方が明らかに弱いと、弱い方の順位が強い方の正解を押し下げます。この記事の後半は、その「下がる場面」と「下げない組み合わせ方」を実測で見ていきます。

BM25とベクトル検索の違い(型番・エラー文と言い換えで何が起きるか)

この節では、同じコーパスで BM25 とベクトル検索がそれぞれどんな質問で勝ち、どんな質問で負けるかが、実際の順位で分かります。

BM25 が強い質問

エラーメッセージをそのまま貼った質問

型番・設定名・関数名(core.longpaths・np.column_stack など)

コーパスに少ししか出てこない固有名詞

ベクトル検索が強い質問

文書と違う言葉で尋ねた質問(言い換え)

「〜したい」「〜できない」のような症状の説明

見出しの要約のような短い質問

手元の BM25(Sudachi の A 単位)と LSA(512次元)で、正解の節が何位に来たかの実例です。「圏外」は上位100件に入らなかったことを表します。RRF は k=60・重みなしです。

質問(種類) BM25 LSA RRF
画面が1秒に何回描き直されるかを表す数値の意味(言い換え)52位1位8位
パケットキャプチャで通信の中身をのぞく(言い換え)20位3位8位
ダイアログを開いているのに後ろの画面がスクロールしてしまう(言い換え)6位1位3位
GitHubの無料と有料の違い(見出し)11位1位2位
mojicli: error: the following arguments are required: text(エラー文)1位80位19位
OTS parsing error: invalid sfntVersion: 1008821359(エラー文)1位42位15位
core.longpaths とは(識別子)1位圏外58位
UserCard.js とは(識別子)1位圏外58位

言い換えの質問では、BM25 は「描き直す」と本文の「リフレッシュレート」を結び付けられず52位でしたが、LSA は同じ記事で一緒に出てくる語の傾向から1位にしました。逆にエラー文や識別子では、BM25 が1位にしたものを LSA は数十位か圏外にしています。RRF の列を見ると、言い換えでは中間に、識別子では大きく下に引っ張られています。この「下に引っ張られる」幅が、片方の検索が弱いときの RRF の弱点です。

実験の作り方(RJCの技術記事3,917節と評価用の質問750件)

この節では、手元で測った評価セットの作り方と、数字を読むときの前提が分かります。

1

記事を見出し単位の節に分ける

247本の本文を H2 で区切り、HTML タグと Mermaid の図を外した。80文字未満の節は捨て、3,917節になった(中央値1,258文字・最大10,286文字)。

検索の単位=節

2

見出しと定型文を本文から外す

見出しをそのまま質問に使うので、索引には見出しを入れない。各節の冒頭の「この節では〜が分かります。」も見出しの言い直しなので消した。

語の漏れを防ぐ

3

4種類の質問を作る

見出し400問・エラー文150問・型番や識別子150問は機械的に作り、言い換え50問は本文と違う言葉で手で書いた。

合計750問

4

正解の節を決めて測る

見出しと言い換えは元の節が正解。エラー文と識別子は、その文字列を含む節(1〜3節)を全部正解にした。Recall@10 と MRR@10 を出す。

乱数の種 253

質問の種類 件数 作り方 例
見出し400重複しない H2 見出しから括弧の補足を外したもの日本語フォントが埋め込まれない問題
エラー文150コードブロックの中の Error・failed・No such などを含む行ls: cannot access 'file.txt': No such file or directory
型番・識別子150本文に1〜3節だけ出る英数字の識別子+「とは」preserve_index とは
言い換え50本文と違う言葉で手で書いた質問13文字ずらす暗号を2回かけると元に戻る理由

数字を読むときの注意が3つあります。1つ目は、4種類の比率で全体の数字が変わることです。この評価セットは見出しが半分、エラー文と識別子が4割あり、BM25 に有利な配分です。この記事では種類ごとの数字を必ず並べるので、本番の質問に近い種類の数字を重く見て読み替えられます。2つ目は、言い換えが50問しかなく、1問で MRR が最大0.02動くことです。3つ目は、ベクトル側が本物の埋め込みではなく LSA であることです。指標の意味と、件数によるぶれの見積もり方は、評価指標の記事(公開準備中)で詳しく扱います。

日本語のBM25を自作する(文字bigramとSudachiの比較)

この節では、日本語の BM25 で分かち書きをどうするかで精度がどれだけ変わるかと、BM25 の式とパラメータ k1・b の効き方が分かります。

BM25 は、語 t の珍しさ(IDF)と、文書の中の出現回数 tf を、文書の長さで補正して足し合わせるスコアです。Apache Lucene の BM25Similarity は、既定値を k1=1.2・b=0.75 とし、IDF を log(1 + (N − df + 0.5) / (df + 0.5)) で計算すると公式ドキュメントに書いています(N は文書数、df はその語を含む文書数)。1語ぶんの点は idf × tf × (k1+1) ÷ (tf + k1 × (1 − b + b × 文書長 ÷ 平均文書長)) です。手元でもこの形で書きました。

import numpy as np, scipy.sparse as sp
from collections import Counter

class BM25:
    def __init__(self, docs, k1=1.2, b=0.75):          # docs: 分かち書き済みの語のリスト
        self.vocab, rows, cols, vals, dfc = {}, [], [], [], Counter()
        dl = np.array([len(d) for d in docs], dtype=float)
        for i, d in enumerate(docs):
            c = Counter(d); dfc.update(c.keys())
            for w, tf in c.items():
                j = self.vocab.setdefault(w, len(self.vocab))
                rows.append(i); cols.append(j); vals.append(tf)
        N = len(docs)
        df = np.array([dfc[w] for w in self.vocab])
        idf = np.log(1 + (N - df + 0.5) / (df + 0.5))   # Lucene と同じ形(負にならない)
        tf = np.array(vals, dtype=float); r = np.array(rows)
        w = idf[cols] * tf * (k1 + 1) / (tf + k1 * (1 - b + b * dl[r] / dl.mean()))
        self.W = sp.csc_matrix((w, (rows, cols)), shape=(N, len(self.vocab)))

    def scores(self, q_tokens):                         # 質問の語の列を足すだけ
        js = [self.vocab[t] for t in set(q_tokens) if t in self.vocab]
        return np.asarray(self.W[:, js].sum(axis=1)).ravel() if js else np.zeros(self.W.shape[0])

あらかじめ「語ごと・文書ごとの点」を疎行列に入れておくと、検索は質問の語の列を足すだけになり、3,917節で1問0.1〜0.2msでした。

分かち書きの違い(文字bigram・Sudachi C単位・A単位)

同じ BM25 で、分かち書きだけを変えた結果です。セルは「Recall@10 / MRR@10」です。

分かち書き 見出し エラー文 識別子 言い換え/全体
文字 bigram(語彙63,691)0.710 / 0.3530.960 / 0.8800.840 / 0.6670.580 / 0.271 | 0.777 / 0.516
Sudachi C 単位(語彙34,942)0.853 / 0.4941.000 / 0.9170.949 / 0.8110.660 / 0.339 | 0.888 / 0.631
Sudachi A 単位(語彙33,220)0.855 / 0.5041.000 / 0.9170.949 / 0.8100.700 / 0.334 | 0.892 / 0.636

文字 bigram は辞書が要らず手軽ですが、全体の MRR@10 が0.516で、Sudachi の0.636より明らかに低くなりました。「の日」「本語」のような意味の無い2文字が大量に一致し、IDF の差が付きにくいためです。分かち書きは Sudachi の normalized_form(正規化形)を使い、助詞・助動詞・記号・空白を落としました。A 単位(短い単位)と C 単位(固有表現をまとめた長い単位)の差は小さく、以降は A 単位を使います。分かち書きにかかった時間は、5,561,447文字で Sudachi が約40秒、bigram が4.2秒でした。

from sudachipy import Dictionary, SplitMode
tok = Dictionary(dict="core").tokenizer()
DROP = ("助詞", "助動詞", "補助記号", "空白")

def sudachi_tokens(text, mode=SplitMode.A):
    out = []
    for m in tok.tokenize(text, mode):                  # 1回に渡せるのは 49,149 バイトまで
        if m.part_of_speech()[0] not in DROP:
            out.append(m.normalized_form())
    return out

print(sudachi_tokens("Webサーバのログを確認する"))
# ['ウェブ', 'サーバー', 'ログ', '確認', '為る']

正規化形は表記の揺れをそろえます。手元では「サーバ」「サーバー」がどちらも「サーバー」に、「Web」が「ウェブ」になりました。「関西国際空港」は C 単位では1語、A 単位では「関西/国際/空港」の3語です。一方で、英単語の example が「エグザンプル」、select が「セレクト」になるなど、英語がカタカナに変わる語もあります。索引と質問の両方を同じ関数で処理していれば一致しますが、片方だけ別の処理をすると0件になります(よくあるエラーの節)。

k1 と b を変えた結果

Sudachi A 単位で k1 と b を変えた全体の Recall@10 / MRR@10 です。

k1 b=0.3 b=0.75(既定) b=1.0
0.90.891 / 0.6520.894 / 0.6360.883 / 0.612
1.2(既定)0.894 / 0.6530.892 / 0.6360.886 / 0.612
1.50.896 / 0.6510.893 / 0.6370.882 / 0.609
2.00.890 / 0.6470.892 / 0.6300.882 / 0.602

k1 はほとんど効かず、b(文書の長さでどれだけ割り引くか)を0.3に下げると MRR@10 が0.02上がりました。この評価セットは長い節に正解が多いためと考えられます。ただし差は分かち書きの違い(0.12)よりずっと小さく、BM25 を直すならパラメータより分かち書きが先です。以降は既定値(k1=1.2・b=0.75)で比べます。

ベクトル検索の代わりにLSAで密ベクトルを作る(次元と精度)

この節では、手元で使った密ベクトルの作り方と、その強さが次元でどう変わるかが分かります。

学習済みの埋め込みモデルが使えないため、TF-IDF の行列を SVD で低い次元に縮める LSA(潜在意味解析)で密ベクトルを作りました。LSA は「同じ文書に一緒に出やすい語」を近いベクトルにするので、語が一致しなくても近い話題の文書を拾えます。ただし語の意味を外から学んでいるわけではないため、本物の埋め込みモデルより弱いベクトル検索として読んでください。

import numpy as np
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.decomposition import TruncatedSVD

tv = TfidfVectorizer(analyzer=lambda x: x, sublinear_tf=True, min_df=2)   # 分かち書き済みを渡す
X = tv.fit_transform(docs_tokens)
svd = TruncatedSVD(512, random_state=0)
D = svd.fit_transform(X)
D /= np.linalg.norm(D, axis=1, keepdims=True)          # 正規化して内積=コサイン類似度

def dense_scores(q_tokens):
    v = svd.transform(tv.transform([q_tokens]))[0]
    return D @ (v / np.linalg.norm(v))
次元 全体 R@10 / MRR@10 言い換え 識別子
320.380 / 0.171――
640.545 / 0.259――
1280.647 / 0.3150.580 / 0.3020.371 / 0.182
2560.717 / 0.3610.640 / 0.3110.451 / 0.278
5120.784 / 0.4330.660 / 0.3450.596 / 0.385
7680.803 / 0.462――

次元を増やすほど強くなりますが、512次元でも全体の MRR@10 は0.433で、BM25(0.636)より低いままです。言い換えの質問に限っても、MRR@10 は LSA 0.345、BM25 0.334 とほぼ同じでした。つまり手元の組み合わせは「BM25 が強く、ベクトル側が弱い」状態です。本物の埋め込みモデルでは、多くの場合言い換えでベクトル側がはっきり強くなり、2つの強さが近づきます。この違いが融合の結果を大きく左右するので、次の節以降では次元を変えて「ベクトル側の強さ」を動かした結果も並べます。以降の基本の比較は512次元です。

RRF(Reciprocal Rank Fusion)とは(式・k=60の意味・kを変えた実測)

この節では、RRF の式と k の意味、k を1〜1000に変えたときの実測が分かります。

RRF は、各検索の結果リストでの順位 r だけを使い、文書ごとに 1/(k + r) を足し合わせた値で並べ直す方法です。Cormack・Clarke・Büttcher の論文「Reciprocal Rank Fusion outperforms Condorcet and individual Rank Learning Methods」(SIGIR 2009)で示されました。原論文は k=60 を予備実験で決めて固定し、最適に近いが厳密な値は重要ではなかった、と書いています。k は、上位の文書を重く見つつ、下位の文書の影響が急に消えないようにする定数です。

def rrf(lists, k=60, weights=None):
    """lists: 文書IDの順位リストを複数。weights: リストごとの重み(省略時は全部1)"""
    score = {}
    for li, ranked in enumerate(lists):
        w = 1.0 if weights is None else weights[li]
        for r, doc in enumerate(ranked, start=1):        # 順位は1から数える
            score[doc] = score.get(doc, 0.0) + w / (k + r)
    return sorted(score, key=lambda d: (-score[d], d))  # 同点は ID で決める

前の節の「ダイアログを開いているのに後ろの画面がスクロールしてしまう」で、実際の RRF の点を見ます。正解は BM25 で6位、LSA で1位でした。

RRF 順位 節(記事/見出し) BM25・LSA の順位 RRF の点
1モーダルとは/よくある質問1位・2位1/61+1/62=0.03252
2モーダルとは/よくある不具合と直し方2位・3位0.03200
3(正解)モーダルとは/背景がスクロールする問題6位・1位1/66+1/61=0.03154
4モーダルとは/モーダルとは(意味と語源)3位・5位0.03126

両方で上位にいる文書が強くなり、片方で1位でも、もう片方で6位なら3位に落ち着きます。RRF の点は元のスコアを捨てているので、BM25 の点がどれだけ大差でも、ベクトルの類似度がどれだけ高くても、「何位だったか」しか効きません。BM25 の点(上限なし)とコサイン類似度(−1〜1)のように単位が違うスコアでも、そのまま合わせられるのが RRF の利点です。

RRF の k を変えた実測

BM25 と LSA の上位100件ずつを RRF でまとめ、k だけを変えた結果です。

k 全体 R@10 / MRR@10 識別子 言い換え
10.888 / 0.5650.911 / 0.6560.680 / 0.371
50.886 / 0.5480.904 / 0.6140.680 / 0.363
100.883 / 0.5410.889 / 0.5840.680 / 0.360
200.865 / 0.5350.809 / 0.5670.680 / 0.355
600.856 / 0.5330.786 / 0.5650.640 / 0.351
1000.856 / 0.5330.786 / 0.5640.640 / 0.350
10000.856 / 0.5330.779 / 0.5620.660 / 0.352

k を小さくすると1位と2位の点の差が大きくなり、「片方だけが1位にした文書」が残りやすくなります。手元では k=1 が最も良く、k≧60 はほぼ同じ値になりました。上位100件の中では、k が60を超えると 1/(k+r) の差が順位にほとんど関係しなくなり、「両方のリストに入っているか」だけでほぼ決まるためです。ただし k=1 でも MRR@10 は BM25 単独(0.636)に届きません。この組み合わせで効くのは k ではなく重みでした。

RRFが順位を下げる場面(片方の検索が弱いときの実測)

この節では、RRF が BM25 単独より悪くなる仕組みと、それが「2つの検索の強さの差」で決まることが分かります。

0.636BM25 単独の MRR@10Sudachi A 単位・750問
0.533RRF(k=60・重みなし)LSA 512次元と融合
58位BM25 で1位の正解が落ちた順位core.longpaths とは

「core.longpaths とは」の正解は BM25 で1位、LSA では上位100件の圏外でした。RRF の点は 1/61=0.01639 だけです。一方、BM25 で2位・LSA で2位の文書は 1/62+1/62=0.03226 になります。正解より上に57件が並び、その多くは両方のリストにそこそこの順位で入った文書でした。片方のリストだけに入った文書の点は最大でも 1/61 なので、両方に入った文書にはまず勝てません。k=60 では、片方のリストで1位でも、両方のリストで40位なら負けます(1/61=0.0164 に対して 2/100=0.0200)。

BM25 と比べて、RRF(k=60)で正解の順位が上がった質問・下がった質問の数です。

質問の種類 RRF で上がった RRF で下がった 線形結合 a=0.3 で上がった/下がった
見出し(400)7014264 / 74
エラー文(150)3291 / 8
型番・識別子(150)5666 / 22
言い換え(50)101211 / 9

2つの検索の強さの差で結果が変わる

ベクトル側の次元を変えて強さを動かし、同じ BM25 と RRF でまとめた結果です。BM25 単独は全体で 0.892 / 0.636 です。

ベクトル側 単独 R@10 / MRR@10 RRF k=60 RRF k=60・ベクトル重み0.3
LSA 32次元0.380 / 0.1710.677 / 0.3680.844 / 0.473
LSA 128次元0.647 / 0.3150.786 / 0.4580.867 / 0.536
LSA 512次元0.784 / 0.4330.856 / 0.5330.890 / 0.591
LSA 768次元0.803 / 0.4620.864 / 0.5600.887 / 0.603

ベクトル側が強くなるほど、RRF の結果は BM25 単独に近づきます。逆向きの例として、BM25 を弱い文字 bigram 版(0.777 / 0.516)に替えて LSA 512次元(0.784 / 0.433)と RRF でまとめると、0.850 / 0.548 になり、どちらの単独よりも上がりました。2つの強さが近いときは RRF が効き、差が大きいときは強い方の足を引っ張る、というのが手元の結果です。原論文の「RRF は個々の最良のシステムを上回ることが多い」という報告も、性能の近い複数のシステムを融合した実験です。

融合の方式の決め方

やってはいけない単独の精度を測らずに、製品の既定の RRF(重みなし)で入れて、全体の数字だけを見る
→
代わりにBM25 単独・ベクトル単独・融合を質問の種類ごとに並べ、弱い方の重みを下げるか、弱い方を先に直す

手元では、重みなしの RRF で識別子の質問の MRR@10 が0.810から0.565に下がった

重み付きRRFと線形結合(min-max・z-score)の比較

この節では、RRF に重みを付けた場合と、スコアを正規化して足す線形結合の精度を同じ表で比べ、重みの決め方が分かります。

線形結合は、各検索のスコアを同じ範囲にそろえてから、重み a(ベクトル側)と 1−a(BM25 側)を掛けて足す方法です。そろえ方は、候補の中の最小を0・最大を1にする min-max と、平均0・標準偏差1にする z-score が代表です。片方のリストにしか無い文書は、もう片方の点を0として足しました。

def linear(cands, alpha, norm="minmax"):
    """cands: [(BM25のID列, スコア列), (ベクトルのID列, スコア列)]、alpha: ベクトル側の重み"""
    out = {}
    for i, (ids, s) in enumerate(cands):
        s = np.asarray(s, dtype=float)
        if norm == "minmax":
            z = (s - s.min()) / ((s.max() - s.min()) or 1)
        else:                                            # z-score
            z = (s - s.mean()) / (s.std() or 1)
        w = (1 - alpha) if i == 0 else alpha
        for d, v in zip(ids, z):
            out[d] = out.get(d, 0.0) + w * v
    return sorted(out, key=lambda d: (-out[d], d))
方式 全体 R@10 / MRR@10 識別子 言い換え
BM25 単独0.892 / 0.6360.949 / 0.8100.700 / 0.334
RRF k=60(重みなし)0.856 / 0.5330.786 / 0.5650.640 / 0.351
RRF k=60・ベクトル重み0.30.890 / 0.5910.911 / 0.6690.700 / 0.361
RRF k=60・ベクトル重み0.50.876 / 0.5670.834 / 0.6100.720 / 0.366
RRF k=60・ベクトル重み2.00.827 / 0.4810.717 / 0.4820.600 / 0.319
線形 min-max a=0.10.900 / 0.6340.949 / 0.8000.720 / 0.354
線形 min-max a=0.30.907 / 0.6190.939 / 0.7820.760 / 0.366
線形 min-max a=0.50.898 / 0.5870.909 / 0.6890.660 / 0.339
線形 min-max a=0.70.852 / 0.5290.781 / 0.5710.600 / 0.346
線形 z-score a=0.30.909 / 0.6230.944 / 0.7860.760 / 0.370
正規化なしの和 a=0.50.896 / 0.6340.949 / 0.8080.700 / 0.348

読み取れることは4つです。

  • 上位10件の再現率(R@10)では、線形結合 a=0.3 が BM25 単独を上回りました(0.892→0.907)。言い換えは0.700→0.760、見出しは0.855→0.880 です。MRR@10 は少し下がる(0.636→0.619)ので、「正解が上位10件に入るか」を重く見る RAG では線形結合が有力です。
  • 重み付き RRF でも、ベクトル側を0.3にすると R@10 は BM25 単独とほぼ同じ(0.890)まで戻りました。重みを2.0にしてベクトル側を重く見ると、すべての種類で悪くなります。
  • z-score と min-max の差は小さく、a の選び方の方がずっと効きます。
  • 正規化なしの和は BM25 単独とほぼ同じでした。BM25 の点は数〜数十、コサイン類似度は1以下なので、足してもほぼ BM25 の順位のままになります。スコアをそのまま足すと、片方を捨てているのと同じです。

重みは評価セットを分けて決める

同じ評価セットで重みを選んでその評価セットで成績を報告すると、数字がよく見えすぎます。質問を種類ごとに半分に分け、片方(調整用)で最も MRR@10 が高い設定を選び、もう片方(確認用)で測りました。

方式 調整用で選んだ設定 確認用 R@10 / MRR@10
BM25 単独―0.884 / 0.636
LSA 単独―0.769 / 0.434
RRF(k を選ぶ)k=10.885 / 0.566
線形 min-max(a を選ぶ)a=0.10.892 / 0.632

MRR@10 を基準に選ぶと、ベクトル側の重みは最小の0.1が選ばれ、確認用でも BM25 単独とほぼ同じになりました。手元のベクトル側が弱い以上、これは妥当な結果です。何を基準に選ぶか(R@10 か MRR@10 か)で、選ばれる重みが変わる点にも注意が要ります。

質問の形で重みを振り分ける

識別子やエラー文を含む質問だけ BM25 を重く見る方法も試しました。正規表現(英数字の間に . _ - : / を含む、英字+数字、Error・failed などを含む)で判定し、当てはまればベクトル側の重みを0.1、そうでなければ0.5にした RRF です。判定は、識別子150問中149問・エラー文150問中131問を拾い、見出し400問中38問・言い換え50問中2問を誤って拾いました。結果は全体で 0.903 / 0.597 で、重みなしの RRF(0.856 / 0.533)より良く、識別子の MRR@10 は0.565から0.727に戻りました。判定を誤ると逆効果になるので、本番の質問ログで判定の当たり外れを数えてから入れる方法です。

上位10件の外まで見る(Recall@50・100とリランクの前段)

この節では、ハイブリッド検索が「上位を並べ替える」のではなく「候補を広く集める」役割で効くことが、Recall@k の実測で分かります。

方式 Recall@1 / @5 Recall@10 / @20 Recall@50 / @100
BM25 単独0.472 / 0.7960.892 / 0.9370.956 / 0.964
LSA 単独0.278 / 0.5860.784 / 0.8910.948 / 0.971
RRF k=600.356 / 0.7060.856 / 0.9250.954 / 0.988
RRF k=60・ベクトル重み0.30.417 / 0.7620.890 / 0.9380.959 / 0.964
線形 min-max a=0.30.449 / 0.7840.907 / 0.9430.975 / 0.988

上位100件で見ると、BM25 単独は0.964、LSA 単独は0.971ですが、RRF と線形結合はどちらも0.988でした。正解の取りこぼしは、BM25 単独の3.6%から1.2%へ3分の1になっています。上位1件の精度では BM25 単独に負けている RRF でも、候補を集める段階としては両方の単独より優れています。

このため、ハイブリッド検索の後ろにリランカー(質問と文書の組を1つずつ採点し直すモデル。クロスエンコーダーなど)を置く構成がよく使われます。融合で上位50〜100件の候補を広く集め、リランカーで上位10件を決め直す形です。手元ではリランカーのモデルを動かせないため、その精度は測っていません。リランカーを置くなら、融合の方式は Recall@50〜100 で選び、置かないなら Recall@10・MRR@10 で選ぶ、と分けて考えると判断がぶれません。

融合の処理そのものは軽く、Python で上位100件ずつの RRF は1問0.05msでした。かかるのは2つの検索を2回走らせる分の時間と、リランカーを置く場合はその時間です。

PostgreSQLで1つのSQLにする(BM25・pgvector・RRF)

この節では、PostgreSQL の中だけでハイブリッド検索を完結させる SQL と、手元で測った精度・応答時間・落とし穴が分かります。

PostgreSQL の標準の全文検索(tsvector と ts_rank・ts_rank_cd)は BM25 ではありません。公式ドキュメントは、ランキング関数が「global information(コーパス全体の情報)を使わない」と書いています。つまり IDF が無く、珍しい語に重みが付きません。日本語の分かち書きの機能もありません。そこで、Python で Sudachi の分かち書きをして、BM25 は「語ごとに文書IDと出現回数の配列を持つ転置インデックス」の表から SQL で計算し、ベクトル側は pgvector の HNSW インデックスで引きました。

1

クラスタを作業場所に作る

initdb は root では動かない(initdb: error: cannot be run as root)ので、postgres ユーザーで作業場所に initdb し、listen_addresses を 127.0.0.1 にして起動した。

PostgreSQL 16.15

2

表を3つ作る

term(語・df・idf・文書IDの配列・出現回数の配列)、sec(節ID・文書長・tsvector・vector(512))、bm25_meta(文書数・平均文書長)。

33,220語・3,917節

3

インデックスを作る

emb に HNSW(vector_cosine_ops)を作成。3,917件・512次元で2.5秒、10MB。term は主キーで引く。

pgvector 0.6.0

4

SQL 関数を1本作る

BM25 の上位100件と、ベクトルの上位100件を CTE で作り、FULL OUTER JOIN して RRF を計算する。

hybrid_search()

CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE term (term text PRIMARY KEY, df int, idf float8, secs int[], tfs int[]);
CREATE TABLE sec  (id int PRIMARY KEY, art text, head text, dl int, tsv tsvector, emb vector(512));
CREATE TABLE bm25_meta AS SELECT count(*)::int AS n, avg(dl)::float8 AS avgdl FROM sec;
CREATE INDEX sec_emb_hnsw ON sec USING hnsw (emb vector_cosine_ops);
CREATE OR REPLACE FUNCTION hybrid_search(q_tokens text[], q_emb vector, rrf_k int DEFAULT 60)
RETURNS TABLE (id int, rrf numeric, bm25_rank bigint, vec_rank bigint)
LANGUAGE sql STABLE AS $f$
WITH q AS (
  SELECT DISTINCT unnest(q_tokens) AS t
),
bm25 AS (                       -- BM25(k1=1.2, b=0.75)を転置インデックスの配列から計算
  SELECT p.sec,
         sum(tm.idf * p.tf * (1.2 + 1)
             / (p.tf + 1.2 * (1 - 0.75 + 0.75 * s.dl / m.avgdl))) AS score
  FROM q
  JOIN term tm ON tm.term = q.t
  CROSS JOIN LATERAL unnest(tm.secs, tm.tfs) AS p(sec, tf)
  JOIN sec s ON s.id = p.sec
  CROSS JOIN bm25_meta m
  GROUP BY p.sec
  ORDER BY score DESC
  LIMIT 100
),
bm25_r AS (
  SELECT sec, row_number() OVER (ORDER BY score DESC) AS r FROM bm25
),
vec AS (                        -- HNSW で近い順に100件
  SELECT id AS sec, emb <=> q_emb AS dist
  FROM sec
  ORDER BY emb <=> q_emb
  LIMIT 100
),
vec_r AS (
  SELECT sec, row_number() OVER (ORDER BY dist) AS r FROM vec
)
SELECT coalesce(b.sec, v.sec) AS id,
       coalesce(1.0 / (rrf_k + b.r), 0) + coalesce(1.0 / (rrf_k + v.r), 0) AS rrf,
       b.r AS bm25_rank,
       v.r AS vec_rank
FROM bm25_r b
FULL OUTER JOIN vec_r v ON b.sec = v.sec
ORDER BY rrf DESC, id
LIMIT 10;
$f$;

-- 呼び出し側:分かち書きした語の配列と、質問のベクトルを渡す
SET hnsw.ef_search = 100;
SELECT * FROM hybrid_search(ARRAY['ダイアローグ','開く','後ろ','画面','スクロール'], '[0.0123, ...]'::vector);

ベクトル側だけを重くしたいときは、最後の SELECT の 1.0 を重みに変えます。750問をこの関数で引いた結果です(psql で1問ずつ実行し、\timing の値を集計)。

ベクトル側の引き方 R@10 / MRR@10 Python 版と上位10件が完全一致 応答時間 中央値 / 95%点
HNSW・ef_search=40(既定)0.832 / 0.520460 / 7505.9ms / 15.6ms
HNSW・ef_search=1000.850 / 0.531577 / 7506.3ms / 14.7ms
HNSW・ef_search=2000.855 / 0.533606 / 7508.8ms / 28.2ms
インデックス無し(全件の厳密な距離)0.857 / 0.533643 / 75020.0ms / 35.8ms

インデックス無しの厳密な計算では、指標は Python 版(R@10 0.856・MRR@10 0.533)と同じになりました。上位10件が完全一致しない107問は、RRF の点が同点の文書の並び順の違いです(上位11件に同点を含む質問は196問ありました)。同点の並べ方を ORDER BY rrf DESC, id のように決めておかないと、実行のたびに順位が変わることがあります。

hnsw.ef_search が LIMIT より小さいと件数が減る

pgvector の公式ドキュメントは「結果は動的候補リストの大きさ(hnsw.ef_search、既定40)で制限される」と書いています。手元でも、ef_search が既定の40のまま LIMIT 100 で引くと、40件しか返りませんでした。

   setting    | count
--------------+-------
 ef_search=40 |    40
(1 row)

SET
   ?column?    | count
---------------+-------
 ef_search=100 |   100
(1 row)

RRF の候補が40件に減るので、上の表のとおり R@10 が0.850から0.832に下がります。エラーにならず件数が減るだけなので気付きにくい落とし穴です。pgvector 0.8.0 以降には、足りないときに探し続ける反復スキャン(hnsw.iterative_scan)があると公式ドキュメントにありますが、手元の 0.6.0 には無いため試していません。

標準の全文検索(ts_rank_cd)と比べた結果

同じ分かち書きを to_tsvector('simple', …) に入れ、ts_rank_cd で並べた結果です。

キーワード側 R@10 / MRR@10 0件になった質問
BM25(SQL で計算)0.892 / 0.6360 / 750
ts_rank_cd・語を OR でつなぐ0.562 / 0.2890 / 750
ts_rank_cd・plainto_tsquery(AND)0.605 / 0.520162 / 750

plainto_tsquery は公式ドキュメントのとおり語を &(AND)でつなぐため、語の多い質問ほど0件になりやすく、750問中162問で何も返りませんでした。OR でつなぐと0件は無くなりますが、IDF が無いので「する」「方法」のようなよく出る語の一致にも同じだけ点が付き、BM25 よりはるかに低くなります。PostgreSQL で RAG のキーワード側を作るなら、ts_rank 系をそのまま BM25 の代わりにしないのが手元の結論です。BM25 を提供する拡張もありますが、この記事では試していません。日本語の全文検索の拡張(pg_trgm・pg_bigm)の比較は、関連記事「PostgreSQL日本語全文検索|pg_trgmとpg_bigmの速度比較と選び方」で扱っています。pgvector の導入とインデックスの基本は関連記事「pgvectorの使い方|PostgreSQLでベクトル検索をコピペで構築」で扱っています。

表の大きさは、term が8,656kB、sec が37MB(tsvector と512次元のベクトルを含む)、GIN インデックスが3,856kB、HNSW インデックスが10MB でした。

製品ごとのハイブリッド検索とRRFの既定値

この節では、主な検索エンジン・ベクトルデータベースで RRF の k や重みがどう決まっているかが、公式ドキュメントで確かめた範囲で分かります(2026年11月4日時点)。

製品 RRF の k の既定 重み 公式ドキュメントの記述
Elasticsearch(rrf retriever)rank_constant=60(1以上)子 retriever ごとに weight(既定1.0)rank_window_size の既定は10
Azure AI SearchRRF の定数 k(例として60)ベクトルクエリの weight(既定1.0)近傍数の k とは別物。既定で上位50件を返す
Qdrant(Query API)k=2(v1.16.0 から変更可)v1.17.0 から重み付き RRF順位を0から数える。DBSF(分布で正規化)もある
Weaviate―(既定は Relative Score Fusion)alpha(1でベクトルのみ、0でキーワードのみ)v1.24 から Relative Score Fusion が既定。alpha を省くとクライアント次第
Milvus(RRFRanker)k=60(0〜16384)別に WeightedRanker がある1/(k+rank) を検索経路ごとに足す
MongoDB($rankFusion)60(MongoDB が決めた値)combination.weights(既定1)MongoDB 8.0 以上
pgvector―(SQL で自分で書く)―ハイブリッド検索には RRF かクロスエンコーダーで結果を合わせられる、と README にある

同じ「RRF」でも、Qdrant は k=2・順位を0から数える、Elasticsearch は rank_window_size が既定10、Weaviate はそもそも既定が RRF ではない、など前提が違います。製品を移すと、同じ設定名のまま結果が変わります。手元の表で k=1 と k=60 の差が全体の MRR@10 で0.03あったように、k の違いは数字に出ます。移行や版上げのたびに評価セットで測り直すのが安全です。

よくあるエラー・つまずき(原文つき)

この節では、手元でハイブリッド検索を組んだときに実際に出たエラー・警告と、気付きにくい結果の崩れが、原因と直し方つきで分かります。

症状・原文 原因 直し方
SudachiError "Error during tokenization": Input is too long, it can't be more than 49149 bytes, was 150000Sudachi に1回で渡せる長さを超えた文(。や改行)で区切り、数千文字ずつ渡す
DeprecationWarning: Dictionary.create() is deprecated, use Dictionary.tokenizer() insteadSudachiPy 0.7.0 で create() が非推奨Dictionary(dict="core").tokenizer() に書き換える
plainto_tsquery で0件(750問中162問)plainto_tsquery は語を AND でつなぐOR でつなぐか、BM25 を SQL で計算する
'ダイアログ' & … で count が0索引は Sudachi の正規化形(ダイアローグ)で、質問は表層形のまま索引と質問を同じ関数で分かち書きする
LIMIT 100 なのに40件しか返らないhnsw.ef_search の既定が40SET hnsw.ef_search を LIMIT 以上にする
ERROR: different vector dimensions 3 and 512質問のベクトルと列の次元が違う同じモデル・同じ次元で埋め込む
ERROR: operator does not exist: vector <=> double precision[]float8[] のまま渡した'[…]'::vector に型変換する
実行するたびに RRF の順位が入れ替わるRRF の点の同点(196問で発生)ORDER BY に ID などの決まった並びを足す
重みを付けたのにほぼ BM25 の順位のままBM25 の点とコサイン類似度を正規化せずに足したmin-max か z-score でそろえてから足す

ハイブリッド検索の判断表(RAGの検索精度を上げる順番)

この節では、ハイブリッド検索を入れるか、どの融合方式にするかを、手元の結果と公式ドキュメントをもとに判断できます。

状況 まずやること 手元の根拠
型番・エラー文・設定名で検索されることが多いBM25 を入れる。日本語は形態素解析で分かち書き識別子の MRR@10 が BM25 0.810、LSA 0.385
言い換え・症状の説明で検索されることが多いベクトル検索を入れ、BM25 と融合する言い換えの R@10 が線形結合で0.700→0.760
2つの単独の精度が近い重みなしの RRF(k=60)から始めるbigram BM25+LSA で、RRF が両方の単独を上回った
片方が明らかに弱い弱い方の重みを0.1〜0.3に下げるか、弱い方を先に直す重みなし RRF で MRR@10 0.636→0.533、重み0.3で0.591
後ろにリランカーを置く融合で上位50〜100件を集め、Recall@50〜100 で方式を選ぶRecall@100 が単独0.964・0.971、融合0.988
評価セットで重みを決められる正規化つきの線形結合を、調整用と確認用に分けて決める確認用で選んだ設定の差を別に測った
PostgreSQL だけで組みたいBM25 を SQL で計算し、pgvector と RRF を1つの関数に1問 約6ms(ef_search=100)

入れる前に確かめておきたいことです。

RAG 全体の作り方(チャンク・プロンプト・評価)は関連記事「RAGの作り方|Pythonで動かす最小構成と精度の上げ方」で扱っています。

よくある質問

この節では、ハイブリッド検索と RRF についてよく聞かれる疑問への答えが分かります。

Q. ハイブリッド検索と RRF は同じものですか。

違います。ハイブリッド検索は複数の検索を組み合わせる構成の名前で、RRF はその結果をまとめる方法の1つです。線形結合や、Qdrant の DBSF のような別のまとめ方もあります。

Q. RRF の k=60 はどこから来た値ですか。

Cormack らの SIGIR 2009 の論文で、予備実験で決めて固定した値です。論文は、最適に近いが厳密な値は重要ではなかったと書いています。Elasticsearch・Milvus・MongoDB の既定も60ですが、Qdrant の既定は2です。

Q. BM25 とベクトル検索の重みはどう決めますか。

評価セットを調整用と確認用に分け、調整用でベクトル側の重みを0.1刻みで変えて選び、確認用で測ります。手元のようにベクトル側が弱いと、小さい重み(0.1〜0.3)が選ばれます。本物の埋め込みモデルなら、もっと大きな重みが選ばれることが多いはずです。

Q. ハイブリッド検索を入れると必ず精度が上がりますか。

上がりません。手元では、重みなしの RRF で MRR@10 が BM25 単独の0.636から0.533に下がりました。上がりやすいのは、2つの検索の強さが近いときと、Recall@50〜100(リランカーに渡す候補の再現率)を見るときです。

Q. ベクトル検索だけで、型番やエラーコードを拾えないのはなぜですか。

埋め込みは意味の近さを表すため、1文字違いの型番や、語として珍しいだけの識別子は区別しにくくなります。手元の LSA でも、識別子の MRR@10 は0.385で BM25 の0.810の半分以下でした。

Q. PostgreSQL の ts_rank を BM25 の代わりに使えますか。

公式ドキュメントのとおり、ts_rank・ts_rank_cd はコーパス全体の情報(IDF)を使いません。手元では OR 検索で MRR@10 0.289、BM25 は0.636 でした。BM25 が要るなら、この記事のように SQL で計算するか、BM25 を提供する拡張を検討します。

Q. 埋め込み(エンベディング)の仕組みから知りたいときは。

ベクトルとコサイン類似度の計算は関連記事「エンベディングとは?ベクトル検索とコサイン類似度を計算例で解説」で扱っています。

Q. RAG の回答全体の良し悪しはどう測りますか。

この記事は検索部分だけを測りました。回答の忠実さなどを含めた評価の枠組みは関連記事「RagasでRAGを評価する|指標の意味とPythonコード」で扱っています。

まとめ

この節では、記事全体の要点が分かります。

  1. ハイブリッド検索は BM25 とベクトル検索の結果をまとめる構成で、RRF はまとめ方の1つ。BM25 は型番・エラー文に、ベクトル検索は言い換えに強く、上位10件の重なりは平均6.1件だった。
  2. 日本語の BM25 は分かち書きで決まる。Sudachi の A 単位で MRR@10 0.636、文字 bigram で0.516。k1・b の調整より差が大きい。
  3. RRF は片方が弱いと強い方の足を引っ張る。BM25 で1位の正解が58位まで落ちる例があり、重みなしの RRF で MRR@10 は0.533に下がった。2つの強さが近いときは両方を上回った。
  4. 効くのは k より重み。ベクトル側を0.3にした RRF や、正規化つきの線形結合(a=0.3で R@10 0.907)が手元では良く、重みは調整用と確認用に分けて決める。
  5. 候補集めとしては融合が強い。Recall@100 は単独0.964・0.971に対して融合0.988。PostgreSQL では BM25・pgvector・RRF を1つの関数にでき、hnsw.ef_search を LIMIT 以上にする。

参考・出典

この節では、この記事で根拠にした論文・公式ドキュメントが分かります(確認日 2026年11月4日)。

  • G. V. Cormack, C. L. A. Clarke, S. Büttcher「Reciprocal Rank Fusion outperforms Condorcet and individual Rank Learning Methods」SIGIR 2009:https://cormack.uwaterloo.ca/cormacksigir09-rrf.pdf
  • Apache Lucene「BM25Similarity」:https://lucene.apache.org/core/9_12_0/core/org/apache/lucene/search/similarities/BM25Similarity.html
  • PostgreSQL 16 文書「Controlling Text Search」(ts_rank・ts_rank_cd・plainto_tsquery):https://www.postgresql.org/docs/16/textsearch-controls.html
  • pgvector README(hnsw.ef_search・iterative scan・Hybrid Search):https://github.com/pgvector/pgvector
  • Sudachi(sudachi.rs)README(分割単位 A・B・C、正規化形、辞書の種類):https://github.com/WorksApplications/sudachi.rs
  • Elasticsearch「RRF retriever」:https://www.elastic.co/docs/reference/elasticsearch/rest-apis/retrievers/rrf-retriever
  • Microsoft Learn「ハイブリッド検索のスコアリング (RRF) - Azure AI Search」:https://learn.microsoft.com/ja-jp/azure/search/hybrid-search-ranking
  • Qdrant「Hybrid and Multi-Stage Queries」:https://qdrant.tech/documentation/concepts/hybrid-queries/
  • Weaviate「Hybrid search」:https://docs.weaviate.io/weaviate/search/hybrid
  • Milvus「RRF Ranker」:https://milvus.io/docs/rrf-ranker.md
  • MongoDB「$rankFusion (aggregation stage)」:https://www.mongodb.com/docs/manual/reference/operator/aggregation/rankfusion/

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「ハイブリッド検索とRRF|BM25+ベクトルを750問で実測・SQL実装」

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

成長のヒミツ、 ぜんぶ丸わかり