Recent Posts

All Posts →

Hugging Face 오픈 모델의 호환성을 만드는 사실상의 표준들

Hugging Face에서 받은 오픈 모델을 누가 학습했든 다운로드해서 바로 실행할 수 있는 배경에는 Safetensors 가중치 포맷, transformers의 config 규약, 소수 아키텍처로의 수렴이라는 사실상의 표준이 맞물려 있습니다.

모델 레이어를 다룬 글에서 모델이 "아는 것"은 가중치(weight)라는 숫자값에 압축돼 있다고 정리했습니다. 그 가중치는 결국 파일로 저장되어 Hugging Face에서 공유됩니다. Hugging Face는 오픈 모델과 데이터셋, 데모를 올리고 내려받는 중심 플랫폼으로, 흔히 "머신러닝계의 GitHub"에 비유됩니다. 그런데 이렇게 공유된 모델은 누가 어떤 환경에서 학습했든, 다운로드해서 바로 실행할 수 있는 경우가 많습니다. 이 글은 그 호환성의 토대가 되는 사실상의 표준(de facto standard)들을 정리합니다.

크게 모델 가중치 포맷, 모델 아키텍처 규약, 메타데이터/설정 표준 세 층위로 나눠 볼 수 있습니다.

1. 모델 가중치 저장 포맷

포맷 특징

Safetensors

현재 사실상 표준. 메모리 매핑[1]이 가능하고, pickle(Python 기본 직렬화 방식)을 쓰지 않아 역직렬화 시 임의 코드가 실행되는 보안 취약점이 없습니다.

GGUF

llama.cpp 생태계의 표준. 양자화[2] 정보를 파일 자체에 포함하며, CPU·엣지를 비롯한 다양한 환경의 추론에 강합니다.

PyTorch (.bin/.pt)

pickle 기반의 레거시 포맷. 여전히 많이 쓰이지만 역직렬화 과정에 보안 우려가 있습니다.

신경망의 가중치는 텐서(tensor), 즉 숫자가 여러 차원으로 늘어선 배열에 담깁니다. 스칼라(0차원)·벡터(1차원)·행렬(2차원)을 임의 차원으로 일반화한 것으로, 각 레이어의 가중치 하나하나가 이런 텐서입니다. 텐서마다 배열의 형태(shape, 예: 4096 × 4096)와 각 숫자의 자료형(dtype, 예: float16·bfloat16)이 정해져 있습니다.

Safetensors는 이 텐서의 이름(key)과 shape, dtype, 바이너리 데이터를 단순한 헤더+바이너리 구조로 저장합니다. 그래서 어떤 프레임워크든 쉽게 읽을 수 있습니다.

다국어 지원 능력도 이 가중치 안에 들어 있습니다. 학습 시 Common Crawl, Wikipedia 등에서 수집한 수십~수백 개 언어의 텍스트 패턴이 신경망 파라미터 전체에 걸쳐 분산 표현(distributed representation) 으로 인코딩됩니다. "한국어 사전" 같은 별도 파일이 있는 게 아니라, embedding layer와 attention/FFN layer의 수치 값들에 각 언어의 문법·어휘·의미가 녹아들어 있습니다. 그래서 특정 언어만 빼거나 넣기가 쉽지 않습니다.

2. 모델 아키텍처 규약: transformers 라이브러리의 역할

Hugging Face `transformers`는 수많은 모델 아키텍처를 통일된 인터페이스로 불러오고 실행하게 해주는 오픈소스 Python 라이브러리입니다. Transformer 아키텍처에서 이름을 따왔지만 지금은 그 밖의 구조도 폭넓게 지원하며, 모델을 불러오는 방식의 사실상 표준 역할을 합니다. 모델을 어떻게 읽고 실행할지는 가중치 파일 옆에 놓인 설정 파일들이 정의합니다.

  • config.json — 모델 구조 정의 (model_type, hidden_size, num_attention_heads 등)

  • tokenizer.json / tokenizer_config.json — 토크나이저 정의. BPE(Byte Pair Encoding) 같은 알고리즘으로 다국어 텍스트를 토큰으로 분리하는 토큰 사전(vocabulary, 보통 3만~25만 토큰)을 담습니다. 한국어 "안녕하세요"도, 영어 "hello"도 이 사전의 토큰 조합으로 표현됩니다.

  • generation_config.json — 생성 파라미터

config.json 예시
{
  "model_type": "llama",
  "hidden_size": 4096,
  "num_attention_heads": 32,
  "num_hidden_layers": 32,
  "vocab_size": 32000
}

model_type`이 `"llama"`이면, 라이브러리가 `LlamaForCausalLM 클래스를 자동으로 선택해 가중치를 로드합니다. 그래서 Llama 아키텍처 기반 모델은 누가 학습했든 동일한 코드로 실행됩니다.

