🙆

Houdiniヘルプを学習したRAGを使ったチャットBotを作成する

に公開

はじめに

https://zenn.dev/nekoco/articles/f9ff33c70cb83c
前回の記事でRAGの構築にはGoogle Colabなどの構築が必要と言ったのですが、興味が湧いたのでやってみようと思いました

動機としてはHoudini RAGのチャットを他の人にも使ってみてほしかったので
まあこちらに関してはGoogle Colabは不向きだったので(構築してから知った)、Hugging Face Spaceにも上げてみました
でもGemini APIの利用上限があるので一般公開は怖くて出来ないんですけどね…

まあこの記事で安易にマネできる人も増えるかもしれないので

Google Colabで作成

https://qiita.com/hiyoko1729/items/bb2d59c721c7b86b0714
こちらの記事がすごく参考になりました

問題点としては、こちらはプログラムを実行するたびにRAG(FAISS?)を生成しています
膨大なHoudiniのヘルプを毎回計算するのは無駄なので、保存するようにします

FAISSは「index.faiss」「index.pkl」のようにファイルに保存することが出来るようです
Google ColabではGoogle Driveを接続できるようなのでそちらから読み出せば解決ですね

FAISS作成

仮想環境を用意して、なんかいい感じに作ってもらいました

create_faiss_index.py
import os
from dotenv import load_dotenv
from langchain.document_loaders import DirectoryLoader, TextLoader
from langchain.text_splitter import CharacterTextSplitter
from langchain.embeddings import HuggingFaceEmbeddings
from langchain.vectorstores import FAISS

# ドキュメントの準備 (dataフォルダ内の全ての.txtファイルを読み込む)
data_dir = "data"
# dataフォルダが存在しない場合は作成
if not os.path.exists(data_dir):
    os.makedirs(data_dir)
    print(f"'{data_dir}' フォルダを作成しました。インデックス作成のためにこのフォルダ内に.txtファイルを配置してください。")
    # 処理を中断するか、サンプルファイルを作成するか検討
    # ここでは処理を続行し、ファイルがない場合は空のインデックスが作成されるようにします

loader = DirectoryLoader(data_dir, glob="**/*.txt", loader_cls=TextLoader, loader_kwargs={'encoding': 'utf-8'})
documents = loader.load()

if not documents:
    print(f"'{data_dir}' フォルダ内に.txtファイルが見つかりませんでした。")
    # ファイルがない場合の処理を検討 (例: 空のインデックスを作成しない、エラーメッセージを表示して終了など)
    # ここでは空のインデックスを作成しないようにします
    exit()

# テキストスプリッターでチャンクに分割
text_splitter = CharacterTextSplitter(
    separator="\n\n",
    chunk_size=1000,
    chunk_overlap=200,
    length_function=len,
)
docs = text_splitter.split_documents(documents)

# Embeddingモデルの準備 (HuggingFaceEmbeddingsを使用)
embeddings = HuggingFaceEmbeddings(model_name="all-mpnet-base-v2")

# FAISSインデックスの作成
print("Creating FAISS index...")
db = FAISS.from_documents(docs, embeddings)
print("FAISS index created.")

# インデックスの保存
index_path = "faiss_index"
db.save_local(index_path)
print(f"FAISS index saved locally at '{index_path}'")

dataフォルダ内にテキストを放り込んで、実行すればFAISSのインデックスが作成されます

GUI側

Google Colabで構築します

まずノートを作成して、Google Driveと連携します
FAISSをアップロードしておけば使えるはず

GeminiのAPIキーも設定します
取得方法や設定方法は元記事に書かれています

プログラム全体は以下の通りです

# -*- coding: utf-8 -*-
"""
Houdini Document ChatBot with Gemini (GUI with ipywidgets - RAG)
- Enterキーでメッセージを自動送信可能
- Uses pre-built FAISS index from Google Drive
"""

# 必要ライブラリを pip
!pip install -q --upgrade google-generativeai langchain langchain-community langgraph langchain-google-genai faiss-cpu sentence-transformers

# API_KEY を設定
import google.generativeai as genai
from google.colab import userdata
import os # For checking FAISS index path

API_KEY = userdata.get('GOOGLE_API_KEY')
if API_KEY:
    genai.configure(api_key=API_KEY)
else:
    print("警告: GOOGLE_API_KEYがColabのシークレットに設定されていません。")
    # API_KEY = "YOUR_API_KEY_HERE" # もしシークレットを使わない場合は、ここに直接キーを入力してください(非推奨)
    # genai.configure(api_key=API_KEY)

