엔지니어링 노트정형 데이터와 NL2SQL

테이블을 잘못 고른 SQL도 실행된다, 검색이 후보부터 좁힌다

테이블이 수천 개인 업무 DB에는 주문 테이블 옆에 ORD_MST_BAK, ORD_MST_202312 같은 백업·작업용 사본이 흔히 함께 남아 있습니다. 컬럼 구성이 본 테이블과 같아서, 모델이 사본을 고르면 SQL은 오류 없이 실행되고 사본을 뜬 시점의 숫자가 답으로 나갑니다. 문법 오류와 달리 DB가 거절하지 않으니 모델이 스스로 바로잡을 기회도 없습니다. 스키마를 프롬프트에 전부 실으면 이런 사본까지 모델 앞에 펼쳐지고, 수천 개 테이블의 DDL은 애초에 컨텍스트 윈도우에 들어가지 않습니다.

그래서 테이블 고르기를 SQL 쓰기와 떼어 따로 설계했습니다. 대화 첫머리에는 DB 이름만 싣습니다. 모델이 질문으로 테이블 검색을 부르면 의미 검색, 키워드 검색, 테이블 메모 검색이 후보를 모으고, 권한 밖 테이블은 빼고, 백업으로 판정된 테이블은 본 테이블 뒤로 보냅니다. 모델은 남은 후보 가운데 1~3개만 열어 컬럼을 확인한 뒤 SQL을 씁니다. 이 글은 단계마다 무엇을 싣고 무엇을 거르는지 다룹니다.

문법 오류는 DB가 알려 주지만, 테이블을 잘못 고른 SQL은 그대로 답이 된다

문법이 틀린 SQL은 덜 위험합니다. DB가 실행을 거절하고 오류 문구를 돌려주면, 읽기 전용 실행 도구가 그 문구를 모델에게 넘깁니다. 모델은 같은 턴의 다음 라운드에서 SQL을 고칩니다. 턴은 질문 하나를 받아 답을 내기까지의 한 번의 질의응답이고, 라운드는 모델을 한 번 부르고 그 응답에 든 도구 호출을 실행하기까지입니다.

테이블을 잘못 고른 SQL은 다릅니다. 업무 DB에서 자주 나오는 모양은 다음과 같습니다.

잘못 고른 것 일어나는 일
백업·작업용 사본 컬럼이 본 테이블과 같아 SQL이 그대로 돕니다. 사본을 뜬 시점의 숫자가 나옵니다.
같은 업무가 든 다른 DB 고객 테이블이 CRM DB와 청구 DB에 모두 있으면 어느 쪽을 골라도 실행되지만, 고객 수가 다릅니다.
선언되지 않은 조인 키 외래 키 없이 이름이 비슷한 컬럼끼리 조인해 행이 불어나거나 빠집니다.

셋 다 실행 상태는 성공이라, 막을 자리는 SQL을 쓰기 전 단계입니다. 받은 행 수를 총계로 읽는 실수와 컬럼·코드 값의 뜻을 미리 정리하는 분석은 각각 「RAG로는 건수와 합계를 셀 수 없다, 원천 DB에 SQL로 묻는다」, 「컬럼의 뜻은 DDL에 없다, 질문 전에 값을 읽어 둔다」에서 다루고, 이 글은 어느 테이블을 볼지 정하는 단계만 다룹니다.

스키마를 다 실어도, 하나도 안 실어도 테이블을 잘못 고른다

가장 단순한 방법은 DDL을 전부 시스템 프롬프트에 넣는 것입니다. 테이블이 수십 개라면 실제로 잘 됩니다. 수천 개가 되면 DDL만으로 컨텍스트 윈도우가 차서 대화와 도구 결과가 들어갈 자리가 남지 않습니다. 윈도우가 넉넉한 모델이라도 매 턴 수십만 토큰을 읽는 비용은 그대로이고, 폐쇄망에서 작은 오픈 모델을 쓰는 설치에서는 고를 수 없는 방법입니다. 다 실었을 때 모델 앞에 놓이는 것은 ORD_MST와 ORD_MST_BAK처럼 이름만 다른 테이블 수십 쌍이고, 어느 쪽이 본 테이블인지는 DDL에 적혀 있지 않습니다.