3. 추론 엔진 간 호환성

같은 Hugging Face 모델 하나(safetensors + config.json)를 여러 추론 엔진(학습된 모델을 받아 실제로 실행하고 응답을 생성하는 런타임)이 받아 실행할 수 있습니다. 일부는 파일을 그대로 읽고, 일부는 포맷 변환을 거칩니다.

  • transformers: Hugging Face 표준 Python 라이브러리

  • vLLM: 고성능 서빙에 특화

  • TGI(Text Generation Inference): Hugging Face 자체 서빙 엔진

  • Ollama / llama.cpp: GGUF로 변환해 CPU/엣지에서 실행

  • ONNX Runtime: ONNX(Open Neural Network Exchange) 포맷으로 변환해 실행

이것이 가능한 이유는 아키텍처가 표준화되어 있기 때문입니다. 대부분의 오픈 모델이 소수의 아키텍처(Llama, Mistral, Qwen, Gemma 등)를 따르고, 각 추론 엔진이 이 아키텍처들을 미리 구현해 둡니다.

4. 앞 글의 레이어 분류와 겹쳐 보기

앞 글에서는 LLM에 정보가 들어가는 자리를 모델 레이어와 프롬프트 레이어로 나누고, 아키텍처·디코딩 같은 축을 덧붙였습니다. Hugging Face 모델 저장소의 파일 구성을 그 분류에 겹쳐 보면 거의 일대일로 대응합니다.

파일 대응하는 레이어 담는 것

model.safetensors

모델 레이어

학습으로 결정된 가중치(텐서) 그 자체

config.json (model_type)

아키텍처 레이어

"어떤 형태로 정보를 담을 것인가" — 레이어 수, 헤드 수, 아키텍처 종류

tokenizer.json

프롬프트 레이어 (토큰화)

컨텍스트로 들어갈 텍스트를 토큰으로 쪼개는 규칙

generation_config.json

디코딩/샘플링 레이어

temperature, top-p 같은 출력 제어 기본값

바꾸려는 대상에 따라 손대는 파일이 갈립니다. 지식 자체를 바꾸려면 `model.safetensors`를, 샘플링이나 출력 길이 같은 생성 기본값만 바꾸려면 `generation_config.json`을 건드립니다. 앞 글이 "정보가 어디에 저장되는가"의 지도였다면, 이 파일 구성은 그 지도가 디스크에 실제로 어떻게 놓이는지를 보여줍니다.

5. 핵심 정리

공식적인 "표준 기구"가 정한 스펙이라기보다는, 다음 세 가지가 맞물린 결과입니다.

  1. Safetensors — 텐서를 저장하는 단순하고 안전한 바이너리 포맷

  2. transformers의 config 규약 — `config.json`의 `model_type`으로 아키텍처를 식별

  3. 소수 아키텍처로의 수렴 — 대부분의 모델이 Llama/Mistral 계열 아키텍처를 채택

이 세 가지가 맞물려 "누가 학습했든 다운받으면 바로 돌릴 수 있는" 생태계가 만들어졌습니다. ISO 같은 공식 표준이 아니라 사실상의 표준(de facto standard)입니다.

6. 참고 자료


1. 파일을 통째로 읽어 들이지 않고, 디스크의 내용을 필요한 부분만 메모리 주소에 바로 연결해 다루는 방식. 큰 모델도 빠르게 로드할 수 있습니다.
2. 가중치를 더 적은 비트로 표현해(예: 16비트를 4비트로) 모델 용량과 메모리 사용량을 줄이는 기법. 약간의 정확도를 내주고 크기를 줄입니다.

대량 데이터를 입출력하는 DBMS별 명령어와 도구(MySQL, Oracle, SQL Server, PostgreSQL)

MySQL, Oracle, MS SQL Server, PostgreSQL이 제공하는 대량 데이터 입출력 방식(LOAD DATA INFILE, SQL*Loader, BULK INSERT, COPY 등)과 성능 옵션을 정리합니다.

DBMS별로 최적화된 대량 데이터 로딩과 추출 기능을 전용 SQL 구문이나 도구로 제공합니다. 이 방식들은 구현 원리상 자바 애플리케이션으로는 도달하기 어려운 성능을 발휘합니다.

1. MySQL

MySQL에서는 SELECT …​ INTO OUTFILE 명령으로 데이터를 익스포트하고, LOAD DATA INFILE 명령으로 임포트합니다. SELECT …​ INTO OUTFILE 은 다음과 같은 형식으로 사용합니다.

SELECT [칼럼명]
INTO OUTFILE [파일명]
FROM [테이블명]

