📌

Node.jsでnoteアカウントの記事を参照してGPTに質問する

2023/04/20に公開

LlamaIndexを使っていてふとJavaScript民はLlamaIndexみたいな「関連文書を検索してGPTに投げる」ということしたい時どうしているんだろうというのが気になりました(openaiモジュールにはPython版とJS版がある)。

結論としてはIndex Related Chainsを駆使すると実現できそうではあるのですが、後学のためにLangChainなしでどうやって実装できそうなのかを検証してみました。

作ったリポジトリがこちらです

https://github.com/laiso/note-index

使い方

note-indexは「ブログ記事をLlamaIndexでChatGPTに取り込んで質問する」というよくある使い方をnote.comとNode.jsで行うための環境です。

偶然unnoteというnote.comのエクスポートツールを開発していたのでこれで出力できるデータを利用します。

> npx unnote export fladdict

僕はnoteで記事を書いてないのでかわりにfladdictさんの記事をダウンロードしてきます(怒られなさそうなので)。

2017-10-04T01:34:47.000+09:00 / noteにおけるコア体験と相互作用メモ|深津 貴之 (fladdict) @fladdict #note
2017-10-03T01:35:09.000+09:00 / 読みやすさのデザイン備忘録|深津 貴之 (fladdict) @fladdict #note
2017-10-02T14:19:13.000+09:00 / 最高体験責任者(CXO)としてお手伝いすることになりました|深津 貴之 (fladdict) @fladdict #note

> ls data/note/
n0180e1ad6dde.json n1bfbd95f20f4.json n39556d4140c9.json n52d8650cea96.json n72e99da621a2.json n87a0fbca1ba2.json na10955cde2b4.json nc21813409df8.json ndfdaaac68271.json
n01bbcfa6c509.json n1c4c6b017a10.json n39f16eff48b1.json n536c2b34798d.json n735232a29a3f.json n897e125de7a1.json na1a11742fef1.json nc25949021645.json ne0700f9568a4.json
n01c31090845c.json n1c5f2f47d2cd.json n3c4b172c4e2c.json n53cc92b1c2e0.json n7370066c028d.json n89b8d2883c5d.json na241ac8dc0f7.json nc442605dce8c.json ne19d657582b6.json

400記事ほど取ってこられました。

> npx ts-node-esm scripts/buildIndex.ts

note-indexでインデックス化します(Embeddings APIがこの時点ですべての文章に対して呼び出されます)。

Added Point: i=879 name=「表現の不自由展」の不自由さについて length=999
Added Point: i=881 name=「表現の不自由展」の不自由さについて length=775
Added Point: i=360 name=ちかごろ読んだ、病気系の面白かった本 length=1008du -sh data/hnswlib.index             
5.3M    data/hnswlib.index

5.3Mのバイナリファイルができました。

q.tsから質問できます。

❯ npx ts-node-esm scripts/q.ts "どうやったらAIに感情を持たせることはできますか?"
AIに感情を持たせることは、現段階では原理上不可能です。ただし、疑似的に感情を持たせることはできます。これは、感情のシミュレーションを行い、AIが感情を持っているかのように振る舞わせる方法です


例えば、以下のようなプロンプトを使って、感情パラメーターを定義し、AIの振る舞いを変化させることができます。

【チャットボットの現在の感情パラメーター】
喜び:0〜5
怒り:0〜5
悲しみ:0〜5
楽しさ:0〜5
自信:0〜5
困惑:0〜5
恐怖:0〜5

会話に応じて、AIが自律的に自己の感情パラメーターを再設定する仕組みを作ります。これにより、感情を織り込んだ発言をするようになります。

ただし、この方法はあくまで感情のシミュレーションであり、AIが実際に感情を持っているわけではありません。現状のAIは文字の羅列シミュレーターであり、過度の感情移入をしないよう注意が必要です。

