📗

DuckDB をつかってローカルなRAGを実装する

に公開

この記事は Ubie Advent Calendar 2025 17日目の記事です。

最近Googleが File Search API を公開しました。使い方はとても簡単で、ファイルをアップロードするだけでGeminiから参照してRAGを実現できるようになります。これまでRAGは避けては通れないけど考えることが多くて面倒だったところが、一気に解消しそうです。ただ、自分としてはRAGにあまり手を出してこなかったのでRAGの構築がどれくらい泥臭いのかを知らないなと思いました。そこで、これを機にローカルで動くRAG環境を基礎的な部分から構築してみたので、そこで得た気づきなどを共有しようと思います。

作ってみたもの

https://github.com/ohtaman/neith

実装した主な機能は以下の通りです。RAGというと何らかの vector store や全文検索エンジンを利用するのが正攻法だと思いますが、今回は分析への利用のしやすさやその拡張性、お手軽さから DuckDB という組み込み型のDBを利用してみました(面白いものの DuckDB の想定した使い方ではないので、実用上課題があるかもしれません)。

  • ローカルディレクトリ監視と自動インデックス構築
  • DuckDBを利用したベクトル検索とキーワード検索のハイブリッド検索
  • ローカルで動くモデルのみを利用
  • AST を利用したMarkdownの構造抽出
  • Fast API とシンプルな検索 UI
  • MCP (STDIO) サーバー

検索 UI
今回作ったRAGの簡易的な検索 UI

DuckDBによるハイブリッド検索アーキテクチャ

DuckDB は組み込み型の列指向データベースです。「SQLiteのデータ分析版」といったイメージを持つと分かりやすいと思います。1つのファイルで完結するだけでなく、Jupyter Notebookのような便利なコンソール(UI)も提供され、さらにCSVやGoogle SpreadSheetなどの外部データにもSQLで直接アクセスできるなど、面白い機能を備えています。

日本語全文検索

以下の記事でDuckDBを使った日本語全文検索について解説されていたので、参考にさせていただきました。

https://voluntas.ghost.io/duckdb-japanese-full-text-search/

duckdb-fts拡張を用いると、DuckDBで全文検索ができます。この拡張自体は日本語をサポートしていませんが、内部的に空白で単語分割を行ってインデックスを構築しているため、分かち書きしたものを保存しておき、その列に対して全文検索用のインデックスを貼ります。

# Create embedding chunks table with deterministic keys
conn.execute("""
    CREATE TABLE IF NOT EXISTS embedding_chunks (
        chunk_id VARCHAR(16) PRIMARY KEY,
        ...
        text TEXT NOT NULL,
        tokenized_text TEXT NOT NULL,
        ...
    )
""")
conn.execute("""
    PRAGMA create_fts_index('embedding_chunks', 'chunk_id', 'tokenized_text', overwrite=1)
""")

上記の記事では、Linderaという形態素解析ライブラリを利用していましたが、英単語の分割がうまくいかなかったため、今回はSudachPyを利用しました。

検索する際は match_bm25 メソッドを使ってBM25を計算し、上位の物だけを抽出します。

results = conn.execute("""
    SELECT
        chunk_id,
        ...
        text, 
        tokenized_text,
        ...
        fts_main_embedding_chunks.match_bm25(chunk_id, ?) as score
    FROM embedding_chunks
    WHERE fts_main_embedding_chunks.match_bm25(chunk_id, ?) IS NOT NULL
    ORDER BY score DESC
    LIMIT ?
""", (tokenized_query, tokenized_query, limit)).fetchall()

ベクトル検索

ベクトル検索は以下の記事を参考にさせていただきました。

https://zenn.dev/tfutada/articles/e8306122f674b0

知らなかったのですが、非常に高速に実用的な精度の埋め込みが得られるStaticEmbeddingという手法があるそうです。StaticEmbeddingはトークンベースの埋め込みの平均をとるだけですが、対照学習を利用することでWord2Vecのような既存の手法よりも高精度となるようです。
DuckDBでは VSS (Vector Similarity Search) 拡張 を利用することで高速なベクトル検索が可能になります。

VSSはデフォルトではインメモリデータベースにしか対応していないので、以下のようにして明示的にHNSWインデックスの作成を許可する必要があります。

# Install and load VSS extension for indexing
conn.execute("INSTALL vss")
conn.execute("LOAD vss")

# Enable experimental persistence for HNSW indexes
conn.execute("SET hnsw_enable_experimental_persistence = true")

