3줄 요약

  1. Rohan Bansal이 2026년 9월에 공개한 개인 실험 기록이다. 4B 크기의 오픈웨이트 모델을 후학습시켜, Postgres가 스스로 고르는 기본 쿼리 플랜보다 빠른 플랜을 내놓게 만들 수 있는지 확인하는 것이 목표였다.
  2. 학습 전의 4B 모델은 조인 순서 벤치마크 113개 질의 가운데 99개에서 유효한 플랜을 하나도 만들어내지 못했다. 저자는 GPT-6 Astra의 에이전트 궤적을 오프폴리시 증류로 먼저 가르치고, 이어서 에이전틱 강화학습을 1,200스텝 돌렸다. 그 결과 113개 질의 전체에서 기하평균 1.81배의 speedup과 총 실행 시간 44.7% 감소를 얻었다.
  3. 저자가 쓴 돈은 H100 임대료 약 800달러와 OpenAI API 요금 약 400달러를 합쳐 1,200달러였다. Bansal의 결론은 이렇다. 자기 데이터를 이미 가진 기업이 좁은 도메인 과제에 맞춰 오픈웨이트 소형 모델을 직접 학습시키는 일은 이제 충분히 현실적인 선택지가 되었다.

쿼리 옵티마이저가 어려운 이유

Leis 등은 2015년 VLDB 논문 「How Good Are Query Optimizers, Really?」에서 제목 그대로의 질문을 던졌고, 10년 뒤인 2025년에 같은 질문을 한 번 더 던졌다. 두 번 모두 결론이 비슷했다. 십 년치 연구가 쌓였는데도 쿼리 옵티마이저는 여전히 아쉬운 답을 내놓는다는 것이었다.

옵티마이저가 해야 하는 일 가운데 하나인 조인 순서 결정은 NP-hard 문제로 알려져 있다. 저자는 IMDb 데이터셋의 세 테이블로 이 문제를 설명한다. company_name이 10만 행, movie_companies가 200만 행, title이 100만 행이다. 이 세 테이블을 어떤 순서로 조인하더라도 두 번째 조인에 들어가는 행 수는 200만 행으로 똑같다.

선택 조건을 붙이는 순간 계산이 달라진다. 일본 회사만 남기면 company_name에 5천 행이 남는다. 2000년대 작품만 남기면 title에는 20만 행이 남는다. 이때 두 조인 순서의 비용 차이가 이렇게 벌어진다.

조인 순서첫 조인 결과두 번째 조인 결과
(일본 회사 ⋈ movie_companies) ⋈ 2000년대 작품약 10만 행약 2만 행
(2000년대 작품 ⋈ movie_companies) ⋈ 일본 회사약 40만 행약 2만 행

최종 결과는 같은데 두 번째 순서를 고르면 중간 단계에서 네 배의 일을 하게 된다.

치비 서소영이 가지 모양의 조인 트리 모형 두 개를 저울에 올려 견주는 모습. 왼쪽 트리에는 구슬 몇 개가 든 작은 통이, 오른쪽 트리에는 구슬이 넘치는 큰 통이 달려 저울이 오른쪽으로 크게 기울어 있다

문제는 여기서 끝나지 않는다. 조인 하나마다 해시 조인, 머지 조인, 중첩 루프 조인 중 하나를 고를 수 있고, 교환법칙까지 따지면 세 테이블에 여덟 가지 방향이 나온다. 테이블을 읽는 방법도 순차 스캔, 인덱스 스캔, 인덱스 온리 스캔, 비트맵 스캔 네 가지가 있다. 이 세 테이블짜리 질의 하나를 실행하는 방법만 4,608가지다. 물론 Postgres가 이 전부를 평가하지는 않는다. 동적 계획법으로 탐색 공간을 쳐내고, 조인이 12개를 넘어가면 유전 알고리즘을 쓴다.

Postgres에는 더 근본적인 제약이 하나 걸려 있다. Postgres는 플래닝 단계에서 카디널리티를 셀 수 없다. 세어 보려면 조인을 실제로 실행해야 하는데, 그러면 빠른 옵티마이저라는 목적이 무너진다. 대신 Postgres는 pg_statistic 테이블에 쌓아 둔 통계, 곧 컬럼별 최빈값과 그 빈도, 나머지 구간의 히스토그램으로 카디널리티를 추정한다. 조인이 끼면 한 테이블의 행이 다른 테이블에 어떻게 분포하는지 알 수 없으므로, 첫 테이블에서 관측한 빈도가 두 번째 테이블에도 그대로 적용된다고 가정한다. 이 가정을 균일 분포 가정이라 부른다.

