🦁

TiDB 自動埋め込み機能と さくら AI Engine を使ったRAGシステム (3) 機能全体のWebアプリ化

に公開

https://zenn.dev/kameoncloud/articles/74239c7ca5057f
https://zenn.dev/sakura_internet/articles/50045f5ede9ee3
https://zenn.dev/sakura_internet/articles/c7aac7ae88d927

今日は今まで作成してきた TiDB Vector Store と さくらの AI Engine を活用した RAG システムをWebアプリ化します。

使い方説明

検索
取り込んだ文章に対して検索を行います。RAG用データストアを用いるケースと、直接LLMに問い合わせるケース、ベクトル検索の結果だけを出するケースの3種類を行えます。

取り込み
WEBサイトとPDFの取り込みに対応しています。最大トークン数,オーバーラップ文字数,強制分割マーカーでチャンクを調整できます。

統計
取り込んだチャンク数を確認できます。

スクリプト

前回までの記事の内容に対してserver.js,public/index.htmlを新たに加えindex.jsを置き換えます。

server.js
// RAGシステム Webアプリケーション
// 必要なパッケージ: npm install express multer cors

import express from 'express';
import multer from 'multer';
import cors from 'cors';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
import dotenv from 'dotenv';
import mysql from 'mysql2/promise';
import { insertWebsiteChunks } from './indexhtml.js';
import { insertPDFChunks } from './index.js';
import fs from 'fs/promises';
import * as pdfjsLib from 'pdfjs-dist/legacy/build/pdf.mjs';
import { encode } from 'gpt-tokenizer';

dotenv.config();

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

const app = express();
const PORT = process.env.PORT || 3000;

// ミドルウェア
app.use(cors());
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(express.static(join(__dirname, 'public')));

// ファイルアップロード設定
const storage = multer.diskStorage({
  destination: (req, file, cb) => {
    cb(null, 'uploads/');
  },
  filename: (req, file, cb) => {
    cb(null, Date.now() + '-' + file.originalname);
  }
});

const upload = multer({ 
  storage: storage,
  fileFilter: (req, file, cb) => {
    if (file.mimetype === 'application/pdf') {
      cb(null, true);
    } else {
      cb(new Error('PDFファイルのみアップロード可能です'));
    }
  }
});

// TiDB接続設定
const dbConfig = {
  host: process.env.TIDB_HOST,
  port: process.env.TIDB_PORT || 4000,
  user: process.env.TIDB_USER,
  password: process.env.TIDB_PASSWORD,
  database: process.env.TIDB_DATABASE,
  ssl: {
    rejectUnauthorized: true
  }
};

// さくらのAI Engine設定
const SAKURA_API_URL = 'https://api.ai.sakura.ad.jp/v1/chat/completions';
const SAKURA_API_KEY = process.env.SAKURA_API_KEY || 'ad0c8ca1-870a-4be0-89fe-320f0acf73fd:qgRrFRv69Yz+ZP5r9jCSmBx9cfwa23QMPm04HmGj';

// ルートページ
app.get('/', (req, res) => {
  res.sendFile(join(__dirname, 'public', 'index.html'));
});

// Webサイト取り込みAPI
app.post('/api/ingest/url', async (req, res) => {
  try {
    const { url, maxTokens, removeLinks, split, overlap } = req.body;
    
    if (!url) {
      return res.status(400).json({ error: 'URLを指定してください' });
    }

    const tokens = parseInt(maxTokens) || 512;
    const overlapChars = parseInt(overlap) || 0;
    const forceSplit = split || null;
    const noLinks = removeLinks === true;

    console.log(`Webサイト取り込み開始: ${url}`);

    await insertWebsiteChunks(url, tokens, false, forceSplit, overlapChars, noLinks, false);

    res.json({ 
      success: true, 
      message: `Webサイトを取り込みました: ${url}` 
    });

  } catch (error) {
    console.error('Webサイト取り込みエラー:', error);
    res.status(500).json({ 
      error: 'Webサイトの取り込みに失敗しました', 
      details: error.message 
    });
  }
});

// PDF取り込みAPI
app.post('/api/ingest/pdf', upload.single('file'), async (req, res) => {
  try {
    if (!req.file) {
      return res.status(400).json({ error: 'PDFファイルを選択してください' });
    }

    const { maxTokens, split, overlap } = req.body;
    const tokens = parseInt(maxTokens) || 512;
    const overlapChars = parseInt(overlap) || 0;
    const forceSplit = split || null;

    console.log(`PDF取り込み開始: ${req.file.originalname}`);

    await insertPDFChunks(req.file.path, tokens, false, forceSplit, overlapChars);

    // アップロードファイルを削除
    await fs.unlink(req.file.path);

    res.json({ 
      success: true, 
      message: `PDFを取り込みました: ${req.file.originalname}` 
    });

  } catch (error) {
    console.error('PDF取り込みエラー:', error);
    
    // エラー時もファイルを削除
    if (req.file) {
      await fs.unlink(req.file.path).catch(() => {});
    }

    res.status(500).json({ 
      error: 'PDFの取り込みに失敗しました', 
      details: error.message 
    });
  }
});

