Chuyển đến nội dung chính

第 11 課:結構化資料查詢 - 文字到 SQL、知識圖和資料庫代理

文字到 SQL 管道、模式註入、SQL 驗證和清理、知識圖查詢、資料庫代理模式、結果格式化、自然語言 BI。

🏗️ 建築 — 第 11 課 第 11 課:結構化資料查詢 — 文字到 SQL、知識圖和資料庫 代理商

企業人工智慧聊天機器人平台架構-從原型到生產

第 3 部分:代理架構

亞洲開發網

1. Text-to-SQL-將問題轉化為查詢

文字到 SQL 允許聊天機器人直接用自然語言查詢資料庫—「這個月的收入是多少?」 → 從訂單中選擇總和(金額),其中...。


┌────────────── TEXT-TO-SQL PIPELINE ───────────────────┐
│                                                       │
│  "Doanh thu tháng này?"                               │
│         │                                             │
│    ┌────▼────┐   ┌──────────┐   ┌──────────────┐     │
│    │ Intent  │──▶│ Schema   │──▶│ SQL          │     │
│    │ Detect  │   │ Selector │   │ Generator    │     │
│    └─────────┘   └──────────┘   └──────┬───────┘     │
│                                        │              │
│                                   ┌────▼────┐        │
│                                   │SQL      │        │
│                                   │Validator│        │
│                                   └────┬────┘        │
│                                        │              │
│    ┌──────────┐   ┌──────────┐   ┌────▼────┐        │
│    │ Format   │◀──│ Execute  │◀──│Sanitize │        │
│    │ Response │   │ Query    │   │& Limit  │        │
│    └──────────┘   └──────────┘   └─────────┘        │
└───────────────────────────────────────────────────────┘

2.模式註入-為LLM提供資料庫上下文


interface TableSchema {
  tableName: string;
  description: string;
  columns: ColumnSchema[];
  sampleData?: Record<string, unknown>[];
  relationships: Relationship[];
}

class SchemaManager {
  private schemaCache = new Map<string, TableSchema[]>();

  async getRelevantSchema(
    tenantId: string,
    userQuery: string,
  ): Promise<TableSchema[]> {
    const allSchemas = await this.loadSchemas(tenantId);

    // Use embedding similarity to find relevant tables
    const queryEmbedding = await this.embedder.embed(userQuery);
    const scored = await Promise.all(
      allSchemas.map(async (schema) => {
        const schemaText = `${schema.tableName}: ${schema.description}. Columns: ${
          schema.columns.map(c => `${c.name} (${c.type}): ${c.description}`).join(', ')
        }`;
        const schemaEmbedding = await this.embedder.embed(schemaText);
        return {
          schema,
          score: this.cosineSimilarity(queryEmbedding, schemaEmbedding),
        };
      }),
    );

    // Return top-K relevant tables
    return scored
      .sort((a, b) => b.score - a.score)
      .slice(0, 5)
      .map(s => s.schema);
  }

  formatSchemaForLLM(schemas: TableSchema[]): string {
    return schemas.map(s => `
-- Table: ${s.tableName}
-- Description: ${s.description}
CREATE TABLE ${s.tableName} (
${s.columns.map(c => `  ${c.name} ${c.type}${c.nullable ? '' : ' NOT NULL'} -- ${c.description}`).join(',\n')}
);
${s.relationships.map(r => `-- FK: ${s.tableName}.${r.column} -> ${r.refTable}.${r.refColumn}`).join('\n')}
${s.sampleData ? `-- Sample: ${JSON.stringify(s.sampleData[0])}` : ''}
`).join('\n');
  }
}

3.安全的SQL產生器


