IDCF テックブログ

IDCF テックブログ

クラウド・データセンターを提供するIDCフロンティアの公式テックブログ

新卒開発研修の集大成!AIチャットボットをDifyで爆誕させてみた話

こんにちは!IDCフロンティア、2025年度 新入社員の古山、松本、村井です!

新卒研修の一環として、社内課題をAIで解決するテーマに取り組みました。
その概要と成果を報告します!

目次

はじめに

私たち25卒の新入社員も、入社して間もなく半年が経ちます。

これまでの研修の集大成となる「開発研修」が行われ、エンジニア11名が3つのチーム(A, B, C)に分かれて開発に取り組みました。

各グループにはそれぞれ異なる開発テーマが割り当てられ、私たちが所属するCグループが担当したのが、

「Zenlogicの仕様に関するチャットボット」

という題材です。

個人開発での基礎学習

チーム開発を行う前に個人での開発!

・GeminiとSlackAPIと使ったチャットボット作成

まず初日から数日間はAIとはなにか、チャットボットとはどういうものなのかをコードを作成して動作させ理解する時間でした。
ほぼAIにコード作成をさせ、バイブコーディングのみで動作するコードを作成することができました!

・ここで個人でどのようなチャットボットを作成したか紹介!

[古山]
オライリー(O'Reilly)でよく使われる言い回しをAIチャットボットにしました。

[松本]
システムプロンプトで「知性があり、よく話す赤ちゃん」というキャラクターを確立しました。

[村井]
システムプロンプトを使っておじさん・おばさん構文でしゃべってくれるチャットボットを作ってみました。

個人の思い思いのチャットボット作成により基礎知識を得ることができました。

チームでのチャットボット開発

具体的には、RAG(Retrieval-Augmented Generation)を用いてZenlogicの膨大なPDF仕様書や各種マニュアルを知識源とするチャットボットを構築し、お客様が24時間いつでも質問できるようにすることを目指しました。

また、当初の計画ではPythonのLangChainライブラリを使い、PDFの読み込み、テキスト分割、ChromaDBを用いたデータベース構築、そしてGeminiによる回答生成という一連のパイプラインを自力で構築する予定でした。

直面した課題

しかし、開発を始めるとすぐに、いくつかの技術的な壁に直面しました。

-PDF抽出の壁:仕様書PDFは複雑な表やレイアウトが多く、単純なテキスト抽出では内容が崩れてしまい、AIが正しく読み取れる品質のデータをなかなか得られませんでした。

-環境の壁:高性能な埋め込みモデルを試そうとすると、Cannot allocate memoryというエラーが発生。研修で与えられたVM環境のメモリ(RAM)不足が原因で、モデルの選定に大きな制約がかかりました。

解決策

これらの課題に直面し、解決はしたもののドキュメント読み取り性能やメンテナンス工数などチームで議論した結果、私たちは開発ツールを根本から見直すという決断をしました。

そこで採用したのが、LLMOpsプラットフォームの「Dify」です。

「Dify」とは?

PDFのアップロード、チャンキング、ベクトル化、プロンプトの管理、AIモデルとの連携といった、チャットボット構築に必要な一連の機能をGUI上で直感的に操作できるプラットフォームです。

私たちがコードで苦戦していた部分を、DifyはGUI操作で解決してくれました。

IDCFクラウドのVM上にDockerでDify環境を構築し、開発を再スタートさせました。

セットアップ

今回のDifyチャットボット開発で使ったアーキテクチャを紹介します

【技術構成】

・プラットフォーム:Dify(v1.9.0,Docker)
・インフラ:IDCFクラウド(standard.M8)
・LLM: Google Vertex AI API(Gemini 2.5 Flash)
・エンべディング:Google Vertex AI Text Embeddings(text-multilingual-embedding-002)
・言語:Python 3

【構成図】

【dify】

Difyをセルフホストする方法はとても簡単で、

git clone https://github.com/langgenius/dify.git
cd dify/docker
cp .env.example .env
sudo docker compose up -d

あとはhttp://localhost:80 でアクセスするだけ!

sudo docker psの様子

Dify上での知識ベースのチャットボット作成方法は既にテンプレートが存在し、

テンプレートから作成」から

Knowledge Retreival + Chatbot」を選択して作成します

すると以下のように、あとはLLMと知識検索の設定を行えばすぐ実行できるチャットボットの完成です!

【slack連携】

・DifyAPIを用意します

スタジオの作成したアプリを選択

左のAPIアクセスを選択

右上のAPIキーを選択

新しいシークレットキーを作成のあとに出てくるAPIキーをメモ

・SlackAPIを用意します
Slack APIキー(xoxb-から始まるBot Token と xapp-から始まるApp Token)を用意しましょう!

-DifyAPIとSlackAPIの橋渡し役のPythonスクリプトを紹介-

・vi .env
DIFY_API_KEY=”app-hogehoge”
SLACK_BOT_TOKEN=”xoxb-hogehoge”
SLACK_APP_TOKEN=”xapp-hogehoge”
・vi dify_slack_api.py
__import__('pysqlite3')
import sys
sys.modules['sqlite3'] = sys.modules.pop('pysqlite3')

import os
import logging
import re
from slack_bolt import App
from slack_bolt.adapter.socket_mode import SocketModeHandler
import requests
from dotenv import load_dotenv

# .envファイルから環境変数を読み込む(任意)
load_dotenv()

# --- 設定項目 ---
SLACK_BOT_TOKEN = os.environ.get("SLACK_BOT_TOKEN")
SLACK_APP_TOKEN = os.environ.get("SLACK_APP_TOKEN")
DIFY_API_KEY = os.environ.get("DIFY_API_KEY")
DIFY_BASE_URL = "http://localhost/v1"

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

if not all([SLACK_BOT_TOKEN, SLACK_APP_TOKEN, DIFY_API_KEY]):
    raise ValueError("必要な環境変数 (SLACK_BOT_TOKEN, SLACK_APP_TOKEN, DIFY_API_KEY) が設定されていません。")

app = App(token=SLACK_BOT_TOKEN)

# 会話IDをユーザーごとに保存するためのシンプルな辞書
conversation_store = {}

def query_dify(user_id: str, message: str) -> str:
    """DifyのチャットAPIに問い合わせて、結果を返す関数"""
    api_url = f"{DIFY_BASE_URL}/chat-messages"
    
    headers = {
        "Authorization": f"Bearer {DIFY_API_KEY}",
        "Content-Type": "application/json",
    }
    
    payload = {
        "inputs": {},
        "query": message,
        "user": user_id,
        "response_mode": "blocking",
    }
    
    # 保存されている会話IDがあればリクエストに追加
    if user_id in conversation_store:
        payload["conversation_id"] = conversation_store[user_id]
        
    logger.info(f"Difyへ送信: {payload}")
    
    try:
        response = requests.post(api_url, headers=headers, json=payload, timeout=120)
        response.raise_for_status()
        
        data = response.json()
        logger.info(f"Difyから受信: {data}")
        
        # レスポンスから新しい会話IDを取得して保存
        if "conversation_id" in data:
            conversation_store[user_id] = data["conversation_id"]
        
        return data.get("answer", "すみません、うまく応答を生成できませんでした。")
        
    except requests.exceptions.RequestException as e:
        # 400エラーの場合、Difyからの詳細なエラーメッセージを表示
        if e.response is not None:
            logger.error(f"Dify APIエラー: Status={e.response.status_code}, Body={e.response.text}")
            error_detail = e.response.json().get('message', e.response.text)
            return f"エラー:Dify APIで問題が発生しました。\n`{error_detail}`"
        logger.error(f"Dify APIへのリクエストでエラーが発生しました: {e}")
        return "エラー:Dify APIへの接続に失敗しました。"
    except Exception as e:
        logger.error(f"予期せぬエラーが発生しました: {e}")
        return "エラー:予期せぬ問題が発生しました。"

@app.event("app_mention")
def handle_mention(event, say):
    """メンションを検知したときの処理"""
    channel_id = event["channel"]
    thread_ts = event.get("ts")
    user_id = event["user"]
    
    message_text = re.sub(f"<@.*?>", "", event["text"]).strip()
    
    if not message_text:
        say(text="ご用件は何でしょうか?", thread_ts=thread_ts)
        return

    initial_reply = say(text="🤖 (考え中...)", thread_ts=thread_ts)
    
    # DifyのユーザーIDとして "slack-" プレフィックスを付けると管理しやすい
    dify_user_id = f"slack-{user_id}"
    dify_response = query_dify(user_id=dify_user_id, message=message_text)
    
    app.client.chat_update(
        channel=channel_id,
        ts=initial_reply["ts"],
        text=dify_response
    )

if __name__ == "__main__":
    logger.info("⚡️ Dify-Slack Bot is running!")
    handler = SocketModeHandler(app, SLACK_APP_TOKEN)
    handler.start()

内部構造

・Zenlogicについての情報収集

Difyではナレッジベースと呼ばれるデータベースに情報をアップロードするとチャットボットでその情報を使えるようにベクトル化などをしてくれます。 PDFでダウンロードできる機能仕様書などはダウンロードしDify上にアップロード ウェブサイトに記載されているマニュアルなどはPythonスクリプトでスクレイプしてテキストファイルとして保存後、Difyにアップロードしています。

・Difyでの設定

GUIをポチポチする感覚でDifyの知識検索というブロックにLLMブロックを繋げて、LLMブロック内でのシステムプロンプトで「知識検索をもとに回答するように」と設定するだけで知識ベースで答えてくれるLLMが完成します。 ここに関しては、今までスクリプトで頑張って作っていたことと比較すると、ノーコードでチャットボットが作成できることはとても感動しました。

・slack連携の仕方

DifyだけでSlack連携もできますが、今回はPythonでSlackAPIとDifyAPIの橋渡し役になるスクリプトを作成し、Slackチャットボットを稼働させています。

・Webページ上にあるマニュアルの取り出し方

DifyでのWEB SCRAPERは1URLのそのページのみしかスクレイプできないため、Zenlogicマニュアルのような階層のあるページの場合はすべての情報を取ってくるのは困難です。
そのため、またまたPythonスクリプトで深い階層までスクレイプするスクリプトを作成して実行し、その結果をテキストファイルとして保存後、そのファイルをDifyにインポートする形になっています。

Webスクレイプはこちら、

・vi web_scrape.py

import os
import re
import time
from urllib.parse import urljoin, urlparse

import requests
from bs4 import BeautifulSoup

# --- 設定項目 ---
# 開始URL
START_URL = 'https://www.idcf.jp/rentalserver/support/manual/'
# スクレイピングする最大階層
MAX_DEPTH = 4
# 結果を保存するディレクトリ名
OUTPUT_DIR = 'output'
# 各リクエスト間の待機時間(秒)。サーバーへの負荷を軽減するため。
SLEEP_TIME = 1

# --- スクリプト本体 ---

def sanitize_filename(name):
    """URLやタイトルを安全なファイル名に変換する"""
    # スラッシュをアンダースコアに置換
    filename = name.replace('/', '_')
    # ファイル名として不適切な文字を削除
    filename = re.sub(r'[\\/:*?"<>|]', '', filename)
    # Windowsの予約語を避ける
    if filename.upper() in ['CON', 'PRN', 'AUX', 'NUL', 'COM1', 'COM2', 'COM3', 'COM4', 'COM5', 'COM6', 'COM7', 'COM8', 'COM9', 'LPT1', 'LPT2', 'LPT3', 'LPT4', 'LPT5', 'LPT6', 'LPT7', 'LPT8', 'LPT9']:
        filename += '_'
    # ファイル名が長すぎる場合に短縮
    return filename[:100]

def scrape_site(start_url, max_depth):
    """指定されたURLからサイトをスクレイピングする"""
    if not os.path.exists(OUTPUT_DIR):
        os.makedirs(OUTPUT_DIR)
        print(f"'{OUTPUT_DIR}' ディレクトリを作成しました。")

    queue = [(start_url, 1)]
    visited = {start_url}

    print("スクレイピングを開始します...")

    while queue:
        current_url, current_depth = queue.pop(0)

        if current_depth > max_depth:
            continue

        print(f"[{current_depth}階層目] 処理中: {current_url}")

        try:
            time.sleep(SLEEP_TIME)
            response = requests.get(current_url, timeout=10)
            response.raise_for_status()
            response.encoding = response.apparent_encoding

            soup = BeautifulSoup(response.text, 'html.parser')

            # --- ページタイトルとコンテンツの抽出 ---
            page_title = soup.title.string.strip() if soup.title else "No Title"
            content_area = soup.find('main') or soup.find('article') or soup.body

            if content_area:
                for element in content_area(['script', 'style', 'header', 'footer', 'nav', 'aside']):
                    element.decompose()
                
                text = content_area.get_text(separator='\n', strip=True)
                
                # --- ファイル名の決定と保存 ---
                # タイトルからファイル名を生成、ダメならURLから
                if page_title != "No Title" and page_title:
                    filename = sanitize_filename(page_title) + ".txt"
                else:
                    # URLからパス部分を取得してファイル名にする
                    path = urlparse(current_url).path.strip('/')
                    if not path:
                        path = 'index'
                    filename = sanitize_filename(path) + ".txt"

                filepath = os.path.join(OUTPUT_DIR, filename)
                with open(filepath, 'w', encoding='utf-8') as f:
                    # ファイルの先頭にURLとタイトルを記載
                    f.write(f"SourceURL: {current_url}\n")
                    f.write(f"PageTitle: {page_title}\n\n")
                    f.write("---\n\n") # メタデータと本文の区切り
                    f.write(text)
                # print(f"  -> 保存しました: {filepath}")

            # --- リンクの探索 ---
            if current_depth < max_depth:
                links = soup.find_all('a', href=True)
                for link in links:
                    href = link['href']
                    next_url = urljoin(current_url, href)

                    if next_url.startswith(START_URL) and next_url not in visited:
                        visited.add(next_url)
                        queue.append((next_url, current_depth + 1))

        except requests.RequestException as e:
            print(f"  -> エラーが発生しました: {e}")
        except Exception as e:
            print(f"  -> 予期せぬエラー: {e}")

    print("スクレイピングが完了しました。")

if __name__ == '__main__':
    scrape_site(START_URL, MAX_DEPTH)

これに関しては、Dify上でスクレイプサービスと接続しそのサービス経由でWebスクレイプできるようになっているため、利用できる方はこちらと連携してスクレイプを行うと尚良しです。

実際の動作紹介

ここで、実際にチャットボットの動作をお見せします!

- このように、Slack上で質問を投げかけると・・・

- チャットボットが回答を考えてくれています。

- 回答が返信として表示されました!

- 引用元を見ると正しい情報が記載されていることが確認できます。

まとめ

今回はDifyを用いたチャットボット開発を行いましたが、ノーコードでここまで出来ればすごく開発効率が上がるなと感じました。

また、自分で持っているナレッジをAIにインポートして答えさせたい場合など個人で多く使いたい場合、GeminiAPIを使うと無料枠が~など制限が存在するため、 「LMStudio」というアプリケーションを使ってご自身のPCでローカルLLMを構築し、RAGを用いてチャットボット開発を行ってみるのも1つの手です!

回答の質を上げる」「Slack上でユーザー判別を可能にする」などなど・・・
今後、応用した内容にも挑戦してみたいと思います!

所感

古山
今回、ドキュメントの文字抽出・整合性機能の実装と自然言語処理によるハイブリッド検索機能の実装を担当しました。キーワード重視と類似語重視の検索重視比率を変えられるよう重み機能も導入できたため、ベクトル分析をできました。最終的にはメンテナンス等の問題点から、Difyで代用することで開発方針を切り替えましたが、重みやモデルなどを微調節して構築するよりも、GUIで効率的に構築できるため興味深いツールだと感じました。

松本
約1週間のAIチャットボット開発研修を終え、チャットボットの仕組み理解に加え、開発業務の全体像も体感することができました。また、「AIの力でここまでできるのか!」と、その大きな可能性も感じました。仮配属先の部署でも、業務自動化/効率化に向けてプロジェクトが進行しています。今回の研修で得た知識を本配属後に活かし、積極的に貢献していきたいと考えています。

村井
今回チャットボット開発研修は1週間程度と短かったのですが、今まで多く使っていたAIの仕組みやどうやってドキュメントなどから情報を抜き取りAIで利用できるようにしているのかを身を持って体感し理解することができました。今回の研修で得た知識を応用し、本配属後の業務効率化につながるソリューションを考えていきたいと思います。


Gemini , Vertex AI は Google LLC の商標です。

Copyright © IDC Frontier Inc.