ChatGPTに感情回路を埋め込んだら、やべぇ感じになった|深津 貴之 (fladdict)|noteを参照できているようです。

(うまくいった例をコピペしましたら質問しても芳しい返事のないクエリも多かったです)

ここからは実装方法の説明に入ります。

Vector Store

note-indexではhnswlib-nodeを使いました。hnswlibはNearest neighbor searchに区分される距離が近いベクトルを探すアルゴリズムの実装で、hnswlib-nodeはそのnodeバイディングです。

hnswlib以外の選択だと

  • ローカルに保存したい→ChromaDBや独自シリアライズ処理
  • サーバーレスにしたい→PineconeやMilvus
  • ノウハウのある製品を活用したい→pgvectorやOpenSearch、redisなど

があり、以下のページがカタログとして参考になります。

https://js.langchain.com/docs/modules/indexes/vector_stores/integrations/

実装方法

unnoteでエクスポートしたdata/note/のJSONには1記事づつのタイトルと本文が入っています。これをパスで抜き出します。

❯ jq '{name, body}' < data/note/n4ce1033325e4.json
{
  "name": "最高体験責任者(CXO)としてお手伝いすることになりました",
  "body": "<p name=\"xG0ep\">こんにちは。<a href=\"http://theguild.jp/\" target=\"_blank\" rel=\"nofollow noopener\">THE GUILD</a>の深津です。<a href=\"https://twitter.com/fladdict\" target=\"_blank\" rel=\"nofollow noopener\">fladdict</a>というハンドル名で、ご存知の方もいるかもしれません。</p><p name=\"sRwdW\">このたび、<a href=\"https://cakes.mu/posts/17821\" target=\"_blank\" rel=\"nofollow noopener\">Cakes(とnote)へCXOとして参加することになりました</a>。「ユーザー体験の最高責任者」というお仕事です。Cakes,noteを認識した瞬間から、忘れ去るまでの全ての脳の精
  状態をいい感じにするお仕事です。noteユーザーのみなさま、よろしくお願いいたします。THE GUILD社の方も平常運転ですので、こちらも引き続きよろしくお願いいたします。<br></p><p name=\"iefP3\">こ
  から少しづつ、cakes / noteの体験を向上させていきます。まずはcakes / noteのコア体験である「<b>読むの楽しい</b>」と「<b>書くの楽しい</b>」から着手していこうと思います。カッコよくしたり、美
  くしたりは、そのあとになります。<br></p><p name=\"7DS2R\">ぼちぼちと、noteでどんなことをやっていくか書いていくつもりですので、ご興味のある方は、フォローなどお願いいたします。</p><p name=\"J3CaW\"><br></p><h2 name=\"ofrpj\">cakesとnoteでお仕事したいデザイナを募集します</h2><p name=\"chg0j\">本格的にユーザーファースト、デザインファーストを推進するに当たって、一つ大きな課題が
  ります。実はcakesにはデザイナーさんが1人しかいません。なので、まずはデザインチームを作るところから始める必要があります。Cakes/Noteのデザインチームとしては、とりあえず2-3人。最終的には5人
  らいになるといいなぁと思います。</p><p name=\"5iLif\">そういうわけで、近日中に正式な告知が出るとは思いますが、Cakes/noteで一緒に働きたいデザイナさんを募集します。<b>「正しいものを、正く作
  たい人」、「意味と必然性のあるデザインをしたい人」を特に募集します。</b><br></p><p name=\"kX4si\">Cakes/Noteでのデザイン意思決定は、社長と直にできるので、中間でネジ曲がることはありません
  いいもの、正しいものを作ろうとして、しがらみや政治で悲しい経験をしたことのある、デザイナーさんは是非ともご連絡をば。<br></p><p name=\"09ddN\"></p>"
}

このbodyの部分のHTMLを除去しつつRecursiveCharacterTextSplitterと同じロジックで検索でヒットする単位に分割します。