이 가정은 어림짐작으로는 괜찮지만 틀릴 때는 크게 틀린다. 앞의 예에서 회사의 5%가 일본 회사라고 해도, 그 5%가 실제로는 전체 영화의 50%를 만들었을 수 있다. 그러면 첫 조인 결과가 10만 행에서 100만 행으로 뛴다. 비용 모델은 여전히 첫 번째 순서를 고르지만 실제로는 두 번째 순서가 더 낫다. 초반 조인 하나의 추정이 틀어지면 그 오차가 나머지 조인 트리의 추정치를 모두 망가뜨린다.

pg_hint_plan으로 플랜 선택을 유도한다

Postgres는 언제나 비용이 가장 낮은 플랜을 고르고, 소스 코드를 고치지 않고서는 그 비용 모델을 바꿀 수 없다. 그렇다면 어떻게 다른 플랜을 고르게 만들 수 있을까. 저자가 쓴 도구는 pg_hint_plan이라는 서드파티 확장이다. SQL 문 위에 /*+ ... */ 형태의 주석 블록을 붙이면, Postgres는 그것을 평범한 주석으로 보고 무시하지만 pg_hint_plan은 그 안의 지시를 읽어 플랜을 유도한다. HashJoin(a b)라고 적으면 두 테이블을 해시 조인으로 붙이고, SeqScan(a)라고 적으면 인덱스 대신 순차 스캔으로 읽는다.

그러니 이 실험의 질문은 다음 한 줄로 정리된다.

언어 모델이 더 나은 쿼리 플랜을 만들어내는 힌트를 학습할 수 있는가.

저자는 처음에 모델에게 Postgres 플래너와 똑같은 정보를 주고 더 나은 카디널리티 추정기를 만들어 보는 방향을 떠올렸다가 접었다. 수십 년치 카디널리티 추정 연구와 정면으로 싸워야 하는 데다, 추론 지연만으로도 Postgres의 빠른 옵티마이저를 따라잡을 수 없기 때문이었다.

대신 저자가 고른 것은 무거운 분석 워크로드라는 특정한 사용 패턴이었다. 같은 질의가 수천 번씩 반복 실행되는 상황이라면, 학습 과정에서 그 질의를 수십에서 수백 번 실행하는 비용을 치르더라도 전체 실행 횟수로 나눈 상각 비용은 훨씬 낮아진다. 일회성 질의에서 Postgres와 시간 대비 효율을 겨루는 일은 목표에서 제외했다. 반복해서 돌아가는 질의에서만 이겨 보겠다는 쪽으로 방향을 잡았다.

하네스와 벤치마크

저자는 집에 둔 RTX 3090 두 장짜리 장비(FLOPper라는 이름을 붙였다)로 학습과 추론을 직접 돌리려고 4B 모델을 골랐다. 실험을 시작할 무렵 Qwen 3.8 계열에는 4B 변종이 없었는데, 독일의 작은 연구소 Empero가 Qwen 3.8의 2.4T 모델을 교사로 삼아 Qwen 3.5 4B에 증류한 empero-ai/Qwen3.8-4B-Distill을 공개했다. 이 증류 모델은 원본 3.5 모델보다 MMLU에서 낫고 GSM8K에서는 조금 못하다. 어느 쪽이 이 과제에 유리한지 저자도 알 수 없었지만 증류 모델로 가기로 했다.

모델에는 qo-agent라는 가벼운 에이전트 하네스를 붙이고 도구 여섯 개를 줬다.

도구하는 일
inspect_relation테이블의 컬럼 타입과 널 허용 여부, 인덱스 정의, 추정 행수와 바이트 수를 보여준다
get_column_stats한 릴레이션의 컬럼 1개에서 8개까지 플래너 통계를 가져온다
get_plan기본 플랜의 추정치 또는 제출된 후보의 저장된 플랜을 가져온다
evaluate_candidate제안된 플랜을 검증한 뒤 실행해서 타이밍과 플랜 진단을 돌려준다
keep_defaultPostgres의 기본 플랜을 그대로 후보로 삼고 탐색을 끝낸다
finish후보 ID나 기본 플랜을 받아 탐색을 끝낸다

에이전트는 구조화 출력을 활용해 PlanAction이라는 JSON 객체를 만들고, evaluate_candidate가 그 객체를 힌트로 컴파일해 원래 질의 앞에 붙인다.

벤치마크는 두 가지를 썼다. 하나는 앞서 언급한 Leis 등의 논문이 함께 내놓은 Join Order Benchmark(JOB)로, IMDb 데이터에 대한 113개 질의가 33개 템플릿에 걸쳐 있다. 다른 하나는 「Flow-loss」 논문이 내놓은 Cardinality Estimation Benchmark(CEB)로, 같은 IMDb 데이터베이스 위에 16개 템플릿에서 합성된 13,646개 질의가 있다. 크기가 큰 CEB를 학습에 쓰고 JOB으로 성능을 검증했다.

