Livebook から SearXNG を呼び出し、ウェブ検索付きのAIチャットを実装する

## はじめに

以前、 Open WebUI と SearXNG でウェブ検索付きのAIチャットを動かしました

https://qiita.com/RyoWakabayashi/items/95dea2b636039449f585

本記事では Livebook を使用し、簡易的なウェブ検索付き AI チャットを実装します

実装したノートブックはこちら

https://github.com/RyoWakabayashi/elixir-learning/blob/main/livebooks/ollama/searxng.livemd

## 事前準備

本記事では日本語に特化したテキスト埋込モデル Ruri を使用します

https://zenn.dev/hpp/articles/b5132c64c40d24

以下の記事を参考に、 `tmp` 配下に変換した Ruri の `tokenizer.json` を用意してください

https://qiita.com/RyoWakabayashi/items/6e12e81606f7ad8e35f6#%E3%83%88%E3%83%BC%E3%82%AF%E3%83%8A%E3%82%A4%E3%82%B6%E3%83%BC%E3%81%AE%E5%A4%89%E6%8F%9B

また、テキスト埋め込みとチャットには Ollama を利用するため、 Ollama をインストールしておいてください

## コンテナ起動

以下の内容で `docker-compose.yml` を作成します

```yml
---

services:
  livebook_with_searxng:
    image: ghcr.io/livebook-dev/livebook:0.15.3
    container_name: livebook_with_searxng
    ports:
      - '8080:8080'
      - '8081:8081'
    volumes:
      - ./tmp:/tmp

  searxng:
    container_name: searxng_host
    image: searxng/searxng:latest
    ports:
      - "8888:8080"
    volumes:
      - ./searxng:/etc/searxng:rw
    environment:
      - SEARXNG_HOSTNAME=localhost:8080/
    restart: unless-stopped
    cap_drop:
      - ALL
    cap_add:
      - CHOWN
      - SETGID
      - SETUID
      - DAC_OVERRIDE
    logging:
      driver: "json-file"
      options:
        max-size: "1m"
        max-file: "1"
```

以下のコマンドでコンテナを起動します

```bash
docker compose up
```

しばらくして初回起動が終わると、以下の URL でブラウザから SearXNG にアクセスできます

<http://localhost:8888>

![スクリーンショット 2025-03-01 17.16.01.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/3003aa0d-c347-41fa-8cdb-0a1ee0d604be.png)

### SearXNG の設定変更

searxng/settings.yml に SearXNG の設定ファイルが作成されています

検索結果を JSON 形式で返せるように `formats` の値として `json` を追加します

```diff_yaml
...
  formats:
    - html
+   - json
...
```

変更を保存したら `Ctrl + c` で一度コンテナを停止し、再度コンテナを起動します

```bash
docker compose up
```

この状態で別ターミナルから以下のコマンドを実行すると、 JSON 形式の検索結果が取得できます

```bash
curl 'http://localhost:8888/search?lang=ja&q=やせうま&format=json'
```

## セットアップ

Livebook で新しいノートブックを作成し、セットアップセルで以下のコードを実行します

```elixir
Mix.install([
  {:req, "~> 0.5"},
  {:ollama, "~> 0.8"},
  {:chunx, github: "preciz/chunx"},
  {:hnswlib, "~> 0.1"},
  {:kino, "~> 0.15"}
])
```

SearXNG の検索結果で得られるウェブサイトの内容は大きすぎるため、 Chunx を使って分割し、質問の内容に近いものだけを使うようにします

https://qiita.com/RyoWakabayashi/private/a05a05be2e1f30407dc2

## 検索

検索用の関数を用意します

コンテナ間の通信であるため、ホスト名はサービス名、ポート番号はコンテナ内の番号になっています

```elixir
search = fn query ->
  "http://searxng:8080/search"
  |> Req.get!(params: [q: query, format: "json"])
  |> Map.get(:body)
end
```

大分銘菓「やせうま」を検索してみましょう

```elixir
results =
  "やせうま"
  |> search.()
  |> Map.get("results")
```

実行結果

