WordCampのセッション情報について回答するRAGアプリを作ってみた話

この記事では、生成AI(OpenAI)とWP API / LangChainを利用したRAGの取り組みが紹介されています。2024年のイベントに向けて、セッション情報を効率的に利用するためにRAGを作成しました。WP APIを使用してセッション情報を取得し、ベクターインデックスを作成しています。質問に応じて適切なセッションを推薦する一方、モデルやAPIの選定によって回答の精度に差が出ることも明らかになりました。LangChainや生成AIモデルの変化もあり、新たな知見を得られたとのことです。

広告ここから
広告ここまで

目次

    この記事では、生成AI(OpenAI)とWP API / LangChainを利用したRAGを作ってみた取り組みについて紹介します。「ChatGPT Advent Calendar 2024」21日目の記事として、2024年初頭に書きかけていたブログを仕上げました。

    当時挑戦したこと

    2024年2月に台北でWordPressのカンファレンス、WordCampAsia 2024が開催されました。その時ちょうどLangChainを触っていたことや、セッション情報を調べる手間を効率化したいと思ったことから、「このセッション情報を使ったRAG」を作ってみることにしました。(このタイミングで実行委員メンバーに、コンテンツを利用する旨の了解は取り付けています)。

    そして出来上がったのがこちらです。コストの関係などですでに閉じていますので、スクリーンショットのみでお送ります。

    セッションに関する相談、例えば「Headless WordPressについて知りたい」のように送信すると、セッションのおすすめとセッションリンクが表示されます。

    生成AIを利用した回答生成なので、もちろん日本語にも対応しています。

    セッション情報のインデックスを作成する

    RAGでは検索に利用するインデックスが必要です。今回はWordPressサイトでしたので、(実行委員メンバーの了解を得た上で)WP APIからデータを取得しました。

    _fieldsクエリを利用して、不要なデータを取得しないようにするなどして、すこしでも軽量化しています。

    const sessionsURL = "https://asia.wordcamp.org/2024/wp-json/wp/v2/sessions?per_page=100&_fields=id,title,meta,excerpt,content,session_date_time,session_speakers,link,.meta"
    
    fetch(sessionsURL)
    .then(data => data.json())
    .then(sessions => {
        console.log(JSON.stringify(sessions))
    })

    収集したJSONをもとにインデックスを作成します。一般的なベクターインデックスを利用する場合、情報は1つの文章テキストとしてまとめる必要があります。そのため、セッションタイトルや開始時間、登壇者・内容などをMarkdown形式の文字列に整形しています。

        const sessions = await loadSessions()
        for await (const session of sessions) {
            if (session.content.protected) continue
            if (session.meta._wcpt_session_type !== 'session') continue
            const content = session.content.rendered
                .replace(/<[^>]*>/g, '')
                .replace(/\n/g, '')
            const splittedTexts = await splitter.splitText(content)
            const sessionHeaderText = [
                `# ${session.title.rendered}\n`,
                `- Session date time: ${session.session_date_time.date} ${session.session_date_time.time}`,
                `- Session speaker: ${session.session_speakers.map(speaker => speaker.name).join(',')}`,
            ].join('\n')
            // @TODO 続きはこの後
        }

    また、情報量が多くなった時に備えてチャンクも作るようにしました。こちらもどのセッションに関するチャンクなのかがわかるよう、metadataの設定や文章テキスト内容の調整を行っています。

        const sessions = await loadSessions()
        const sessionsDocuments: Array<Document> = []
        const sessiontIndexes: string[] = []
        const splitter = RecursiveCharacterTextSplitter.fromLanguage('html', {
          chunkSize: 400,
          chunkOverlap: 100,
          keepSeparator: true,
          separators: ['\n', '。']
        });
        for await (const session of sessions) {
            if (session.content.protected) continue
            if (session.meta._wcpt_session_type !== 'session') continue
            const content = session.content.rendered
                .replace(/<[^>]*>/g, '')
                .replace(/\n/g, '')
            const splittedTexts = await splitter.splitText(content)
            const sessionHeaderText = [
                `# ${session.title.rendered}\n`,
                `- Session date time: ${session.session_date_time.date} ${session.session_date_time.time}`,
                `- Session speaker: ${session.session_speakers.map(speaker => speaker.name).join(',')}`,
            ].join('\n')
            const docsMetadata = {
              id: session.id.toString(),
              title: session.title.rendered,
              url: session.link
            }
            splittedTexts.forEach((text, i) => {
                const pageContent =  `${sessionHeaderText}\n- Session Detail-${i + 1}: ${text}`
                const doc = new Document({
                    pageContent,
                    metadata: docsMetadata
                })
                sessionsDocuments.push(doc)
                sessiontIndexes.push(`${session.id}:${i + 1}`)
            })
        }
        const { vectorStore } = initModels(c.env, 'VECTORIZE_SESSIONS_INDEX')
        await vectorStore.addDocuments(sessionsDocuments, { ids: sessiontIndexes });

    回答と検索(RAG)をおこなうAPIを作る

    インデックスが終われば、あとは検索側を作ります。基礎はこのような形で、質問をリクエストBodyで受け取ってStreamで返すPOST APIとして作っています。

    ragApp.post('/ask', async c => {
      const {query:question} = await c.req.json<{
          query: string
      }>()
      const chain = createIndexChain(c.env)
      const answerStream = await chain.stream({question})
      return streamText(c, async (stream) => {
        let answer: string = ''
          for await (const s of answerStream) {
            const answerChunk = (s as any).answer
            if (answerChunk) {
              answer = `${answer}${answerChunk}`
            }
            await stream.write(JSON.stringify(s))
          }
      })
    })

    セッションとそれ以外で参照するベクターストアのインデックスを変更する

    ここで1手間入れたのが、検索に利生するベクターストアの切り替えです。セッションに関する質問なのか、イベントに関する一般的な質問なのかで、回答内容を変える必要があったため、次のようなステップを追加しました。

    const createIndexChain = (bindings: Bindings) => {
      const {
          chat
      } = initModels(bindings, 'VECTORIZE_SESSIONS_INDEX')
      const evaluateQuestionChain = RunnableSequence.from([
        {
          question: input => input.question,
        },
        ChatPromptTemplate.fromMessages([
          [
            "system",
            `
    あなたはWordPressに関するカンファレンスのスタッフです。
    ユーザーの質問が、「カンファレンスのセッションについて」か「それ以外か」を評価してください。
    質問が「WordPressに関係する内容」の場合は、「セッションである」と判断します。
    返答は"session"もしくは"other"のみで回答してください。
    
    ## 例
    - "ブロックテーマのセッションはありますか?" -> "session"
    - "テーマ開発について" -> "session"
    - "コントリビューターデイは?" -> "other"
    - "会場はどこ?" -> "other"
    `
          ],
          ["human", "{question}"],
        ]),
        chat,
        new StringOutputParser()
      ])
      
      const route = (input: {topic: string; question: string}) => {
        if (input.topic.toLocaleLowerCase().includes('session')) {
          return createSessionChain(bindings)
        }
        return createGeneralChain(bindings)
      }
      const fullChain = RunnableSequence.from([
        {
          topic: evaluateQuestionChain,
          question: input => input.question
        },
        route
      ])
      return fullChain
    }

    このステップでは、「何について質問されているのか?」を判断する処理をLLMにやらせています。そこで生成された結果に基づいて、検索と回答を行う後続処理の呼び出しを分けるようにしました。

    セッション情報と回答文の両方を返すChain

    この辺りをざっくりまとめると、このようなChain ( LCEL )となります。HydeRetrieverを利用して、検索時のクエリチューニングなどもやらせるようにしました。

    const createSessionChain = (bindings: Bindings) => {
        const {
            vectorStore,
            llm,
            chat
        } = initModels(bindings, 'VECTORIZE_SESSIONS_INDEX')
        const retriever = new HydeRetriever({
          vectorStore,
          llm,
          k: 5,
        });
        const generateAnswerChain = RunnableSequence.from([
          {
            context: async input => {
                const relevantDocuments = await retriever.getRelevantDocuments(input.question)
                return relevantDocuments
            },
            question: input => input.question,
          },
          RunnableMap.from({
            sessions: input => {
              const sessions = distinctDocuments(input.context)
              return sessions.map((session: Document) => {
                    return session.metadata
              })
            },
            answer: RunnableSequence.from([{
                    context: input => {
                      const data = input.context.map((sesison: Document) => sesison.pageContent).join('\n')
                      return data
                    },
                    question: input => input.question,
                },
                ChatPromptTemplate.fromMessages([
                [
                    "system",
                    `Imagine you are helping someone gather detailed information about specific sessions at an event. 
        Answer the question based on only the following context:
        
        {context}
        
        The context provided includes detailed information for multiple sessions of an event, such as titles, scheduled dates and times, speakers' names, and in-depth descriptions of the sessions. This information aims to assist in identifying sessions that are closely related to each other or to specific topics of interest. Based on the detailed session information provided, you are to offer comprehensive responses that highlight not only individual sessions but also how they might relate or complement each other. This will help the user to make informed decisions about which sessions to attend, enabling them to maximize the relevance and benefit of their attendance based on their specific interests and the content's relevance.`,
                ],
                ["human", "{question}"],
                ]),
                chat,
                new StringOutputParser()
            ])
          }),
        ])
        return generateAnswerChain
    }

    RAGのモデルは、回答生成・Embedding両方で評価する

    テキスト生成についてもですが、embeddingでもモデルの違いによる回答の差が出ました。当時利用できた2つのEmbeddingモデルでインデックスをそれぞれ作り、回答させたところ、このような結果となっています。

    Cloudflare Workers AI(@cf/baai/bge-large-en-v1.5)を利用した場合

    こちらのモデルでは、ベクターインデックスに対する検索のスコアが大体70-75%でした。とはいえレコメンドされているセッションの一覧をみる限り、「WordPressのブロックテーマについて知りたい」という質問意図は満たせるセッションを選んでいます。

    OpenAI(LangChain.jsのOpenAIEmbeddings)の場合

    こちらは80-85%のスコアがでました。ただ、推奨されているセッションは前者とほぼ同じため、コスト面と精度を何パターンか試してモデルを選定するとよいかなとは思います。

    試してみた感想として

    LangChainや生成AIモデル・APIの変化も激しい一年だったため、このコードが来年のイベントで使える自信はあまりありません。とはいえ、実際に動くものを作ってみて、「あぁ、ここは質問意図に沿った回答を出し分けるために分岐が必要だ」とか「Embeddingモデル1つで10%もスコアに差が出るのか」という経験に基づいた知見を積めたことは大きいです。

    あとはRAGを含めたオーケストレーション的な実装を自分で書くのか、LangGraphなどを使ってコレオグラフィ的に柔軟さをとれるパターンを目指すのかなどを、もう少し試してみたいなと思います。

    広告ここから
    広告ここまで
    Home
    Search
    Bookmark