FIELDSLINES 절을 추가하면 필드와 행 사이를 구분하는 문자를 지정할 수 있습니다. 디폴트로는 탭 문자(\t)로 칼럼 사이를, 새 줄 문자(\n)로 행 사이를 구분합니다. baseball.players 테이블의 내용을 players.csv 라는 파일명으로 출력하는 예시는 아래와 같습니다.

SELECT back_num, name, position
INTO OUTFILE 'players.csv'
FROM baseball.players
FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '"'
LINES TERMINATED BY '\n'

LOAD DATA INFILE 명령어는 아래와 같은 형식으로 사용합니다.

LOAD DATA INFILE [파일명] INTO TABLE [테이블명]

마찬가지로 필드와 라인 구분자를 지정할 수 있고, SELECT …​ INTO OUTFILE 과 같은 방식으로 옵션을 지정합니다. players.csv 의 내용을 baseball.players 테이블로 로딩하는 명령어는 아래와 같습니다.

LOAD DATA INFILE 'players.csv'
INTO TABLE baseball.players
FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '"'
LINES TERMINATED BY '\n'

대량 적재의 성능은 스토리지 엔진에 따라 손볼 곳이 다릅니다. MySQL 5.5부터 기본 스토리지 엔진이 InnoDB이고 요즘은 대부분 InnoDB를 쓰므로, InnoDB 기준으로 정리합니다. InnoDB 테이블에 대량으로 적재할 때는 innodb_buffer_pool_size 를 충분히 확보하고, 적재 구간에서 자동 커밋과 무결성 검사를 잠시 꺼 부가 작업을 줄이는 방식이 효과적입니다.

SET autocommit = 0;
SET unique_checks = 0;
SET foreign_key_checks = 0;

LOAD DATA INFILE 'players.csv'
INTO TABLE baseball.players
FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '"'
LINES TERMINATED BY '\n';

COMMIT;

적재가 끝나면 꺼 두었던 unique_checksforeign_key_checks 는 다시 켜야 합니다. 참고로 지금은 거의 쓰이지 않는 MyISAM 엔진에서는 bulk_insert_buffer_size, myisam_sort_buffer_size 같은 변수가 대량 삽입 성능에 영향을 줍니다. 다만 이 값들은 InnoDB 테이블에는 적용되지 않습니다.

LOAD DATA INFILE 명령은 mysqlimport 라는 도구로도 수행할 수 있습니다. 이 도구를 쓸 때 네트워크를 통해 파일이 전송된다면 데이터를 압축해 성능 향상을 꾀할 수 있습니다. 예전에는 --compress 옵션을 썼으나, MySQL 8.0.18부터는 이 옵션이 deprecated되어 --compression-algorithms 로 압축 알고리즘을 지정하는 방식으로 바뀌었습니다.

한 가지 주의할 점은 최근 MySQL이 보안상의 이유로 파일 입출력에 제약을 둔다는 것입니다. SELECT …​ INTO OUTFILE 과 (LOCAL 을 붙이지 않은) LOAD DATA INFILE 은 서버의 secure_file_priv 시스템 변수가 가리키는 디렉터리 안에서만 동작하며, 이 변수는 MySQL 5.7.6부터 기본값이 설정되어 있습니다. 또한 클라이언트 쪽 파일을 읽는 LOAD DATA LOCAL INFILE 은 MySQL 8.0부터 local_infile 변수가 기본적으로 꺼져 있어, 서버와 클라이언트 양쪽에서 이를 명시적으로 켜야 동작합니다.

자세한 내용은 아래 페이지를 참조합니다.

2. Oracle

Oracle에서는 SQL*Loader, Data Pump API 같은 도구나 External Table을 생성해서 데이터 로딩을 할 수 있습니다.

SQL*Loader에서 다루는 파일들은 역할이 상세하게 구분됩니다.

  • 컨트롤 파일 : 수행할 명령문을 담은 파일.

  • 입력 데이터 파일 : DB로 임포트할 데이터를 담은 파일. 컨트롤 파일에서 INFILE * 구문을 사용하면 컨트롤 파일에 데이터를 포함시킬 수도 있습니다.

  • 로그 파일 : 작업 과정과 결과를 기록하는 파일.

  • Bad 파일 : 입력이 거부된 행들이 저장되는 파일. 예를 들면 형식이 틀려서 SQL*Loader가 해석할 수 없거나 DB의 제약조건을 충족시키지 못한 행들이 이 파일로 기록됩니다.

  • Discard 파일 : 컨트롤 파일에서 정한 규칙에 의해 필터링되는 행들이 기록되는 파일.

컨트롤 파일은 아래와 같이 명령행에서 위치를 지정합니다.

sqlldr userid=scott/tiger control='./control.ctl'

