Contextual Retrieval이란?
Contextual Retrieval은 Anthropic이 제안한 RAG 검색 품질 개선 기법입니다. 청킹 과정에서 잘려나간 문맥을 각 청크에 다시 주입하고, 벡터 유사도 검색에 BM25 키워드 검색을 결합하는 두 가지 축으로 구성됩니다.
Anthropic에 따르면 Contextual Embedding만 적용해도 검색 실패율이 35% 낮아지고, BM25까지 함께 적용하면 기본 RAG 대비 실패율이 약 49% 감소한다고 합니다.
등장 배경
RAG의 검색 품질은 결국 청크가 얼마나 잘 만들어졌는가에 달려 있습니다. 그런데 대부분의 RAG 구현체가 기본으로 제공하는 청킹은 단순히 토큰 개수 단위로 문서를 자르는 방식입니다.
예를 들어 1200 토큰 윈도우, 100 토큰 overlap으로 문서를 자른다고 하면 표, 문장, 문단, 헤더 같은 맥락이 전혀 고려되지 않습니다. 그 결과 다음과 같은 문제가 발생합니다.
- 표가 중간에서 단절되어 헤더와 데이터가 서로 다른 청크로 분리됨
- 단어나 코드 블록이 중간에서 잘림
- 청크 하나만 봐서는 이 내용이 무엇에 대한 것인지 알 수 없음
세 번째가 특히 골치 아픕니다. “이 회사의 매출은 전 분기 대비 3% 성장했다”라는 청크를 예로 들어 보겠습니다. 어느 회사의 언제 실적인지 청크 안에는 아무 단서가 없고, 임베딩 벡터에도 그 정보가 들어가지 않습니다. 그러니 “ACME사의 2023년 2분기 실적”으로 검색해도 이 청크는 걸리지 않습니다.
Contextual Retrieval은 청크에 문맥 설명을 덧붙여 이 구멍을 메웁니다.
청킹 파이프라인 개선
Contextual Embedding을 적용하기 전에, 먼저 청크 자체가 의미 단위로 나뉘어야 합니다. 문맥을 고려하지 않고 잘린 청크에 설명을 붙여봐야 효과가 제한적이기 때문입니다.
1단계: Markdown 헤더 분할
Confluence, Notion 등 대부분의 문서 도구는 Markdown으로 추출이 가능하고, 일반적인 문서라면 Heading이 잘 작성되어 있습니다. 이 Heading을 기준으로 1차 분할을 진행합니다.
1
2
3
4
5
6
7
8
header_splitter = ExperimentalMarkdownSyntaxTextSplitter(
headers_to_split_on=[
("#", "h1"),
("##", "h2"),
("###", "h3"),
],
strip_headers=True,
)
Heading 3까지만 분할하는 것을 권장합니다. 그보다 더 깊은 단계까지 분할하면 청크가 지나치게 잘게 쪼개지는 문제가 발생합니다.
2단계: Recursive 분할
헤더로 1차 분할된 청크를 다시 기준 사이즈에 맞게 분할합니다. 단락 → 줄바꿈 → 문장 → 단어 순서로 재귀적으로 분할을 시도하기 때문에, 가능한 한 큰 의미 단위를 보존한 채로 잘립니다.
1
2
3
4
5
6
recursive = RecursiveCharacterTextSplitter.from_tiktoken_encoder(
model_name="gpt-4o-mini",
chunk_size=chunk_token_size,
chunk_overlap=chunk_overlap_token_size,
separators=["\n\n", "\n", " ", ""],
)
이때 단순 문자 길이(len)가 아닌 실제 토큰(tiktoken) 기준으로 분할해야 임베딩 모델의 입력 한계와 일치시킬 수 있습니다.
| 설정 | 값 |
|---|---|
| CHUNK_SIZE | 1024 |
| CHUNK_OVERLAP | 150 |
3단계: 청크 병합
위 두 단계를 거치면 지나치게 작은 청크가 생깁니다. 실제로 테스트해보면 한 청크에 20토큰만 들어있는 경우도 나옵니다.
토큰이 너무 적은 청크는 문맥을 완전히 잃어 검색에서 영영 선택되지 않는 죽은 청크가 됩니다. 따라서 일정 토큰(예: 60토큰) 이하의 청크는 인접 청크와 병합합니다.
이때 URL이나 파일 경로처럼 의미가 없는 문자열은 카운트에서 제외하고 의미 있는 텍스트만 기준으로 계산해야 합니다. 링크만 잔뜩 들어있는 청크가 토큰 수만 채워서 병합을 피해가는 상황을 막기 위함입니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
_MD_URL_RE = re.compile(r"(!?\[[^\]]*\])\([^\n]*?\)")
def _effective_token_len(body: str, tokenizer: Tokenizer) -> int:
return len(tokenizer.encode(_MD_URL_RE.sub(r"\1", body)))
def _merge_small_chunks(
pairs: list[tuple[str, str, int, int]], tokenizer: Tokenizer, min_tokens: int
) -> list[tuple[str, str, int, int]]:
if min_tokens <= 0 or len(pairs) < 2:
return pairs
merged: list[list] = []
for crumb, body, os_, oe_ in pairs:
if merged and _effective_token_len(body, tokenizer) < min_tokens:
pc, pb, ps, pe = merged[-1]
sep = f"\n\n{crumb}\n" if (crumb and crumb != pc) else "\n\n"
merged[-1][1] = pb + sep + body
merged[-1][2], merged[-1][3] = _combine_span(ps, pe, os_, oe_)
else:
merged.append([crumb, body, os_, oe_])
# 맨 앞 청크가 단독으로 작으면 다음 청크로 전방 흡수
if len(merged) >= 2 and _effective_token_len(merged[0][1], tokenizer) < min_tokens:
c0, b0, s0, e0 = merged[0]
c1, b1, s1, e1 = merged[1]
sep = f"\n\n{c1}\n" if (c1 and c1 != c0) else "\n\n"
ns, ne = _combine_span(s0, e0, s1, e1)
merged[1] = [c0 or c1, b0 + sep + b1, ns, ne]
merged.pop(0)
return [(c, b, s, e) for c, b, s, e in merged]
Contextual Embedding
청크가 의미 단위로 나뉘었다면, 이제 각 청크 앞에 이 청크가 문서 안에서 어떤 역할인지 설명하는 문장을 붙입니다.
Anthropic이 제시한 예시는 다음과 같습니다.
original_chunk
The company’s revenue grew by 3% over the previous quarter.contextualized_chunk
This chunk is from an SEC filing on ACME corp’s performance in Q2 2023; the previous quarter’s revenue was $314 million. The company’s revenue grew by 3% over the previous quarter.
원본 청크는 “회사의 매출이 전 분기 대비 3% 증가했다”는 내용뿐입니다. Contextual Embedding을 적용한 청크에는 ACME사, 2023년 2분기, 이전 분기 매출 3억 1400만 달러라는 부가 정보가 앞에 추가됩니다. 이 상태로 임베딩하면 고립되어 있던 청크가 문서 전체 맥락과 연결되므로 검색 정확도가 올라갑니다.
Anthropic이 제시한 기본 프롬프트는 다음과 같습니다.
1
2
3
4
5
6
7
8
9
10
<document>
</document>
Here is the chunk we want to situate within the whole document
<chunk>
</chunk>
Please give a short succinct context to situate this chunk within the overall
document for the purposes of improving search retrieval of the chunk.
Answer only with the succinct context and nothing else.
그런데 이 프롬프트를 그대로 쓰면 실제 운영 환경에서는 여러 문제가 생깁니다. 아래는 실제 적용 과정에서 추가한 개선 사항 4가지입니다.
개선 1. 전체 문서 요약 추가
경량 모델(gpt-4o-mini 등)은 긴 문서를 통째로 넣으면 맥락을 제대로 파악하지 못합니다. 게다가 청크마다 전체 문서를 넣으면 입력 토큰 비용도 크게 늘어납니다.
그래서 문서당 요약을 1회만 생성해두고, 이후 해당 문서의 모든 청크에서 재사용하는 방식으로 처리합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
_SUMMARY_PROMPT = """
다음 문서의 핵심 내용을 2~3문장으로 요약하세요.
문서의 주요 주제, 목적, 핵심 정보를 포함하여 전체 내용을 파악할 수 있게 요약하세요.
요약 외 다른 말은 절대 하지 마세요.
"""
async def _generate_doc_summary(client, model: str, content: str) -> str:
try:
resp = await client.chat.completions.create(
model=model,
messages=[
{"role": "system", "content": _SUMMARY_PROMPT},
{"role": "user", "content": f"<document>\n{content}\n</document>"},
],
max_tokens=_SUMMARY_MAX_TOKENS,
temperature=0,
)
return (resp.choices[0].message.content or "").strip()
except Exception as e:
logger.warning("[contextual] 문서 요약 생성 실패 — 요약 없이 진행: %s", e)
return ""
개선 2. 청크의 문서 내 위치 정보 추가
문서가 길면 모델이 해당 청크가 문서의 어느 지점에 있는지 파악하지 못합니다. 도입부인지 결론부인지에 따라 청크의 역할 설명이 달라져야 하므로, 원본 문서 기준 위치를 비율(%) 로 계산해 함께 전달합니다.
1
2
3
4
5
6
7
8
def _position_note(span: tuple[int, int], content_len: int) -> str:
"""청크의 원본 char span 을 전체 문서 기준 위치 비율 문자열로 변환."""
co_s, co_e = span
if co_s < 0 or co_e < 0 or content_len <= 0 or co_e <= co_s:
return ""
pct_s = round(100 * co_s / content_len)
pct_e = round(100 * co_e / content_len)
return f"전체 문서의 약 {pct_s}%~{pct_e}% 지점 (문자 {co_s}–{co_e} / 총 {content_len}자)"
개선 3. Markdown 헤더 경로 추가
해당 청크가 어떤 Heading 아래에 속하는지 계층 경로(breadcrumb)를 함께 제공합니다.
배송비 정책 > 지역별 할증 > 도서산간 할증 기준
경로만 봐도 모델은 청크의 소속과 목적을 상당 부분 추론합니다. 게다가 이 경로는 청크 본문 맨 앞에도 그대로 붙여 저장하므로, 나중에 검색 단계의 키워드 매칭에서 한 번 더 일합니다.
개선 4. 출력 구조화로 CoT 유도
위 정보들을 그냥 한 프롬프트에 몰아넣으면, 모델이 문서 맥락은 무시하고 청크 자체의 요약만 생성하는 현상이 나타납니다. “이 청크는 A, B, C에 대해 설명합니다” 같은 내용 나열은 검색에 전혀 도움이 되지 않습니다.
이를 막기 위해 청크 유형을 사전에 정의해두고, 유형을 먼저 확정한 뒤 그 유형에 맞는 설명을 작성하도록 강제했습니다. 유형 판단이 일종의 Chain of Thought 역할을 하면서 설명의 서술 방향이 고정됩니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class _ChunkType(str, Enum):
"""청크 유형 — chunk_type 을 먼저 확정시켜 description 서술 방향을 고정(CoT)."""
# 주요 내용
MAIN_CLAIM = "주요_내용_주장분석" # 핵심 주장·결론·분석·평가
MAIN_PROCEDURE = "주요_내용_절차설명" # 과정·방법·단계·절차 설명
MAIN_POLICY = "주요_내용_정책규정" # 정책·규정·기준·한도·조건
MAIN_DEFINITION = "주요_내용_정의개념" # 용어·개념 정의
MAIN_BACKGROUND = "주요_내용_배경목적" # 배경·동기·목적·도입부
# 보조 자료
AUX_EXAMPLE = "보조_자료_예시" # 개념 설명을 위한 예시·사례
AUX_TEST_OUTPUT = "보조_자료_테스트출력" # 테스트·실험·실행 결과물
AUX_CODE = "보조_자료_코드블록" # 코드 샘플·스크립트
AUX_CONFIG = "보조_자료_설정파라미터" # env·config·파라미터·명령어
AUX_TABLE = "보조_자료_표데이터" # 비교·정리 표·데이터 테이블
AUX_APPENDIX = "보조_자료_부록참고" # 참고 데이터·부록·스펙 목록
AUX_TOC = "보조_자료_목차" # 목차·인덱스·개요 나열
class _ChunkContext(BaseModel):
"""OpenAI Responses API 구조화 출력 스키마."""
chunk_type: _ChunkType
description: str
최종 프롬프트
위 개선 사항을 모두 반영하면 입력과 출력이 다음과 같은 구조가 됩니다.
프롬프트는 다음과 같습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
입력 태그 우선순위: <section_path> → <document_summary> → <document> 순서로
참고해 청크의 맥락을 파악하세요.
<section_path>는 이 청크가 속한 문서 섹션의 계층 경로(breadcrumb)입니다.
<document_summary>는 문서 전체의 핵심 요약입니다.
<document>는 현재 청크가 포함된 섹션의 발췌본입니다.
[출력 형식 — JSON]
{"chunk_type": "<유형 값 중 하나>", "description": "<한국어 설명 1문장>"}
[분석 순서 — 이 순서대로 판단 후 JSON 출력]
1. section_path를 읽고 이 청크가 문서의 어느 섹션에 속하는지 파악
2. chunk_type을 목록에서 먼저 확정
3. chunk_type에 따라 description 작성
- 주요_내용_주장분석 / 절차설명 : 문서 흐름 내 위치와 앞뒤 내용과의 연결 관계
- 주요_내용_정책규정 : 어떤 대상에게 어떤 조건에서 어떤 규칙·한도가 적용되는지
- 주요_내용_정의개념 : 어떤 용어를 정의하며 무엇을 이해하기 위한 기반인지
- 주요_내용_배경목적 : 이 배경·목적이 문서 전체에서 무엇을 정당화·도입하는지
- 보조_자료_* : "무엇을 담고 있는가"가 아니라 "왜 이 섹션에 포함되었는가"
[description 작성 기준]
- 120~180자 1문장의 간결한 한국어
- section_path 기반 위치(어느 섹션)와 역할(무슨 목적)을 반드시 포함
- 청크 내용을 나열하거나 요약하지 말 것
[참고] <chunk_location> 태그가 주어지면 청크가 도입부/중반부/후반부 중
어디인지 판단에만 활용하고, 비율 수치(예: "67%")를 description에 그대로 쓰지 마세요.
핵심은 description 작성 기준입니다.
내용을 요약하지 말 것, 위치와 역할을 반드시 포함할 것 두 가지를 명시해야 검색에 실제로 도움이 되는 설명이 생성됩니다.
few-shot 예시도 유형별로 하나씩 넣어주면 출력 품질이 눈에 띄게 안정됩니다.
1
2
3
4
{"chunk_type": "주요_내용_정책규정", "description": "이 청크는 '배송비 정책 > 도서산간 할증' 섹션에서 지역 구분별로 적용되는 추가 배송비 기준과 면제 조건을 규정하여 정산 로직의 근거를 제공합니다."}
{"chunk_type": "주요_내용_정의개념", "description": "이 청크는 '용어 정의' 섹션에서 ETA(도착예정시각) 개념을 정의하며, 문서 전반의 배송 지연 판정 로직을 이해하기 위한 기반 용어를 설명합니다."}
{"chunk_type": "보조_자료_표데이터", "description": "이 표는 '택배사 연동 현황' 섹션에서 연동사별 API 방식과 갱신 주기를 한눈에 비교·정리하여 앞서 서술한 수집 주기 설계 근거를 뒷받침하기 위해 포함되었습니다."}
{"chunk_type": "보조_자료_테스트출력", "description": "이 JSON 출력은 '라우팅 엔진 > 경로 산출' 섹션에서 엔진이 다중 경유지 요청을 실제로 어떻게 분해했는지 보여주기 위해 포함되었습니다."}
실제 호출부는 다음과 같습니다. Contextual Embedding은 청크 수만큼 LLM을 호출하기 때문에 Rate Limit에 걸리기 쉽고, 실패 시 폴백 처리가 반드시 필요합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
@retry(
stop=stop_after_attempt(CONTEXTUAL_MAX_RETRIES),
wait=wait_exponential(multiplier=1, min=4, max=30),
retry=(
retry_if_exception_type(RateLimitError)
| retry_if_exception_type(APIConnectionError)
| retry_if_exception_type(APITimeoutError)
),
before_sleep=before_sleep_log(logger, logging.WARNING),
reraise=True,
)
async def _responses_parse_with_retry(client, model: str, input_msgs: list):
"""일시적 오류(RateLimit/연결/타임아웃)에 한해 지수 백오프로 재시도."""
return await client.responses.parse(
model=model,
input=input_msgs,
text_format=_ChunkContext,
max_output_tokens=_CONTEXT_MAX_TOKENS,
temperature=0,
)
async def _generate_context(
client, model: str, source: str, chunk_text: str,
breadcrumb: str = "", position_note: str = "", doc_summary: str = "",
) -> str:
chunk_msg = ""
if breadcrumb:
chunk_msg += f"<section_path>{breadcrumb}</section_path>\n"
chunk_msg += f"<chunk>\n{chunk_text}\n</chunk>"
if position_note:
chunk_msg += f"\n<chunk_location>{position_note}</chunk_location>"
user_msgs = []
if doc_summary:
user_msgs.append({"role": "user", "content": f"<document_summary>\n{doc_summary}\n</document_summary>"})
user_msgs.append({"role": "user", "content": f"<document>\n{source}\n</document>"})
user_msgs.append({"role": "user", "content": chunk_msg})
try:
resp = await _responses_parse_with_retry(
client, model,
[{"role": "system", "content": _PROMPT_INSTRUCTION}, *user_msgs],
)
parsed: _ChunkContext | None = resp.output_parsed
if parsed is None:
logger.warning("[contextual] 구조화 출력 없음(거부/미완성) — breadcrumb 만 사용")
return ""
return parsed.description.strip()
except Exception as e:
logger.warning("[contextual] 상황설명 생성 실패 — breadcrumb 만 사용: %s", e)
return ""
생성이 실패해도 예외를 던지지 않고 빈 문자열을 반환해 breadcrumb만 붙은 청크로 폴백합니다. 인덱싱 전체가 중단되는 것보다 일부 청크의 설명이 없는 편이 낫기 때문입니다.
적용 전후 비교
아래는 “배송 라우팅 엔진 성능 테스트” 라는 가상의 문서를 인덱싱했을 때의 비교입니다.
적용 전 청크는 1200 토큰이라는 기계적 기준에 맞춰 잘려 있습니다.
1
2
3
4
5
{
"tokens": 1200,
"content": "[물류 플랫폼](../../물류%20플랫폼.md) > [실험](../실험.md)\n\n# 라우팅 엔진 성능 테스트\n\n- {...} - 4.2 [경로 산출 시간](#경로산출시간)\n\n## 조건\n\n### {...} 모든 지표는 낮을수록 좋음\n\n### 요청 파라미터\n\n```java\nROUTE_REQUEST = (\n {...} 경유지가 5개를 초과하면 분할 요청으로 처리하며, 재시도는 고려하지 않고 산출하세요. \"\n \"",
"chunk_order_index": 0
}
1
2
3
4
5
{
"tokens": 1200,
"content": "(\n \"경유지 최적화는 도착 순서를 재배열하지 않습니다. {...} 참고\n\n## 평가 1. 엔진 타입에 따른 차이\n\n#### 전체 평균\n\n- {...} 전체적으로 그리디 방식보다 경로 품질이 우수하나, 일부 케이스에서 경유지 순서를 잡지 못한다고 해석 가능\n\n#### 경로 산출 시간\n\n| | **평균 소요** |\n|:-----------------|:-------------|\n| **그리디** | 0.8초 |\n| **2-opt** | 2.6초 |\n\n- 2-opt가 그리디 대비 약 3.2배의 시간 소요\n\n#### 평가 지표\n\n| | **그리디** | **2-opt** |\n|:-----------------|------------:|-----------:|\n| **총 이동거리** | 142.3 | 118.7 |\n| **SLA 초과율** | 0.184 | 0.092 |\n\n- {...}\n- 2-o",
"chunk_order_index": 1
}
- 문맥상 묶여야 하는 요청 파라미터 정보가 중간에서 잘림
- 단어가 이어지지 않고 끊김 (
2-o) - 표 헤더와 데이터가 서로 다른 청크로 분리됨
적용 후 청크는 다음과 같은 형태가 됩니다.
1
2
3
4
5
{
"tokens": 168,
"content": "라우팅 엔진 성능 테스트 > 평가 기준\n\n이 청크는 '라우팅 엔진 성능 테스트 > 평가 기준' 섹션에서 경로 품질을 판단하기 위한 세 가지 지표인 총 이동거리, SLA 초과율, 경로 산출 시간을 정의하며, 이후 엔진별 비교 결과를 해석하기 위한 기반 정보를 제공합니다.\n\n- 총 이동거리(km), SLA 초과율(약속 시간 초과 배송 비율), 경로 산출 시간(초)을 측정\n- 총 이동거리와 SLA 초과율은 낮을수록 좋음\n- 동일한 주문 스냅샷 1,000건을 모든 엔진에 동일하게 투입",
"chunk_order_index": 1
}
1
2
3
4
5
{
"tokens": 254,
"content": "라우팅 엔진 성능 테스트 > 평가 1. 엔진 타입에 따른 차이 > 2-opt\n\n이 청크는 '평가 1. 엔진 타입에 따른 차이' 섹션에서 2-opt 방식의 측정 결과를 제시하며, 앞서 서술한 그리디 방식과 대조하여 경로 품질과 산출 시간 사이의 트레이드오프를 분석하는 역할을 합니다.\n\n#### 전체 평균\n\n- 총 이동거리: 118.7km\n- SLA 초과율: 0.092\n- 경로 산출 시간: 2.6초\n\n#### 결과 해석\n\n- 경유지가 5개 이하인 주문에서는 그리디와 품질 차이가 크지 않음\n- 경유지가 늘어날수록 총 이동거리 격차가 벌어짐\n- 다만 산출 시간이 3배 이상 늘어 실시간 배차에는 부담",
"chunk_order_index": 4
}
- 청크 맨 앞에 헤더 경로 정보가 추가됨
- 그다음 이 청크가 문서에서 어떤 역할인지 설명이 추가됨
- 토큰 개수가 아닌 문맥 기준으로 분할되어 단어나 표 중간에서 잘리지 않음
- 표 헤더와 데이터가 한 청크 안에 함께 유지됨
“현재 연동된 택배사는 어디고 각각 연동 방식이 뭔지 정리해줘” 처럼 여러 문서를 훑어야 하는 추상적인 질문에서 차이가 특히 두드러집니다. 적용 전에는 일부 대상만 찾아 답변하고 질문과 무관한 정보까지 섞여 들어왔지만, 적용 후에는 더 많은 대상을 찾아내고 질문 의도에 맞는 항목 중심으로 답변이 구성되었습니다.
비용
Contextual Embedding은 문서 요약 1회 + 청크 수만큼의 LLM 호출이 발생하므로 인덱싱 비용이 크게 늘어납니다. 문서 952개 규모의 스페이스 전체를 인덱싱했을 때 약 19달러, 문서 하나당 약 0.02달러 수준이었습니다.
한 번 인덱싱하면 문서가 변경되기 전까지 재사용되므로 감당할 만한 수준이지만, 코퍼스 규모가 크다면 사전에 예산을 계산해두는 것이 좋습니다.
BM25
Contextual Embedding으로 청크 품질을 올렸어도 벡터 유사도 검색만으로는 여전히 한계가 있습니다.
- 약어, 줄임말처럼 의미는 같으나 형태가 다른 단어는 임베딩이 잘 잡아내지만, 반대로 형태가 정확히 일치해야 하는 검색에는 약함
gpt-4o-mini, 제품 코드, 사번 같은 일반적이지 않은 고유 명사는 임베딩 공간에서 변별력이 떨어짐
BM25는 이 약점을 보완합니다. 질문과 문서의 단어 빈도를 이용해 랭킹을 산출하는 키워드 기반 검색 방식으로, 벡터 검색과 상호 보완적입니다.
BM25 점수는 각 단어의 TF와 IDF를 곱한 뒤 합산한 값입니다. 점수가 높을수록 질문과 관련성이 높은 문서라는 의미입니다.
\[\text{BM25}(q, d) = \sum_{t \in q} \text{IDF}(t) \cdot \text{TF}_{weight}(t, d)\]TF (Term Frequency, 단어 빈도)
특정 단어가 문서 안에서 얼마나 자주 등장하는지를 수치화한 값입니다. 단, BM25는 등장 횟수를 그대로 쓰지 않습니다.
\[\text{TF}_{weight}(t, d) = \frac{f \cdot (k_1 + 1)}{f + k_1 \cdot (1 - b + b \cdot \frac{dl}{avgdl})}\]- $f$ = 문서 $d$에서 단어 $t$의 등장 횟수
- $k_1$ = TF 포화 파라미터 (기본 1.5), 클수록 등장 횟수 의존도가 커짐
- $b$ = 길이 정규화 파라미터 (기본 0.75), 1이면 완전 정규화, 0이면 정규화 없음
- $dl$ = 이 문서의 토큰 수
- $avgdl$ = 전체 평균 문서 토큰 수
이 수식에는 두 가지 직관이 담겨 있습니다.
포화(saturation): 단어가 아무리 많이 등장해도 $k_1$에 의해 점수가 무한히 커지지 않습니다. 같은 단어를 100번 반복한 문서가 10번 등장한 문서보다 10배 관련성이 높지는 않기 때문입니다.
길이 정규화: 긴 문서일수록 같은 단어가 더 많이 등장할 확률이 높습니다. $dl/avgdl$로 문서 길이를 보정해 짧은 문서가 불리해지지 않게 합니다.
IDF (Inverse Document Frequency, 역문서빈도)
특정 단어가 전체 문서 집합 중 몇 개의 문서에 등장하는지를 역수로 취하고 로그를 씌운 값입니다.
\[\text{IDF}(t) = \log\left(\frac{N}{df(t)}\right)\]- $N$ = 컬렉션 전체 문서 수
- $df(t)$ = 단어 $t$가 1회 이상 등장한 문서 수
IDF가 높을수록 희귀한 단어라는 의미이고, 희귀한 단어일수록 검색에서 변별력이 큽니다.
“배송”이 1000개 문서 중 800개에 등장 → $\log(1000/800) \approx 0.22$
“도서산간”이 1000개 문서 중 12개에만 등장 → $\log(1000/12) \approx 4.42$
“배송”은 물류 문서 어디에나 있으니 검색어로서 변별력이 거의 없고, “도서산간”은 특정 문서에만 있으니 강력한 신호입니다. 그래서 IDF가 높은 단어를 품은 청크일수록 가중치를 크게 잡습니다.
RRF (Reciprocal Rank Fusion, 역순위 융합)
BM25 점수와 벡터 유사도 점수는 스케일이 완전히 다릅니다. 코사인 유사도는 0~1 범위지만 BM25 점수는 상한이 없습니다. 그래서 두 점수를 그냥 더하거나 가중평균할 수 없습니다.
RRF는 점수 대신 순위만 사용해서 이 문제를 우회합니다.
\[\text{RRF}(d) = \sum_{r} \frac{1}{k + \text{rank}_r(d)}\]- $r$ = 각 검색 채널 (dense, sparse)
- $\text{rank}_r(d)$ = 채널 $r$에서 문서 $d$의 순위 (1위 = 1, 2위 = 2, …)
- $k$ = 완충 상수 (Qdrant 기본값 60), 상위 순위 독점 방지
$k$가 클수록 1위와 2위의 점수 차이가 완만해집니다. $k=60$이면 1위는 $1/61$, 2위는 $1/62$로 거의 차이가 없어서, 한 채널에서 압도적 1위를 차지한 문서가 최종 순위를 독점하는 것을 막아줍니다. 그 덕에 두 채널 모두에서 상위권인 문서가 최종 상위로 올라옵니다.
BM25 구현 방법
TF는 인덱싱 시점에 계산해 청크 벡터와 함께 저장하고, IDF는 검색 시점에 벡터 DB 내부에서 연산하는 구조로 구현합니다. IDF는 전체 코퍼스 통계가 필요하므로 인덱싱 시점에 확정할 수 없기 때문입니다.
1. 토큰화 및 TF 계산
문서 인덱싱 시 토크나이저로 명사를 추출하고, 추출된 명사로 TF 값을 계산합니다. 문서 길이와 단어 등장 횟수 등 TF 계산에 필요한 정보는 모두 해당 문서 안에서 얻을 수 있습니다.
한국어 문서라면 형태소 분석기가 필수입니다. 공백 기준으로 자르면 조사가 붙은 “운송장번호를”, “운송장번호는”이 서로 다른 토큰이 되어버리기 때문입니다. 여기서는 Kiwi를 사용했습니다.
2. 벡터 DB 저장
하나의 청크에 임베딩 벡터(dense)와 TF 벡터(sparse) 쌍을 함께 저장합니다.
1
2
3
4
5
6
7
vector = {
"dense": embeddings[i], # OpenAI 임베딩 (float[3072])
"bm25": models.SparseVector(
indices=[hash("도서산간"), hash("배송비")], # 토큰 해시 (정수)
values=[0.87, 0.44], # TF 가중치 (IDF 제외)
),
}
여기서 sparse vector에 넣는 값은 IDF를 뺀 TF 가중치뿐입니다.
3. 검색 시 IDF 적용
검색 시점에 Qdrant가 내부적으로 컬렉션 통계를 계산해 IDF를 적용합니다.
1
2
3
4
5
6
7
sp_idx, sp_val = tokenizer.encode_query(query)
# sp_val = [1.0, 1.0, ...] term 있으면 1, 없으면 인덱스 자체 없음
models.Prefetch(
query=models.SparseVector(indices=sp_idx, values=sp_val),
using="bm25",
)
질문 쪽 sparse vector의 값은 단순히 존재 여부(1.0)만 표시합니다. 질문은 짧기 때문에 TF를 계산할 의미가 없고, 실제 가중치는 저장된 문서 쪽 TF와 IDF에서 나옵니다.
4. RRF 수행
dense 채널과 sparse 채널에서 각각 후보를 뽑은 뒤 RRF로 융합합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
prefetch_limit = max(top_k, top_k * BM25_PREFETCH_MULT)
prefetch = [
models.Prefetch(
query=embedding,
using=BM25_DENSE_NAME,
limit=prefetch_limit,
filter=ws_filter,
score_threshold=self.cosine_better_than_threshold,
)
]
if sp_idx:
prefetch.append(
models.Prefetch(
query=models.SparseVector(indices=sp_idx, values=sp_val),
using=BM25_SPARSE_NAME,
limit=prefetch_limit,
filter=ws_filter,
)
)
두 채널 모두 최종 top-k보다 넉넉하게(BM25_PREFETCH_MULT 배수) 후보를 뽑습니다.
토크나이저 딕셔너리 관리
BM25를 실제로 운영하면서 가장 손이 많이 가는 부분입니다.
Kiwi를 비롯한 형태소 분석기는 기본 사전에 없는 도메인 용어를 제대로 분해하지 못합니다. “도서산간할증료”가 “도서 / 산간 / 할증 / 료”로 쪼개지면, “도서”(책)가 들어간 엉뚱한 문서까지 검색 결과에 올라옵니다.
다행히 Kiwi는 사용자 사전 추가 기능을 지원하므로, 이를 활용해 도메인 명사를 주기적으로 보강합니다.
1. 업데이트 트리거
매일 정해진 시각(예: KST 04시)에 Crontab으로 트리거합니다. 여러 RAG 인스턴스가 있다면 부하 분산을 위해 20분 간격으로 배치를 나누는 것이 좋습니다.
2. 청크 표본 수집
직전 업데이트 시점 이후 추가된 청크만 추출합니다.
청크의 update_time 필드를 기준으로 필터링하면 전체 재처리 없이 증분 처리가 가능합니다.
3. LLM으로 키워드 추출
수집된 청크를 50개 단위로 묶어 LLM에 넘겨 도메인 명사를 추출합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
_PROMPT = """다음은 사내 문서 코퍼스의 청크 표본이다.
한국어 형태소 분석기(Kiwi)가 잘못 분해하거나 놓치기 쉬운 **도메인 명사 키워드**를 추출하라.
[추출 기준]
- 표본 내 **2개 이상의 서로 다른 청크**에 등장하는 단어만 (1회성 단어 제외)
- 실제로 사용자가 **검색어로 입력할 법한** 도메인 고유 명사
- 업무 약어·이니셜리즘: SLA, ETA, TMS, WMS, POD
- 제품·기술 고유명사: Redis, Celery, OAuth, Qdrant, Kiwi
- 분해되면 의미가 깨지는 복합 명사: 운송장번호, 도서산간할증료, 집화지연
[반드시 제외]
- 언더스코어(_)가 포함된 식별자 (예: kv_store_text_chunks)
- 코드·설정·경로에서 유래한 변수명·테이블명·함수명·파일명
- 특정 문서 1건에만 등장하는 고유 식별자
- 일반 명사·동사·형용사·조사 (API, SQL, JSON 등 범용 약어는 제외)
각 용어는 공백 없는 단일 토큰 형태로 적는다. JSON 으로 terms 배열만 반환한다."""
제외 조건은 최대한 촘촘하게 적어두는 편이 좋습니다. 안 그러면 코드 블록에서 나온 변수명이 딕셔너리에 무더기로 들어와 오히려 토큰화 품질을 떨어뜨립니다.
배치 처리에서는 일부 배치가 실패해도 전체가 멈추지 않게 막아둡니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
async def _extract_batch(
client, model: str, chunks: list[str],
sem: asyncio.Semaphore, batch_idx: int, total_batches: int,
) -> list[str]:
"""청크 배치 1개에서 LLM 으로 도메인 용어 추출."""
async with sem:
text = "\n---\n".join(chunks)
try:
resp = await client.responses.parse(
model=model,
input=[
{"role": "system", "content": _PROMPT},
{"role": "user", "content": text},
],
text_format=_Terms,
temperature=0,
)
parsed: _Terms | None = resp.output_parsed
terms = parsed.terms if parsed else []
except Exception as e:
# 출력 토큰 초과로 JSON 이 잘리면 ValidationError 발생 → 해당 배치 건너뜀
logger.warning(
"[BM25 사전] 배치 %d/%d 파싱 실패 — 건너뜀 (%s: %s)",
batch_idx, total_batches, type(e).__name__, str(e)[:120],
)
return []
return terms
4. 검증 후 딕셔너리 업데이트
기존 딕셔너리와 중복되는 단어는 제외하고, 새로운 단어만 추가합니다.
1
2
3
4
5
6
7
8
9
10
...
+ 운송장번호
+ 집화지연
+ 도서산간할증료
+ 반품접수취소
+ 재배송요청
+ 배송완료증빙
+ RouteOptimizer
+ deliveryState
사전 전체 용어 수: 1182
5. TF 재인덱싱
신규 용어가 추가되면 같은 청크라도 토큰화 결과가 달라집니다. 따라서 딕셔너리 갱신 후에는 전체 청크를 대상으로 TF를 재계산해 덮어써야 합니다.
이 단계를 빼먹으면 새로 인덱싱된 청크와 기존 청크의 토큰화 기준이 어긋나 검색 결과가 왜곡됩니다. 그나마 TF 재계산은 LLM 호출이 없는 순수 연산이라 추가 비용은 들지 않습니다.
워크스페이스 분리
여러 RAG 인스턴스가 하나의 컬렉션을 태그로만 구분해 쓰고 있었다면, BM25 도입 시 반드시 워크스페이스를 분리해야 합니다.
IDF는 컬렉션 전체 문서 수($N$)와 문서 빈도($df$)로 계산되기 때문에, 서로 다른 도메인의 문서가 같은 컬렉션에 섞여 있으면 한쪽 코퍼스의 문서 분포가 다른 쪽의 IDF 값을 오염시킵니다.
적용 결과
Contextual Embedding + BM25를 적용한 뒤, 적용 전에는 제대로 답변하지 못했던 질문을 다시 던져 비교했습니다. 청킹 전략 외에 사용 모델, top-k 개수 등 나머지 조건은 모두 동일하게 유지했습니다.
- 적용 전: 질문의 의도를 명확히 파악하지 못하고, 관련 있는 청크도 찾지 못함
- 적용 후: 질문에서 키워드를 추출한 뒤 BM25 랭킹으로 정확한 문서를 탐색하고, 해당 청크를 근거로 답변 생성
특히 특정 화면명이나 기능명처럼 고유 명사가 핵심인 질문에서 개선 폭이 컸습니다. 예를 들어 “정산 관리 > 미정산 내역 화면에서 일괄 삭제가 안 되는데 어떻게 처리하나요?” 같은 질문은 벡터 유사도만으로는 내부 화면명을 정확히 매칭하지 못했지만, BM25는 “미정산”, “일괄삭제” 같은 키워드를 정확히 잡아냅니다.
물론 대가도 있습니다.
| 항목 | 적용 전 | 적용 후 |
|---|---|---|
| 평균 응답 시간 | 40~50초 | 70~80초 |
| 인덱싱 비용 | 낮음 | 문서당 약 0.02달러 |
응답 시간이 늘어난 이유는 dense/sparse 두 채널 검색과 RRF 융합, 그리고 재랭킹 단계가 추가되었기 때문입니다. 검색 품질을 응답 속도와 맞바꾼 셈입니다. 실시간성이 걸린 서비스라면 prefetch 개수나 재랭킹 범위를 줄여 속도 쪽으로 당기면 됩니다.
정리
- 청킹: 토큰 단위 기계적 분할 대신 Markdown 헤더 → Recursive → 소형 청크 병합 순으로 의미 단위 분할
- Contextual Embedding: 문서 요약 + 위치 정보 + 헤더 경로를 근거로 청크의 역할 설명을 생성해 앞에 주입
- BM25: TF/IDF 기반 키워드 검색을 sparse vector로 구현하고 RRF로 벡터 검색과 융합
- 딕셔너리 관리: 형태소 분석기의 도메인 용어 대응을 위해 주기적으로 사용자 사전을 갱신하고 TF 재인덱싱
인덱싱 비용과 응답 시간은 확실히 늘어납니다. 그래도 추상적인 질문이나 고유명사가 섞인 질문에서 체감되는 차이를 보면 적용할만한 가치가 있습니다.


