March 17, 2026

AI Chat DB

Membangun AI Chat to Database: Natural Language ke SQL dengan Dual-LLM Pipeline

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


Masalah yang Ingin Diselesaikan

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:

  1. Generate SQL yang tepat
  2. Jalankan query di PostgreSQL
  3. Kembalikan jawaban dalam Bahasa Indonesia yang conversational

Arsitektur: Dual-LLM Call Pipeline

Keputusan arsitektur paling penting dalam project ini adalah menggunakan dua kali LLM call untuk setiap pertanyaan user, bukan satu.

Mengapa Dua LLM Call?

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:

  • SQL generation butuh output yang complete — tidak ada gunanya streaming SQL setengah jadi
  • Answer generation benefit dari streaming — user langsung melihat jawaban muncul karakter per karakter, membuat UX terasa responsif

Dengan memisahkan dua concern ini, masing-masing prompt juga bisa di-optimize secara independen.

Request Flow

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

Implementasi Backend: FastAPI + Anthropic SDK

Streaming Service — Jantung dari Pipeline

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})

LLM Service — Provider Abstraction

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.

SQL Generation Prompt — Few-Shot Examples

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.

Schema Enrichment — Konteks Bisnis untuk LLM

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.


Security: Defense in Depth

Memberikan LLM akses ke database terdengar berbahaya. Ini benar — dan itulah mengapa security-nya berlapis:

Layer 1: SQL Validator (Application Level)

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

Layer 2: Database Level

  • Read-only PostgreSQL user — role readonly_user hanya punya SELECT grant
  • Statement timeout 10 detik — query yang terlalu lama otomatis di-kill
  • Table whitelist — hanya sales dan products yang di-expose ke LLM

Layer 3: Application Safeguards

  • Rate limiting — 20 request per IP per menit
  • Audit logging — setiap query di-log sebagai structured JSON (timestamp, IP, pertanyaan, SQL, status)

Bahkan 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.


Frontend: Nuxt 4 + AI SDK v6

Chat Singleton Pattern

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.

Custom SSE Event: data-query-result

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.

UI Components

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.


Token Economy: Menjaga Biaya Tetap Rendah

Beberapa keputusan untuk efisiensi token/biaya:

  1. Schema cache (1 jam TTL) — Tidak perlu introspect database setiap request
  2. Truncate data untuk LLM — LLM Call #2 hanya melihat 50 baris pertama, meskipun query mengembalikan ratusan baris. Cukup untuk generate insight yang akurat
  3. System prompt yang ringkas — Few-shot examples minimal (5 contoh) tapi mencakup pattern utama
  4. Auto-LIMIT 1000 — Mencegah query yang mengembalikan jutaan baris

Hasil dan Pembelajaran

Apa yang Berhasil

  • Akurasi SQL cukup tinggi untuk query umum — agregasi, filter tanggal, join, grouping
  • Streaming UX membuat app terasa cepat — meskipun total latency bisa 5-8 detik, user sudah melihat jawaban muncul setelah ~3 detik
  • Transparansi SQL membangun trust — user bisa lihat query yang di-generate dan verify hasilnya
  • Bilingual support bekerja natural — user bisa mix Bahasa Indonesia dan English

Apa yang Bisa Diperbaiki

  • Query kompleks (subquery bertingkat, window functions) kadang tidak akurat — perlu lebih banyak few-shot examples
  • Tidak ada conversation memory — setiap pertanyaan independen, tidak bisa "follow-up" dari pertanyaan sebelumnya
  • Rate limiting in-memory — reset saat app restart, perlu Redis untuk production
  • Hanya 2 tabel — perlu testing dengan schema yang lebih kompleks

Tech Stack Lengkap

LayerTeknologi
FrontendNuxt 4 + Vue 3 (Composition API)
UI Componentsshadcn-vue + Tailwind CSS v4
Chat SDK@ai-sdk/vue v3 + Vercel AI SDK v6
Data Table@tanstack/vue-table
Charts@unovis/vue
BackendFastAPI (Python 3.10+)
LLMClaude Sonnet (Anthropic API)
DatabasePostgreSQL 16
DB Clientasyncpg (connection pool)
ContainerizationDocker + Docker Compose
CI/CDGitHub Actions → GHCR → VPS SSH deploy
Package Managerspnpm (frontend) + uv (backend)

Penutup

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