같은 데이터베이스에서 학습하고 시험하는 것이 말이 되느냐는 질문에 저자는 그것이 바로 이 실험의 전제라고 답한다. 특정 기업의 분석 워크로드에 이 에이전트를 계속 쓴다면 모든 데이터베이스로 일반화할 필요가 없고, 그 데이터베이스 하나를 잘 아는 편이 낫다는 것이다. 걱정해야 할 것은 JOB 템플릿 자체에 과적합되는 쪽이었다. 그래서 질의의 조인 그래프만 남긴 구조(테이블을 노드로, 조인을 엣지로 본 위상)를 계산해 JOB과 CEB 사이에 겹치는 위상이 있는지 확인했다. 겹침이 없어서 학습 데이터를 걸러낼 필요는 없었다. JOB은 113개 질의에 33개 템플릿과 33개 위상, CEB는 13,646개 질의에 16개 템플릿과 12개 위상이었다.

측정 노이즈를 줄이는 일

저자는 학습 과정보다 측정 장비를 만드는 이야기에 훨씬 많은 분량을 할애한다. 같은 질의를 Postgres에서 스무 번 연달아 돌려도 매번 실행 시간이 다르기 때문이다. 평소 업무라면 신경 쓸 일이 아니지만, 이 실험의 전제 자체가 어떤 플랜이 기본 플랜보다 빠른지를 측정하는 데 달려 있다.

FLOPper는 물리 코어 16개, RAM 64GB, 2TB NVMe SSD를 갖췄고 실험에 쓴 IMDb 조각은 디스크에서 8.5GB다. 저자는 Postgres 이미지로 만든 컨테이너 네 개를 띄우고 각각 코어 4개와 메모리 8GB를 할당했다. 컨테이너를 그보다 줄이면 학습이 너무 느려진다고 봤다. 반대로 늘리면 CPU 경합 탓에 노이즈가 커진다. 네 컨테이너가 공유 큐에서 113개 질의를 하나씩 꺼내 가며 측정하는 구조다.

측정은 두 단계로 이뤄진다. 먼저 질의를 몇 번 돌려 워밍업을 하고, 그다음 스무 번 더 돌려 실행 시간을 기록한다. 워밍업이 끝났는지는 Postgres가 EXPLAIN (ANALYZE, BUFFERS)로 알려주는 두 카운터로 판단했다. 자기 캐시에서 페이지를 찾으면 shared hit blocks가 올라간다. 리눅스에 다시 요청해야 할 때는 shared read blocks가 올라간다. 직전 실행과 비교해 두 카운터 값의 차이가 모두 2% 이내이고 플랜이 바뀌지 않았으면 워밍업이 끝난 것으로 봤다. 최소 두 번은 돌려야 비교할 값이 생기고, 다섯 번을 채우면 무조건 멈췄다.

shared_buffers를 128MB로 두고 첫 보정을 돌렸을 때, 절반에 해당하는 질의가 두 번 만에 워밍업을 통과했다. 그런데 자세히 보니 shared read blocks가 0으로 떨어지지 않고 큰 값에서 안정적으로 머물러 있었다. 8.5GB 데이터베이스에 128MB 캐시였으니 Postgres는 매번 자기 캐시를 놓치고 리눅스에 페이지를 요청하고 있었던 것이다. 안정되었다는 말과 캐시에 상주한다는 말은 달랐다.

리눅스의 페이지 캐시도 빠르기 때문에 그것만으로 재앙이 되지는 않는다. 문제는 스무 번의 측정값을 실제로 들여다봤을 때 드러났다. job-13b 질의는 스무 번 중 열네 번이 186에서 204밀리초 사이에 들어왔고, 나머지 여섯 번은 227에서 253밀리초 사이에 들어왔다. 편차가 고르게 퍼져 있지도 않았다. 이 질의에는 두 개의 속도가 따로 있었고, 세 번에 한 번꼴로 느린 쪽에서 돌았다.

치비 서소영이 똑같이 생긴 작은 상자들 옆에 쪼그려 앉아 한 상자에 청진기를 대고 있다. 그 상자 위에만 스톱워치 두 개가 서로 다른 시각을 가리키고 있어 서소영이 눈살을 찌푸린다

여기서 저자가 무엇을 줄여야 하는지 다시 정의하는 대목이 이 글의 백미다. 에이전트 실행 중에는 후보 플랜과 기본 플랜을 번갈아 세 쌍 돌리고, 양쪽의 중앙값을 비교해 5% 이내면 무승부로 처리한다. 그런데 job-13b처럼 실행 시간이 두 무리를 이루는 질의라면, 세 번 중 두 번이 느린 시간대에서 나올 때 중앙값도 느린 시간대의 값이 된다. 스무 개 중 세 개를 뽑는 경우의 수 1,140가지 가운데 230가지가 느린 값을 두 개 이상 포함하므로, 한쪽 중앙값이 느린 시간대의 값이 될 확률은 약 20%다. 기본 플랜과 똑같이 동작하는 후보를 놓고도 다섯 번에 한 번은 14%에서 26%짜리 유령 speedup이나 유령 퇴행을 모델에게 보상 신호로 넘기게 된다.