class TextToSQLAgent {
  async generateSQL(
    query: string,
    tenantId: string,
    context: QueryContext,
  ): Promise<SQLResult> {
    // 1. Get relevant schema
    const schemas = await this.schemaManager.getRelevantSchema(tenantId, query);
    const schemaText = this.schemaManager.formatSchemaForLLM(schemas);

    // 2. Generate SQL
    const response = await this.llm.chat({
      messages: [{
        role: 'system',
        content: `You are a SQL expert. Generate PostgreSQL queries based on user questions.

DATABASE SCHEMA:
${schemaText}

RULES:
1. Only use SELECT statements (no INSERT, UPDATE, DELETE, DROP, etc.)
2. Always include WHERE tenant_id = '${tenantId}' for multi-tenant safety
3. Add LIMIT 100 to prevent large result sets
4. Use CTEs for complex queries  
5. Handle NULL values appropriately
6. Format dates in Vietnamese locale

Output JSON:
{
  "sql": "SELECT ...",
  "explanation": "brief explanation of the query",
  "expectedColumns": ["col1", "col2"]
}`,
      }, {
        role: 'user',
        content: query,
      }],
      response_format: { type: 'json_object' },
      model: 'gpt-4o',
      temperature: 0,
    });

    const generated = JSON.parse(response.content);

    // 3. Validate SQL
    const validation = this.validateSQL(generated.sql, tenantId);
    if (!validation.safe) {
      throw new Error(`Unsafe SQL detected: ${validation.reason}`);
    }

    // 4. Execute
    const results = await this.executeQuery(generated.sql, tenantId);

    // 5. Format results
    const formattedResponse = await this.formatResults(query, results, generated);

    return {
      sql: generated.sql,
      results,
      explanation: generated.explanation,
      formattedResponse,
    };
  }

  private validateSQL(sql: string, tenantId: string): ValidationResult {
    const upperSQL = sql.toUpperCase().trim();

    // Block dangerous statements
    const blocked = ['INSERT', 'UPDATE', 'DELETE', 'DROP', 'ALTER', 'TRUNCATE',
      'CREATE', 'GRANT', 'REVOKE', 'EXEC', 'EXECUTE'];
    for (const keyword of blocked) {
      if (upperSQL.startsWith(keyword)) {
        return { safe: false, reason: `${keyword} statements are not allowed` };
      }
    }

    // Must include tenant filter
    if (!sql.includes(tenantId)) {
      return { safe: false, reason: 'Missing tenant_id filter' };
    }

    // Must have LIMIT
    if (!upperSQL.includes('LIMIT')) {
      return { safe: false, reason: 'Missing LIMIT clause' };
    }

    // Block subqueries that could bypass tenant filter
    const subqueryCount = (sql.match(/SELECT/gi) ?? []).length;
    if (subqueryCount > 3) {
      return { safe: false, reason: 'Too many subqueries' };
    }

    return { safe: true };
  }
}

4. 唯讀執行層


class SafeQueryExecutor {
  private readOnlyPool: Pool;

  constructor(config: PoolConfig) {
    // Use read-only replica with limited permissions
    this.readOnlyPool = new Pool({
      ...config,
      user: 'chatbot_readonly',     // Read-only user
      database: config.database,
      max: 10,                       // Limited connections
      idleTimeoutMillis: 30_000,
      connectionTimeoutMillis: 5_000,
    });
  }

  async execute(sql: string, tenantId: string): Promise<QueryResult> {
    const client = await this.readOnlyPool.connect();

    try {
      // Set statement timeout (prevent long-running queries)
      await client.query('SET statement_timeout = 10000'); // 10s max

      // Set row security policy context
      await client.query('SET app.tenant_id = $1', [tenantId]);

      // Execute in read-only transaction
      await client.query('BEGIN READ ONLY');
      const result = await client.query(sql);
      await client.query('COMMIT');

      return {
        rows: result.rows,
        rowCount: result.rowCount ?? 0,
        fields: result.fields.map(f => ({ name: f.name, dataType: f.dataTypeID })),
      };
    } catch (error) {
      await client.query('ROLLBACK');
      throw error;
    } finally {
      client.release();
    }
  }
}

5. 知識圖譜查詢