# 以下本体
import ipywidgets as widgets
from IPython.display import display, clear_output
from langchain_google_genai import ChatGoogleGenerativeAI
from langchain_core.messages import SystemMessage, HumanMessage, AIMessage
from langchain_community.vectorstores import FAISS
from langchain_community.embeddings import HuggingFaceEmbeddings
from google.colab import drive

# --- FAISSインデックスの読み込み ---
knowledge_base = None
try:
    # 1. Google Driveをマウント
    drive.mount('/content/drive', force_remount=True) # 既にマウント済みでも再マウントを試みる

    # 2. エンベディングモデルの初期化 (ローカルでFAISS作成時に使用したものと完全に同じものを指定)
    EMBEDDING_MODEL_NAME = "all-mpnet-base-v2" # FAISSインデックス作成時と同じモデル名
    embeddings_for_load = HuggingFaceEmbeddings(model_name=EMBEDDING_MODEL_NAME)

    # 3. Google Drive上のFAISSインデックスのパスを指定 (ご自身のパスに変更してください)
    # 例: ローカルで "my_houdini_faiss_index" というフォルダに保存した場合、
    # そのフォルダをDriveにアップロードし、そのフォルダへのパスを指定します。
    GDRIVE_FAISS_FOLDER_PATH = "/content/drive/MyDrive/faiss_index" # ★★★ ご自身のGoogle Drive上のFAISSインデックス "フォルダ" のパスに変更してください ★★★

    if not os.path.exists(GDRIVE_FAISS_FOLDER_PATH):
        print(f"エラー: 指定されたFAISSインデックスのフォルダパスが見つかりません: {GDRIVE_FAISS_FOLDER_PATH}")
        print("Google Driveのパスを確認し、FAISSインデックス(index.faissとindex.pklファイルを含むフォルダ)が正しくアップロードされているか確認してください。")
    else:
        print(f"Google DriveからFAISSインデックスを {GDRIVE_FAISS_FOLDER_PATH} から読み込み中...")
        knowledge_base = FAISS.load_local(
            GDRIVE_FAISS_FOLDER_PATH,
            embeddings_for_load,
            allow_dangerous_deserialization=True # Pickleファイルの読み込み許可 (自己責任で)
        )
        print("FAISSインデックスを読み込みました。Houdiniドキュメントに関する質問を開始できます。")

except Exception as e:
    print(f"FAISSインデックスの読み込み中にエラーが発生しました: {e}")
    print("エラーメッセージと上記の指示を確認してください。")
# --- FAISSインデックスの読み込み完了 ---

# LLMの初期化 (APIキーが設定されている場合のみ)
llm = None
if API_KEY and knowledge_base:
    llm = ChatGoogleGenerativeAI(model="gemini-2.0-flash", google_api_key=API_KEY, temperature=0.7)
    # llm = ChatGoogleGenerativeAI(model="gemini-2.5-flash-preview-04-17", google_api_key=API_KEY, temperature=0.7)
elif not API_KEY:
    print("LLMの初期化に失敗しました: APIキーがありません。")
elif not knowledge_base:
    print("LLMの初期化はスキップされました: ナレッジベースの読み込みに失敗したため。")


# 初期システムメッセージ
system_message_content = (
    "あなたはHoudiniに関する質問に答えるAIアシスタントです。"
    "提供されたHoudiniドキュメントの情報を参照して、ユーザーの質問に明確かつ丁寧に回答してください。"
    "もしドキュメント内に該当する情報が見つからない場合は、その旨を伝えてください。"
    "まずは自己紹介と、どのような手助けができるかをユーザーに伝えてください。"
)
system_message = SystemMessage(content=system_message_content)