이에 변동계수 대신 이 속임수 비율 자체를 최소화 대상으로 삼았다. 원시 보정 데이터에서 연속된 여섯 번의 실행으로 창을 만들고 그 안에서 후보와 기본을 번갈아 짝지으면 창 하나당 여덟 가지 경우가 나온다. 스무 개 측정값이면 창을 열다섯 번 미끄러뜨릴 수 있으므로 질의 하나당 120가지 경우가 생긴다. 이 중 5%의 무승부 구간을 벗어나는 비율이 그 질의의 no-op 오류율이다. 113개 질의에서의 평균이 전체 no-op 오류율이고, 정렬했을 때 90% 지점에 해당하는 질의가 p90 질의다.

그다음 메모리 관련 설정 두 개를 바꿔 가며 네 번 보정을 돌렸다.

shared_bufferswork_memno-op 오류율 (1회 / 2회)p90 질의중앙 CV총 실행 시간
128MB4MB5.0% / 5.4%13% / 20%2.3%95초
2GB4MB1.8% / 1.2%1.3% / 0%1.1%60초
128MB32MB7.0% / 6.6%20% / 23%2.6%94초
2GB32MB1.7% / 1.3%0% / 0%1.2%60초

work_mem은 노이즈에 아무 영향이 없었고, 노이즈를 좌우한 것은 shared_buffers 하나였다. 2GB로 올리자 중앙값 질의는 워밍업이 끝나는 시점에 shared read blocks가 정확히 0이 되었다. 작업 집합이 Postgres 자기 캐시 안에 온전히 들어왔다는 뜻이다. no-op 오류율은 약 4분의 1로 떨어졌고, 두 속도를 오가던 job-13b는 변동계수 10.3%에서 0.9%로 내려가 스무 번의 실행이 7밀리초 안에 모였다.

덤도 하나 따라왔다. 캐시에 상주하게 되자 기본 플랜 자체가 빨라져서, 113개 질의를 모두 돌리는 시간이 95초에서 60초로 줄었다. 학습 중에 치러야 할 측정 비용이 그만큼 싸진 것이다. 저자는 shared_buffers 2GB와 work_mem 4MB로 설정을 고정하고 나머지 실험을 진행했다.

출발점: 프런티어 모델과 학습 전 4B

4B 모델을 돌리기 전에 저자는 이 문제가 오늘날의 프런티어 모델로 풀리는 문제인지부터 확인했다. JOB 질의 10개를 뽑아 GPT-6 Astra와 Qwen 3.8 2.4T를 같은 하네스에 넣고 돌렸다. 아래 표에서 [m]은 모델만 쓴 설정, [m, r]은 추론 요약을 켠 설정이다.

모델후보 수점수 산정기하평균 speedup총 워크로드 speedup퇴행
Astra [m]19/100.85x1.00x3
Astra [m]510/102.54x2.12x0
Astra [m, r]510/102.39x1.57x1
Qwen 3.8 2.4T [m]17/102.02x1.30x1
Qwen 3.8 2.4T [m]510/102.26x1.35x1

후보를 하나만 낼 때와 다섯 개를 낼 때의 차이가 컸다. 에이전트가 후보를 순차적으로 실행하면서 문맥 안에서 학습하고 있다는 뜻이었고, 여기서 한 번에 좋은 플랜을 뽑게 만드는 대신, 여러 턴을 도는 에이전트 방식으로 가기로 결정했다.

그다음 학습하지 않은 4B 모델을 같은 조건으로 JOB 전체에 돌렸다. 결과는 참담했다.

궤적이 끝난 방식질의 수
유효한 후보 없음81
선택 실패16
타임아웃1
후보가 기본 플랜과 동일7
기본 플랜 유지2
후보를 기본 플랜과 비교 측정6

점수에 반영되는 것은 마지막 세 줄뿐이라 113개 중 15개만 점수가 매겨졌고, 그중 아홉 개는 구성상 1.00x로 확정된 값이었다. 구조가 온전하고, 스키마상 유효하고, Postgres의 플랜과 구별되는 새 플랜은 여섯 개뿐이었다. 그중 다섯 개가 1.02x에서 1.30x 사이의 speedup을 냈고 하나는 0.05x까지 떨어졌다.

