🦁
TiDB 自動埋め込み機能と さくら AI Engine を使ったRAGシステム (3) 機能全体のWebアプリ化
今日は今まで作成してきた 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 = ``;
}
} catch (error) {
result.innerHTML = ``;
} 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 = ``;
} else {
result.innerHTML = ``;
}
} catch (error) {
result.innerHTML = ``;
} 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 = ``;
fileInput.value = '';
} else {
result.innerHTML = ``;
}
} catch (error) {
result.innerHTML = ``;
} 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 = ``;
} else {
result.innerHTML = ``;
}
} catch (error) {
result.innerHTML = ``;
} 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