컨트롤 파일 안에서는 아래와 같이 입력할 데이터 파일의 위치와 형식, 값이 들어갈 테이블을 지정합니다.

control.ctl
LOAD DATA
INFILE 'players.csv'
INTO players
FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '"'
( back_num, name, position )

성능을 높이기 위해 아래 옵션값을 조정해볼 수 있습니다.

  • direct : Direct Path Load 를 사용할지를 true/false로 지정합니다. 이 방식을 쓰면 내부적으로 SQL 명령을 생성하지 않고 데이터베이스 블록에 직접 쓰기 작업을 하기 때문에 성능이 더 좋아집니다. direct 옵션의 디폴트는 false로, 특별히 지정하지 않으면 데이터 로딩을 INSERT 명령으로 수행하는 Conventional Path Load 가 활성화됩니다. Direct Path Load 를 쓸 때는 데이터 로딩 도중에 인덱스 업데이트와 제약조건 등이 바로 적용되지 않으며, 로딩 작업의 시작과 끝에 테이블과 인덱스에 락을 잡습니다. 따라서 로딩 작업 도중에 다른 프로세스에서도 대상 테이블에 접근해야 한다면 적합하지 않습니다. 클러스터링된 테이블에도 사용할 수 없습니다.

  • parallel : 병렬 수행 여부입니다. parallel 모드로 데이터를 올리는 중에는 인덱스가 업데이트되지 않으므로, 데이터 업로드가 끝난 후에 인덱스를 재생성해줘야 합니다. direct=true 와 함께 쓰려면 대상 테이블에 트리거나 제약조건이 비활성화되어 있어야 합니다.

  • rows : 한 번에 운반하는 행의 개수입니다. direct=false 일 때는 commit이 일어나는 단위로, 스프링 배치의 commit-interval 과 유사합니다. direct=trueDirect Path Load 를 쓸 때도 rows가 데이터를 저장하는 단위라는 점은 비슷하지만, 이 모드에서는 중간에 인덱스가 갱신되지 않습니다.

그 외에도 direct 옵션을 사용할 때는 columnarrayrows, streamsize, 아닐 때는 bindsize 등의 옵션으로 데이터의 운반 단위를 조정할 수 있습니다. 위의 옵션은 명령행에서 지정할 수 있습니다. 예를 들어 direct와 parallel 모드를 동시에 사용해서 데이터를 임포트하려면 아래와 같이 실행합니다.

sqlldr userid=scott/tiger control='./control1.ctl' DIRECT=TRUE PARALLEL=true
sqlldr userid=scott/tiger control='./control2.ctl' DIRECT=TRUE PARALLEL=true

Oracle 9i부터는 데이터를 테이블스페이스 바깥의 파일로 유지하는 External Table이 제공됩니다. CSV 형식의 외부 파일을 External Table로 연결하고 SELECT 구문으로 조회해서 테이블에 저장하는 작업도 가능합니다. External Table에 SELECT를 할 때 JOIN, SORT도 사용할 수 있고, 읽기 작업을 병렬로 수행할 수 있다는 장점도 있습니다. 그러나 읽기 전용 작업만 허용되기 때문에 DELETE나 UPDATE 같은 SQL을 External Table을 대상으로 수행할 수는 없습니다.

같은 Oracle DB 간의 테이블 단위 데이터 로딩에는 import, export 유틸리티가 더 성능에 유리합니다. Oracle Database 10.1(10g release 1) 이후부터는 이를 대체하는 Data Pump API가 제공됩니다. 자세한 내용은 아래를 참조합니다.

3. MS SQL Server

SQL Server에서는 OPENROWSET과 BULK INSERT 구문이 제공되고, bcp나 SSIS(Integration Services) 패키지 같은 별도의 도구로도 비슷한 작업을 수행할 수 있습니다.

BULK INSERT 구문은 파일을 지정하는 것 외에는 보통의 INSERT 문처럼 사용할 수 있습니다. players.csv 에서 players 테이블로 데이터를 입력하는 예제는 아래와 같습니다.

BULK INSERT players
FROM 'players.csv'
WITH (
  FIELDTERMINATOR = ',',
  ROWTERMINATOR = '\n'
)

BULK INSERT를 할 때는 아래와 같은 옵션을 조정해 성능을 높일 수 있습니다.

  • tablock : 대량 입력 작업 동안 테이블을 잠급니다.

  • rows_per_batch : 하나의 트랜잭션으로 처리할 행의 개수입니다. 스프링 배치의 commit-interval 속성과 유사합니다. 되도록 큰 값을 지정하는 것이 성능 향상에 유리하나, 인덱스가 있는 테이블에서 지나치게 큰 값을 지정하면 메모리 사용량이 많아지고 병렬 업로드 시 테이블 락을 유발할 수 있습니다.