반대로 아무것도 싣지 않으면 모델은 테이블 이름을 짐작하거나 사용자에게 묻습니다. 업무 사용자는 테이블 이름을 모릅니다. 그래서 채팅 시스템 프롬프트는 테이블·컬럼 이름을 사용자에게 묻지 말고 도구로 찾으라고 지시하고, 사용자에게 되묻는 질문은 업무 용어로만 하게 합니다.

남는 길은 그 사이입니다. 처음에는 DB 이름만 주고, 범위를 좁힐 때마다 더 자세히 내줍니다.

대화 첫머리에는 DB 이름만 싣는다

첫 턴의 입력에는 사용자가 조회할 수 있는 DB가 MariaDB, Tibero 같은 엔진 종류와 함께 최대 10개 실립니다. 순서는 질문과 겹치는 단어가 많은 DB부터이고, 위의 3개에는 분석이 만든 DB 요약이 120자 안쪽으로 붙습니다. 권한이 없는 DB는 이름만 따로 적습니다. 사용자가 그 DB를 물으면 모델이 없는 DB로 오해하지 않고 권한이 없다고 답하게 하려는 것입니다. 권한 없는 DB에는 요약을 붙이지 않습니다. 요약만으로도 그 DB에 어떤 업무 데이터가 있는지 드러나기 때문입니다.

테이블은 검색으로 찾습니다.

테이블 검색은 의미 검색과 키워드 검색을 RRF로 합친다

테이블 검색 도구는 질문을 받아 후보 테이블을 기본 8개, 많게는 30개 돌려줍니다. 검색 대상은 테이블 이름이 아니라 분석이 만든 테이블 카드의 텍스트입니다(「컬럼의 뜻은 DDL에 없다, 질문 전에 값을 읽어 둔다」). 카드에는 테이블의 목적, 컬럼마다의 설명, 외래 키, 코드 값의 뜻, 오용하기 쉬운 컬럼이 들어 있고, 고객이 DB에 단 주석도 실립니다. 이름이 ORD_DTL_H여도 카드에 「주문 상세 이력」이라고 적혀 있으면 「주문 내역」을 묻는 질문이 그 테이블에 닿습니다.

카드 텍스트는 두 가지 검색에 함께 겁니다. 이런 방식을 하이브리드 검색이라고 부릅니다.

검색 잘 찾는 것
의미 검색(pgvector 벡터 검색) 「매출」과 「판매 금액」처럼 표현은 다르지만 뜻이 같은 말
키워드 검색(PostgreSQL 전문 검색) CUST_STAT_CD처럼 질문에 그대로 나온 식별자

두 검색의 순위는 RRF(상호 순위 융합)로 합칩니다. 두 점수는 척도가 달라 그대로 더할 수 없지만 순위는 더할 수 있고, 양쪽에서 모두 위에 오른 테이블이 앞에 섭니다.

컬럼이 수백 개인 테이블은 카드를 나눠서 색인합니다. 카드 하나를 벡터 하나로 두면 컬럼 수백 개의 뜻이 한 벡터에 섞여서, 컬럼 하나와만 맞는 질문은 그 테이블을 찾기 어렵습니다. 그래서 머리 청크(목적·키·외래 키·주의할 컬럼·컬럼 이름 목록) 하나와, 컬럼을 35개씩 묶은 청크 여러 개로 나눕니다. 검색은 청크 단위로 하고, 결과는 테이블마다 가장 가까운 청크 하나로 모읍니다. 질문이 300번째 컬럼과만 맞아도 그 컬럼이 든 청크를 거쳐 테이블이 후보에 오릅니다.