```elixir
[
  %{
    "category" => "general",
    "content" => "練った小麦粉を平たくの...地ののばし方にはコツがあり、熟練者ほど薄く長くのばすことができるという。 「やせうま」の発祥の地は古野（大分県由布市）とされている。平安時代、信仰心の厚い乳母の八...",
    "engine" => "brave",
    "engines" => ["duckduckgo", "brave"],
    "parsed_url" => ["https", "www.maff.go.jp",
     "/j/keikaku/syokubunka/k_ryouri/search_menu/menu/yaseuma_oita.html", "", "", ""],
    "positions" => [1, 2],
    "publishedDate" => nil,
    "score" => 3.0,
    "template" => "default.html",
    "thumbnail" => "",
    "title" => "やせうま 大分県 | うちの郷土料理：農林水産省",
    "url" => "https://www.maff.go.jp/j/keikaku/syokubunka/k_ryouri/search_menu/menu/yaseuma_oita.html"
  },
  %{
    "category" => "general",
    "content" => "やせうまは、弘法大師（空海）の命日である「お大師さま」で振る舞われたり、お盆や七夕に供えものにされた [1] 。 「お大師さま」の3月21日には、普段は道ばたや辻にある弘法大師の像を床の間に移して祀り、訪ねてくる近所の住民にやせうまを振る舞った。 。この行事の時には、やせうま ...",
    "engine" => "brave",
    "engines" => ["duckduckgo", "brave"],
    "parsed_url" => ["https", "ja.wikipedia.org", "/wiki/やせうま", "", "", ""],
    "positions" => [2, 1],
    "publishedDate" => "2024-10-10T00:00:00",
    "score" => 3.0,
    "template" => "default.html",
    "thumbnail" => "",
    "title" => "やせうま - Wikipedia",
    "url" => "https://ja.wikipedia.org/wiki/やせうま"
  },
  %{
    "category" => "general",
    "content" => "やせうま本舗では通販でもお求めいただけます。 「豊後銘菓やせうま」は大分県の郷土料理をアレンジした、きな粉を使った和菓子です。 明治神宮・靖国神社への献上をはじめ、全国菓子大博覧会等での数多くの受賞歴があります。",
    "engine" => "brave",
    "engines" => ["duckduckgo", "brave"],
    "parsed_url" => ["https", "yaseuma.com", "/", "", "", ""],
    "positions" => [4, 5],
    "publishedDate" => "2023-11-19T00:00:00",
    "score" => 0.9,
    "template" => "default.html",
    "thumbnail" => "",
    "title" => "やせうま本舗 | 明治神宮献上の大分銘菓やせうまの通販",
    "url" => "https://yaseuma.com/"
  },
  ...
]
```

やせうまに関するウェブサイトがスコアの降順で得られました

次のコードでウェブサイトのコンテンツをドキュメントとして取得します

- URL が `https` から始まっている（`http` ではない）ウェブサイトのうち、上位10件を抜き出す
- ウェブサイトのコンテンツをダウンロードする
- コンテンツから HTML タグや JavaScript 、スタイルシートなどを削除する
- UTF-8 以外の文字コード（EUC-JP や SJIS）のドキュメントは今回扱わないため、 `String.valid?` で対象外とする
- 上位5件だけを取り出す

```elixir
documents =
  results
  |> Enum.filter(fn result ->
    String.starts_with?(result["url"], "https")
  end)
  |> Enum.slice(0..9)
  |> Enum.map(fn result ->
    content =
      result["url"]
      |> URI.encode()
      |> Req.get!()
      |> Map.get(:body)
      |> String.replace(~r/\n/, "🦛")
      |> String.replace(~r/<script.*?\/script>/, "")
      |> String.replace(~r/<style.*?\/style>/, "")
      |> String.replace(~r/[\s]+/, "")
      |> String.replace("&nbsp;", " ")
      |> String.replace(~r/<br.*?>/, "\n")
      |> String.replace(~r/<.*?>/, "\t")
      |> String.replace("🦛", "\n")
      |> String.replace(~r/[\r\n\t]+/, "\n")
      |> String.trim()

    %{
      url: result["url"],
      title: result["title"],
      content: content
    }
  end)
  |> Enum.filter(fn result ->
    String.valid?(result.content)
  end)
  |> Enum.slice(0..4)
```

実行結果