앞선 예제에서 테이블 락을 걸고 3000개씩 commit을 하는 옵션을 추가하면 다음과 같습니다.

BULK INSERT players
FROM 'players.csv'
WITH (
  FIELDTERMINATOR = ',',
  ROWTERMINATOR = '\n',
  TABLOCK,
  ROWS_PER_BATCH = 3000
)

파일로부터 데이터를 읽어 들이는 또 하나의 방법인 OPENROWSET 구문은 아래와 같이 SELECT 문장과 함께 사용합니다.

SELECT *
FROM OPENROWSET('MSDASQL',
     'Driver={Microsoft Text Driver (*.txt; *.csv)};DefaultDir=C:\data;',
     'SELECT * FROM players.csv')

칼럼명은 텍스트 파일의 첫 줄에 포함시키거나 Schema.ini 파일로 정의할 수 있습니다. 다만 위 예제에서 쓰인 Microsoft Text Driver 는 32비트 전용인 구형 Jet 기반 드라이버로, 현재는 권장되지 않습니다. 최근 환경에서는 64비트를 지원하는 ACE(Access Connectivity Engine) 공급자(Microsoft.ACE.OLEDB.x)를 쓰거나, 아예 SQL Server가 직접 파일을 읽는 OPENROWSET(BULK …​) 구문을 사용하는 편이 낫습니다.

SQL Server는 bcp라는 별도의 데이터 임포트/익스포트 유틸리티도 제공합니다. bcp는 본래 Sybase에서 유래한 도구로, SAP ASE(과거 Sybase ASE)의 bcp와도 사용법이 비슷합니다. 데이터 익스포트와 임포트를 모두 SQL Server를 대상으로 할 때는 네이티브 형식을 이용하면 성능이 더 향상됩니다. BULK INSERT 구문에서는 DATAFILETYPE = 'native' 옵션으로, bcp에서는 -n 옵션을 지정해서 네이티브 파일을 사용할 수 있습니다.

그리고 SQL Server와 함께 제공되는 데이터 통합 도구인 SSIS(Integration Services) 패키지로도 다양한 자원에서 데이터를 입출력할 수 있습니다. GUI 도구에서 작업을 설계하고 dtexec.exe 라는 실행 파일로 커맨드라인에서 작업을 실행하는 방식도 가능합니다. ETL 도구와 유사한 접근법입니다. 앞에서 BULK INSERT 문의 옵션이었던 tablock, rows_per_batch 등의 값도 SSIS에서 설정할 수 있습니다.

각각의 방식에 대한 자세한 설명은 아래 페이지를 참고합니다.

4. PostgreSQL

PostgreSQL에서는 COPY 구문, Foreign Data Wrapper, pg_bulkload를 활용해 텍스트 파일을 바로 DB에 익스포트/임포트할 수 있습니다.

테이블에 있는 데이터를 COPY 구문으로 아래와 같이 익스포트합니다.

COPY (SELECT name, position, back_num FROM players)
TO 'players.csv'
WITH (FORMAT CSV);

임포트 명령은 아래와 같습니다.

COPY players (name, position, back_num)
FROM '/data/players.csv' DELIMITER ',' CSV;

마찬가지로 같은 PostgreSQL끼리 데이터를 주고받을 때는 포맷을 BINARY 로 지정하면 성능 향상을 유도할 수 있습니다.

PostgreSQL에서는 DB 밖에 저장된 데이터를 DB 안의 객체처럼 접근하는 Foreign Data Wrapper라는 개념도 지원합니다. Oracle의 External Table이나 SQL Server의 OPENROWSET 방식과 유사합니다. 아래와 같이 CREATE EXTENSION, CREATE SERVER 구문으로 file_fdw 모듈을 설치합니다.

CREATE EXTENSION file_fdw;
CREATE SERVER baseball FOREIGN DATA WRAPPER file_fdw;

그리고 파일에서 읽어올 데이터의 스키마를 아래와 같이 정의합니다.

CREATE FOREIGN TABLE players (
  name VARCHAR(30),
  position VARCHAR(30),
  back_num INTEGER
) SERVER baseball
OPTIONS ( filename '/home/benelog/players.csv', format 'csv' );

format, header, delimiter 등의 옵션은 COPY 구문과 똑같이 사용할 수 있습니다.

pg_bulkload라는 전용 도구도 지원합니다. Oracle의 SQL*Loader처럼 ctl 파일에 작업의 명세를 정의할 수 있습니다. CSV 파일을 로딩하는 ctl 파일은 아래와 같이 정의합니다.

players_csv.ctl
OUTPUT = players
INPUT = /home/benelog/players.csv
TYPE = CSV
QUOTE = "\""
ESCAPE = \
DELIMITER = ","