class KnowledgeGraphAgent {
  async query(
    naturalLanguageQuery: string,
    tenantId: string,
  ): Promise<GraphQueryResult> {
    // 1. Detect entities
    const entities = await this.extractEntities(naturalLanguageQuery);

    // 2. Generate Cypher query (Neo4j)
    const cypherQuery = await this.generateCypher(
      naturalLanguageQuery,
      entities,
      tenantId,
    );

    // 3. Execute on graph DB
    const graphResults = await this.neo4j.run(cypherQuery, { tenantId });

    // 4. Combine with vector search for richer context
    const vectorResults = await this.vectorStore.search({
      query: naturalLanguageQuery,
      filter: { tenantId },
      topK: 5,
    });

    // 5. Merge and format
    return this.mergeResults(graphResults, vectorResults, naturalLanguageQuery);
  }

  private async generateCypher(
    query: string,
    entities: Entity[],
    tenantId: string,
  ): Promise<string> {
    const response = await this.llm.chat({
      messages: [{
        role: 'system',
        content: `Generate a Cypher query for Neo4j.
Graph schema:
- (Product {name, price, category, tenantId})
- (Customer {name, email, segment, tenantId})
- (Order {id, date, total, tenantId})
- (Product)-[:BELONGS_TO]->(Category)
- (Customer)-[:PLACED]->(Order)
- (Order)-[:CONTAINS]->(Product)

RULES:
1. Always filter by tenantId = $tenantId
2. LIMIT 50
3. Return meaningful properties`,
      }, {
        role: 'user',
        content: `Query: ${query}\nEntities detected: ${JSON.stringify(entities)}`,
      }],
      temperature: 0,
    });

    return response.content;
  }
}

6. 自然語言結果格式化


class ResultFormatter {
  async format(
    originalQuery: string,
    results: QueryResult,
    context: FormatContext,
  ): Promise<FormattedResponse> {
    // Determine best format based on data shape
    const formatType = this.detectBestFormat(results);

    const response = await this.llm.chat({
      messages: [{
        role: 'system',
        content: `Convert query results to a natural language response in Vietnamese.
Format the data as: ${formatType}

Rules:
- Use Vietnamese number formatting (dấu chấm ngàn: 1.000.000)
- Format currency as VNĐ
- Format dates as DD/MM/YYYY
- If data is tabular, use markdown table
- Highlight key metrics
- Be concise but complete`,
      }, {
        role: 'user',
        content: `Question: ${originalQuery}\n\nQuery results:\n${JSON.stringify(results.rows, null, 2)}`,
      }],
    });

    return {
      text: response.content,
      format: formatType,
      rawData: results.rows,
      chartSuggestion: this.suggestChart(results),
    };
  }

  private detectBestFormat(results: QueryResult): string {
    if (results.rowCount === 1 && results.fields.length <= 3) return 'single_value';
    if (results.rowCount <= 5) return 'bullet_list';
    if (results.fields.some(f => f.name.includes('date'))) return 'time_series';
    return 'table';
  }

  private suggestChart(results: QueryResult): ChartSuggestion | null {
    const hasNumeric = results.fields.some(f => [23, 20, 701].includes(f.dataType));
    const hasDate = results.fields.some(f => [1082, 1114].includes(f.dataType));

    if (hasDate && hasNumeric) return { type: 'line', xAxis: 'date', yAxis: 'value' };
    if (hasNumeric && results.rowCount <= 10) return { type: 'bar' };
    return null;
  }
}

第 11 課總結

  • 文字轉SQL:模式註入→SQL產生→驗證→唯讀執行
  • 安全:唯讀使用者、語句逾時、租戶過濾、屏蔽關鍵字、需要 LIMIT
  • 知識圖譜:實體擷取→Cypher產生→圖查詢+向量混合
  • 結果格式化:自動檢測格式(單值、表格、時間序列)→ 越南語本地化
  • 行級安全性:PostgreSQL RLS+ SET app.tenant_id 確保資料隔離

下一篇: 護欄與 AI 安全 — 輸入/輸出過濾、毒性偵測、PII 屏蔽、內容審核、越獄預防。