실패 양상은 대부분 하네스 자체를 이해하지 못한 데서 나왔다. PlanAction이 유효한 객체가 아닌 경우가 많았다. 조인 트리에 릴레이션이 정확히 한 번씩 들어가지 않거나, 액션을 쓸데없는 action 키로 감싸는 일도 있었다. Leading 트리 안에 질의의 조인 그래프에서 실제로 연결되지 않은 하위 트리를 넣기도 했다. 존재하지 않는 도구를 부르는 일도 잦았다.

오프폴리시 증류로 하네스 언어를 가르친다

저자는 순서를 나눴다. 쿼리 최적화를 잘하게 만드는 것은 나중 일이고, 먼저 qo-agent의 언어를 말하게 만들어야 했다. 여기에 쓴 방법이 지도 미세조정을 통한 오프폴리시 증류다.

오프폴리시 증류는 학생 모델이 교사 모델의 출력을 흉내 내도록 학습시키는 방법이다. 학습 데이터를 학생이 직접 만들지 않기 때문에 오프폴리시라고 부른다. 교사의 완결된 궤적을 학생에게 보여주고, 궤적의 토큰 하나하나에 대해 학생이 그 토큰에 부여한 확률로 손실을 계산한다. 궤적 하나가 수천에서 수만 개의 토큰 예측을 제공하므로 데이터가 많지 않아도 가중치가 빠르게 적응한다.

치비 서소영이 길게 펼쳐진 두루마리의 문장을 작은 로봇 학생이 펼쳐 든 공책에 옮겨 적고 있다. 두루마리의 몇 줄은 종이띠로 가려져 있어 그 줄은 옮겨 적히지 않는다

실제로 돌리려면 세 가지 처리가 필요했다. 첫째는 렌더링이다. Astra는 OpenAI Responses API 형식으로 궤적을 내놓는데 Qwen 모델은 그 형식을 모르므로, 사람이 읽는 JSON을 모델이 읽는 토큰 열로 옮겨야 한다. 둘째는 손실 마스킹이다. 시스템 프롬프트, 사용자 프롬프트, 도구 실행 결과는 모델이 만들어낸 토큰이 아니므로, 문맥으로는 보여주되 손실은 계산하지 않도록 표시해야 한다. 셋째는 언롤링이다. 궤적 하나를 통째로 학습 단위로 쓰지 않고, 어시스턴트 응답마다 그 앞의 모든 문맥을 붙여 (문맥, 응답) 쌍으로 쪼갠다. 응답이 세 번 있는 궤적 하나는 학습 예제 세 개가 된다.

파라미터 효율 문제도 있었다. 4B 모델의 학습 가능한 파라미터는 46.6억 개이고 bf16으로 9.32GB를 차지한다. 이걸 한 번에 갱신하려면 최소 64GB VRAM이 필요한데 RTX 3090은 24GB다. 여기서 LoRA를 썼다. 실제로 학습시킨 어댑터의 크기는 42.5MB였고, 학습 가능한 파라미터는 2,120만 개였다.

교사 모델로는 Astra와 Qwen 3.8 2.4T 중 하나를 골라야 했다. 앞서 JOB 10개 질의 평가에서 쓴 문맥 길이를 다시 보면 이렇다.

궤적당 후보 5개Astra (요약)Qwen 3.8 2.4T
모델 턴 수 평균7.612.2
최종 문맥 평균 토큰22,00945,144
최종 문맥 최대 토큰28,66373,608
턴당 출력 토큰 평균1611,795
턴당 추론 토큰 평균651,417
10개 질의 총 소요 시간3분 13초15분 51초

Astra가 모든 항목에서 앞서 보이지만 결정적인 약점이 하나 있다. API로 부르면 추론 토큰을 주지 않고 짧은 요약만 준다. 「How to Steal Reasoning Without Reasoning Traces」 논문은 추론 요약만으로 학습시키면 학생 모델의 성능이 떨어진다고 보고했다. 그 대안으로 이 논문이 내놓은 것이 트레이스 역전이다. 추론 요약에서 원래 추론 토큰을 합성해 학습 데이터를 늘리는 기법이다. Bansal은 Astra로 가되 성능이 나빠지면 트레이스 역전을 시도하기로 했다. 문맥 길이 한도도 5만 토큰 언저리로 잡았는데, Astra 궤적은 모두 그 안에 들어왔지만 Qwen 궤적 일부는 넘쳤다.

Astra 궤적 120개를 CEB에서 무작위로 뽑은 질의에 대해 생성했다. 이 가운데 100개를 학습에 쓰고 20개는 검증용으로 남겼다. Prime Intellect의 renderers 라이브러리로 Qwen 형식에 맞게 렌더링하고, 손실 마스킹과 언롤링과 패킹을 거치자 100개 궤적이 학습 행 382개가 되었다. prime-rl로 한 에폭만 돌린 결과가 이렇다.