명령행에서 다음과 같이 실행하면 CSV 파일의 내용이 테이블로 입력됩니다.

pg_bulkload players_csv.ctl

각각의 방식에 대한 자세한 사용법은 다음을 참조합니다.

RAG 시스템의 구성요소와 성능을 좌우하는 요소

RAG 시스템을 구성하는 요소들 — Vector DB, 임베딩 모델, 청킹, 재순위, 생성 LLM — 의 역할을 정리하고, 한국어 처리에서 중요한 지점과 성능이 나쁠 때 어디를 손봐야 하는지 살펴봅니다.

앞 글에서 RAG가 결국 프롬프트 레이어에 데이터를 공급하는 장치지만, 앞단의 검색/필터링 파이프라인이 충분히 복잡해 별도 서브시스템으로 다룰 만하다고 정리했습니다. 이 글은 그 서브시스템 안을 들여다봅니다. 어떤 부품으로 구성되는지, 한국어를 다룰 때 어디가 까다로운지, 성능이 안 나올 때 어디를 의심해야 하는지를 정리합니다.

1. RAG 파이프라인의 기본 흐름

전형적인 RAG는 두 시점으로 나눠 볼 수 있습니다.

인덱싱 시점 (오프라인)

원본 문서 → 청킹 → 임베딩 → Vector DB 저장. 메타데이터(출처, 작성일, 권한 등)도 함께 저장.

질의 시점 (온라인)

사용자 질문 → 쿼리 재작성 → 임베딩 → Vector DB 검색 (+ 키워드 검색) → 재순위(rerank) → 컨텍스트 조립 → LLM에 프롬프트로 전달 → 응답 생성.

각 단계마다 별도 모델/시스템이 끼어들 수 있고, 단계마다 다른 튜닝이 필요합니다.

2. Vector DB는 어떤 역할을 하는가

Vector DB는 한마디로 "고차원 벡터의 근사 최근접 이웃(approximate nearest neighbor, ANN) 검색에 특화된 저장소" 입니다. 임베딩 모델이 만든 수백~수천 차원 벡터를 저장하고, "쿼리 벡터와 가장 가까운 K개"를 빠르게 돌려줍니다.

핵심 책임은 세 가지입니다.

  • 벡터 인덱싱: HNSW, IVF, PQ 같은 ANN 인덱스로 검색을 sub-linear 시간에 처리합니다. 정확도 vs. 속도/메모리의 트레이드오프가 있습니다.

  • 유사도 계산: 코사인 유사도, 내적, L2 거리 등. 임베딩 모델이 가정한 거리에 맞춰야 합니다.

  • 메타데이터 필터링: "2025년 이후 문서만", "권한이 있는 문서만" 같은 조건을 벡터 검색과 결합. pre-filter / post-filter 시점이 성능을 크게 좌우합니다.

흔한 오해 지점들이 있습니다.

  • Vector DB가 의미를 이해하지는 않습니다. 의미를 벡터로 인코딩하는 건 임베딩 모델이 하고, Vector DB는 벡터 간 기하학적 거리만 봅니다. 검색 품질이 나쁘면 대개 임베딩 모델 문제이지 Vector DB 문제가 아닙니다.

  • 벡터 검색이 항상 키워드 검색보다 낫지는 않습니다. 고유명사, 제품번호, 코드 같은 토큰은 BM25 같은 lexical 검색이 더 정확합니다. 그래서 실제 RAG는 벡터 + 키워드 하이브리드가 기본입니다.

  • 전용 Vector DB가 늘 필요하지도 않습니다. PostgreSQL pgvector, Elasticsearch/OpenSearch의 dense vector 필드, Redis vector search 모듈로도 상당 규모까지 커버됩니다. 운영 부담을 줄이려면 기존 DB 확장이 합리적일 때가 많습니다. Pinecone, Weaviate, Qdrant, Milvus 같은 전용 솔루션은 규모/성능 요구가 분명해질 때 고려하면 됩니다.

3. 한국어 처리에서 중요한 구성요소

한국어 RAG는 영어보다 손이 많이 갑니다. 어디서 차이 나는지 짚어봅니다.

3.1. 임베딩 모델의 한국어 이해 능력

가장 결정적인 부품입니다. OpenAI text-embedding-3, Cohere embed-multilingual, Voyage, BGE-M3 같은 다국어 임베딩이 점점 좋아지고 있지만, 한국어 전용 튜닝 모델(KURE, bge-m3-korean, 네이버 HyperCLOVA 임베딩 등)이 도메인에 따라 더 잘 맞기도 합니다. MTEB 한국어 벤치마크를 참고하되, 자기 도메인 문서로 직접 평가해보는 게 가장 정확합니다.