# Create HNSW index on embeddings for fast similarity search
# Using cosine distance (1 - cosine_similarity) for semantic similarity
conn.execute("""
    CREATE INDEX IF NOT EXISTS embedding_chunks_hnsw_index 
    ON embedding_chunks 
    USING HNSW (embedding) 
    WITH (metric = 'cosine')
""")

検索時には以下のように array_cosine_similarity を使うことで高速な検索が行えます。

results = conn.execute("""
    SELECT chunk_id, document_id, workspace_name, relative_path, block_id, text, tokenized_text, 
            last_indexed_at,
            array_cosine_similarity(embedding::FLOAT[1024], ?::FLOAT[1024]) as similarity_score
    FROM embedding_chunks 
    WHERE embedding IS NOT NULL
    ORDER BY similarity_score DESC
    LIMIT ?
""", (query_embedding_float, limit)).fetchall()

実装してみて気づいたことや工夫したこと

日本語特有の処理の必要性

昔と違って、自然言語処理で日本語特有の処理をすることは激減しました。しかし、キーワード検索や小さいモデルを使った埋め込みの生成が必要な場合には、まだまだ日本語特有の考慮が必要です。

  1. まず、上述の通り日本語は英語と違って単語がスペースで区切られていないので、キーワード検索には単語分割が必要になります。単語分割の際には、活用形をどうすべきか考える必要があります。また、ノイズとなりうる品詞の除外などの工夫の余地があります。今回は表層系ではなく基本形を利用し、瀕死による除外はしていません。
  2. ベクトル検索についても、ローカルで動かす場合は軽量なモデルを使うことになるため、日本語に強いモデルを選択する必要があります。今回は上述のとおり hotchpotch/static-embedding-japanese を用いました。非常に高速に計算できる上に、検索結果をみると確かにいい感じの埋め込みになっていて、素晴らしいです。

https://secon.dev/entry/2025/01/21/060000-static-embedding-japanese/

ファイル形式変換の沼

さまざまなファイル形式に対応させたかったので、MicrosoftのMarkItDownというライブラリを利用して一度 Markdown に変換してから埋め込みを計算するようにしました。ただ、PDFは文書構造を保持していないので、単純に変換すると、Markdownではなく単なるテキストの羅列となってしまします。

以下の例は、arXivの論文を Markdown に変換した例です。章タイトルがヘッダー行として認識されず、章番号とも分離してしまっています。これで十分なケースも多いと思いますが、今回は後述のドキュメント割で文書構造を利用する際に問題になりました。また、論文のように整っていないPDFの場合はさらにひどい結果となります。

...
1

INTRODUCTION

State-of-the-art LLMs continue to struggle with factual errors (Mallen et al., 2023; Min et al., 2023)
despite their increased model and data scale (Ouyang et al., 2022). Retrieval-Augmented Generation
(RAG) methods (Figure 1 left; Lewis et al. 2020; Guu et al. 2020) augment the input of LLMs
with relevant retrieved passages, reducing factual errors in knowledge-intensive tasks (Ram et al.,
2023; Asai et al., 2023a). However, these methods may hinder the versatility of LLMs or introduce
unnecessary or off-topic passages that lead to low-quality generations (Shi et al., 2023) since they
retrieve passages indiscriminately regardless of whether the factual grounding is helpful. Moreover,
...