체크포인트유효 후보점수 산정기하평균총 워크로드승리퇴행
학습 전 4B14/11315/1130.85x0.85x31
1 에폭48/11344/1130.72x0.76x516

speedup 자체는 내려갔지만 어댑터가 하네스를 배웠다는 신호는 분명했다. 저자는 새 궤적을 더 만드는 대신 같은 데이터로 에폭을 더 돌리기로 했고, 이 시점에 Lambda에서 H100 두 장짜리 노드를 빌렸다. 첫 에폭은 RTX 3090에서 네 시간이 걸렸는데 두 번째 에폭은 H100에서 45분 만에 끝났다.

체크포인트유효 후보점수 산정기하평균총 워크로드승리퇴행
1 에폭48/11344/1130.72x0.76x516
2 에폭85/113108/1131.08x1.04x128
3 에폭57/11399/1130.82x0.89x915

두 에폭은 결과를 끌어올렸는데 세 에폭은 도로 망가뜨렸다. 흥미롭게도 두 번째 에폭 동안 검증 손실은 거의 변하지 않았다. 20개 홀드아웃 궤적에서 측정한 손실은 0.485에서 0.305로 떨어진 뒤 0.306, 0.322로 거의 그대로였다. 검증 손실이 평평하다고 해서 모델이 쓸모 있는 행동을 더 배우지 않는다는 뜻은 아니라는 교훈이다.

저자는 학습 궤적을 전혀 걸러내지 않았고 빠진 능력이 있는지도 점검하지 않았다는 점을 떠올렸다. 실제로 유효한 Leading 트리를 만드는 능력이 부족했다. 그래서 Astra 궤적 320개를 더 만들고(300개 학습, 20개 검증), 후보를 하나도 시도하지 않고 기본 플랜을 유지해 버린 여섯 개를 걸러낸 뒤 두 번에 나눠 에폭을 더 돌렸다.

체크포인트유효 후보점수 산정기하평균총 워크로드승리퇴행
첫 100개로 2 에폭85/113108/1131.08x1.04x128
새 300개로 1 에폭 추가77/113101/1131.10x1.05x2913
새 300개로 2 에폭 추가71/113107/1131.16x1.06x205

하네스를 익혔을 뿐 아니라 여러 JOB 질의에서 제법 괜찮은 판단을 내리기 시작했다. 추론 요약으로 학습시킨 것이 성능을 해치지도 않았다.

에이전틱 RL로 실력을 올린다

강화학습에서는 현재 정책으로 학습 질의를 하네스 안에서 여러 번 실행한다. 한 번의 실행을 롤아웃이라 부르고, 각 롤아웃의 결과를 검증 가능한 기준으로 채점한 뒤 같은 질의의 다른 롤아웃과 견줘 상대 점수를 매긴다. 양의 어드밴티지를 받은 궤적이 앞으로 더 자주 나오도록 가중치를 조정하고, 음의 어드밴티지를 받은 궤적에는 반대 방향으로 조정한다.

처음 설계한 보상은 단순했다. 기본 플랜과 후보 플랜을 각각 세 번 측정해 중앙값을 구한다. 기본 플랜 중앙값을 후보 플랜 중앙값으로 나눈 speedup에 자연로그를 취하고, 유효하지 않은 후보 하나당 0.1을 뺐다. 결과 플랜이 기본 플랜과 같은 지문을 가지면 0.05를 더 뺐다. 궤적이 유효한 후보 없이 끝나면 앞의 감점들 대신 3을 통째로 뺐다.

문제는 이 마지막 조항에서 터졌다. 3점짜리 감점이 너무 가혹해서 모델은 유효하지 않은 플랜을 내놓기를 두려워하게 됐고, 안전하게 Postgres의 기본 플랜을 반복해서 돌려주며 0.05짜리 작은 감점을 감수하는 쪽을 택했다.

여기에 GRPO가 겹치면서 문제가 더 커졌다. prime-rl이 구현한 GRPO는 그룹의 평균 보상을 각 롤아웃의 보상에서 빼는 방식이다. 한 질의에 네 롤아웃을 돌렸는데 두 개가 유효하지 않아 -3.00을 받고, 하나가 기본보다 느려서 -0.23을 받고, 하나가 기본 플랜과 같은 지문이라 -0.05를 받았다고 하자. 그룹 안에서 Postgres를 이긴 롤아웃이 하나도 없는데도 뒤의 두 개는 그룹 평균보다 높다는 이유로 양의 어드밴티지를 받는다. 기본 플랜과 똑같은 플랜을 내놓아도 괜찮다고 모델에게 가르치는 셈이다.