셋째 검색은 테이블 메모입니다. 메모는 사용자가 대화 중에 「이 테이블은 2023년부터 안 쓴다」, 「반품은 이 테이블에 따로 쌓인다」처럼 남긴 지식입니다. 카드에는 없고 메모에만 있는 말로 묻더라도 그 메모가 걸린 테이블이 후보에 오르고, 왜 올랐는지 보여 주는 메모 발췌가 함께 붙습니다. 메모로 찾은 테이블이 섞이면 리랭커가 질문과의 관련도로 후보를 다시 정렬합니다.

DB를 지정하지 않았고 조회할 수 있는 DB가 많으면, 검색 전에 DB부터 고릅니다. 질문과 각 DB 요약의 관련도를 임베딩으로 재고(임베딩을 쓸 수 없으면 단어 겹침으로), 관련도가 크게 떨어지는 DB는 검색하지 않습니다. 결과에는 몇 개 DB 가운데 몇 개를 찾아봤는지가 실려서, 모델은 필요하면 DB를 지정해 다시 찾습니다. 모델이 DB를 지정했거나 사용자가 멘션으로 고른 DB는 전부 찾습니다.

질문에서 후보 테이블까지: 검색 셋, 합치기, 거르기, 카드 1~3장
그림 1. 질문에서 후보 테이블까지: 검색 셋, 합치기, 거르기, 카드 1~3장

백업과 권한 밖 테이블은 검색 결과에서 거른다

검색이 모은 후보는 모델에게 가기 전에 세 번 거릅니다.

먼저 연결 계정이 더는 볼 수 없게 된 테이블을 뺍니다. 원천 DB에서 테이블이 지워졌거나 계정 권한이 회수된 경우로, 분석 결과가 남아 있어도 후보에 두지 않습니다.

다음으로 사용자 역할로 볼 수 없는 테이블을 뺍니다. 이 테이블들은 후보 목록에서 빠지고 이름만 따로 실립니다. 모델은 그 이름으로 「관련 자료는 있지만 볼 권한이 없다」고 답하고, 그 테이블에 SQL을 시도하지 않습니다.

마지막으로 백업·초안·스테이징 테이블의 순위를 낮춥니다. 어떤 테이블이 어느 테이블의 사본인지는 DB 전체를 함께 보는 분석이 판정해 둡니다(「컬럼의 뜻은 DDL에 없다, 질문 전에 값을 읽어 둔다」). 판정된 사본은 점수를 낮추고, 리랭커가 순서를 바꾼 뒤에도 본 테이블 뒤로 다시 보냅니다. 리랭커는 질문과의 관련도만 보고 줄을 세우기 때문에, 본 테이블과 내용이 같은 사본을 앞에 세울 수 있습니다. 파티션 테이블은 실데이터가 나뉘어 있는 것이라 순위를 낮추지 않습니다.

여러 DB를 함께 찾은 결과에는 후보마다 어느 DB에서 왔는지가 붙습니다. 고객 테이블이 CRM DB와 청구 DB에서 함께 나오면, 시스템 프롬프트는 모델에게 한쪽을 짐작하지 말고 사용자에게 업무 용어로 물으라고 지시합니다. 「고객 수」가 계약 고객인지 청구 대상 고객인지는 사용자만 압니다.

모델은 후보 가운데 1~3개만 카드로 연다

후보마다 테이블 이름과 DB·스키마, 점수, 카드 텍스트가 실리고, 분석이 판정한 테이블 역할과 백업 여부, 코드 값의 뜻, 오용하기 쉬운 컬럼, 조인 후보, 그 테이블에 붙은 메모 최대 5개가 함께 옵니다. 그 DB의 엔진과 버전도 붙습니다. 모델은 이것만 보고 후보 대부분을 버릴 수 있습니다.

시스템 프롬프트는 두 가지를 함께 요구합니다. SQL을 쓰기 전에는 쓰려는 테이블마다 테이블 카드를 열어 컬럼을 확인할 것, 그리고 검색 후보 가운데 가장 유력한 1~3개에서 멈추고 전부 열지 말 것. 카드도 컨텍스트를 차지하기 때문입니다. 카드에는 컬럼마다 타입·키 역할·DB 주석·통계·자주 나오는 값·뜻이 한 줄로 모여 있습니다. 주문 상태를 STATUS_CODE 대신 자유 메모 컬럼인 REMARK에서 찾는 실수는 카드의 오용 주의 목록이 막습니다.