class HoudiniChatBotGUI:
    def __init__(self):
        """
        GUIを初期化し、レイアウトを構築
        """
        if not llm or not knowledge_base:
            print("チャットボットGUIの初期化ができません。LLMまたはナレッジベースの準備に失敗しています。")
            return

        self.conversation_history = [system_message]
        self.chat_log = widgets.Textarea(
            value='', placeholder='チャットログが表示されます...', disabled=True,
            layout=widgets.Layout(width='95%', height='400px')
        )
        self.input_entry = widgets.Text(
            placeholder='Houdiniに関する質問を入力してください (終了は "q")', layout=widgets.Layout(width='95%')
        )
        self.input_entry.on_submit(self.handle_enter)

        # 初回メッセージ(AIからの挨拶)を表示
        self.display_initial_greeting()

        display(widgets.VBox([
            widgets.HTML("<h2 style='color:#FF6F00; text-align:center;'>HoudiniドキュメントQAボット</h2>"), # タイトル変更
            self.chat_log,
            self.input_entry
        ]))

    def display_initial_greeting(self):
        """ 初回のAIからの挨拶を生成・表示 """
        try:
            # 最初の挨拶はコンテキストなしでシステムメッセージに基づいて生成
            initial_response = llm.invoke([system_message, HumanMessage(content="こんにちは、自己紹介をお願いします。")])
            self.display_message("Houdini AI", initial_response.content)
            self.conversation_history.append(AIMessage(content=initial_response.content))
        except Exception as e:
            self.display_message("エラー", f"初期挨拶の生成に失敗しました: {e}")


    def display_message(self, sender, message):
        """
        チャットログにメッセージを追加
        """
        self.chat_log.value += f"{sender}: {message}\n\n"

    def send_message(self):
        """
        ユーザ入力を処理して応答を生成
        """
        user_input = self.input_entry.value.strip()
        if not user_input:
            return

        self.display_message("あなた", user_input)
        self.input_entry.value = '' # 入力フィールドをクリア

        if user_input.lower() == "q":
            self.display_message("Houdini AI", "ご利用ありがとうございました!またお気軽にご質問ください。")
            # GUIを非表示にするか、入力不可にするなどの処理も検討可能
            self.input_entry.disabled = True
            return

        if not knowledge_base:
            self.display_message("エラー", "ナレッジベースが読み込まれていません。質問に回答できません。")
            return
        if not llm:
            self.display_message("エラー", "LLMが初期化されていません。質問に回答できません。")
            return

        try:
            # RAG: ナレッジベースから関連情報を検索
            retriever = knowledge_base.as_retriever(search_kwargs={"k": 3}) # 上位3件の情報を取得
            relevant_docs = retriever.invoke(user_input)
            context_text = "\n\n---\n\n".join([doc.page_content for doc in relevant_docs])

            # プロンプトの準備
            prompt_template = f"""以下のHoudiniドキュメントの情報を参考に、ユーザーの質問に回答してください。

【参考ドキュメント情報】
{context_text}
---

【ユーザーの質問】
{user_input}

回答:
"""
            self.conversation_history.append(HumanMessage(content=prompt_template))

            # LLMに質問とコンテキストを渡して回答を生成
            response = llm.invoke(self.conversation_history)
            ai_response_content = response.content

            self.conversation_history.append(AIMessage(content=ai_response_content))
            self.display_message("Houdini AI", ai_response_content)

        except Exception as e:
            self.display_message("エラー", f"回答の生成中にエラーが発生しました: {e}")


    def handle_enter(self, widget): # widget引数を追加 (on_submitのコールバック仕様)
        """
        Enterキーでメッセージを送信
        """
        self.send_message()

if __name__ == "__main__":
    if API_KEY and knowledge_base and llm:
        print("チャットボットを起動します...")
        app = HoudiniChatBotGUI()
    else:
        print("チャットボットの起動に必要な設定(APIキー、ナレッジベース、LLM)が完了していません。上記のログを確認してください。")

Shift+Enterで実行できます

いい感じにノードの情報を持っているようですね!

さて冒頭でも書いた通り、Google Colabは共有に向かないそうなので困りました
聞いてみたところ、Streamlitで書き換えてHugging Face Spacesにデプロイすると良いそうです(?)

Hugging Face Spacesで作成

まず、Hugging Faceにてアカウントを作成します

Spacesにて「New Space」で新しく作成します
名前やライセンスなどは適当
SDKなどはAIにStreamlitをオススメされたのでそちらを
Hardwareは無料版はこれなのでこれで

ファイル構成は以下のようにしました

それぞれ内容を書いていきます
importするパッケージなどが関係してくるみたいなので、同じようにすると良いかも

title: HoudiniドキュメントQAボット
emoji: 🪄
colorFrom: indigo
colorTo: green
sdk: streamlit
app_file: app.py
pinned: false

Houdiniのドキュメントに関する質問に答えるチャットボットです。
streamlit
google-generativeai
langchain
langchain-community
langchain-google-genai
faiss-cpu
sentence-transformers

本体