3.2. 토크나이저와 형태소 분석

  • 벡터 검색 쪽: 임베딩 모델 내부 토크나이저를 따라가므로 사용자가 손댈 여지가 적습니다.

  • 키워드 검색 쪽: 한국어에서 가장 손이 많이 가는 지점입니다. 영어는 공백 분리만으로도 어느 정도 작동하지만, 한국어는 조사/어미가 붙은 채로 인덱싱되면 검색이 망가집니다. "은행에서", "은행이", "은행은"이 다 다른 토큰이 되니까요.

    Elasticsearch/OpenSearch는 Nori 분석기, 그 외 Mecab-ko / Khaiii / KIWI 같은 형태소 분석기를 식별자 추출/색인에 씁니다. 사용자 사전(custom dictionary) 관리가 매우 중요합니다. 신조어, 사내 용어, 제품명을 분석기가 모르면 엉뚱한 토큰으로 쪼개집니다.

3.3. 청킹 전략

한국어 문서는 한 문장이 길고 접속사 없이 늘어지는 경우가 많아 고정 길이 청킹이 의미 단위를 자주 끊습니다.

  • 문장 분리(sentence splitting) 가 마침표 한 종류만으로는 안 됩니다. "다.", "요.", "음.", "임." 같은 어미와 줄바꿈, 표/리스트 마크업까지 함께 봐야 합니다. KSS(Korean Sentence Splitter) 같은 라이브러리가 도움이 됩니다.

  • 의미 단위 청킹(semantic chunking) — 임베딩 유사도가 급격히 변하는 지점에서 자르기 — 이 한국어에서 효과가 좋은 편입니다.

  • 계층적 청킹: 큰 청크로 검색하고 매칭된 부분의 작은 청크를 LLM에 넘기는 식. 문맥 의존성이 강한 한국어에 특히 유용합니다.

3.4. 재순위(rerank) 모델

Cross-encoder 기반 reranker는 검색 결과를 다시 정렬해 정확도를 크게 끌어올립니다. Cohere Rerank 다국어 모델, BGE-reranker, Jina multilingual reranker 등이 한국어를 다룹니다. 한국어 전용 reranker는 아직 선택지가 적어, 다국어 모델로 시작해 자체 평가셋으로 fine-tune을 검토하는 흐름이 흔합니다.

3.5. 쿼리 처리

한국어 질문은 구어체, 줄임말, 오타가 많습니다. 쿼리 재작성(query rewriting) 이나 HyDE(hypothetical document embedding) 로 LLM을 한 번 거쳐 정제하면 검색 품질이 눈에 띄게 좋아집니다. 이때 쓰는 LLM은 작아도 충분합니다.

4. RAG 안에서 쓰이는 LLM들

"RAG에 LLM 하나"라고 생각하기 쉽지만, 실제 시스템에는 서로 다른 역할의 모델 여러 개가 들어가고, 각각 요구되는 특성도 다릅니다.

위치 역할 필요한 특성

임베딩 모델

텍스트를 벡터로 변환

다국어/한국어 품질, 적절한 차원 수, 빠른 추론 속도, 긴 입력 지원

쿼리 재작성 / 분기

사용자 질문을 정제하거나 어떤 도구/인덱스를 쓸지 결정

작고 빠른 instruction-following 모델로 충분. 지연 시간이 중요

재순위 모델

검색된 후보 K개를 정밀 점수화

Cross-encoder. 일반 LLM이 아니라 전용으로 학습된 reranker가 효율적

응답 생성 LLM

검색된 문맥을 바탕으로 최종 답변 작성

긴 컨텍스트, 인용 능력, 환각 억제, 한국어 자연스러움. 대형 모델이 유리

평가 / 가드레일

응답이 컨텍스트에 충실한지, 안전한지 검증

비교적 작은 모델로 LLM-as-a-judge. 일관성과 비용이 중요

흔한 구성은 "쿼리 처리/판정은 작고 빠른 모델, 최종 생성은 큰 모델" 입니다. 전부 큰 모델로 돌리면 비용과 지연이 누적되고, 전부 작은 모델로 돌리면 답변 품질이 무너집니다. 단계별 적정 사이즈 선택이 RAG 설계의 중요한 부분입니다.

또 한 가지, 검색 결과를 잘 활용하는 능력은 모델마다 다릅니다. 같은 컨텍스트를 줘도 어떤 모델은 인용에 충실하고, 어떤 모델은 사전 지식으로 답을 만들어버립니다. RAG 평가 때는 모델의 groundedness(컨텍스트 충실도)를 따로 측정하는 게 좋습니다.

5. 성능이 안 나올 때 어디를 손볼 것인가

