Ubuntu上のOllama＋Open WebUIで構築したローカルLLMでRAG（後編）

## はじめに

[前編](https://qiita.com/minnsou/items/0ae3006d398f3d4316fc)では環境構築から始め、ファイルを添付してRAGが問題なくできるところまでを確認しました。
今回はOpen WebUIでのRAGがどのように実行されているのか、コードを実際に見ていきます。

確認したバージョン
・Open WebUI Ver. 0.6.2
・Ollama Ver. 0.6.1

![スクリーンショット 2025-04-09 10.53.55.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/395943/99a807bf-6607-4aab-978e-e4fcae221f22.png)

## ファイルアップロードのコード実装

Open WebUIには事前に貯めておきたい知識を置いておく**ナレッジベース**という機能があります。
ファイルを添付したときに**どのように処理されているのか**を確認していきます。

なお、[公式のドキュメント](https://docs.openwebui.com/features/rag#document-parsing)にはドキュメントパース部分は`get_loader`関数を見ろ、書いてあります。

![スクリーンショット 2025-03-22 9.39.56.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/395943/01245efc-5d45-4c5d-b561-1fcd7971de10.png)

ただしその[リンク](https://github.com/open-webui/open-webui/blob/2fa94956f4e500bf5c42263124c758d8613ee05e/backend/apps/rag/main.py#L328)は古くなっており、現在（v0.6.2）のコードではありません。

この部分を追うために、テストファイルを添付した際のログから確認していきます。

テスト用のナレッジベースを作り、テストファイルとしては[RFC3501のPDF版](https://www.rfc-editor.org/rfc/pdfrfc/rfc3501.txt.pdf)である`rfc3501.txt.pdf`を添付します。

![スクリーンショット 2025-03-24 21.12.18.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/395943/91816705-a4e5-4973-91a6-dd66465e4e0f.png)

アップロードの際にどのような処理が行われているのか、ログを確認します。

```
docker container logs open-webui
```

![スクリーンショット 2025-04-02 18.28.18.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/395943/945d4b1c-915e-4bcb-92fa-774fb10948ba.png)

画像の1行目に`open_webui.routers.files:upload_file`という記載があります。
これは`open_webui/routers/files.py`の`upload_file`関数が呼ばれていることを意味します。
GitHubのコードで言うと[この81行目](https://github.com/open-webui/open-webui/blob/main/backend/open_webui/routers/files.py#L81)です。

![スクリーンショット 2025-04-13 0.21.18.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/395943/9952f333-ee96-43ad-bb65-6da5e96d323c.png)

この`upload_file`関数の一番重要なファイルを処理する部分は上記のリンク部分より少し進んだ`process_file`関数（125行目）です。

![スクリーンショット 2025-04-03 21.36.50.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/395943/00c47eca-4bfd-436d-8d6c-f1e1862bb856.png)

このファイルを処理していると思われる`process_file`関数は`open_webui/routers/retrieval.py`で定義されています。
GitHubのコードは[この946行目](https://github.com/open-webui/open-webui/blob/main/backend/open_webui/routers/retrieval.py#L946)です。

![スクリーンショット 2025-04-08 18.24.55.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/395943/22456504-336f-4d8b-bfe7-9ae7dc4e6867.png)

この関数を読み進めると`loader`を読み込み（1021行目）、その`load`関数を使ってドキュメントを読み込んでいること（1030行目）がわかります。

![スクリーンショット 2025-04-08 18.27.08.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/395943/266692d4-2ee4-4f8f-833d-d38c1bcfdbc8.png)

そこでさらに`Loader`クラスを確認していきますと、`load`関数の具体的な中身が見えてきます。
`Loader`クラスを定義しているのは`open_webui/retrieval/loaders/main.py`であり、コードは[この169行目](https://github.com/open-webui/open-webui/blob/main/backend/open_webui/retrieval/loaders/main.py#L169)です。

![スクリーンショット 2025-04-08 18.29.16.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/395943/aefbe199-ea42-42da-b62d-48b7d7c68bf2.png)

`_get_loader`関数では、`self.engine`の値に分かれてその処理内容が記載されています。

![スクリーンショット 2025-04-05 0.11.15.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/395943/3088673f-8dfe-4d75-930a-386c65eef4b9.png)

デフォルトの設定だと`self.engine`には`tika`や`document_intelligence`などは入らないため、一番下の`else`に分岐します。
この`self.engine`の値は管理者パネルの「ドキュメント」の「Content Extraction Engine」に当たります。

![スクリーンショット 2025-04-08 18.30.30.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/395943/e6abb2db-74aa-4e89-b091-4c10151b3dc6.png)

「Content Extraction Engine」に「デフォルト」を指定した場合は、一番下の`else`に行くため、コードにあるように拡張子ごとに処理が分岐されています。

例えば拡張子が「.pdf」になっているPDFファイルの場合を見てみますと、「PyPDFLoader」つまり[LangChainのpyPDFLoader](https://python.langchain.com/docs/integrations/document_loaders/pypdfloader/)を使っていること（245行目）がわかります。

![スクリーンショット 2025-04-08 18.33.45.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/395943/48543c49-8b7a-4f89-8026-895eee982348.png)

公式の`get_loader`関数のリンクは古くなっていると先程述べましたが、[backend/open_webui/retriebal/loaders/main.pyの192行目](https://github.com/open-webui/open-webui/blob/main/backend/open_webui/retrieval/loaders/main.py#L192)の`_get_loader`関数が2025年4月のVer0.6.2時点では**最新の箇所**ということになります。

話を`process_file`関数に戻します。
添付したファイルをこの`loader`関数に従ってテキストにした後の処理です。
次に大事な処理として`save_docs_to_vector_db`関数があります。
GitHubのコードは[この1073行目](https://github.com/open-webui/open-webui/blob/main/backend/open_webui/routers/retrieval.py#L1073)です。

![スクリーンショット 2025-04-08 18.38.47.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/395943/bde9f211-436d-49bd-8baf-2501c9c578b8.png)

これは先ほどのログ（`docker container logs open-webui`）の2行目でも表示されていた関数になります。
（`open_webui.routers.retrieval:save_docs_to_vector_db`の部分）

この`save_docs_to_vector_db`関数が実際に定義されているのは、ログにもあるように
`open_webui/routers/retrieval.py`です。
GitHubのコードだと[この785行目](https://github.com/open-webui/open-webui/blob/main/backend/open_webui/routers/retrieval.py#L785)になります。

![スクリーンショット 2025-04-08 18.41.49.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/395943/654a7aa0-2a8d-4333-8a91-9afe1ef44d5c.png)

`save_docs_to_vector_db`関数を読み進めていきますと、テキスト分割において、チャンクの設定（`chunk_size`と`chunk_overlap`）が使われています。

![スクリーンショット 2025-04-08 18.43.17.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/395943/397d9b89-33e0-4520-98e2-acaf5f43c0b7.png)

管理者パネルの「ドキュメント」内で設定できる「チャンクサイズ」や「チャンクオーバーラップ」がここで使われていることが確認できます。

| 単語              | 意味        |
|:-----------------|:------------|
|チャンクサイズ       | `Text Spliter`が`Charactor`の場合は、`RecursiveCharacterTextSplitter`を使っており、文字数と同値 |
|チャンクオーバーラップ| `Text Spliter`が`Charactor`の場合は、連続したチャンクで被る文字数。チャンクオーバーラップは文字列分割で大事な情報が落ちないように、文脈を保持するために使用します|

![スクリーンショット 2025-04-02 21.12.44.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/395943/60b820f4-0769-4b89-a3cd-33ec52b4cf42.png)

さらに`save_docs_to_vector_db`関数を読み進めていくと`embeddings`に`texts`を与えています。

![スクリーンショット 2025-04-08 18.46.12.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/395943/93427205-c9c8-4a09-80c2-6e912ea08c79.png)

これは埋め込みモデル（embedding model）にチャンクサイズで分割したテキストを与えていることがわかります。
よく読むと`replace("¥n", " ")`も実行しているので、改行は半角空白に置き換わっていることもわかります。

つまり改行はOpen WebUIの埋め込みモデルでは**空白と同じ**になるということがここからわかります。

このベクトル化した結果をDBに入れて、`save_docs_to_vector_db`関数は終わります。

![スクリーンショット 2025-04-08 18.47.59.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/395943/c6235ba3-880c-4341-8a75-c46d49612d4f.png)

以上で、ファイルをアップロードした際にOpen WebUIでどのような処理が行われるのかを見てきました。

簡単にファイルアップロードの流れをまとめると
1. 添付されたファイルを拡張子に沿ってパースする
2. 埋め込みモデル（embedding model）を使ってベクトル化する
3. ベクトル化したものをDBに保存する

という形になります。

## RAGのコード実装

次にRAGを実施しているときのスクリプト部分を確認します。
「#」からすでに作成されたナレッジベース内のコレクションを選択し、RAGを動かした時のログを見ていきます。
例では「人物紹介」というコレクションを使っています。この中身は[前編](https://qiita.com/minnsou/items/0ae3006d398f3d4316fc)で解説しているので、詳細が気になる人はそちらを見てください。

![スクリーンショット 2025-04-12 21.53.06.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/395943/9cf93ef7-ed08-4805-ae97-343c6e9c3b78.png)

ログは以下です。

```
docker container logs open-webui
```

![スクリーンショット 2025-04-12 21.56.14.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/395943/c25220e5-46ac-492b-8fb1-4b922f41732c.png)

大事なのは、ログの4行目に表示されている`open_webui.retrieval.utils:query_doc`です。
これは[open_webui/retrieval/utils.pyの76行目](https://github.com/open-webui/open-webui/blob/main/backend/open_webui/retrieval/utils.py#L76)に当たります。

![スクリーンショット 2025-04-03 21.43.55.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/395943/43f19a1a-baed-4e26-a36f-0f5fc88e1a58.png)

見るとわかりますが、この関数のメインの処理は`VECTOR_DB_CLIENT.search`だけです。
この関数を追う前に`query_doc`関数がどこから来たのかを先に確認します。

これは`open_webui/routers/retrieval.py`の`query_doc_handler`関数から呼ばれています。
こちらのGitHubのコードは[この1535行目](https://github.com/open-webui/open-webui/blob/main/backend/open_webui/routers/retrieval.py#L1535)です。

![スクリーンショット 2025-04-12 22.05.47.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/395943/5743fb10-f911-4451-b1d9-bf2c500cee6a.png)

この`query_doc_handler`関数で大事なことは2点です。
(1) 管理者パネルの「ドキュメント」の「ブリッジ検索」がONになっている場合は1541行目のif文が`true`になるため、呼ばれる関数は`query_doc`関数ではなく、`query_doc_with_hybrid_search`関数になります。

![スクリーンショット 2025-04-12 23.38.22.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/395943/227afd1a-7aad-4e17-8f3a-336d740557b9.png)

「ブリッジ検索」をONにした場合はBM25アルゴリズムを使ったり、リランキングしたりしてコレクションの検索をするようになります。記事が冗長になるのでこれ以上は踏み込みませんが、詳細が気になる人は下の`query_doc`と同じようにコードを読んでいけば分かると思います。

(2)`query_doc`関数に渡しているのは以下の内容です。
| 変数              | 解説        |
|:-----------------|:------------|
|collection_name|これはナレッジベースで作られたコレクションの名前です|
|query_embedding|埋め込み関数（embedding function）にクエリを渡したもの、つまりクエリをベクトル化したものです|
|k              |これは管理者パネルの「ドキュメント」内の「トップK」で指定できる数字で、検索の上位K件を結果に用います|
|user           |これもそのままユーザです（一般ユーザと管理者ユーザでできることが異なるので、この値を渡していると思われますが要調査）|

この(1)(2)の情報を踏まえて、`query_doc`関数内の`VECTOR_DB_CLIENT.search`を追っていきます。

`VECTOR_DB_CLIENT`は`open_webui/retrieval/vector/connector.py`です。
GitHubのコードだと[ここ](https://github.com/open-webui/open-webui/blob/main/backend/open_webui/retrieval/vector/connector.py)ですが、そんなに中身があるわけではなく、`VECTOR_DB`で値を分岐させているだけです。

![スクリーンショット 2025-04-09 11.11.17.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/395943/1ebe46a0-4eb8-4e31-896c-dc7a6d13ebf6.png)

`VECTOR_DB`の値が大事ですが、これは一番上に書かれているように`open_webui/config.py`に書かれているので、そちらにいきます。GitHubのコードは[この1616行目](https://github.com/open-webui/open-webui/blob/main/backend/open_webui/config.py#L1616)に書かれています。

![スクリーンショット 2025-04-12 21.29.17.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/395943/07f57af9-6e15-4ffc-95f7-bdf796ff01d4.png)

これは記載の通りOSの環境変数の`VECTOR_DB`の値を使います。
設定されていない場合は第二引数の`chroma`が指定されるということなので、**デフォルトだとDBとしては使うものは`Chroma`になります**。

なお、この環境変数の値は[公式ドキュメント](https://docs.openwebui.com/getting-started/env-configuration/#retrieval-augmented-generation-rag)にも記載があり、コードと同じ挙動が書かれていることが確認できます。

![スクリーンショット 2025-04-12 21.37.37.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/395943/27a3cf53-b0c3-40fa-a18f-702ad5e8b59b.png)

それでは`VECTOR_DB_CLIENT.search`に話を戻しましょう。

デフォルトではDBはChromaを用いるので`connector.py`の記載に従い、`open_webui/retrieval/vector/dbs/chroma.py`を見にいきます。
GitHubのコードだと[この66行目](https://github.com/open-webui/open-webui/blob/main/backend/open_webui/retrieval/vector/dbs/chroma.py#L66)に`search`関数が記載されています。

![スクリーンショット 2025-04-12 21.40.50.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/395943/2c2c388c-baa1-49cb-8ff4-62333a4a6349.png)

71行目では引数に与えられたコレクション名からDB内に保存されているコレクションを選び、73行目でそのコレクションの検索をしています。
Chroma DBのコードの詳細は[別の記事](https://qiita.com/kenichiro_nishioka/items/9f578cc670264d8fdf6b)に譲りますが、ここではクエリをベクトル化したものを渡して、近しい文書を検索しているだけです。

80行目にも記載がありますが、**コサイン類似度**を使ってクエリ（をベクトル化したもの）とコレクションに保存された文書（をベクトル化したもの）の類似度を出していることがわかります。[^1]

[^1]:コサイン類似度についてよくわからない方は[別の記事](https://qiita.com/hi-ku/items/8140a06c381479c16304)を参照してください。


以上で、Open WebUIでRAGがどのような処理が行われるのかを見てきました。

大きな流れとしては
1. 埋め込み関数を使ってクエリをベクトル化する
2. コサイン類似度を使って、クエリに近い文章を検索する
3. 検索で得られた上位K件の文章を使い、LLMに渡して回答生成をする
という形になります。


## 終わりに

[似たようなこと](https://qiita.com/Tadataka_Takahashi/items/f0f621b1ec62707b95eb)をやっている方もいますが、UIが少し古くなっているのと個人的には`Loader`以外も気になったので色々調べてみました。

Open WebUI＋OllamaでローカルLLMを構築し、RAGをやりたい人の参考になれば幸いです。

もし記事の内容で誤りなどがあれば、コメント欄でご指摘ください。