// 検索API
app.post('/api/search', async (req, res) => {
  try {
    const { query, limit, directSearch, llmOnly } = req.body;
    
    if (!query) {
      return res.status(400).json({ error: '質問を入力してください' });
    }

    console.log(`検索実行: ${query} (直接検索: ${directSearch ? 'ON' : 'OFF'}, LLMのみ: ${llmOnly ? 'ON' : 'OFF'})`);

    // LLMのみモード:Vector Storeを使わず直接LLMに質問
    if (llmOnly) {
      const response = await fetch(SAKURA_API_URL, {
        method: 'POST',
        headers: {
          'Accept': 'application/json',
          'Authorization': `Bearer ${SAKURA_API_KEY}`,
          'Content-Type': 'application/json'
        },
        body: JSON.stringify({
          model: 'gpt-oss-120b',
          messages: [
            { role: 'user', content: query }
          ],
          temperature: 0.7,
          max_tokens: 1000,
          stream: false
        })
      });

      const data = await response.json();
      const answer = data.choices[0].message.content;

      return res.json({
        success: true,
        contexts: [],
        answer: answer,
        usage: data.usage,
        llmOnly: true
      });
    }

    const resultLimit = parseInt(limit) || 3;

    // ベクトル検索
    const connection = await mysql.createConnection(dbConfig);
    
    const sql = `
      SELECT id, content, source, chunk_index 
      FROM documents
      ORDER BY VEC_EMBED_COSINE_DISTANCE(
        content_vector,
        ?
      )
      LIMIT ${resultLimit}
    `;

    const [rows] = await connection.execute(sql, [query]);
    await connection.end();

    if (rows.length === 0) {
      return res.json({ 
        success: true,
        contexts: [],
        answer: '関連する文書が見つかりませんでした。',
        directSearch: directSearch
      });
    }

    // 直接検索モードの場合はAI生成をスキップ
    if (directSearch) {
      return res.json({
        success: true,
        contexts: rows.map(r => ({
          id: r.id,
          source: r.source,
          chunk_index: r.chunk_index,
          content: r.content
        })),
        answer: null,
        directSearch: true
      });
    }

    // AI回答生成(RAGモード)
    const contextText = rows
      .map((ctx, idx) => `[文書${idx + 1}]\n${ctx.content}`)
      .join('\n\n');

    const systemPrompt = `あなたは親切なアシスタントです。以下の文書を参考にして、ユーザーの質問に正確に答えてください。
文書に記載されていない情報については「文書には記載がありません」と答えてください。`;

    const userPrompt = `【参考文書】
${contextText}

【質問】
${query}

【回答】`;

    const response = await fetch(SAKURA_API_URL, {
      method: 'POST',
      headers: {
        'Accept': 'application/json',
        'Authorization': `Bearer ${SAKURA_API_KEY}`,
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({
        model: 'gpt-oss-120b',
        messages: [
          { role: 'system', content: systemPrompt },
          { role: 'user', content: userPrompt }
        ],
        temperature: 0.7,
        max_tokens: 1000,
        stream: false
      })
    });

    const data = await response.json();
    const answer = data.choices[0].message.content;

    res.json({ 
      success: true,
      contexts: rows.map(r => ({
        id: r.id,
        source: r.source,
        chunk_index: r.chunk_index,
        content: r.content.substring(0, 200) + '...'
      })),
      answer: answer,
      usage: data.usage,
      directSearch: false
    });

  } catch (error) {
    console.error('検索エラー:', error);
    res.status(500).json({ 
      error: '検索に失敗しました', 
      details: error.message 
    });
  }
});

// データベース統計API
app.get('/api/stats', async (req, res) => {
  try {
    const connection = await mysql.createConnection(dbConfig);
    
    const [countResult] = await connection.execute(
      'SELECT COUNT(*) as total FROM documents'
    );
    
    const [sourcesResult] = await connection.execute(
      'SELECT source, COUNT(*) as count FROM documents GROUP BY source'
    );
    
    await connection.end();

    res.json({
      success: true,
      total: countResult[0].total,
      sources: sourcesResult
    });

  } catch (error) {
    console.error('統計取得エラー:', error);
    res.status(500).json({ 
      error: '統計の取得に失敗しました', 
      details: error.message 
    });
  }
});

// サーバー起動
app.listen(PORT, () => {
  console.log(`\n=== RAGシステム Webサーバー起動 ===`);
  console.log(`URL: http://localhost:${PORT}`);
  console.log(`\nエンドポイント:`);
  console.log(`  GET  /              - Webインターフェース`);
  console.log(`  POST /api/ingest/url - Webサイト取り込み`);
  console.log(`  POST /api/ingest/pdf - PDF取り込み`);
  console.log(`  POST /api/search     - RAG検索`);
  console.log(`  GET  /api/stats      - データベース統計`);
  console.log(`\nCtrl+C で停止`);
});

// uploadsディレクトリを作成
import { mkdir } from 'fs/promises';
mkdir('uploads', { recursive: true }).catch(() => {});
public/index.html
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>RAGシステム - TiDB + さくらのAI Engine</title>
    <style>
        * {
            margin: 0;
            padding: 0;
            box-sizing: border-box;
        }

        body {
            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
            min-height: 100vh;
            padding: 20px;
        }

        .container {
            max-width: 1200px;
            margin: 0 auto;
        }

        .header {
            text-align: center;
            color: white;
            margin-bottom: 40px;
        }

        .header h1 {
            font-size: 2.5em;
            margin-bottom: 10px;
        }

        .header p {
            opacity: 0.9;
        }

        .card {
            background: white;
            border-radius: 12px;
            padding: 30px;
            margin-bottom: 20px;
            box-shadow: 0 10px 30px rgba(0,0,0,0.2);
        }

        .tabs {
            display: flex;
            gap: 10px;
            margin-bottom: 20px;
        }

        .tab {
            padding: 12px 24px;
            background: #f0f0f0;
            border: none;
            border-radius: 8px;
            cursor: pointer;
            font-size: 16px;
            transition: all 0.3s;
        }

        .tab.active {
            background: #667eea;
            color: white;
        }

        .tab-content {
            display: none;
        }

        .tab-content.active {
            display: block;
        }

        .form-group {
            margin-bottom: 20px;
        }

        label {
            display: block;
            margin-bottom: 8px;
            font-weight: 600;
            color: #333;
        }

        input[type="text"],
        input[type="number"],
        input[type="file"],
        input[type="radio"],
        textarea {
            font-size: 16px;
            transition: border-color 0.3s;
        }

        input[type="text"],
        input[type="number"],
        input[type="file"],
        textarea {
            width: 100%;
            padding: 12px;
            border: 2px solid #e0e0e0;
            border-radius: 8px;
        }

        input[type="radio"] {
            width: 18px;
            height: 18px;
            margin-right: 8px;
            cursor: pointer;
        }

        input:focus,
        textarea:focus {
            outline: none;
            border-color: #667eea;
        }

        textarea {
            min-height: 120px;
            resize: vertical;
        }

        .checkbox-group {
            display: flex;
            align-items: center;
            gap: 8px;
        }

        .checkbox-group input[type="checkbox"] {
            width: 20px;
            height: 20px;
        }

        .btn {
            padding: 14px 28px;
            background: #667eea;
            color: white;
            border: none;
            border-radius: 8px;
            font-size: 16px;
            font-weight: 600;
            cursor: pointer;
            transition: all 0.3s;
        }

        .btn:hover {
            background: #5568d3;
            transform: translateY(-2px);
        }

        .btn:disabled {
            background: #ccc;
            cursor: not-allowed;
            transform: none;
        }

        .message {
            padding: 16px;
            border-radius: 8px;
            margin-top: 20px;
        }

        .message.success {
            background: #d4edda;
            color: #155724;
            border: 1px solid #c3e6cb;
        }

        .message.error {
            background: #f8d7da;
            color: #721c24;
            border: 1px solid #f5c6cb;
        }

        .loading {
            display: none;
            text-align: center;
            padding: 20px;
        }

        .loading.active {
            display: block;
        }

        .spinner {
            border: 4px solid #f3f3f3;
            border-top: 4px solid #667eea;
            border-radius: 50%;
            width: 40px;
            height: 40px;
            animation: spin 1s linear infinite;
            margin: 0 auto;
        }

        @keyframes spin {
            0% { transform: rotate(0deg); }
            100% { transform: rotate(360deg); }
        }

        .result {
            margin-top: 30px;
        }

        .answer-box {
            background: #f8f9fa;
            padding: 20px;
            border-radius: 8px;
            border-left: 4px solid #667eea;
            margin-bottom: 20px;
        }

        .answer-box h3 {
            margin-bottom: 12px;
            color: #333;
        }

        .answer-box p {
            line-height: 1.6;
            color: #555;
        }

        .contexts {
            margin-top: 20px;
        }

        .context-item {
            background: white;
            padding: 16px;
            border-radius: 8px;
            border: 1px solid #e0e0e0;
            margin-bottom: 12px;
        }

        .context-item h4 {
            font-size: 14px;
            color: #667eea;
            margin-bottom: 8px;
        }

        .context-item p {
            font-size: 14px;
            color: #666;
            line-height: 1.5;
        }

        .stats {
            display: grid;
            grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
            gap: 16px;
        }

        .stat-card {
            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
            color: white;
            padding: 20px;
            border-radius: 8px;
            text-align: center;
        }

        .stat-card h3 {
            font-size: 2em;
            margin-bottom: 8px;
        }

        .stat-card p {
            opacity: 0.9;
        }

        .advanced-options {
            background: #f8f9fa;
            padding: 20px;
            border-radius: 8px;
            margin-top: 20px;
        }

        .advanced-options h3 {
            margin-bottom: 16px;
            color: #333;
        }

        .options-grid {
            display: grid;
            grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
            gap: 16px;
        }
    </style>
</head>
<body>
    <div class="container">
        <div class="header">
            <h1>🤖 RAGシステム</h1>
            <p>TiDB Cloud + さくらのAI Engine</p>
        </div>

        <div class="card">
            <div class="tabs">
                <button class="tab active" onclick="switchTab('search')">🔍 検索</button>
                <button class="tab" onclick="switchTab('ingest')">📥 取り込み</button>
                <button class="tab" onclick="switchTab('stats')">📊 統計</button>
            </div>

            <!-- 検索タブ -->
            <div id="search-tab" class="tab-content active">
                <h2>質問を入力してください</h2>
                
                <div class="form-group">
                    <label>質問</label>
                    <textarea id="search-query" placeholder="例: ガンダムの主人公は誰ですか?"></textarea>
                </div>

                <div class="form-group">
                    <label>参照文書数</label>
                    <input type="number" id="search-limit" value="3" min="1" max="10">
                </div>

                <div style="background: #f8f9fa; padding: 16px; border-radius: 8px; margin-bottom: 20px;">
                    <h4 style="margin-bottom: 12px; color: #333;">検索モード</h4>
                    
                    <div class="checkbox-group" style="margin-bottom: 12px;">
                        <input type="radio" id="mode-rag" name="search-mode" value="rag" checked>
                        <label>RAG(ベクトル検索 + AI回答生成)</label>
                    </div>
                    
                    <div class="checkbox-group" style="margin-bottom: 12px;">
                        <input type="radio" id="mode-direct" name="search-mode" value="direct">
                        <label>直接検索(ベクトル検索のみ、全文表示)</label>
                    </div>
                    
                    <div class="checkbox-group">
                        <input type="radio" id="mode-llm" name="search-mode" value="llm">
                        <label>LLMのみ(Vector Store不使用、LLMの知識で回答)</label>
                    </div>
                </div>

                <button class="btn" onclick="executeSearch()">検索実行</button>

                <div id="search-loading" class="loading">
                    <div class="spinner"></div>
                    <p>検索中...</p>
                </div>

                <div id="search-result" class="result"></div>
            </div>

            <!-- 取り込みタブ -->
            <div id="ingest-tab" class="tab-content">
                <h2>データの取り込み</h2>

                <div class="tabs" style="margin-top: 20px;">
                    <button class="tab active" onclick="switchIngestTab('url')">🌐 Webサイト</button>
                    <button class="tab" onclick="switchIngestTab('pdf')">📄 PDF</button>
                </div>

                <!-- Webサイト取り込み -->
                <div id="url-ingest" class="tab-content active" style="margin-top: 20px;">
                    <div class="form-group">
                        <label>URL</label>
                        <input type="text" id="url-input" placeholder="https://example.com">
                    </div>

                    <div class="advanced-options">
                        <h3>詳細オプション</h3>
                        <div class="options-grid">
                            <div class="form-group">
                                <label>最大トークン数</label>
                                <input type="number" id="url-tokens" value="512">
                            </div>
                            <div class="form-group">
                                <label>オーバーラップ文字数</label>
                                <input type="number" id="url-overlap" value="0">
                            </div>
                            <div class="form-group">
                                <label>強制分割マーカー</label>
                                <input type="text" id="url-split" placeholder="例: ##">
                            </div>
                        </div>
                        <div class="checkbox-group" style="margin-top: 12px;">
                            <input type="checkbox" id="url-nolinks">
                            <label>リンクを削除する</label>
                        </div>
                    </div>

                    <button class="btn" onclick="ingestURL()" style="margin-top: 20px;">取り込み開始</button>
                </div>

                <!-- PDF取り込み -->
                <div id="pdf-ingest" class="tab-content" style="margin-top: 20px;">
                    <div class="form-group">
                        <label>PDFファイル</label>
                        <input type="file" id="pdf-file" accept=".pdf">
                    </div>

                    <div class="advanced-options">
                        <h3>詳細オプション</h3>
                        <div class="options-grid">
                            <div class="form-group">
                                <label>最大トークン数</label>
                                <input type="number" id="pdf-tokens" value="512">
                            </div>
                            <div class="form-group">
                                <label>オーバーラップ文字数</label>
                                <input type="number" id="pdf-overlap" value="0">
                            </div>
                            <div class="form-group">
                                <label>強制分割マーカー</label>
                                <input type="text" id="pdf-split" placeholder="例: \f">
                            </div>
                        </div>
                    </div>

                    <button class="btn" onclick="ingestPDF()" style="margin-top: 20px;">取り込み開始</button>
                </div>

                <div id="ingest-loading" class="loading">
                    <div class="spinner"></div>
                    <p>処理中...</p>
                </div>

                <div id="ingest-result"></div>
            </div>

            <!-- 統計タブ -->
            <div id="stats-tab" class="tab-content">
                <h2>データベース統計</h2>
                <button class="btn" onclick="loadStats()">統計を更新</button>
                
                <div id="stats-loading" class="loading">
                    <div class="spinner"></div>
                    <p>読み込み中...</p>
                </div>

                <div id="stats-result" class="stats" style="margin-top: 20px;"></div>
            </div>
        </div>
    </div>

    <script>
        function switchTab(tab) {
            document.querySelectorAll('.tabs .tab').forEach(t => t.classList.remove('active'));
            document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
            
            event.target.classList.add('active');
            document.getElementById(tab + '-tab').classList.add('active');
        }

        function switchIngestTab(type) {
            document.querySelectorAll('#ingest-tab .tabs .tab').forEach(t => t.classList.remove('active'));
            document.querySelectorAll('#ingest-tab .tab-content').forEach(c => c.classList.remove('active'));
            
            event.target.classList.add('active');
            document.getElementById(type + '-ingest').classList.add('active');
        }

        async function executeSearch() {
            const query = document.getElementById('search-query').value.trim();
            const limit = document.getElementById('search-limit').value;
            
            // 検索モードを取得
            const mode = document.querySelector('input[name="search-mode"]:checked').value;
            const directSearch = mode === 'direct';
            const llmOnly = mode === 'llm';
            
            if (!query) {
                alert('質問を入力してください');
                return;
            }

            const loading = document.getElementById('search-loading');
            const result = document.getElementById('search-result');
            
            loading.classList.add('active');
            result.innerHTML = '';

            try {
                const response = await fetch('/api/search', {
                    method: 'POST',
                    headers: { 'Content-Type': 'application/json' },
                    body: JSON.stringify({ 
                        query, 
                        limit: parseInt(limit),
                        directSearch: directSearch,
                        llmOnly: llmOnly
                    })
                });

                const data = await response.json();

                if (data.success) {
                    let html = '';
                    
                    // モード表示
                    let modeLabel = '';
                    if (data.llmOnly) {
                        modeLabel = '<span style="background: #ff6b6b; color: white; padding: 4px 12px; border-radius: 4px; font-size: 12px;">LLMのみ</span>';
                    } else if (data.directSearch) {
                        modeLabel = '<span style="background: #4ecdc4; color: white; padding: 4px 12px; border-radius: 4px; font-size: 12px;">直接検索</span>';
                    } else {
                        modeLabel = '<span style="background: #667eea; color: white; padding: 4px 12px; border-radius: 4px; font-size: 12px;">RAG</span>';
                    }
                    
                    // AI回答を表示
                    if (data.answer) {
                        html += `
                            <div class="answer-box">
                                <h3>💡 ${data.llmOnly ? 'LLM回答' : 'AI回答'} ${modeLabel}</h3>
                                <p>${data.answer.replace(/\n/g, '<br>')}</p>
                            </div>
                        `;
                    }
                    
                    // 検索結果(文書)を表示
                    if (data.contexts && data.contexts.length > 0) {
                        html += `
                            <div class="contexts">
                                <h3>📚 ${data.directSearch ? '検索結果' : '参照した文書'} (${data.contexts.length}件)</h3>
                                ${data.contexts.map((ctx, i) => `
                                    <div class="context-item">
                                        <h4>文書${i+1}: ${ctx.source} (チャンク ${ctx.chunk_index})</h4>
                                        <p>${ctx.content}</p>
                                    </div>
                                `).join('')}
                            </div>
                        `;
                    }
                    
                    // LLMのみモードの場合は注意書き
                    if (data.llmOnly) {
                        html += `
                            <div style="background: #fff3cd; border: 1px solid #ffc107; padding: 12px; border-radius: 8px; margin-top: 16px;">
                                <p style="margin: 0; color: #856404; font-size: 14px;">
                                    ℹ️ この回答はLLMの学習データに基づいており、取り込んだ文書は参照していません。
                                </p>
                            </div>
                        `;
                    }
                    
                    // トークン使用量(LLMを使った場合のみ)
                    if (data.usage) {
                        html += `
                            <p style="margin-top: 20px; color: #666; font-size: 14px;">
                                トークン使用量: ${data.usage.total_tokens} (入力: ${data.usage.prompt_tokens}, 出力: ${data.usage.completion_tokens})
                            </p>
                        `;
                    }
                    
                    result.innerHTML = html;
                } else {
                    result.innerHTML = `<div class="message error">${data.error}</div>`;
                }
            } catch (error) {
                result.innerHTML = `<div class="message error">エラー: ${error.message}</div>`;
            } finally {
                loading.classList.remove('active');
            }
        }

        async function ingestURL() {
            const url = document.getElementById('url-input').value.trim();
            const maxTokens = document.getElementById('url-tokens').value;
            const overlap = document.getElementById('url-overlap').value;
            const split = document.getElementById('url-split').value.trim();
            const removeLinks = document.getElementById('url-nolinks').checked;

            if (!url) {
                alert('URLを入力してください');
                return;
            }

            const loading = document.getElementById('ingest-loading');
            const result = document.getElementById('ingest-result');
            
            loading.classList.add('active');
            result.innerHTML = '';

            try {
                const response = await fetch('/api/ingest/url', {
                    method: 'POST',
                    headers: { 'Content-Type': 'application/json' },
                    body: JSON.stringify({ 
                        url, 
                        maxTokens: parseInt(maxTokens),
                        overlap: parseInt(overlap),
                        split: split || null,
                        removeLinks
                    })
                });

                const data = await response.json();

                if (data.success) {
                    result.innerHTML = `<div class="message success">${data.message}</div>`;
                } else {
                    result.innerHTML = `<div class="message error">${data.error}: ${data.details}</div>`;
                }
            } catch (error) {
                result.innerHTML = `<div class="message error">エラー: ${error.message}</div>`;
            } finally {
                loading.classList.remove('active');
            }
        }

        async function ingestPDF() {
            const fileInput = document.getElementById('pdf-file');
            const file = fileInput.files[0];

            if (!file) {
                alert('PDFファイルを選択してください');
                return;
            }

            const maxTokens = document.getElementById('pdf-tokens').value;
            const overlap = document.getElementById('pdf-overlap').value;
            const split = document.getElementById('pdf-split').value.trim();

            const loading = document.getElementById('ingest-loading');
            const result = document.getElementById('ingest-result');
            
            loading.classList.add('active');
            result.innerHTML = '';

            const formData = new FormData();
            formData.append('file', file);
            formData.append('maxTokens', maxTokens);
            formData.append('overlap', overlap);
            if (split) formData.append('split', split);

            try {
                const response = await fetch('/api/ingest/pdf', {
                    method: 'POST',
                    body: formData
                });

                const data = await response.json();

                if (data.success) {
                    result.innerHTML = `<div class="message success">${data.message}</div>`;
                    fileInput.value = '';
                } else {
                    result.innerHTML = `<div class="message error">${data.error}: ${data.details}</div>`;
                }
            } catch (error) {
                result.innerHTML = `<div class="message error">エラー: ${error.message}</div>`;
            } finally {
                loading.classList.remove('active');
            }
        }

        async function loadStats() {
            const loading = document.getElementById('stats-loading');
            const result = document.getElementById('stats-result');
            
            loading.classList.add('active');
            result.innerHTML = '';

            try {
                const response = await fetch('/api/stats');
                const data = await response.json();

                if (data.success) {
                    result.innerHTML = `
                        <div class="stat-card">
                            <h3>${data.total}</h3>
                            <p>総チャンク数</p>
                        </div>
                        ${data.sources.map(s => `
                            <div class="stat-card">
                                <h3>${s.count}</h3>
                                <p>${s.source}</p>
                            </div>
                        `).join('')}
                    `;
                } else {
                    result.innerHTML = `<div class="message error">${data.error}</div>`;
                }
            } catch (error) {
                result.innerHTML = `<div class="message error">エラー: ${error.message}</div>`;
            } finally {
                loading.classList.remove('active');
            }
        }

        // ページ読み込み時に統計を表示
        window.addEventListener('load', () => {
            loadStats();
        });
    </script>
</body>
</html>
index.js
// メイン処理(直接実行された場合のみ)
if (import.meta.url === `file://${process.argv[1].replace(/\\/g, '/')}`) {
  (async () => {
    console.log('=== TiDB Auto Embedding データ挿入サンプル ===');
    
    const args = process.argv.slice(2);
    const input = args[0];
    
    if (!input) {
      console.error('\nエラー: PDFファイルのパスまたはURLを指定してください');
      console.log('使用方法: node index.js <PDFファイル|URL> [最大トークン数] [オプション]');
      console.log('\nオプション:');
      console.log('  --recreate              テーブルを再作成');
      console.log('  --split=<文字列>        指定した文字列で強制分割');
      console.log('  --overlap=<文字数>      チャンク間のオーバーラップ文字数');
      console.log('  --no-links              マークダウンからリンクを削除 (Webサイトのみ)');
      console.log('  --show-content          取得した内容を表示 (Webサイトのみ、デバッグ用)');
      console.log('\n例 (PDF):');
      console.log('  node index.js ./sample.pdf 512');
      console.log('  node index.js ./sample.pdf 512 --split="\\f" --overlap=100');
      console.log('\n例 (Webサイト):');
      console.log('  node index.js https://example.com 512 --show-content  (内容確認)');
      console.log('  node index.js https://example.com 512 --no-links');
      console.log('  node index.js https://example.com 512 --split="##" --overlap=50 --no-links');
      process.exit(1);
    }
    
    const isUrl = input.startsWith('http://') || input.startsWith('https://');
    const maxTokens = parseInt(args[1]) || 512;
    const recreate = args.includes('--recreate');
    
    let forceSplitMarker = null;
    const splitArg = args.find(arg => arg.startsWith('--split='));
    if (splitArg) {
      forceSplitMarker = splitArg.replace('--split=', '');
      forceSplitMarker = forceSplitMarker
        .replace(/\\n/g, '\n')
        .replace(/\\r/g, '\r')
        .replace(/\\t/g, '\t')
        .replace(/\\f/g, '\f');
    }
    
    let overlapChars = 0;
    const overlapArg = args.find(arg => arg.startsWith('--overlap='));
    if (overlapArg) {
      overlapChars = parseInt(overlapArg.replace('--overlap=', '')) || 0;
      if (overlapChars < 0) overlapChars = 0;
    }
    
    const removeLinks = args.includes('--no-links');
    const showContent = args.includes('--show-content');
    
    console.log(`\n設定:`);
    console.log(`  入力: ${input}`);
    console.log(`  タイプ: ${isUrl ? 'Webサイト' : 'PDFファイル'}`);
    console.log(`  最大トークン数: ${maxTokens}`);
    if (recreate) {
      console.log(`  テーブル再作成: はい`);
    }
    if (forceSplitMarker) {
      console.log(`  強制分割マーカー: "${forceSplitMarker.replace(/\n/g, '\\n').replace(/\f/g, '\\f')}"`);
    }
    if (overlapChars > 0) {
      console.log(`  オーバーラップ: ${overlapChars}文字`);
    }
    if (removeLinks && isUrl) {
      console.log(`  リンク削除: はい`);
    }
    if (showContent && isUrl) {
      console.log(`  内容表示: はい`);
    }
    
    if (isUrl) {
      await insertWebsiteChunks(input, maxTokens, recreate, forceSplitMarker, overlapChars, removeLinks, showContent);
    } else {
      await insertPDFChunks(input, maxTokens, recreate, forceSplitMarker, overlapChars);
    }
    
  })().catch(error => {
    console.error('致命的なエラー:', error);
    process.exit(1);
  });
}

      // TiDB Cloud Starter PDFデータ挿入サンプル
// 必要なパッケージ: npm install mysql2 dotenv pdfjs-dist gpt-tokenizer

import mysql from 'mysql2/promise';
import dotenv from 'dotenv';
import fs from 'fs/promises';
import { encode } from 'gpt-tokenizer';
import * as pdfjsLib from 'pdfjs-dist/legacy/build/pdf.mjs';
import { insertWebsiteChunks } from './indexhtml.js';

dotenv.config();

// TiDB接続設定
const config = {
  host: process.env.TIDB_HOST,
  port: process.env.TIDB_PORT || 4000,
  user: process.env.TIDB_USER,
  password: process.env.TIDB_PASSWORD,
  database: process.env.TIDB_DATABASE,
  ssl: {
    rejectUnauthorized: true
  }
};

// テーブルが存在しない場合は作成、または再作成
async function createTableIfNotExists(connection, recreate = false) {
  try {
    console.log('テーブルの存在を確認中...');
    
    const [tables] = await connection.execute(
      "SHOW TABLES LIKE 'documents'"
    );
    
    if (tables.length > 0 && recreate) {
      console.log('既存のテーブルを削除します...');
      await connection.execute('DROP TABLE documents');
      console.log('✓ テーブルを削除しました');
    }
    
    if (tables.length === 0 || recreate) {
      console.log('テーブルを作成します...');
      
      const createTableSQL = `
        CREATE TABLE documents (
          id INT PRIMARY KEY AUTO_INCREMENT,
          content TEXT,
          source VARCHAR(255),
          chunk_index INT,
          content_vector VECTOR(1024) GENERATED ALWAYS AS (
            EMBED_TEXT("tidbcloud_free/amazon/titan-embed-text-v2", content)
          ) STORED
        )
      `;
      
      await connection.execute(createTableSQL);
      console.log('✓ テーブル "documents" を作成しました (source, chunk_indexカラム付き)');
    } else {
      console.log('✓ テーブル "documents" は既に存在します');
      
      // テーブル構造を確認
      const [columns] = await connection.execute(
        "SHOW COLUMNS FROM documents"
      );
      const hasSource = columns.some(col => col.Field === 'source');
      const hasChunkIndex = columns.some(col => col.Field === 'chunk_index');
      
      if (!hasSource || !hasChunkIndex) {
        console.log('\n⚠️  警告: 既存テーブルにはsourceまたはchunk_indexカラムがありません');
        console.log('テーブルを再作成する場合は、以下のコマンドを実行してください:');
        console.log('node index.js <PDFファイル> <トークン数> --recreate');
        throw new Error('テーブル構造が不一致です。--recreateオプションを使用してテーブルを再作成してください。');
      }
    }
  } catch (error) {
    if (error.message.includes('テーブル構造が不一致')) {
      throw error;
    }
    console.error('テーブル作成エラー:', error.message);
    throw error;
  }
}

// PDFファイルを読み込んでテキストを抽出
async function extractTextFromPDF(pdfPath) {
  try {
    console.log(`\nPDFファイルを読み込み中: ${pdfPath}`);
    const dataBuffer = await fs.readFile(pdfPath);
    
    // PDFドキュメントを読み込む
    const loadingTask = pdfjsLib.getDocument({
      data: new Uint8Array(dataBuffer),
      useSystemFonts: true,
    });
    
    const pdfDocument = await loadingTask.promise;
    const numPages = pdfDocument.numPages;
    
    console.log(`✓ PDF読み込み完了`);
    console.log(`  - ページ数: ${numPages}`);
    
    // 全ページからテキストを抽出
    let fullText = '';
    for (let pageNum = 1; pageNum <= numPages; pageNum++) {
      const page = await pdfDocument.getPage(pageNum);
      const textContent = await page.getTextContent();
      const pageText = textContent.items.map(item => item.str).join(' ');
      fullText += pageText + '\n';
    }
    
    console.log(`  - テキスト長: ${fullText.length}文字`);
    
    return fullText;
  } catch (error) {
    console.error('PDF読み込みエラー:', error.message);
    throw error;
  }
}

// テキストを指定トークン数で分割(強制分割文字列とオーバーラップに対応)
function splitTextByTokens(text, maxTokens = 512, forceSplitMarker = null, overlapChars = 0) {
  console.log(`\nテキストを${maxTokens}トークンごとに分割中...`);
  if (forceSplitMarker) {
    console.log(`強制分割マーカー: "${forceSplitMarker}"`);
  }
  if (overlapChars > 0) {
    console.log(`オーバーラップ: ${overlapChars}文字`);
  }
  
  // 強制分割マーカーが指定されている場合、まずそれで分割
  let segments = [text];
  if (forceSplitMarker) {
    segments = text.split(forceSplitMarker);
    console.log(`✓ 強制分割マーカーにより${segments.length}個のセグメントに分割`);
  }
  
  const chunks = [];
  
  // 各セグメントを処理
  for (let segmentIndex = 0; segmentIndex < segments.length; segmentIndex++) {
    const segment = segments[segmentIndex].trim();
    if (!segment) continue;
    
    // セグメントをトークン数で更に分割(オーバーラップ対応)
    const segmentChunks = splitSegmentByTokens(segment, maxTokens, overlapChars);
    chunks.push(...segmentChunks);
  }
  
  console.log(`✓ 最終的に${chunks.length}個のチャンクに分割しました`);
  
  // 各チャンクのトークン数を表示
  chunks.forEach((chunk, index) => {
    const tokens = encode(chunk).length;
    console.log(`  チャンク${index + 1}: ${tokens}トークン, ${chunk.length}文字`);
  });
  
  return chunks;
}

// セグメント内をトークン数で分割(オーバーラップ対応)
function splitSegmentByTokens(text, maxTokens, overlapChars = 0) {
  const sentences = text.split(/(?<=[.!?。!?])\s+|\n+/);
  const chunks = [];
  let currentChunk = '';
  let currentTokens = 0;
  let previousChunkEnd = ''; // 前のチャンクの末尾(オーバーラップ用)
  
  for (const sentence of sentences) {
    const sentenceTokens = encode(sentence).length;
    
    // 1文が最大トークン数を超える場合は強制分割
    if (sentenceTokens > maxTokens) {
      if (currentChunk) {
        // オーバーラップ用に末尾を保存
        previousChunkEnd = currentChunk.slice(-overlapChars);
        chunks.push(currentChunk.trim());
        currentChunk = previousChunkEnd; // 次のチャンクはオーバーラップで開始
        currentTokens = encode(currentChunk).length;
      }
      
      // 長い文を単語レベルで分割
      const words = sentence.split(/\s+/);
      let tempChunk = currentChunk;
      let tempTokens = currentTokens;
      
      for (const word of words) {
        const wordTokens = encode(word).length;
        if (tempTokens + wordTokens > maxTokens) {
          if (tempChunk.trim()) {
            // オーバーラップ用に末尾を保存
            previousChunkEnd = tempChunk.slice(-overlapChars);
            chunks.push(tempChunk.trim());
          }
          tempChunk = previousChunkEnd + word + ' '; // オーバーラップで開始
          tempTokens = encode(tempChunk).length;
        } else {
          tempChunk += word + ' ';
          tempTokens += wordTokens;
        }
      }
      
      if (tempChunk.trim()) {
        currentChunk = tempChunk;
        currentTokens = tempTokens;
      }
      continue;
    }
    
    // 現在のチャンクに追加できるか確認
    if (currentTokens + sentenceTokens <= maxTokens) {
      currentChunk += sentence + ' ';
      currentTokens += sentenceTokens;
    } else {
      // 現在のチャンクを保存
      if (currentChunk.trim()) {
        // オーバーラップ用に末尾を保存
        previousChunkEnd = currentChunk.slice(-overlapChars);
        chunks.push(currentChunk.trim());
      }
      // 新しいチャンクをオーバーラップで開始
      currentChunk = previousChunkEnd + sentence + ' ';
      currentTokens = encode(currentChunk).length;
    }
  }
  
  // 最後のチャンクを追加
  if (currentChunk.trim()) {
    chunks.push(currentChunk.trim());
  }
  
  return chunks;
}

// PDFから抽出したチャンクをデータベースに挿入
async function insertPDFChunks(pdfPath, maxTokens = 512, recreate = false, forceSplitMarker = null, overlapChars = 0) {
  let connection;
  
  try {
    // データベース接続
    console.log('\nTiDB Cloudに接続中...');
    connection = await mysql.createConnection(config);
    console.log('✓ 接続成功!');

    // テーブルが存在しない場合は作成
    await createTableIfNotExists(connection, recreate);

    // PDFからテキストを抽出
    const text = await extractTextFromPDF(pdfPath);
    
    // テキストをトークン数で分割(強制分割マーカーとオーバーラップ対応)
    const chunks = splitTextByTokens(text, maxTokens, forceSplitMarker, overlapChars);
    
    // チャンクをデータベースに挿入
    console.log('\nデータベースに挿入中...');
    const fileName = pdfPath.split(/[/\\]/).pop();
    
    for (let i = 0; i < chunks.length; i++) {
      const [result] = await connection.execute(
        'INSERT INTO documents (content, source, chunk_index) VALUES (?, ?, ?)',
        [chunks[i], fileName, i + 1]
      );
      console.log(`✓ チャンク${i + 1}/${chunks.length} 挿入完了 (ID: ${result.insertId})`);
    }

    // 挿入されたデータを確認
    console.log('\n挿入されたデータを確認:');
    const [rows] = await connection.execute(
      'SELECT id, source, chunk_index, LEFT(content, 100) as content_preview FROM documents WHERE source = ? ORDER BY chunk_index',
      [fileName]
    );
    console.table(rows);

    console.log(`\n✓ 処理完了: ${chunks.length}個のチャンクを挿入しました`);

  } catch (error) {
    console.error('エラーが発生しました:', error.message);
    throw error;
  } finally {
    if (connection) {
      await connection.end();
      console.log('\n接続を閉じました。');
    }
  }
}

// メイン処理(直接実行された場合のみ)
if (import.meta.url === `file://${process.argv[1].replace(/\\/g, '/')}`) {
  (async () => {
    console.log('=== TiDB Auto Embedding データ挿入サンプル ===');
    
    // コマンドライン引数を取得
    const args = process.argv.slice(2);
    const input = args[0];
    
    if (!input) {
      console.error('\nエラー: PDFファイルのパスまたはURLを指定してください');
      console.log('使用方法: node index.js <PDFファイル|URL> [最大トークン数] [オプション]');
      console.log('\nオプション:');
      console.log('  --recreate              テーブルを再作成');
      console.log('  --split=<文字列>        指定した文字列で強制分割');
      console.log('  --overlap=<文字数>      チャンク間のオーバーラップ文字数');
      console.log('  --no-links              マークダウンからリンクを削除 (Webサイトのみ)');
      console.log('  --show-content          取得した内容を表示 (Webサイトのみ、デバッグ用)');
      console.log('\n例 (PDF):');
      console.log('  node index.js ./sample.pdf 512');
      console.log('  node index.js ./sample.pdf 512 --split="\\f" --overlap=100');
      console.log('\n例 (Webサイト):');
      console.log('  node index.js https://example.com 512 --show-content  (内容確認)');
      console.log('  node index.js https://example.com 512 --no-links');
      console.log('  node index.js https://example.com 512 --split="##" --overlap=50 --no-links');
      process.exit(1);
    }
    
    // URLかPDFかを判定
    const isUrl = input.startsWith('http://') || input.startsWith('https://');
    
    // 最大トークン数(オプション、デフォルトは512)
    const maxTokens = parseInt(args[1]) || 512;
    
    // --recreateオプションの確認
    const recreate = args.includes('--recreate');
    
    // --splitオプションの確認
    let forceSplitMarker = null;
    const splitArg = args.find(arg => arg.startsWith('--split='));
    if (splitArg) {
      forceSplitMarker = splitArg.replace('--split=', '');
      forceSplitMarker = forceSplitMarker
        .replace(/\\n/g, '\n')
        .replace(/\\r/g, '\r')
        .replace(/\\t/g, '\t')
        .replace(/\\f/g, '\f');
    }
    
    // --overlapオプションの確認
    let overlapChars = 0;
    const overlapArg = args.find(arg => arg.startsWith('--overlap='));
    if (overlapArg) {
      overlapChars = parseInt(overlapArg.replace('--overlap=', '')) || 0;
      if (overlapChars < 0) overlapChars = 0;
    }
    
    // --no-linksオプションの確認
    const removeLinks = args.includes('--no-links');
    
    // --show-contentオプションの確認
    const showContent = args.includes('--show-content');
    
    console.log(`\n設定:`);
    console.log(`  入力: ${input}`);
    console.log(`  タイプ: ${isUrl ? 'Webサイト' : 'PDFファイル'}`);
    console.log(`  最大トークン数: ${maxTokens}`);
    if (recreate) {
      console.log(`  テーブル再作成: はい`);
    }
    if (forceSplitMarker) {
      console.log(`  強制分割マーカー: "${forceSplitMarker.replace(/\n/g, '\\n').replace(/\f/g, '\\f')}"`);
    }
    if (overlapChars > 0) {
      console.log(`  オーバーラップ: ${overlapChars}文字`);
    }
    if (removeLinks && isUrl) {
      console.log(`  リンク削除: はい`);
    }
    if (showContent && isUrl) {
      console.log(`  内容表示: はい`);
    }
    
    // URLの場合はindexhtml.jsの関数を呼び出し
    if (isUrl) {
      await insertWebsiteChunks(input, maxTokens, recreate, forceSplitMarker, overlapChars, removeLinks, showContent);
    } else {
      // PDFの場合は既存の処理
      await insertPDFChunks(input, maxTokens, recreate, forceSplitMarker, overlapChars);
    }
    
  })().catch(error => {
    console.error('致命的なエラー:', error);
    process.exit(1);
  });
}

// 関数をエクスポート(他のファイルから使用可能にする)
export { insertPDFChunks };
さくらインターネット株式会社

Discussion