app.py
import streamlit as st
import google.generativeai as genai
import os
from langchain_google_genai import ChatGoogleGenerativeAI
from langchain_core.messages import SystemMessage, HumanMessage, AIMessage
from langchain_community.vectorstores import FAISS
from langchain_community.embeddings import HuggingFaceEmbeddings

# ★★★ st.set_page_config() をスクリプトの最初に移動 ★★★
# このコマンドは、他のどのStreamlitコマンドよりも先に呼び出す必要があります。
st.set_page_config(page_title="HoudiniドキュメントQAボット", layout="wide")

# --- 定数設定 ---
EMBEDDING_MODEL_NAME = "all-mpnet-base-v2"
FAISS_INDEX_PATH = "faiss_index" # ローカルのFAISSインデックスフォルダパス
LLM_MODEL_NAME = "gemini-2.0-flash"

# --- APIキーの読み込み ---
# このブロック内で st.error() や st.stop() が呼ばれる可能性があるため、
# st.set_page_config() の後に配置します。
GOOGLE_API_KEY = None
try:
    # 環境変数から直接取得を試みる (Hugging Face Spaces推奨)
    GOOGLE_API_KEY = os.environ.get("GOOGLE_API_KEY")
    if not GOOGLE_API_KEY:
        # 環境変数になければ st.secrets を試みる (ローカル開発用などのフォールバック)
        try:
            GOOGLE_API_KEY = st.secrets["GOOGLE_API_KEY"]
        except KeyError:
            # st.error はStreamlitコマンドなので、set_page_config の後ならOK
            st.error("Hugging Face SpacesのSecretsまたは環境変数に GOOGLE_API_KEY が見つかりません。設定を確認してください。")
            st.stop() # アプリケーションをここで停止
        except Exception as e_secrets:
            st.error(f"st.secretsからのAPIキー読み込み中にエラー: {e_secrets}")
            st.stop()

    if GOOGLE_API_KEY:
        genai.configure(api_key=GOOGLE_API_KEY)
    else:
        # このケースは上の分岐で既にst.errorとst.stopが呼ばれているはずだが念のため
        st.error("APIキーが読み込めませんでした。Hugging Face SpacesのSecretsを確認してください。")
        st.stop()

except Exception as e:
    st.error(f"APIキーの設定中に予期せぬエラーが発生しました: {e}")
    st.stop()


# --- 関数の定義 ---
# @st.cache_resource や関数内の st.error もStreamlitコマンドと見なされることがあるため、
# set_page_config の後に定義・呼び出しを行います。
@st.cache_resource
def load_knowledge_base():
    if not os.path.exists(FAISS_INDEX_PATH):
        st.error(f"FAISSインデックスフォルダが見つかりません: {FAISS_INDEX_PATH}")
        st.error("プロジェクトルートに 'faiss_index' フォルダを配置し、その中に 'index.faiss' と 'index.pkl' を格納してください。")
        return None, None # エラー時はNoneを返す
    try:
        embeddings = HuggingFaceEmbeddings(model_name=EMBEDDING_MODEL_NAME)
        knowledge_base = FAISS.load_local(
            FAISS_INDEX_PATH,
            embeddings,
            allow_dangerous_deserialization=True
        )
        return knowledge_base, embeddings
    except Exception as e:
        st.error(f"FAISSインデックスの読み込みに失敗しました: {e}")
        return None, None

@st.cache_resource
def get_llm():
    try:
        return ChatGoogleGenerativeAI(model=LLM_MODEL_NAME, google_api_key=GOOGLE_API_KEY, temperature=0.7)
    except Exception as e:
        st.error(f"LLMの初期化に失敗しました: {e}")
        return None

# --- 初期化処理 ---
# 以前はここに st.set_page_config があった
knowledge_base, embeddings = load_knowledge_base()
llm = get_llm()

# knowledge_base や llm が None の場合 (ロード失敗時) は、ここで処理を止めるか、
# UI側で適切にハンドリングする。st.stop() は既にロード関数内で呼ばれる可能性がある。
if not knowledge_base or not llm:
    # st.warning は表示されるが、既にロード関数内で st.error, st.stop されている可能性あり
    st.warning("ナレッジベースまたはLLMの初期化に失敗したため、チャット機能を利用できません。エラーメッセージを確認してください。")
    # ここで再度 st.stop() しても良いが、ロード関数内で停止していれば不要な場合も。
    # ただし、ロード関数がエラー時に st.stop() せずに None だけを返した場合に備える。
    if not (knowledge_base is None and llm is None): # 両方Noneなら既に停止している可能性が高い
        st.stop()


