
최근에 챗봇 멀티모달과 관련하여 작업할 내용이 있었다.
어째 하다보니 단순 일회성으로 파일이나 이미지를 입출력하는게 아니라 과거 특정 날짜에 올렸거나 특정 내용을 담은 파일에 대한 검색이 가능해야한다는 요구사항이 추가되었다.
때문에 가장 필요로 했던 것이 바로 자연어로 파일을 찾는 기능이었다.
예를 들어 이런 질문이다.
처음에는 LLM에게 SQL을 생성하게 하면 되겠지. 하고 단순하게 생각했다.
하지만 실제 서비스에 붙이면서 생각보다 많은 문제가 터졌고 새로운 설계가 필요하게 되었다.
핵심은 하나이다.
SQL을 생성하게 하지 말고, 조회 스펙(Query Spec)을 생성하게 하자.
이번 글은 자연어 → SQL 조회 조건을 만드는 에이전트와 그 결과를 RDB / VectorDB에서 공통으로 해석하는 구조를 만들게 된 과정에 대한 기록이다.
서비스에는 파일(assets)이 꽤 많이 쌓인다.
유저는 파일명을 정확히 기억하지 않는다.
그래서 보통 이렇게 묻는다.
맨 처음 이런 요구사항을 처리하기 위해서 두 가지 정도의 방법을 생각했다.
예를 들면 이런 식이다.
/files?type=pdf/files?name=회의록/files?created_after=...문제는 자연어 질문은 이런 식으로 정형화되어 있지 않다는 것이다.
"저번에 올린 회의록 중 최신 5개"
이 질문은 사실 아래 조건을 동시에 요구한다.
하지만 API만으로 처리하면 예측할 수 없는 수 많은 조합이 생기게 되고 이 방법으로는 지속가능한 서비스를 제공할 수 없을 것이라는 생각이 들었다.
그래서 다음으로 LLM에게 SQL을 생성하게 했다.
SELECT *
FROM assets
WHERE asset_type = 'pdf'
ORDER BY created_at DESC
LIMIT 5
처음에는 꽤 잘 되는 것처럼 보였지만 QA단계에서 바로 문제가 생겼다.
SQL을 그대로 생성하게 하면 생각보다 문제가 많다.
LLM은 이런 걸 아무렇지 않게 만든다.
WHERE file_type = 'pdf'
하지만 실제 컬럼은
asset_type
이다.
아무리 테이블 정보를 준다 한들, 확률은 0이 아니라는게 문제다.
사용자가 파일명을 말하면 LLM은 높은 확률로 이런 sql문을 출력한다.
WHERE file_name LIKE '%회의록%'
하지만 실제로는 이미 파일 ID 목록이 주어져 있고 LIKE문 보다는 ID 접근이 훨씬 정확하다.
이 문제가 가장 큰 문제인데 바로 다음과 같은 것들이 튀어나온다는 것이다.
NOW()
INTERVAL
DATE_TRUNC
SUBQUERY
이런 것들은 DBMS에 과하게 종속적이기도 하고 검증하기도 어려울 뿐더러 통제하기도 힘들다.
현재 챗봇 서비스는 RDB + VectorDB를 같이 사용한다.
문제는 SQL 문자열은 VectorDB에서 재사용할 수 없다는 것이다.
여기에 예상치 못한 프롬프트로 인한 SQL Injection 가능성까지 고려해보면 문자열로 생성된 sql문은 서비스에서 바로 사용하기에 부적합하다는 것이 결론이었다.
결국 통제 가능성과 재사용성을 고려한 sql 생성 에이전트를 만들어야하는 상황이 되었다.
여기서 생각을 바꿨다.
SQL을 생성하게 하지 말자.
대신 조회 스펙(Query Spec) 을 생성하게 하자.
즉 이런 구조다.
User Query
↓
LLM
↓
Query Spec(JSON)
↓
Interpreter
↓
SQLAlchemy / Qdrant Filter
LLM은 더 이상 SQL을 생성하지 않는다.
대신 조회 조건을 구조화된 JSON으로 반환한다.
먼저 조회 DSL을 정의했다.
기본적으로 sql에서 사용할 수 있는 대부분의 연산자와 데이터타입을 구성하는걸 목표로 했다.
Operator = Literal[
"eq", "neq", "gt", "gte", "lt", "lte",
"like", "ilike",
"in", "not_in",
"is_null", "is_not_null",
"between"
]
Direction = Literal["asc", "desc"]
Scalar = Union[str, int, float, bool, datetime, date]
Value = Union[Scalar, List[Scalar]]
Where 조건은 트리 구조로 만든다.
타겟 컬럼과 연산, 비교값 및 구간을 설정할 수 있다.
우선순위는 value에 있고 만약 없을 경우 구간의 시작과 끝인 start와 end를 확인한다.
class WhereCondition(BaseModel):
column: str
operator: Operator
value: Optional[Value] = None
start: Optional[Scalar] = None
end: Optional[Scalar] = None
논리 그룹도 지원한다.
이제 WhereCondition을 or나 and로 묶을 수 있게 되었다.
class WhereGroup(BaseModel):
op: Literal["and", "or"] = "and"
conditions: List[Union["WhereGroup", WhereCondition]] = Field(default_factory=list)
정렬은 단순하게 유지한다.
class OrderBy(BaseModel):
column: str
direction: Direction = "desc"
최종 조회 스펙은 다음과 같다.
class Statement(BaseModel):
where: Optional[WhereGroup] = None
order_by: List[OrderBy] = Field(default_factory=list)
limit: int = Field(20, ge=1, le=200)
offset: int = Field(0, ge=0)
allowed_columns: Optional[Iterable[str]] = None
LLM이 받는 입력은 다음과 같다.
class SqlInput(BaseModel):
user_id: str
query: str
table_info: str
data_list: str
여기서 중요한 건 data_list다.
파일 목록을 같이 주기 때문에 LLM은 파일명을 보고 ID 필터로 변환할 수 있다.
출력은 단순하다.
class SqlOutput(BaseModel):
where: Optional[WhereGroup] = None
order_by: List[OrderBy] = Field(default_factory=list)
limit: int = Field(20, ge=1, le=200)
offset: int = Field(0, ge=0)
LLM은 이제 문자열로 된 SQL을 절대 생성하지 않는다. 오직 JSON만 반환한다.
이제 이 스펙을 실제 DB 쿼리로 변환해야 한다.
그래서 번역기 를 만들었다.
class QuerySpecError(ValueError):
pass
쿼리 생성 과정에서 발생하는 오류를 명확히 분리하기 위한 예외 타입이다.
예를 들어 다음 상황에서 사용된다.
def _get_col(entity, col_name, allowed_cols=None):
이 함수는 문자열 컬럼 이름을 실제 SQLAlchemy 컬럼 객체로 변환한다.
핵심 역할은 두 가지다.
if allowed_cols and col_name not in allowed_cols:
raise QuerySpecError
외부 입력이 임의의 컬럼을 건드리지 못하게 막는다.
예를 들어 내부 컬럼
같은 컬럼은 외부에서 직접 접근하게 하고 싶지 않을 수 있다.
이때 whitelist로 제어한다.
if hasattr(entity, col_name)
if hasattr(entity.c, col_name)
entity.columnentity.c.column둘 다 지원한다.
가장 중요한 함수는 build_where_expr이다.
def build_where_expr(...)
이 함수는 다음을 수행한다.
을 받아서 SQLAlchemy boolean expression으로 변환한다.
AI나 외부 시스템이 생성한 조건은 예상보다 복잡해질 수 있다.
그래서 다음 제한을 둔다.
max_nodes
max_depth
이런 상황을 방지한다.
이는 성능 보호와 악의적 입력 방어 역할을 한다.
핵심 구현은 재귀 순회다.
def _walk(node, depth):
노드를 순회하면서
WhereGroup → 논리 연산 조합WhereCondition → 실제 연산 해석을 수행한다.
WhereGroup은 SQLAlchemy의
and_(...)
or_(...)
같은 논리 표현식으로 변환되고, 실제 중요한 로직은 WhereCondition을 해석하는 부분이다.
위에서 언급했지만 간단하게 다시 정리하자면 WhereCondition의 구조는 다음과 같다.
WhereCondition
- column
- operator
- value
해석 과정은 단순하다.
col = _get_col(entity, node.column)
이 부분이 사실상 Query Spec DSL을 해석하는 인터프리터 역할을 한다.
지원하는 비교 연산은 다음과 같다.
eq
neq
gt
gte
lt
lte
이에 대한 SQLAlchemy 매핑은 다음과 같다.
col == value
col != value
col > value
col >= value
col < value
col <= value
LLM이 만든 Query Spec은 결국 이 연산들로 변환된다.
문자열 검색 연산과 그 매핑은 다음과 같다.
like
ilike
col.like(value)
col.ilike(value)
검색 패턴 %keyword% 생성은 해석기가 아니라 에이전트 단계에서
처리한다.
즉 Query Spec에는 이미 패턴 문자열이 들어 있어야 한다.
IN 연산은 다음과 같이 매핑된다.
in
not_in
col.in_(value)
not_(col.in_(value))
여기에 중요한 방어 로직이 들어가는데 다음과 같은 경우다.
id in []
sql 에이전트에서 위와 같은 경우가 나올 수 있는데 이 경우 SQL 의미가 없기 때문에 해석 단계에서 바로 예외 처리한다.
SQL에서는 NULL 비교가 일반 비교와 다르기 때문에 별도 연산이 필요하다.
is_null
is_not_null
col.is_(None)
col.is_not(None)
BETWEEN은 두 가지 입력 방식을 지원한다.
value=[start, end]
또는
start / end 필드
해석기는 두 방식을 모두 처리한다.
col.between(start, end)
이 설계는 LLM 출력이 약간 흔들리더라도 복구가 가능하도록 하기 위한 것이다.
마지막 단계는 build_statement이다.
stmt = select(entity)
여기에 다음 요소를 붙인다.
stmt.where(where_clause)
stmt.order_by(col.asc())
여기서도 _get_col을 사용한다.
즉, 정렬 컬럼도 검증된다.
stmt.limit()
stmt.offset()
이 설계의 가장 큰 특징은
SQL 문자열을 만들지 않는다
는 점이다.
대신 쿼리 스펙 트리를 생성한다.
설계를 하면서 가장 고려했던 부분은 아래와 같다.
다시 말해서 아래와 같은 장점을 가질 수 있다는 말이다.
아직 완전히 운영단계로 넘어간 설계는 아니다.
그래서 지금도 추가 보완이 이뤄지고 있는 영역이긴 하다.
하지만 기존 자연어 -> sql변환이 필요한 영역의 구현을 온전히 llm에 맡기는 구조에서 통제 가능한 구조로 변형해본 경험은 꽤 재밌었다.
부디 운영단에서 문제없이 돌아갔으면 하는 바람과 함께 글을 마무리해보겠다.