
Bagaimana saya membangun POC yang memungkinkan tim bisnis query database menggunakan bahasa natural, tanpa menulis SQL — dan mengapa arsitekturnya menggunakan dua kali LLM call.
This article is also available in English on Medium.
Live Demo: ai-chat-db-app.warsono.dev
Di banyak organisasi, data ada di database — tapi akses ke data itu terbatas pada orang-orang yang bisa menulis SQL. Tim sales ingin tahu produk terlaris bulan ini? Buat ticket ke tim data. Tim finance ingin tahu total revenue per kategori? Tunggu report mingguan.
Saya membangun AI Chat Database Assistant sebagai POC untuk menjawab pertanyaan sederhana: bagaimana jika tim bisnis bisa langsung bertanya ke database menggunakan bahasa sehari-hari?
User cukup mengetik: "Produk apa yang paling laris bulan ini?" — dan sistem akan:
Keputusan arsitektur paling penting dalam project ini adalah menggunakan dua kali LLM call untuk setiap pertanyaan user, bukan satu.
LLM Call #1 — SQL Generation (non-streaming) LLM pertama menerima pertanyaan user + database schema, lalu menghasilkan SQL query. Call ini tidak bisa streaming — kita butuh SQL yang lengkap sebelum bisa menjalankan query.
LLM Call #2 — Answer Generation (streaming) Setelah query dijalankan dan hasilnya didapat, LLM kedua menerima pertanyaan + SQL + hasil query, lalu streaming jawaban konversasional ke user via SSE.
Kenapa tidak satu call saja? Karena kedua task ini punya requirement yang berbeda:
Dengan memisahkan dua concern ini, masing-masing prompt juga bisa di-optimize secara independen.
User bertanya
↓
[Frontend] POST /api/chat (SSE)
↓
[Backend] Ambil schema dari cache (TTL 1 jam)
↓
[LLM Call #1] Pertanyaan + Schema → SQL
↓
[Validator] Whitelist · Blacklist · Injection · Auto-LIMIT
↓
[PostgreSQL] Execute query (read-only user, 10s timeout)
↓
[SSE Event] data-query-result: SQL + data + timing
↓
[LLM Call #2] Pertanyaan + SQL + Data → Stream jawaban
↓
[SSE Events] text-delta: karakter per karakter
↓
User melihat jawaban + SQL + tabel data
Seluruh orchestration terjadi di satu async generator:
async def event_generator() -> AsyncIterator[str]:
# 1. Ambil schema yang sudah di-enrich
schema = await schema_cache.get_schema()
# 2. LLM Call #1: Generate SQL (non-streaming, butuh output lengkap)
sql = await llm_service.generate_sql(question, schema)
# 3. Validasi SQL (multi-layer security)
is_valid, result = validate_sql(sql)
if not is_valid:
yield error_event(result)
return
# 4. Execute query
rows, exec_time_ms = await db_service.execute_query(result)
# 5. Kirim metadata query sebagai custom SSE event
yield sse_event({
"type": "data-query-result",
"data": {
"sql": result,
"data": serialize_rows(rows[:100]),
"execution_time_ms": exec_time_ms,
"total_rows": len(rows),
},
})
# 6. LLM Call #2: Stream jawaban (streaming, untuk UX responsif)
async for delta in llm_service.generate_answer_stream(
question, result, rows, len(rows)
):
yield sse_event({"type": "text-delta", "delta": delta})
Untuk fleksibilitas, saya menggunakan Python Protocol sebagai abstraksi:
class LLMService(Protocol):
async def generate_sql(self, question: str, schema: str) -> str: ...
async def generate_answer_stream(
self, question: str, sql: str, data: list[dict], total_rows: int
) -> AsyncIterator[str]: ...
Dengan dua implementasi: AnthropicLLMService (Claude) dan OpenRouterLLMService (OpenAI-compatible API). Switch provider cukup dengan mengubah environment variable LLM_PROVIDER.
Prompt untuk SQL generation menggunakan few-shot examples dalam Bahasa Indonesia:
## Contoh
Pertanyaan: "Berapa total penjualan bulan ini?"
SQL: SELECT SUM(total) AS total_penjualan FROM sales
WHERE sale_date >= DATE_TRUNC('month', CURRENT_DATE)
AND status = 'completed'
Pertanyaan: "Produk apa yang paling laris?"
SQL: SELECT p.name AS nama_produk, SUM(s.quantity) AS total_terjual
FROM sales s JOIN products p ON s.product_id = p.id
WHERE s.status = 'completed'
GROUP BY p.name ORDER BY total_terjual DESC LIMIT 10
Few-shot examples penting karena memberikan pattern yang konsisten — bagaimana menangani "bulan ini" (DATE_TRUNC), bagaimana join antar tabel, dan bagaimana handle filter status.
Alih-alih mengirim raw database schema ke LLM, saya memperkaya setiap kolom dengan deskripsi dalam Bahasa Indonesia:
COLUMN_DESCRIPTIONS = {
"sales": {
"unit_price": "Harga per unit dalam Rupiah",
"total": "Total harga (quantity x unit_price) dalam Rupiah",
"status": "Status transaksi: 'completed', 'pending', 'cancelled'",
"sale_date": "Tanggal transaksi (format: YYYY-MM-DD)",
},
"products": {
"category": "Kategori: Elektronik, Fashion, Makanan, dll.",
},
}
Schema ini di-cache selama 1 jam dan di-refresh otomatis dari information_schema.columns. Hasilnya, LLM mendapat konteks bisnis yang kaya — tahu bahwa total adalah Rupiah, tahu enum apa saja yang valid untuk status, dan tahu format tanggal yang dipakai.
Memberikan LLM akses ke database terdengar berbahaya. Ini benar — dan itulah mengapa security-nya berlapis:
def validate_sql(sql: str) -> tuple[bool, str]:
# Whitelist: hanya SELECT dan WITH (CTE)
if not upper.startswith(("SELECT", "WITH")):
return False, "Only SELECT queries are allowed"
# Blacklist: blokir DML/DDL keywords
BLACKLISTED = ["DROP", "DELETE", "UPDATE", "INSERT",
"ALTER", "TRUNCATE", "GRANT", "REVOKE"]
# Injection: blokir multiple statements
if re.search(r";\s*\S", sql):
return False, "Multiple SQL statements are not allowed"
# Injection: blokir SQL comments
if "--" in sql:
return False, "SQL comments are not allowed"
# Safety net: auto-append LIMIT 1000
if "LIMIT" not in sql.upper():
sql = f"{sql} LIMIT 1000"
return True, sql
readonly_user hanya punya SELECT grantsales dan products yang di-expose ke LLMBahkan jika LLM entah bagaimana menghasilkan DROP TABLE (yang seharusnya tidak mungkin karena prompt engineering), query tetap akan ditolak oleh validator, dan kalaupun lolos validator, database user tidak punya permission untuk mengeksekusinya.
Saya menggunakan Vercel AI SDK v6 dengan @ai-sdk/vue untuk menangani streaming. Chat instance dikelola sebagai singleton via Vue composable:
const chat = shallowRef<Chat | null>(null)
export function useChat() {
function ensureChat() {
if (!chat.value) {
chat.value = new Chat({
transport: new DefaultChatTransport({
api: `${config.public.apiBaseUrl}/api/chat`,
}),
})
}
return chat.value
}
function newChat() {
chat.value = new Chat({
transport: new DefaultChatTransport({
api: `${config.public.apiBaseUrl}/api/chat`,
}),
})
return chat.value
}
return { chat, ensureChat, newChat }
}
shallowRef dipakai agar Vue tidak deep-track seluruh internal state dari Chat object — cukup track referensi-nya saja.
Yang menarik adalah bagaimana SQL dan data table ditampilkan di UI. Saya mengextend protocol AI SDK v6 dengan custom event data-query-result:
<script setup lang="ts">
const queryResult = computed(() => {
const part = props.message.parts.find(p => p.type === 'data-query-result')
if (!part?.data) return undefined
return {
sql: part.data.sql,
data: part.data.data,
execution_time_ms: part.data.execution_time_ms,
total_rows: part.data.total_rows,
}
})
</script>
Event ini membawa metadata query (SQL yang di-generate, hasil data, execution time) yang terpisah dari text response. Di UI, hasilnya ditampilkan sebagai collapsible SQL viewer dan data table di bawah jawaban conversational.
Frontend menggunakan shadcn-vue (New York style) + Tailwind CSS v4 untuk komponen UI, @tanstack/vue-table untuk data table hasil query, dan @unovis/vue untuk chart di halaman dashboard.
Beberapa keputusan untuk efisiensi token/biaya:
| Layer | Teknologi |
|---|---|
| Frontend | Nuxt 4 + Vue 3 (Composition API) |
| UI Components | shadcn-vue + Tailwind CSS v4 |
| Chat SDK | @ai-sdk/vue v3 + Vercel AI SDK v6 |
| Data Table | @tanstack/vue-table |
| Charts | @unovis/vue |
| Backend | FastAPI (Python 3.10+) |
| LLM | Claude Sonnet (Anthropic API) |
| Database | PostgreSQL 16 |
| DB Client | asyncpg (connection pool) |
| Containerization | Docker + Docker Compose |
| CI/CD | GitHub Actions → GHCR → VPS SSH deploy |
| Package Managers | pnpm (frontend) + uv (backend) |
POC ini membuktikan bahwa natural language to SQL sudah cukup mature untuk use case internal — terutama untuk query yang umum dan repetitif. Ini bukan menggantikan data engineer atau BI tools, tapi memberikan fast lane bagi tim bisnis yang butuh jawaban cepat tanpa harus masuk antrian ticket.
Dual-LLM call pattern terbukti efektif untuk memisahkan concern antara SQL generation dan answer generation. Dan dengan security berlapis (validator + read-only user + audit log), risikonya bisa dikelola dengan baik.
Kalau tertarik mencoba, demo-nya bisa diakses di: ai-chat-db-app.warsono.dev