저자는 보상과 어드밴티지 계산을 함께 다시 설계했다. 보상 쪽에서는 speedup 비율을 클리핑해 보상값의 상한과 하한을 정했다. 측정 노이즈를 감안해 0.05만큼 소프트 임계를 뒀고, 에이전트가 keep_default나 finish(default)를 부르면 0점을 줬다. 유효한 후보 없이 끝난 궤적의 감점은 3.0에서 0.1로 낮췄고, 기본 플랜과 같은 지문인 후보에는 0.02의 작은 감점만 매겼다. 어드밴티지 쪽에서는 그룹 평균 대신 기본 플랜의 실행 시간을 기준점으로 삼는 앵커드 GRPO를 직접 만들어 넣었다. 같은 네 롤아웃을 새 방식으로 채점하면 넷 모두 음의 어드밴티지를 받는다. 좋은 플랜이 하나도 없었으니 강화할 것도 없다는 판정이다.

치비 서소영이 낮은 책상에 놓인 카드 네 장을 채점한다. 카드끼리 견주는 대신, 벽에 고정된 기준 카드 한 장에 자를 대어 네 장을 각각 재고 있다

최종 speedup 측정에도 클리핑을 적용했다. clip(median(기본) / median(후보), 0.1, 10) 형태로 상한과 하한을 두어, 극단적인 speedup이나 극단적인 퇴행 하나가 기하평균을 통째로 좌우하는 일을 막았다.

학습 환경은 기계 두 대로 구성했다. Lambda 노드에서도 Postgres 보정을 여러 번 돌려 봤는데 노이즈가 FLOPper에 비해 감당할 수 없을 만큼 컸다. GPU 외의 자원을 다른 사용자와 나눠 쓰고 있다고 판단하고, Tailscale로 집의 FLOPper와 Lambda 노드를 연결했다. 롤아웃은 FLOPper에서 시작한다. 추론은 첫 번째 H100의 vLLM이 맡는다. 측정은 FLOPper의 Postgres 컨테이너에서 이뤄진다. 그 결과를 두 번째 H100으로 보내 어드밴티지를 계산하고 가중치를 갱신한다.

첫 RL 실행은 아주 보수적으로 잡았다. 학습률 1e-06, 배치 크기 8, 질의당 롤아웃 4개, 옵티마이저 갱신 120번이었다. 이 설정으로 돌린 결과는 출발점과 거의 같았다.

체크포인트유효 후보점수 산정기하평균총 워크로드승리퇴행
SFT 출발점71/113107/1131.16x1.06x205
RL 120 갱신71/113106/1131.14x0.99x217

보상 설계가 나쁜 것인지, 앵커드 GRPO가 작동하지 않는 것인지, 그저 덜 공격적이었던 것인지 셋 중 하나였다. 셋째가 가장 확인하기 쉬웠다. 학습률을 한 자릿수 올려 1e-05로 바꾸고, 배치 크기를 16으로, 질의당 롤아웃을 8개로 올리고, 갱신 횟수를 600번으로 늘렸다.

이 무렵 저자는 롤아웃 시간의 92%가 vLLM 추론에 쓰인다는 점을 알게 됐다. 그전까지는 롤아웃 하나가 시작부터 끝까지 Postgres 워커 하나를 점유했으므로, 동시 롤아웃 수가 워커 수인 4개로 묶여 있었다. 측정이 필요한 순간에만 워커를 빌려 쓰도록 비동기로 바꾸자, 워커 4개를 두고 롤아웃 20개를 동시에 돌릴 수 있게 됐다.

600번 갱신을 두 번 연달아 돌린 결과다.

체크포인트유효 후보점수 산정기하평균총 워크로드승리퇴행
SFT 출발점71/113107/1131.16x1.06x205
RL 600 갱신99/113113/1131.35x1.16x340
RL 1,200 갱신101/113112/1131.41x1.29x382

마지막으로 각 질의에서 최종 체크포인트의 궤적을 세 번 실행해 다시 평가했다. 궤적마다 후보를 다섯 개까지 낼 수 있으므로, 질의 하나당 최대 15개 후보 가운데 가장 좋은 것을 고르게 된다. 실제로 워크로드를 튜닝하려는 사람이라면 단발 롤아웃보다 이런 방식을 쓸 것이라는 게 저자의 설명이다.

선택 방식점수 산정기하평균총 워크로드승리퇴행
궤적마다 모델 자신의 선택339/3391.40x1.24x1197
궤적 안에서 가장 좋은 피드백339/3391.44x1.29x1291
세 궤적을 통틀어 가장 좋은 피드백113/1131.81x1.81x680

세 궤적 중 최선을 고르면 기하평균이 1.81배까지 올라갔고, 총 워크로드 speedup도 우연히 같은 1.81배가 나왔다. 원문 제목에 적힌 81%가 이 숫자에서 나왔다.