出力例(https://arxiv.org/pdf/2310.11511 の変換の抜粋)

今回は試していませんが、LLMを使うことでPDFから綺麗に抽出できるライブラリがいくつかあります。ローカルにこだわる必要がなければそういったライブラリの利用も検討すると良さそうです。

チャンキングと文脈拡張

ベクトル検索では、ドキュメントを検索用のチャンクに分割して、埋め込みベクトルを計算します。埋め込みベクトルは文章の意味をたった1つのベクトルで表す物なので、1つのチャンクに複数の話題が含まれてしまうと、検索精度が下がってしまいます。逆に、ある程度小さなチャンクとすることで精度の高い検索が可能となります。
しかし、RAGで欲しいのは文脈も含めた情報です。チャンクが小さすぎると周辺の文脈が欠落してしまい、得たいものが得られません。そこで、チャンクだけではなくチャンク周辺の情報も合わせて返してあげる方法が考えられます。これをSmall-to-Large 戦略と呼ぶようです。

具体的には以下のような流れになります。

  1. 2種類のチャンクを作成: ドキュメントを、検索に適した「小さなチャンク」と、LLMに渡して回答を生成させるための「大きなチャンク」の2種類に分割
  2. 検索: ユーザーのクエリに対して小さなチャンクで検索を行い、関連する箇所を特定
  3. 拡張: 検索で見つかった小さなチャンクに対応する大きなチャンクを取得して検索結果として返す

これにより、検索の精度を保ちながら文脈の欠落を防げます。

Structure based Splitting

今回は、Markdownの構造を利用してヘッダ要素(h1h2)分割するようにしました。LangChainを使う場合はMarkdownHeaderTextSplitterを使えば良いですが、泥臭さを知りたかったのでmarkdown-it-py(MarkItDownではありません。MarkdownItです)を利用して、Markdownファイルを AST(抽象構文木) に変換してヘッダ要素(h1h2)を元に分割するようにしてみました。分割されたものをさらに単語数ベースでチャンキングしています。これにより、上で紹介したUIの画像のように、検索語と関連のある章のみを綺麗に抽出することができました。

tokens = MarkdownIt().parse(content)
...
# Add markdown headings
for i, token in enumerate(tokens):
    if token.type == 'heading_open' and token.tag in ['h1', 'h2']:
        line_num = token.map[0] if token.map else 0
        heading_text = self._extract_heading_text(tokens, i)
        breakpoints.append({'line': line_num, 'text': heading_text})

ここで引っかかったのが上述のPDFの変換問題です。MarkItDownでは章タイトルを正しくヘッダ要素に変換してくれないので、Markdownの構造に頼った分割はできません。そこで今回はルールベースでそれっぽく分割するようにしてみました。大まかには分割できるものの、誤判断もたくさんあります。実用的にするには、もっと手の込んだことをするか諦めるか、もしくは前述のようにPDFをもっと綺麗に変換してくれるライブラリを使うと良いはずです。


ISSUP は章タイトルではない

メタデータの付与

チャンキングの実験を通して思ったのは、検索結果の周辺だけを渡しても文脈不足ではないかということです。実際に自分が文献調査する時は、たとえ気になる箇所があったとしても、タイトルやアブストラクトを読まないと理解できません。今回作ったUIでいくつかの単語で検索した結果、やはりそういったメタ情報が重要そうだと感じました。リッチなメタ情報を生成するにはLLMが必要なので(あくまでローカルで構築できることを前提とした)今回は深掘りしませんでしたが、重要なアプローチだと思います。

ハイブリッド検索のマージ戦略

RAGといえばベクトル検索のイメージが強いですが、実際には全文検索と組み合わせた方が精度が上がると言われています。ベクトル検索が文脈や意味の類似性を捉えるのが得意な一方で、固有名詞や専門用語の抽出には全文検索の方が適しているためです。今回もベクトル検索と全文検索の両方を実装しましたが、どうやって組み合わせるか?という問題にぶつかりました。標準的なマージ方法として、RRF(Reciprocal Rank Fusion)が挙げられます。

RRFでは、文書(チャンク)dの複数の検索結果リストiの順位\mathrm{rank}_i(d)をもとに以下の計算式で順位を決めます。

\mathrm{RRF}(d) =\sum_{i}{\frac{1}{k+\mathrm{rank}_i(d)}}

ここで k はハイパーパラメーターで、60くらいが使われることが多いようです。RRFの良さは、順位しか使っていない点だと思いました。全文検索のBM25とベクトル検索のコサイン類似度のように、それぞれのスコアを比較できない場合でも利用可能です。

さらに今回は、検索手法の重要度を調整できるよう重み付きRRFを実装しました:

\mathrm{RRF}(d) = \alpha \times \frac{1}{k + \mathrm{rank}_{\mathrm{vector}}(d)} + (1-\alpha) \times \frac{1}{k + \mathrm{rank}_{\mathrm{fts}}(d)}

ここで \alpha は重みパラメーターです。\alpha が大きければベクトル検索を重視し、小さければ全文検索を重視します。今回は検索結果をみて\alpha=0.75にしてみました。

DuckDB のファイルサイズ

今回実験で10ファイル程度を登録してみたところ、データベースのサイズが111M程度になってしまいました。動くので良いといえば良いのですが、元ファイルサイズを合計しても全然小さいので、改善ポイントがありそうです。

まとめ

今回、DuckDBを使って一からRAG環境を構築してみました。思っていた通り考慮すべき点が多く、奥の深い分野だと実感しました。
そして、やっぱり手を動かしてみるのが自分にはあっているなぁと感じました。

Discussion