ブラウザのみでテキスト埋め込みからコサイン類似度を測る

# 概要
テキストを「数値ベクトル」に変換することを「テキスト埋め込み」とかtext embeddingとかいいますが、それによって、テキスト間の類似度を測ることができます。
ベクトルの類似度を測る方法はいくつかあるようですが、コサイン類似度をとる方法が一般的です。
OpenAIなどがテキスト埋め込みを行うための埋め込みモデルを提供していますが、今回は、ブラウザのみかつ無料で、テキスト埋め込みからコサイン類似度を測るところまでをやってみました。

# 方法
私が試したのは2種類です。

- MediaPipe からLiteRTを使う方法
- APIを使う方法

また、お金を使いたくなかったので無料でできる方法を試しました。

# MediaPipe からLiteRTを使う方法

[MediaPipe](https://ai.google.dev/edge/mediapipe/solutions/guide?hl=ja)は、Googleで開発を主導しているオープンソースの機械学習ライブラリーですが、ブラウザからTensorFlowLite(今はLiteRTというらしい)のモデルを利用することができるので、それを使いました（それはメインの使いかたではないようですが）。
コサイン類似度の計算も、MediaPipeのメソッドがあるのでそれを使っています。

雑ですが、htmlを用意します。
```html:index.html
<!DOCTYPE html>
<html lang="ja">
<script  type="module" src="mediapipe.js"></script>
<h1>Measure text similarity using the MediaPipe Text Embedder task</h1> <p>MediaPipe Text Embedderを使用して、テキストの類似性を測定します。</p>
<input type="text" id="input" value="こんにちは世界！" />
 <button id="button">ボタン</button>
<ol id="result"></ol>
</html> 
```
スクリプトは以下のとおり。
```javascript:mediapipe.js
import text from "https://cdn.skypack.dev/@mediapipe/tasks-text@0.10.0";
const { TextEmbedder, FilesetResolver, TextEmbedderResult } = text;
let textEmbedder;
// Before we can use TextEmbedder class we must wait for it to finish loading.
async function createEmbedder() {
  const textFiles = await FilesetResolver.forTextTasks(
    "https://cdn.jsdelivr.net/npm/@mediapipe/tasks-text@0.10.0/wasm"
  );
  textEmbedder = await TextEmbedder.createFromOptions(textFiles, {
    baseOptions: {
      modelAssetPath: 'https://storage.googleapis.com/mediapipe-models/text_embedder/bert_embedder/float32/1/bert_embedder.tflite'
      }
  });
}
createEmbedder();

const similarity = (qury, text) => {
  let result_array = [];
  console.log('text:', text[0]);
  text.forEach(function( value ) {
    result_array.push(cosineSimilarity(qury, value));
  });
  return result_array;
}

// Compute cosine similarity.
const cosineSimilarity = (embeddingResult1,embeddingResult2) => {
  // const similarity_value = TextEmbedder.cosineSimilarity( embeddingResult1,embeddingResult2  );
  return TextEmbedder.cosineSimilarity(embeddingResult1.embeddings[0], embeddingResult2.embeddings[0]);
}

// サンプルのテキストデータ配列
const text_array = [
  "これは何ですか?",
  "How much wood would a woodchuck chuck?",
  "こんにちは世界",
  "Hello world",
  "こんにちは、私はAIです"
  ];

  // クリックイベントで、input.valueを取得し、main関数を実行する
let button = document.getElementById('button');
button.addEventListener('click', () => {
    let query_array = document.getElementById('input').value;
    console.log("query_array", query_array);
    main(query_array)
});

  // 結果を表示する関数
  const displayResult = (result, text_array) => {
    const resultElement = document.getElementById('result');
    const keys = result.map((_, index) => text_array[index]);
    const values = result;

    // 連想配列を作成
    const resultObject = Object.fromEntries(keys.map((key, index) => [key, values[index]]));

    // オブジェクトを配列に変換
    const resultObject_array = Object.entries(resultObject).map(([key, value]) => ({key, value}))
    resultObject_array.sort((a,b) => b.value - a.value);

    // 表示用HTMLを生成
    const html = [...resultObject_array]
        .map(element => `<li>${element.key}: ${element.value}</li>`)
        .join("");
    resultElement.innerHTML = html;
};

const main = async (query_array) => {
    const embedded_query_array = await textEmbedder.embed(query_array);
    const embedded_text_array = text_array.map((text) => {
        return textEmbedder.embed(text);
    });
    const result_array = similarity(embedded_query_array, embedded_text_array);
    displayResult(result_array, text_array);
}
```
この方法はAPIキーなどもないので、[デモ画面](https://wonox.github.io/embeddings/index_mediapipe.html)で試していただけます。

```javascript
// サンプルのテキストデータ配列
const text_array = [
  "これは何ですか?",
  "How much wood would a woodchuck chuck?",
  "こんにちは世界",
  "Hello world",
  "こんにちは、私はAIです"
  ];
```
に対して、「こんにちは世界！」と入力した結果が以下のとおり。

1. こんにちは、私はAIです: 0.995502057773233
1. こんにちは世界: 0.9914263179352746
1. Hello world: 0.9860293866642383
1. これは何ですか?: 0.8971670430708084
1. How much wood would a woodchuck chuck?: 0.39064353551429903

ちなみにエクスクラメーションマークなしで「こんにちは世界」と入力すれば、類似度は１になります。

# APIを使う方法
無料にこだわったので、現在はプレビュー版として無料提供中の [gemini-embedding-exp-03-07](https://developers.googleblog.com/ja/gemini-embedding-text-model-now-available-gemini-api/) を使いました。

htmlを用意します。
```html
<head>
    <meta charset="UTF-8">
    <title>テキスト埋め込みのテスト</title>
    <meta name="viewport" content="width=device-width,initial-scale=1">
    <script src="./env/env.js"></script>
    <script type="importmap">
        {
            "imports": {
                "@google/generative-ai": "https://esm.run/@google/generative-ai"
            }
        }
    </script>
    <script type="module" src="gemini_emmbedded.js"></script>
</head>
<body>
    <h1>サンプル</h1>
    <input type="text" id="input" value="こんにちは世界！">
    <button id="button">ボタン</button>
    <ol id="result"></ol>
</body>
</html>
```
nodejsモジュールの @google/generative-ai をブラウザから使うために、importmapを使ってCDNから読み込んでから、以下のgemini_embeddings.jsを使います。

```javascript:gemini_embeddings.js
import { GoogleGenerativeAI } from "@google/generative-ai"

// サンプルのテキストデータ配列
const text_array = [
    "これは何ですか?",
    "How much wood would a woodchuck chuck?",
    "こんにちは世界",
    "Hello world",
    "こんにちは、私はAIです"
    ];

// This is the secret key from env.js;
const API_KEY = secretPhrase(); 
const genAI = new GoogleGenerativeAI(API_KEY);
// embedのモデルを指定
const model = genAI.getGenerativeModel({
    model: "gemini-embedding-exp-03-07"
});

// テキストをembedする関数
async function embed_run(text) {
    const result = await model.embedContent(text)
    return result.embedding.values;
}

  const embed_text = async (text_array) => {
    const embedded_test_array = [];
    console.log(text_array.length);
    for (let i = 0; i < text_array.length; i++) {
        embedded_test_array.push(await embed_run(text_array[i]));
    };
     return embedded_test_array;
 };

// クリックイベントで、input.valueを取得し、main関数を実行する
let button = document.getElementById('button');
button.addEventListener('click', () => {
    let query_array = document.getElementById('input').value;
    console.log("query_array", query_array);
    main(query_array)
});

// コサイン類似度を計算する関数
const cosineSimilarity = (embedded_query_value, embedded_test_array) => {
    const cosine_similarity_array = embedded_test_array.map((value) => {
        return cosineSimilarity_one(embedded_query_value, value);
    }   );
    return cosine_similarity_array;
}

// コサイン類似度を計算する関数
const cosineSimilarity_one = (vecA, vecB) => {
    const dotProduct = vecA.reduce((acc, val, i) => acc + val * vecB[i], 0);
    const magnitudeA = Math.sqrt(vecA.reduce((acc, val) => acc + val * val, 0));
    const magnitudeB = Math.sqrt(vecB.reduce((acc, val) => acc + val * val, 0));
    return dotProduct / (magnitudeA * magnitudeB);
  }

  // 結果を表示する関数
  const displayResult = (result, text_array) => {
    const resultElement = document.getElementById('result');
    const keys = result.map((_, index) => text_array[index]);
    const values = result;

    // 連想配列を作成
    const resultObject = Object.fromEntries(keys.map((key, index) => [key, values[index]]));

    // オブジェクトを配列に変換
    const resultObject_array = Object.entries(resultObject).map(([key, value]) => ({key, value}))
    resultObject_array.sort((a,b) => b.value - a.value);

    // 表示用HTMLを生成
    const html = [...resultObject_array]
        .map(element => `<li>${element.key}: ${element.value}</li>`)
        .join("");
    resultElement.innerHTML = html;
};

  // main関数を定義
  const main = async (query_array) => {
    console.log("main function is loaded.");
    const embedded_query_value = await embed_run(query_array);
    console.log("query:", embedded_query_value);
    const embedded_test_array = await embed_text(text_array);
    const cosineSimilarityValue = await cosineSimilarity(embedded_query_value, embedded_test_array);
    // 結果を表示する
    displayResult(cosineSimilarityValue, text_array);
}
```
また、APIキーは以下のファイルに記述しました。
```javascript
function secretPhrase() {
  return "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXX";
}
```

同じサンプルのテキストデータ配列に対して、「こんにちは世界！」と入力した結果が以下のとおり。

1. こんにちは世界: 0.9771477861563825
1. こんにちは、私はAIです: 0.7789761475545237
1. Hello world: 0.7088145265599778
1. これは何ですか?: 0.693081764891331
1. How much wood would a woodchuck chuck?: 0.5101336744756272

モデルが違うので結果も違います。
ちなみに、さっきと同じように、エクスクラメーションマークなしで「こんにちは世界」と入力すると「こんにちは世界: 0.9999999999999998」となりました。１にならないのはなぜでしょうか。
また、「what is this?」と入力すると「これは何ですか?: 0.778866211220932」がトップになるのは、多言語モデルの強みでしょうか。

なお、[現在のAPIのレートの上限](https://ai.google.dev/gemini-api/docs/rate-limits?hl=ja)は、
- 1 分あたりのリクエスト数RPM10
- 1 分あたりのトークン数TPM--	1
- 日あたりのリクエスト数RPD	1,000
ということもあり、わりとすぐに、以下のようなエラーになります。
```
POST https://generativelanguage.googleapis.com/v1beta/models/gemini-embedding-exp-03-07:embedContent 429 (Too Many Requests)
```

# おわりに
MediaPipeの方は、おそらくモデルが適切ではないような気もします。LiteRTは、モデルを自作すればよいようですが、そこまでは試していません。

gemini-embedding-exp-03-07　は精度は高い印象ですが、正式版（有償？）になれば使い道はありそうです。