RAG 결과가 만족스럽지 않을 때, 무작정 더 큰 LLM으로 갈아끼우기 전에 파이프라인의 어느 단계가 망가졌는지 진단하는 게 우선입니다. 흔한 원인을 단계별로 정리합니다.

5.1. 1단계 — 검색이 잘못 가져온다 (retrieval failure)

가장 흔한 원인입니다. 답이 든 문서를 애초에 못 찾아왔다면, 뒤에 어떤 LLM을 써도 좋아질 수 없습니다.

진단: 정답이 든 청크가 top-K 안에 들어오는지 확인. 안 들어오면 검색 단계 문제입니다.

손볼 곳:

  • 임베딩 모델 교체: 한국어 품질이 더 좋은 모델로. 도메인 특화 모델 검토.

  • 하이브리드 검색 도입: 벡터 + BM25 결합. 고유명사/숫자 매칭이 안 되던 케이스가 풀립니다.

  • 청킹 재설계: 너무 크면 노이즈, 너무 작으면 문맥이 끊깁니다. 한국어는 보통 300~800 토큰에서 시작해 조정.

  • 메타데이터 필터: 검색 공간을 좁히는 가장 효과적인 수단. 날짜, 출처, 권한 등을 적극 활용.

  • 사용자 사전: 키워드 검색 쪽 도메인 용어가 잘못 토큰화되는지 점검.

5.2. 2단계 — 가져오긴 했는데 순서가 나쁘다 (ranking failure)

top-20에는 정답이 있는데 top-3에는 없는 경우. LLM에 넘기는 컨텍스트가 한정돼 있어 순서가 결과를 좌우합니다.

손볼 곳:

  • Reranker 추가: cross-encoder reranker가 가장 큰 효과를 내는 지점입니다.

  • 쿼리 재작성: 모호하거나 구어체인 질문을 검색에 유리한 형태로 변환.

  • 다중 쿼리 (multi-query): 한 질문을 여러 변형으로 던져 결과를 합칩니다.

5.3. 3단계 — 컨텍스트는 좋은데 생성이 망가진다 (generation failure)

검색은 잘 됐고 정답 청크가 top에 있는데 최종 응답이 틀리거나 환각이 섞이는 경우.

손볼 곳:

  • 프롬프트 구조: "주어진 문맥에만 근거해 답하라", "문맥에 없으면 모른다고 답하라" 같은 지시. 인용 형식 강제.

  • 컨텍스트 배치: 중요 문서를 맨 앞이나 맨 뒤에. "lost in the middle" 회피.

  • 생성 모델 업그레이드: 여기서는 큰 모델이 효과적. 단, 1~2단계가 망가져 있으면 모델만 키워도 효과가 없습니다.

  • citation / grounded generation: 답변마다 출처 청크를 명시하게 하면 환각이 줄어듭니다.

5.4. 4단계 — 평가가 없어서 어디가 망가졌는지 모른다 (evaluation gap)

실은 가장 근본적인 문제입니다. 평가셋이 없으면 변경이 개선인지 알 수 없습니다. "감으로" 튜닝하다 보면 한쪽을 고치며 다른 쪽이 망가집니다.

손볼 곳:

  • 질문-정답-출처 셋으로 된 평가 데이터셋 구축. 50~100개로도 시작 가능.

  • 단계별 지표: 검색은 recall@K, MRR. 생성은 faithfulness, answer relevance.

  • Ragas, TruLens 같은 평가 프레임워크 활용.

  • LLM-as-a-judge로 자동화하되 사람이 주기적으로 보정.

6. 어디부터 손댈지

새 RAG 시스템을 만들거나 기존 것을 개선할 때 제가 보는 우선순위는 다음과 같습니다.

  1. 평가셋부터 만든다. 없으면 개선이 개선인지 모릅니다.

  2. 하이브리드 검색을 기본으로. 벡터만 쓰지 않습니다.

  3. 한국어라면 형태소 분석기와 사용자 사전 정비. 키워드 검색의 비중이 의외로 큽니다.

  4. reranker 도입. 비용 대비 효과가 큰 편입니다.

  5. 생성 모델 업그레이드는 마지막에. 가장 비싸고, 1~3이 부실하면 효과가 작습니다.

RAG의 성능 개선은 "더 큰 모델"이 아니라 "더 정확한 검색" 에서 오는 경우가 대부분입니다. 앞 글에서 RAG를 "프롬프트 레이어로 데이터를 흘려보내는 파이프라인"이라 부른 이유도 여기 있습니다. 모델은 토큰으로 들어온 것에 답할 뿐, 들어오지 않은 것에는 답할 수 없습니다.


이 포스트는 Claude(claude.ai)와 정상혁이 함께 작성했습니다.