```elixir
[
  %{
    title: "やせうま 大分県 | うちの郷土料理：農林水産省",
    url: "https://www.maff.go.jp/j/keikaku/syokubunka/k_ryouri/search_menu/menu/yaseuma_oita.html",
    content: "やせうま大分県|うちの郷土料理：農林水産省\nこのページの本文へ移動\nEnglish\n..."
  },
  %{
    title: "やせうま - Wikipedia",
    url: "https://ja.wikipedia.org/wiki/やせうま",
    content: "やせうま-Wikipedia\nコンテンツにスキップ\nメインメニュー\n..."
  },
  %{
    title: "やせうま本舗 | 明治神宮献上の大分銘菓やせうまの通販",
    url: "https://yaseuma.com/",
    content: "やせうま本舗|明治神宮献上の大分銘菓やせうまの通販\n100\n明治神宮献上銘菓..."
  },
  %{
    title: "やせうま本舗│ 田口菓子舗",
    url: "https://www.rakuten.ne.jp/gold/onsenken-oita/yaseuma_sm.html",
    content: "\uFEFF\nやせうま本舗│田口菓子舗\n税抜き8,000円以上購入で送料無料！\n郷土料理「やせうま」..."
  },
  %{
    title: "やせうま | 全国学校栄養士協議会",
    url: "https://www.zengakuei.or.jp/kyodosyoku/pref/oita_03.html",
    content: "やせうま|全国学校栄養士協議会\nホーム\n協議会紹介\n概要・沿革\n会長ご挨拶\n概要\n沿革..."
  }
]
```

## チャンキング

Chunx でセマンティックチャンキングを実行します

セマンティックチャンキング時にテキスト埋め込みも実行しているため、そのまま利用します

```elixir
{:ok, tokenizer} = Tokenizers.Tokenizer.from_file("/tmp/ruri_base/tokenizer.json")
```

```elixir
client = Ollama.init(base_url: "http://host.docker.internal:11434/api", receive_timeout: 300_000)
```

```elixir
Ollama.pull_model(client, name: "kun432/cl-nagoya-ruri-base")
```

```elixir
embedding_fn = fn texts ->
  texts
  |> Enum.map(fn text ->
    client
    |> Ollama.embed(
      model: "kun432/cl-nagoya-ruri-base",
      input: "文章: #{text}"
    )
    |> elem(1)
    |> Map.get("embeddings")
    |> hd()
    |> Nx.tensor()
  end)
end
```

```elixir
chunks =
  documents
  |> Enum.map(fn document ->
    {:ok, doc_chunks} =
      document.content
      |> Chunx.Chunker.Semantic.chunk(
        tokenizer,
        embedding_fn,
        delimiters: ["。", ".", "!", "?", "\n"]
      )

    doc_chunks
    |> Enum.map(fn chunk ->
      chunk.sentences
      |> Enum.filter(fn sentence -> String.length(sentence.text) > 2 end)
      |> Enum.map(fn sentence ->
        document
        |> Map.put(:chunk, chunk.text)
        |> Map.put(:sentence, sentence.text)
        |> Map.put(:embedding, sentence.embedding)
        |> Map.delete(:content)
      end)
    end)
    |> Enum.concat()
  end)
  |> Enum.concat()
```

実行結果

```elixir
[
  %{
    title: "やせうま 大分県 | うちの郷土料理：農林水産省",
    chunk: "やせうま大分県|うちの郷土料理：農林水産省\nこのページの本文へ移動\nEnglish\nこどもページ\nサイトマップ\n文字サイズ\n標準\n大きく\n逆引き事典から探す\n組織別から探す\n閉じる\n大臣官房\n食料・農業・農村基本法\n食料・農業・農村基本計画\n",
    url: "https://www.maff.go.jp/j/keikaku/syokubunka/k_ryouri/search_menu/menu/yaseuma_oita.html",
    sentence: "やせうま大分県|うちの郷土料理：農林水産省\nこのページの本文へ移動\nEnglish\nこどもページ\nサイトマップ\n文字サイズ\n標準\n大きく\n逆引き事典から探す\n組織別から探す\n閉じる\n大臣官房\n食料・農業・農村基本法\n",
    embedding: #Nx.Tensor<
      f32[768]
      [0.013750884681940079, -0.004406194668263197, 0.07910104095935822, ...]
    >
  },
  %{
    title: "やせうま 大分県 | うちの郷土料理：農林水産省",
    chunk: "やせうま大分県|うちの郷土料理：農林水産省\nこのページの本文へ移動\nEnglish\nこどもページ\nサイトマップ\n文字サイズ\n標準\n大きく\n逆引き事典から探す\n組織別から探す\n閉じる\n大臣官房\n食料・農業・農村基本法\n食料・農業・農村基本計画\n",
    url: "https://www.maff.go.jp/j/keikaku/syokubunka/k_ryouri/search_menu/menu/yaseuma_oita.html",
    sentence: "食料・農業・農村基本計画\n",
    embedding: #Nx.Tensor<
      f32[768]
      [0.01888609491288662, -0.004494194872677326, 0.07754290103912354, ...]
    >
  },
  %{
    title: "やせうま 大分県 | うちの郷土料理：農林水産省",
    chunk: "食料安定供給・農林水産業基盤強化本部\nTPP（国内対策）\n",
    url: "https://www.maff.go.jp/j/keikaku/syokubunka/k_ryouri/search_menu/menu/yaseuma_oita.html",
    sentence: "食料安定供給・農林水産業基盤強化本部\nTPP（国内対策）\n",
    embedding: #Nx.Tensor<
      f32[768]
      [0.009003493003547192, -0.00902487337589264, 0.07743033766746521, ...]
    >
  },
  ...
]
```