모델이 배운 것

저자는 최종 평가의 339개 탐색 궤적을 열어 모델의 행동을 분석했다.

치비 서소영이 지켜보는 가운데 작은 로봇이 열린 공구함에서 가지 모양의 조인 트리 조각, 돋보기, 나란한 화살표 한 쌍 세 가지만 골라 들고 나머지 공구는 그대로 두는 모습

  • 후보를 제출한 337개 탐색 중 295개가 먼저 릴레이션이나 컬럼 통계, 기본 플랜을 들여다본 뒤에 후보를 냈다.

  • 339개 탐색 중 235개가 후보 다섯 번의 기회를 모두 썼다.

  • 90배 speedup이 나온 job-01d에서 모델의 추론에는 「mi 인덱스 순차 스캔이 57만 5천 행에서 손실 필터를 돌리며 11밀리초를 쓰고 있다. 비트맵을 쓰면 훨씬 빠를 것 같다」라고 적혀 있었다. 그리고 모델은 실제로 비트맵 스캔을 강제했다.

  • 액션 1,347개 가운데 스캔 힌트가 1,141번, Leading 트리가 917번, Parallel 힌트가 572번 나왔다. Rows 보정은 146번뿐이었다.

  • 해시 조인보다 중첩 루프를, 비트맵이나 순차 스캔보다 인덱스 스캔을 자주 강제했다. enable_sort=off와 random_page_cost=1.1을 즐겨 썼다.

  • 실제 이득의 대부분은 세 가지 방식에서 나왔다. Leading으로 조인 순서를 다시 쓰기, 조인 순서는 그대로 두고 스캔 하나만 고치기, 그리고 Parallel 쓰기다.

비용

저자는 Lambda에서 H100 SXM 두 장짜리 노드를 약 95시간 빌리는 데 약 800달러를, Astra 궤적을 만드는 OpenAI API 요금으로 약 400달러를 썼다. 합계 1,200달러다. FLOPper의 전기 요금은 두 GPU를 계속 돌릴 때 하루 9달러 정도다.

Bansal은 결과를 기다리는 조바심만 없었다면 이 프로젝트가 거의 공짜였을 것이라고 적었다. Astra 궤적에 쓴 400달러에 대해서도, DeepSeek-R1-Zero가 보여줬듯 4B 모델이 강화학습만으로 하네스 언어를 익혔을 수도 있다고 본다. 그 대신 롤아웃이 훨씬 많이 필요했을 것이다.

측정이 결과를 바꾼 지점

나는 성적표보다 측정 장비를 만드는 대목을 더 중요하게 본다. 저자는 노이즈를 변동계수로 재려다가, 그 지표가 평균에 기대고 있어 이상치 몇 개에 휘둘린다는 이유로 버린다. 그리고 실제로 걱정해야 할 것을 다시 정의한다. 저자가 최소화 대상으로 삼은 값은 스무 번의 측정값이 얼마나 흩어져 있는가보다, 그 흩어짐 때문에 학습 중의 보상 판정이 얼마나 자주 속는가였다. 같은 변동계수를 가진 두 질의라도, 편차가 고르게 퍼져 있는 경우와 5% 넘게 벌어진 두 무리를 이루는 경우는 보상이 속는 빈도가 다르다는 관찰에서 나온 결정이다.

측정 기준을 다시 세운 이 결정 덕분에 나머지 결과를 믿을 수 있게 된다. 만약 저자가 변동계수를 줄이는 데 만족하고 shared_buffers를 128MB로 둔 채 학습에 들어갔다면, 다섯 번에 한 번씩 유령 신호를 받은 모델이 무엇을 배웠을지 알 수 없다. 보상이 검증 가능하다는 말은 측정이 정직하다는 전제 위에서만 성립한다는 것을, 이 글은 실측으로 보여준다.

work_mem을 바꿔도 노이즈에 아무 변화가 없었다는 결과도 남겨 둘 만하다. 손댈 만한 설정 두 개를 골라 네 조합을 모두 돌려 보지 않았다면, 두 설정 중 어느 쪽이 효과가 없었는지 구분하지 못한 채 둘 다 튜닝한 것으로 기록했을 것이다.

출처

Rohan Bansal, “Training a 4B model to produce 81% faster query plans than Postgres”, rohanbansal.com, 2026년 9월. 저자는 Recurse Center에서 안식 기간을 보내며 이 연구를 진행했다.

원문: https://rohanbansal.com/qorl

코드: https://github.com/polyphilz/qorl

원문의 도식은 모두 인터랙티브 SVG라 그대로 옮길 수 없어, 본문 삽화는 「느낌적인 느낌을 숫자로 옮기는 일」의 치비 서소영 라인아트를 참조하여 gpt-image-2 image-to-image로 새로 그렸다.