컬럼이 아주 많은 테이블은 카드 한 장도 커집니다. 카드가 도구 결과의 상한을 넘으면 줄여서 싣되, 모든 컬럼의 이름·타입·키는 남기고 상세를 뺀 컬럼은 이름을 따로 적습니다. 모델은 필요한 컬럼만 지정해 카드를 다시 불러 그 컬럼의 상세를 읽습니다.

여러 테이블을 조인해야 하면 조인 경로 도구를 부릅니다. 이 도구는 DB에 선언된 외래 키와 분석이 추정한 조인 후보를 나눠서 돌려줍니다. 추정 후보에는 근거와 확신도가 붙고, 시스템 프롬프트는 확인한 뒤에 쓰라고 지시합니다. 선언된 키와 확인한 추정 후보 밖의 조인 키는 지어내지 말라는 지시도 함께 둡니다.

테이블 이름 목록 도구는 검색이 아무것도 찾지 못했을 때만 씁니다. 큰 DB에서 이름 목록은 대부분 잡음입니다.

이렇게 나누면 모델이 한 턴에 보는 스키마는 보통 DB 목록 몇 줄, 후보 몇 개의 요약, 카드 한두 장입니다. 후보 수와 여는 카드 수가 정해져 있어서, 테이블이 수천 개로 늘어도 이 크기는 크게 달라지지 않습니다.

설계에서 고른 것

검색을 테이블 선택의 첫 단계로 두었습니다. 모델은 검색이 고른 후보에서 출발하므로, 검색이 본 테이블을 놓치면 엉뚱한 후보에서 시작합니다. 그래서 검색은 한 턴에 여러 번 부를 수 있게 했고, 첫 후보가 맞지 않으면 모델이 질의를 바꾸거나 DB를 지정해 다시 찾습니다. 그래도 찾지 못하면 테이블 이름 목록이 남아 있습니다.

키워드 검색은 PostgreSQL의 simple 설정을 골랐습니다. 형태소 분석기를 쓰는 설정은 「주문을」처럼 조사가 붙은 말까지 맞히지만 한 언어에 묶입니다. 테이블·컬럼 이름은 CUST_STAT_CD처럼 영문 약어인 경우가 많고, simple은 어느 언어의 테이블·컬럼 이름에도 같은 방식으로 걸립니다. 조사가 붙은 표현은 의미 검색이 메웁니다.

백업 테이블은 빼지 않고 순위만 낮췄습니다. 빼 버리면 「지난달 백업과 지금 테이블의 건수 차이」처럼 사본을 일부러 묻는 질문에 답할 수 없습니다. 대가는 사본이 후보 목록 아래쪽에 남는다는 점이고, 시스템 프롬프트가 사용자가 백업을 묻지 않는 한 본 테이블을 쓰라고 지시합니다.

대화 첫머리의 DB 정렬은 단어 겹침으로 가볍게 합니다. 매 대화 첫머리에 도는 계산이라 정밀한 쪽보다 빠른 쪽을 골랐습니다. 정렬이 빗나가도 상위 10개 안의 DB는 요약 없이 이름으로 목록에 남고, 목록 밖의 DB는 모델이 테이블 검색으로 찾아갑니다.

카드를 1~3장만 열게 한 대가는 라운드입니다. 첫 카드가 맞지 않으면 한 번 더 돌지만, 컨텍스트는 질문에 실제로 쓰이는 테이블이 차지합니다.

테이블이 수백 개를 넘고 백업 사본과 같은 업무의 DB 여러 개가 섞인 환경에서 SQL이 틀린 숫자를 냈다면, SQL보다 검색이 돌려준 후보 목록을 먼저 봅니다. 본 테이블이 후보에 없었는지, 있었는데 사본 뒤에 섰는지에 따라 고칠 곳이 다릅니다.

어떤 문제를 해결하나요?

정형 데이터와 NL2SQL의 다른 글