## インデックス作成

チャンキングしたテキスト毎の埋め込みベクトルをインデックスに登録します

``elixir
{:ok, index} = HNSWLib.Index.new(:cosine, 768, 1_000_000)

chunks
|> Enum.each(fn chunk ->
  HNSWLib.Index.add_items(index, chunk.embedding)
end)
```

インデックスを使って、「やせうまの材料は何ですか」というテキストに近い文章を検索してみましょう

```elixir
query = "やせうまの材料は何ですか"

embeddings =
  client
  |> Ollama.embed(
    model: "kun432/cl-nagoya-ruri-base",
    input: "クエリ: #{query}"
  )
  |> elem(1)
  |> Map.get("embeddings")
  |> hd()
  |> Nx.tensor()

{:ok, labels, dist} = HNSWLib.Index.knn_query(index, embeddings, k: 10)
````

実行結果

```elixir
{:ok,
 #Nx.Tensor<
   u64[1][10]
   [
     [365, 303, 302, 304, 358, 211, 357, 366, 231, 283]
   ]
 >,
 #Nx.Tensor<
   f32[1][10]
   [
     [0.12406063079833984, 0.12798833847045898, 0.12906521558761597, 0.13681596517562866, 0.13692384958267212, 0.13862568140029907, 0.14217764139175415, 0.14507663249969482, 0.1468788981437683, 0.1480928659439087]
   ]
 >}
```

上位4件のテキスト（インデックス番号）が取得できました

それぞれのテキストを結合してコンテキスト情報とします

```elixir
context =
  labels
  |> Nx.to_flat_list()
  |> Enum.map(fn index ->
    chunks
    |> Enum.at(index)
    |> Map.get(:chunk)
  end)
  |> Enum.join("\n\n")

Kino.Markdown.new(context)
```

実行結果

![スクリーンショット 2025-03-11 19.19.26.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/a9c8d0cf-e2c7-4034-adfc-1e8e2c4a2fe8.png)

上位に材料の情報が入ってくれました

## AIチャット

チャットには Gemma 2 を使用します

```elixir
Ollama.pull_model(client, name: "hf.co/alfredplpl/gemma-2-2b-jpn-it-gguf")
```

```elixir
Ollama.preload(client, model: "hf.co/alfredplpl/gemma-2-2b-jpn-it-gguf")
```

ウェブ検索結果から得られたコンテキスト情報を元にして質問に答えます

```elixir
{:ok, %{"response" => response}} =
  Ollama.completion(
    client,
    model: "hf.co/alfredplpl/gemma-2-2b-jpn-it-gguf",
    prompt: """
    質問に一般的な情報ではなく、コンテキスト情報のみに基づいて回答してください
  
    ## コンテキスト情報
  
    #{context}
  
    ## 質問
  
    #{query}
    """
  )

Kino.Markdown.new(response)
```

実行結果

![スクリーンショット 2025-03-11 19.24.05.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/73877b00-6f63-4c2e-9c75-e36d8acff0ce.png)

正しく答えることができました

## まとめ

SearXNG 、 Ollama 、 Chunx の組み合わせでウェブ検索付 AI チャットが実装できました

色々組み合わせれば何でも実装できそうですね