# --- Streamlit UI ---
# st.set_page_config() は既にスクリプトの先頭で呼び出し済み

st.title("HoudiniドキュメントQAボット 🪄")
st.caption("Houdiniのドキュメントに関する質問を入力してください。")

# 会話履歴を保存するセッション状態の初期化
if "conversation_history" not in st.session_state:
    system_message_content = (
        "あなたはHoudiniに関する質問に答えるAIアシスタントです。"
        "提供されたHoudiniドキュメントの情報を参照して、ユーザーの質問に明確かつ丁寧に回答してください。"
        "もしドキュメント内に該当する情報が見つからない場合は、その旨を伝えてください。"
    )
    st.session_state.conversation_history = [SystemMessage(content=system_message_content)]
    if llm: # llmが正常に初期化されている場合のみ挨拶を試みる
        try:
            initial_ai_greeting = llm.invoke([
                SystemMessage(content=system_message_content),
                HumanMessage(content="こんにちは、自己紹介をお願いします。")
            ]).content
            st.session_state.conversation_history.append(AIMessage(content=initial_ai_greeting))
        except Exception as e:
            st.session_state.conversation_history.append(AIMessage(content=f"AIアシスタントです。どうぞご質問ください。(初期挨拶生成エラー: {e})"))
    else:
        st.session_state.conversation_history.append(AIMessage(content="AIアシスタントです。現在LLMが利用できません。"))


# チャット履歴の表示 (llmやknowledge_baseがNoneでも履歴自体は表示できるようにする)
for i, message in enumerate(st.session_state.get("conversation_history", [])): # .getで存在しない場合も安全に
    if isinstance(message, SystemMessage):
        pass
    elif isinstance(message, AIMessage):
        with st.chat_message("assistant", avatar="🤖"):
            st.markdown(message.content)
    elif isinstance(message, HumanMessage):
        user_query_for_display = message.content.split("【ユーザーの質問】")[-1].split("回答:")[0].strip() if "【ユーザーの質問】" in message.content else message.content
        with st.chat_message("user", avatar="🧑‍💻"):
            st.markdown(user_query_for_display)

# ユーザー入力 (llmやknowledge_baseがNoneの場合は入力させないか、エラー表示)
if not knowledge_base or not llm:
    st.markdown("---")
    st.warning("チャットボットは現在利用できません。管理者にお問い合わせください。")
else:
    user_input = st.chat_input("Houdiniについて質問を入力してください...")
    if user_input:
        st.session_state.conversation_history.append(HumanMessage(content=user_input))
        with st.chat_message("user", avatar="🧑‍💻"):
            st.markdown(user_input)

        with st.spinner("関連情報を検索中..."):
            retriever = knowledge_base.as_retriever(search_kwargs={"k": 3})
            relevant_docs = retriever.invoke(user_input)
            context_text = "\n\n---\n\n".join([doc.page_content for doc in relevant_docs])

        messages_for_llm = [
            st.session_state.conversation_history[0],
            HumanMessage(content=f"""以下のHoudiniドキュメントの情報を参考に、ユーザーの質問に回答してください。
【参考ドキュメント情報】
{context_text}
---
【ユーザーの質問】
{user_input}
回答:
""")
        ]

        with st.spinner("AIが回答を生成中..."):
            try:
                response = llm.invoke(messages_for_llm)
                ai_response_content = response.content
            except Exception as e:
                ai_response_content = f"申し訳ありません、エラーが発生しました: {e}"

        st.session_state.conversation_history.append(AIMessage(content=ai_response_content))
        with st.chat_message("assistant", avatar="🤖"):
            st.markdown(ai_response_content)

続いてGeminiのAPIキーを設定します
このSpacesのSettingsにVariables and secretsというタブがあるのでそこで定義します
New secretで作成すれば他の人から分からないはずです

これまでの設定をすべて終えて、Spacesの状態がRunningになったら準備完了です
BuildingやRestartingなどでは正常な画面は表示されません

Appタブを開くとチャットBotが表示されます!

感想

結局他の人に大っぴらに公開できないのは悲しいですね
まあ楽しかったのでいいか~

Botの性能に関しては、正直良くないです
AIモデルの性能が発揮されることなく、ヘルプの情報を報告するだけのBotとなっています
それならヘルプを開けばいいんだよな~

なので前回の記事のMCPの方が性能は良いと思います、完

Discussion