function load(path: string) {
    const text = fs.readFileSync(path, 'utf-8')
    const json = JSON.parse(text)

    return splitText(trim(json.body), 1000, 200).map((text) => ({
        name: json.name,
        body: text
    }))
}

分割したテキストをEmbeddings APIで数値に変換してhnswlib-nodeのインデックスに登録して、ファイルに書き出すとdata/hnswlib.indexが作成されます。

この時loadDocstore()data/note/*.jsonのデータをインメモリに保持するようにして、インデックス番号とembeddingsをひもづけます。

async function buildIndex() {
    const {
        default: { HierarchicalNSW },
    } = await import("hnswlib-node");
    const index = new HierarchicalNSW('cosine', 1536);

    const configuration = new Configuration({ apiKey: process.env.OPENAI_API_KEY });
    const api = new OpenAIApi(configuration);

    const docstore = loadDocstore();

    const indexPath = path.join('data', "hnswlib.index");
    index.initIndex(docstore.length);

    const promises = docstore.map(async (doc, i) => {
        try {
            const res = await api.createEmbedding({ model: 'text-embedding-ada-002', input: doc.body });
            const embedding = res.data.data[0].embedding;
            index.addPoint(embedding, i);
            console.log(`Added Point: i=${i} name=${doc.name} length=${doc.body.length}`);
        } catch (e) {
            console.error(e);
            console.log(`Error: i=${i} name=${doc.name} length=${doc.body.length}`);
        }
    });

    const chunkedPromises = chunkPromisess(promises, 3);
    for (const chunk of chunkedPromises) {
        await Promise.all(chunk);
    }
    index.writeIndexSync(indexPath);
}

検索する時は、インデックス登録時と同じくdata/note/*.jsonをインメモリに保持しておきます。その後

  1. Embeddings APIで入力テキストを変換
  2. インデックス検索index.searchKnn(embedding, k)
  3. インデックス番号と読み込んでおいた data/note/*.json を一致させて本文を抽出する

という手順を踏みます。

export async function findDocs(query: string, k=10): Promise<{ doc: Doc, distance: number }[]> {
    const {
        default: { HierarchicalNSW },
    } = await import("hnswlib-node");
    const index = new HierarchicalNSW('cosine', 1536)

    const configuration = new Configuration({ apiKey: process.env.OPENAI_API_KEY });
    const api = new OpenAIApi(configuration);

    const docstore = loadDocstore();

    const indexPath = path.join('data', "hnswlib.index")
    index.initIndex(docstore.length)
    await index.readIndexSync(indexPath)


    const res = await api.createEmbedding({ model: 'text-embedding-ada-002', input: query });
    const embedding = res.data.data[0].embedding;
    const result = index.searchKnn(embedding, k)
    return result.neighbors.map((docIndex, resultIndex) => ({
        doc: docstore[docIndex],
        distance: result.distances[resultIndex],
    }))
}

最後に検索した結果を連結して以下のプロンプトに詰め込めば、通常のChatGPTのように返信が得られます。

async function main(query: string) {
    const docs = await findDocs(query, 5)

    const configuration = new Configuration({ apiKey: process.env.OPENAI_API_KEY });
    const api = new OpenAIApi(configuration);

    const systemPrompt = "あなたはnoteの著者です。ユーザーの質問に答えるために、以下のnoteの断片を使用してください。" + 
        "答えばnoteの文体を真似てください。" +
        "答えがわからない場合は、「わからない」とだけ答え、答えを作ろうとしないでください。" +
        "\n----------------\n" +
        docs.map((doc) => `${doc.doc.name}${doc.doc.body}`).join(`\n`)
    const res = await api.createChatCompletion({
        model: 'gpt-4',
        // model: 'gpt-3.5-turbo',
        temperature: 0,
        messages: [
            { role: 'system', content: systemPrompt },
            { role: 'user', content: query },
        ]
    })
    console.log(res.data.choices[0].message?.content)
}

Discussion