본문으로 건너뛰기

"RAG" 태그 — 2개 게시물

RAG 관련 프로젝트 경험

모든 태그 보기

메모랜덤 flow에 사용된 문서영역별 Clustering 성능평가

· 약 8분
강민석
AI Research Engineer, Brain Crew

TL;DR

GS Caltex 메모랜덤-연구노트 매칭 프로젝트에서 PyMuPDF 기반 파서, Titan Embed V2 임베딩, ChromaDB 벡터 검색, Claude Sonnet 4.5 LLM 판정을 결합한 파이프라인을 구축했습니다. 137개 연구노트 중 82.5%가 메모랜덤과 매칭되었으며, Label Propagation 알고리즘이 BCubed F1 0.763으로 최고 성능을 기록했습니다. 단일 클러스터로 수렴하는 경향이 강해 의미 기반보다 서사 구조 기반 접근이 더 효과적임을 확인했습니다.

Key Takeaways

  • LLM 기반 매칭 판정의 보수적 설계: 벡터 유사도만으로는 부족한 정확도를 Claude Sonnet 4.5의 명시적 판정(표 데이터 유무, 섹션 타입 필터링)으로 보완하여 82.5% 매칭률 달성. Temperature=0.0으로 일관성 확보가 핵심.

  • 클러스터링 알고리즘 선택은 데이터 특성에 의존적: Label Propagation(F1 0.763)이 평균적으로 우수하나, 단일 주제 데이터(ELN3)는 Connected Components로 완벽 매칭(F1 1.0), 복잡한 다중 주제(ELN5)는 HDBSCAN이 유리(F1 0.812). 사전 데이터 분석 필수.

  • HDBSCAN 과분할 문제와 파라미터 민감도: min_cluster_size 5 이상에서 평균 37개 클러스터 생성으로 recall 급락. 최적값은 2~3 + min_samples=3 조합으로 F1 0.736 달성. 정성 피드백과 정량 평가 일치.

  • 단일 클러스터 수렴 현상의 근본 원인: 연구노트 간 임베딩 유사도가 높아 GraphCommunity 알고리즘에서 평균 1.1~1.9개 클러스터만 생성. 의미 기반 분할보다 서사 구조(시간순, 실험 단계) 기반 청킹이 더 효과적.

  • BCubed F1 메트릭의 실무 적용성: Precision/Recall 균형 평가로 과분할(precision 하락)과 과소분할(recall 하락) 동시 탐지 가능. 클러스터 수와 F1을 함께 모니터링하여 알고리즘 선택 가이드 제공.

상세 내용

배경: 연구노트-메모랜덤 자동 매칭 시스템 구축

GS Caltex 프로젝트는 PDF 형태의 연구노트(ELN)와 메모랜덤을 자동으로 매칭하여 연구 결과를 체계화하는 시스템 개발을 목표로 했습니다. 기존 수작업 매칭은 시간이 많이 소요되고 일관성이 부족했으며, 메모랜덤의 목차 구조를 활용하여 관련 연구노트를 자동으로 클러스터링하는 솔루션이 필요했습니다.

핵심 과제는 두 가지였습니다:

  1. 정확한 정답셋 생성: 벡터 유사도만으로는 부정확한 매칭이 많아 LLM 판정 단계 추가
  2. 효과적인 클러스터링: 메모랜덤 목차별로 연구노트를 의미있게 그룹화

아키텍처 설계: 파싱 → 임베딩 → 벡터 검색 → LLM 판정

1단계: 문서 파싱 및 청킹

메모랜덤과 연구노트는 서로 다른 파싱 전략을 적용했습니다:

# 메모랜덤: 폰트 크기 기반 목차 추출
class MemorandumNaiveParser:
def extract_toc(self, page):
if font_size >= 13.5:
return "대제목" # "1. 목적"
elif font_size >= 11.5:
return "소제목" # "2.1. 균주"
else:
return "본문"

# 연구노트: 키워드 기반 섹션 분류
SECTION_KEYWORDS = {
"실험개요": ["실험개요", "실험 개요"],
"실험방법": ["실험방법", "실험 방법"],
"실험결과": ["실험결과", "Task Results"],
"결론": ["결론", "고찰", "결과 및 토의"],
}

의사결정 포인트: PyMuPDF를 선택한 이유는 로컬 처리 가능, 빠른 속도, 폰트 메타데이터 추출 지원 때문입니다. Upstage Document Parse API도 테스트했으나, 대부분의 문서에서 PyMuPDF와 유사한 품질을 보여 비용 효율적인 로컬 처리를 선택했습니다.

2단계: 임베딩 및 벡터 저장

# Amazon Titan Embed Text V2 설정
embedding_config = {
"model_id": "amazon.titan-embed-text-v2:0",
"dimensions": 1024,
"normalize": True, # L2 정규화로 코사인 유사도 계산
"max_tokens": 8192,
"safety_margin": 0.85 # 실제 최대: 6,553 토큰
}

# ChromaDB 디스크 기반 저장
chroma_client = chromadb.PersistentClient(path="./chroma_db")
collection = chroma_client.create_collection(
name="memorandum_eln",
metadata={"hnsw:space": "cosine"}
)

의사결정 포인트: Titan V2를 선택한 이유는 AWS Bedrock 통합 용이성과 8K 토큰 컨텍스트 길이입니다. 메모랜덤 청크가 평균 500 토큰으로 긴 편이라 OpenAI의 512 토큰 제약은 부적합했습니다.

3단계: 벡터 검색 + LLM 판정

# Top-K=5 벡터 검색
results = collection.query(
query_embeddings=[eln_embedding],
n_results=5,
where={"session_id": session_id}
)

# Claude Sonnet 4.5 매칭 판정
prompt = f"""
다음 연구노트와 메모랜덤 청크가 같은 실험을 다루는지 판단하세요.

[판정 기준]
- True: 표(Table)에 구체적 실험 데이터(OD, 수율, 농도 등) 있음
- False: 결론/목적/향후 계획 섹션
- False: 판단 어려움 (보수적 판정)

[연구노트]
{eln_content}

[메모랜덤 청크]
{memorandum_chunk}

JSON 형식으로 응답: {{"match": true/false, "confidence": "high/medium/low"}}
"""

response = bedrock.invoke_model(
modelId="global.anthropic.claude-sonnet-4-5-20250929-v1:0",
body=json.dumps({
"anthropic_version": "bedrock-2023-05-31",
"max_tokens": 1000,
"temperature": 0.0, # 일관성 최대화
"messages": [{"role": "user", "content": prompt}]
})
)

의사결정 포인트: 벡터 유사도만 사용했을 때 "실험 방법" 섹션과 "실험 결과" 섹션이 오매칭되는 문제가 빈번했습니다. LLM을 추가하여 표 데이터 유무를 명시적으로 확인하도록 설계한 결과, precision이 크게 향상되었습니다. Temperature=0.0은 반복 실행 시 일관된 판정을 보장하기 위한 설정입니다.

정답셋 생성 결과: 137개 노트 중 82.5% 매칭

4개 데이터셋(ELN1, 3, 4, 5)에서 총 270개 매칭 쌍을 생성했습니다:

데이터셋주제매칭률평균 매칭/노트평균 거리
ELN13-HP 발효86.5%2.1개0.763
ELN3일반 연구100.0%2.5개0.699
ELN4CO2 Polyol68.8%2.5개0.732
ELN5Pilot 촉매 공정90.0%2.5개0.733

주목할 점:

  • 평균 거리 0.699~0.763: 코사인 거리 기준으로 상당히 가까운 편이나, 절대값으로는 명확한 경계 설정이 어려움 → LLM 판정 필요성 입증
  • ELN3 100% 매칭: 단일 주제로 집중된 데이터셋은 벡터 유사도가 높고 LLM 판정도 명확
  • ELN4 68.8% 매칭: 다양한 실험 방법론이 혼재되어 낮은 매칭률

클러스터링 성능 평가: BCubed F1 메트릭

BCubed F1 선택 이유:

  • Precision: 같은 클러스터에 속한 아이템 중 실제로 같은 카테고리인 비율 → 과분할 패널티
  • Recall: 같은 카테고리에 속한 아이템 중 같은 클러스터에 할당된 비율 → 과소분할 패널티
  • F1-Score: Precision과 Recall의 조화평균으로 균형 평가
def bcubed_precision(item_i, cluster_assignments, ground_truth):
"""아이템 i에 대한 BCubed Precision"""
cluster_i = cluster_assignments[item_i]
category_i = ground_truth[item_i]

# 같은 클러스터에 속한 아이템들
same_cluster = [j for j, c in enumerate(cluster_assignments) if c == cluster_i]

# 그 중 실제로 같은 카테고리인 아이템들
correct = [j for j in same_cluster if ground_truth[j] == category_i]

return len(correct) / len(same_cluster)

# 전체 BCubed F1 계산
precision = np.mean([bcubed_precision(i, clusters, labels) for i in range(n)])
recall = np.mean([bcubed_recall(i, clusters, labels) for i in range(n)])
f1 = 2 * precision * recall / (precision + recall)

알고리즘별 성능 비교: Label Propagation 우승

전체 평균 성능:

알고리즘평균 F1평균 클러스터 수특징
Label Propagation0.7631.9안정적, 단순 구조
Connected Components0.7331.1최소 클러스터 생성
Louvain0.6893.1세분화 경향
HDBSCAN0.6148.8과분할 심각

Label Propagation이 우수한 이유:

  1. 그래프 기반 전파: 이웃 노드 라벨 중 다수결로 자신의 라벨 업데이트
  2. 자연스러운 경계 형성: 유사도가 높은 영역은 하나의 라벨로 수렴
  3. 적절한 클러스터 수: 평균 1.9개로 과분할/과소분할 균형
# Label Propagation 작동 원리
def label_propagation(graph, max_iter=100):
# 초기: 모든 노드에 고유 라벨
labels = {node: i for i, node in enumerate(graph.nodes())}

for _ in range(max_iter):
for node in graph.nodes():
# 이웃 라벨 중 가장 많은 것 선택
neighbor_labels = [labels[n] for n in graph.neighbors(node)]
labels[node] = max(set(neighbor_labels), key=neighbor_labels.count)

return labels

데이터셋별 최적 알고리즘:

데이터셋최적 알고리즘F1이유
ELN3CC/LP1.000단일 주제로 명확한 경계
ELN4Louvain0.952다양한 실험 방법론, 세분화 필요
ELN5HDBSCAN0.812복잡한 pilot 공정, 노이즈 존재
ELN1CC/LP0.5973-HP 발효 단계별 구분 어려움

HDBSCAN 과분할 문제와 해결책

문제 상황:

  • min_cluster_size=5에서 ELN1에 평균 37개 클러스터 생성 (실제 목차 5개)
  • 단일 연구노트가 여러 클러스터로 분할되어 recall 급락 (F1 0.15~0.27)

파라미터 튜닝 결과:

# 최적 조합: (min_cluster_size=2~3, min_samples=3)
best_config = {
"min_cluster_size": 3, # 최소 클러스터 크기
"min_samples": 3, # 핵심 포인트 판정 기준
"metric": "euclidean",
"cluster_selection_method": "eom" # Excess of Mass
}

# 성능 개선
# Before (5, 1): F1 0.540, 평균 16.5개 클러스터
# After (3, 3): F1 0.736, 평균 5.3개 클러스터

의사결정 포인트: min_cluster_size를 낮추면 과분할 완화되나, 너무 낮으면 노이즈를 클러스터로 인식. min_samples=3으로 핵심 포인트 판정을 엄격히 하여 균형 확보.

GraphCommunity 파라미터 영향 분석

k_neighbors 영향:

kLP F1Louvain F1CC F1경향
50.7650.7530.812k 증가 시 성능 저하
100.7330.7540.707-
150.7790.7590.687-
200.7340.7180.687-

의사결정: k=5를 기본값으로 선택. k가 클수록 약한 연결까지 포함하여 단일 클러스터로 수렴하는 경향 강화.

유사도 메트릭 비교:

# Euclidean vs Cosine
# Euclidean: 평균 F1 0.731
# Cosine: 평균 F1 0.718

# 의사결정: Euclidean 선택
# 이유: Titan V2는 이미 L2 정규화 적용하여 방향성보다 거리가 유의미

핵심 인사이트: 단일 클러스터 수렴 현상

발견 사항:

  • CC/LP 알고리즘에서 평균 클러스터 수 1.1~1.9개
  • ELN3 전체가 하나의 클러스터로 수렴했으나 F1=1.0 (정답도 단일 클러스터)

원인 분석:

  1. 높은 임베딩 유사도: 연구노트 간 평균 코사인 거리 0.7대로 근접
  2. 공통 도메인 용어: "발효", "OD", "수율" 등 반복 출현
  3. 서사 구조의 부재: 시간순/실험 단계 정보가 임베딩에 미반영

실무 적용 제안:

# 개선 방향 1: 메타데이터 통합
chunk_metadata = {
"content": text,
"date": extract_date(text),
"experiment_phase": classify_phase(text), # "준비", "진행", "분석"
"section_type": section_type
}

# 개선 방향 2: 하이브리드 임베딩
hybrid_embedding = concatenate([
semantic_embedding, # Titan V2
temporal_embedding, # 날짜 정보
structural_embedding # 섹션 타입
])

실무 적용 가이드

1. 데이터 특성 파악 후 알고리즘 선택:

def select_algorithm(data_characteristics):
if data_characteristics["topic_diversity"] == "single":
return "connected_components" # F1 1.0 기대

elif data_characteristics["cluster_boundaries"] == "clear":
return "louvain" # F1 0.95+ 기대

elif data_characteristics["noise_level"] == "high":
return {
"algorithm": "hdbscan",
"params": {"min_cluster_size": 3, "min_samples": 3}
}

else:
return "label_propagation" # 범용적으로 안정적

2. BCubed F1과 클러스터 수 동시 모니터링:

# 과분할 탐지
if avg_clusters > expected_clusters * 2 and f1 < 0.7:
print("과분할 의심: min_cluster_size 증가 필요")

# 과소분할 탐지
if avg_clusters < expected_clusters * 0.5 and f1 < 0.7:
print("과소분할 의심: k_neighbors 감소 또는 algorithm 변경")

3. LLM 판정 프롬프트 엔지니어링:

# 핵심: 명시적 기준 + 보수적 판정
prompt_template = """
[판정 기준] (우선순위 순)
1. 표(Table) 데이터 존재 여부
2. 정량적 수치 (농도, 수율, 온도 등) 존재 여부
3. 섹션 타입 (결론/계획은 제외)

[보수적 판정 원칙]
- 애매한 경우 False 반환
- 추론 과정을 reasoning 필드에 기록
"""

References

근사 최근접 탐색(ANN) 오차와 데이터 분포 밀도의 관계 고찰

· 약 9분
김태한
AI Research Engineer, Brain Crew

TL;DR

RAG 시스템에서 n_results는 단순히 '반환할 결과 개수'가 아닌 '검색 반경(search radius)'을 의미합니다. ANN 알고리즘의 근사 특성으로 인해 작은 n_results 값은 진짜 근접 벡터를 놓칠 수 있으며, 특히 고밀도 데이터 분포와 추상적 쿼리에서 이 문제가 심화됩니다. LGE RAG 프로젝트에서 n_results=100일 때 찾지 못했던 정답 문서가 n_results=500에서는 25번째로 검색되는 현상을 통해, 데이터 밀도와 쿼리 특성에 따라 충분히 큰 n_results 설정이 필수적임을 확인했습니다.

Key Takeaways

  • n_results는 검색 반경을 결정하는 파라미터: 단순 출력 개수가 아니라 ANN 알고리즘이 탐색할 벡터 공간의 범위를 의미하며, 작은 값은 근사 오차에 더 취약함
  • 데이터 밀도가 높을수록 더 큰 n_results 필요: 유사한 문서가 밀집된 환경에서는 작은 검색 반경으로 진짜 근접 벡터를 놓칠 확률이 급증함
  • 추상적 쿼리는 밀도 문제를 악화: "7키로 러닝", "별자리 보기" 같은 일반적 표현은 임베딩 공간에서 넓은 영역에 분산되어 충분한 탐색 범위가 더욱 중요함
  • 프로덕션 환경에서는 넉넉한 n_results 설정 후 reranking 전략 권장: 초기 검색에서 후보를 충분히 확보한 뒤, 상위 k개를 재정렬하여 정확도와 성능의 균형을 맞춤
  • 벡터 검색은 정렬된 전수 탐색이 아님: HNSW 등 ANN 알고리즘은 근사 방식이므로 n_results 변화에 따라 결과 순서와 내용이 모두 달라질 수 있음

상세 내용

배경: n_results 파라미터에 대한 오해

벡터 검색 시스템을 처음 접하는 엔지니어들은 n_results를 '최종 출력 개수'로만 이해하는 경향이 있습니다. 예를 들어 "상위 10개만 필요하니까 n_results=10"처럼 설정하는 것이죠.

LGE RAG 프로젝트에서 저 역시 동일한 접근을 했습니다. 그러나 동일한 쿼리에 대해 n_results 값만 변경했을 때 상위 결과의 순서와 내용이 완전히 달라지는 현상을 발견했습니다. 더 놀라운 점은 n_results를 늘렸을 때 더 관련성 높은 문서가 상위에 등장했다는 것입니다.

문제 상황: 동일 쿼리, 다른 결과

쿼리: "스마트폰 앱으로 별 보기를 한다"

n_results=100 결과

[1] 스마트폰 화면을 켠다 (무관한 문서)
[2] 스마트폰을 집어 든다 (무관한 문서)
[3] 휴대폰 밝기를 조절한다 (무관한 문서)
→ 정답 문서("스카이뷰 앱") 포함 안 됨

n_results=500 결과

[1] 스마트폰을 하늘로 향해 초기 별자리 지도를 띄운다 ✓
[2] 가이드에 따라 스마트폰을 원을 그리듯 움직여 센서를 보정한다 ✓
...
[25] 스마트폰에서 스카이뷰 앱을 실행한다 ✓
→ 정답 문서들이 상위권 및 25번째 등장

동일한 쿼리, 동일한 임베딩 모델, 동일한 벡터 DB에서 오직 n_results만 달랐는데 왜 이런 결과가 나올까요?

원인 분석 1: ANN 알고리즘의 근사 특성

전수 탐색과 ANN의 차이

많은 엔지니어들이 벡터 검색을 다음과 같이 상상합니다:

# 머릿속 기대: 전수 탐색 후 정렬
def ideal_search(query_vector, all_vectors):
distances = [cosine_distance(query_vector, v) for v in all_vectors]
sorted_results = sorted(distances)
return sorted_results[:n_results] # 상위 n개 자르기

하지만 실제 Chroma, FAISS 등이 사용하는 **ANN(Approximate Nearest Neighbor)**은 다릅니다:

# 실제 동작: 그래프 기반 근사 탐색 (HNSW 예시)
def ann_search(query_vector, hnsw_graph, n_results):
entry_point = hnsw_graph.top_layer_node
visited = set()
candidates = []

# 상위 레이어부터 탐색
for layer in range(top_layer, 0, -1):
entry_point = search_layer(query_vector, entry_point, layer, ef=1)

# 최하위 레이어에서 ef 크기만큼 탐색
candidates = search_layer(query_vector, entry_point, layer=0, ef=n_results)

# 탐색한 candidates 중 상위 n_results 반환
return heapq.nsmallest(n_results, candidates, key=lambda x: x.distance)

핵심 차이점:

  • 전수 탐색이 아님: 모든 벡터를 확인하지 않고 그래프 구조를 따라 '탐험'
  • n_results가 탐색 범위(ef) 결정: 작은 n_results = 좁은 탐험 영역
  • 그래프 경로 의존성: 초기 진입점과 이웃 노드 구조에 따라 특정 영역을 아예 방문하지 않을 수 있음

주요 ANN 알고리즘 비교

알고리즘핵심 원리n_results 영향적합한 시나리오
HNSW계층적 그래프, 각 층에서 greedy 탐색ef_search 파라미터와 연동, 작으면 탐색 중단 빠름높은 정확도 필요, 메모리 여유 있음
IVFk-means 클러스터링 후 클러스터 내 탐색nprobe 값으로 탐색 클러스터 수 결정대규모 데이터, 속도 우선
PQ벡터 양자화로 압축압축으로 인한 정보 손실 존재메모리 제약, 약간의 정확도 손실 허용
LSH해시 함수로 유사 벡터 버킷 분류해시 충돌 가능, 작은 k에서 누락 위험빠른 프로토타이핑, 저차원

결론: n_results가 작을수록 ANN 알고리즘은 더 좁은 범위만 탐색하고 조기 종료합니다. 진짜 근접 벡터가 탐색 경로에서 벗어나 있으면 영영 발견하지 못합니다.

원인 분석 2: 데이터 분포 밀도의 영향

고밀도 데이터의 특성

LGE 프로젝트의 행동 로그 데이터는 다음과 같은 특성을 가졌습니다:

<!-- 문서들이 구조적으로 거의 동일 -->
<activity_info>
{날짜} {시간대} {장소} 일정이다.
{시작시간}부터 {종료시간}까지 {상세장소}에서 {행동}한다.
이 활동은 {동반자}와 함께 했다. {도구}를 사용했다.
전에는 {이전행동}한다. 그 후에는 {다음행동}한다.
</activity_info>

이런 데이터는 임베딩 공간에서 어떻게 배치될까요?

# 추상화된 시각화
벡터 공간 상의 분포:

"러닝화 벗기" 클러스터 (초고밀도)
┌─────────────────┐
│ ●●●●●●●●●●●●●● │ ← 날짜/시간만 다른 수백 개 문서
│ ●●●●●●●●●●●●●● │ (의미적으로 거의 동일)
│ ●●●●●●●●●●●●●● │
└─────────────────┘

vs

"별자리 관측" 영역 (저밀도)
● ← "스카이뷰 앱" 문서
(적은 빈도, 멀리 떨어짐)

문제가 되는 시나리오

추상적 쿼리 + 고밀도 클러스터 = 재앙

# 쿼리: "7키로 러닝을 하다"
query_embedding = model.encode("7키로 러닝을 하다")

# 문제 1: "러닝" 키워드가 포함된 고밀도 클러스터가 탐색 시작점
# 문제 2: "7키로"라는 구체적 정보는 임베딩에서 약하게 표현됨
# 문제 3: n_results=100이면 고밀도 클러스터 내부만 탐색하고 종료

실제 데이터 예시:

[유사도 0.89] 러닝화를 벗는다 (9:25~9:33, 집 현관)
[유사도 0.88] 러닝화를 벗는다 (9:06~9:25, 집 현관)
[유사도 0.87] 러닝화 끈을 조인다 (8:30~9:00, 아파트 출입구)
[유사도 0.86] 러닝화를 벗는다 (19:56~20:00, 대전 현관)
...
[유사도 0.65] ← n_results=100 여기서 중단
---
[유사도 0.64] 스포츠워치 러닝 모드 7km 기록 저장 ← 정답!

왜 n_results=500에서는 성공했나?

# n_results=500 탐색 과정
1. "러닝" 고밀도 클러스터 탐색 (0~200번째)
2. 유사도가 낮아지면서 인접 클러스터로 확장
3. "운동 기록" 관련 클러스터 진입 (300~400번째)
4. "7km 기록" 문서 발견! (425번째)
5. 재정렬 후 상위권으로 부상

핵심: 큰 n_results는 여러 클러스터를 가로지르는 탐색을 가능하게 합니다.

해결 과정: 적절한 n_results 설정 전략

1. 데이터 밀도 분석

먼저 자신의 데이터 특성을 파악해야 합니다:

from sklearn.neighbors import NearestNeighbors
import numpy as np

def analyze_density(embeddings, sample_size=1000):
"""벡터 공간의 밀도 분석"""
sample_indices = np.random.choice(len(embeddings), sample_size)
sample_vectors = embeddings[sample_indices]

nbrs = NearestNeighbors(n_neighbors=100, metric='cosine').fit(embeddings)
distances, indices = nbrs.kneighbors(sample_vectors)

# 10번째, 50번째, 100번째 이웃까지의 평균 거리
print(f"10th neighbor avg distance: {distances[:, 9].mean():.4f}")
print(f"50th neighbor avg distance: {distances[:, 49].mean():.4f}")
print(f"100th neighbor avg distance: {distances[:, 99].mean():.4f}")

# 거리 증가율
growth_rate = distances[:, 99].mean() / distances[:, 9].mean()
print(f"Distance growth rate (10th->100th): {growth_rate:.2f}x")

if growth_rate < 1.5:
print("⚠️ HIGH DENSITY - 큰 n_results 필요")
elif growth_rate < 2.5:
print("⚡ MEDIUM DENSITY - 적절한 n_results 필요")
else:
print("✅ LOW DENSITY - 작은 n_results도 안전")

# 실행 결과 예시 (LGE 프로젝트)
# 10th neighbor: 0.1234
# 50th neighbor: 0.1456
# 100th neighbor: 0.1589
# Growth rate: 1.29x ← 매우 고밀도!

2. 쿼리 추상화 수준 평가

def estimate_query_abstraction(query):
"""쿼리의 추상화 정도 추정"""
concrete_markers = ["스카이뷰", "7km", "09:25", "평창 공원"]
abstract_markers = ["러닝", "별 보기", "운동", "휴식"]

concrete_score = sum(1 for m in concrete_markers if m in query)
abstract_score = sum(1 for m in abstract_markers if m in query)

if abstract_score > concrete_score:
return "ABSTRACT", 500 # 추상적이면 큰 n_results
else:
return "CONCRETE", 100 # 구체적이면 작은 n_results

# 예시
estimate_query_abstraction("스마트폰 앱으로 별 보기")
# → ("ABSTRACT", 500)

estimate_query_abstraction("평창 공원에서 스카이뷰 앱 실행")
# → ("CONCRETE", 100)

3. 동적 n_results + Reranking 전략

프로덕션 환경에서는 다음과 같은 파이프라인을 권장합니다:

from sentence_transformers import CrossEncoder

class AdaptiveRetriever:
def __init__(self, vectordb, reranker_model="cross-encoder/ms-marco-MiniLM-L-6-v2"):
self.vectordb = vectordb
self.reranker = CrossEncoder(reranker_model)

def search(self, query, final_k=10, safety_factor=10):
"""
안전한 검색 전략:
1. 넉넉한 후보 추출 (final_k * safety_factor)
2. Reranking으로 정확도 보정
3. 최종 k개 반환
"""
# Step 1: 넉넉한 초기 검색
initial_n = final_k * safety_factor # 예: 10 * 10 = 100
candidates = self.vectordb.query(
query_texts=[query],
n_results=initial_n
)

# Step 2: Cross-encoder로 재정렬
pairs = [[query, doc] for doc in candidates['documents'][0]]
rerank_scores = self.reranker.predict(pairs)

# Step 3: 상위 k개 추출
sorted_indices = np.argsort(rerank_scores)[::-1][:final_k]
final_results = [candidates['documents'][0][i] for i in sorted_indices]

return final_results

# 사용 예시
retriever = AdaptiveRetriever(chroma_collection)

# 안전한 검색 (내부적으로 100개 검색 후 10개로 rerank)
results = retriever.search("스마트폰 앱으로 별 보기", final_k=10)

4. A/B 테스트 기반 최적화

def find_optimal_n_results(queries, ground_truth, vectordb):
"""다양한 n_results 값 비교"""
test_n_values = [50, 100, 200, 500, 1000]
results = {}

for n in test_n_values:
recall_scores = []
for query, true_docs in zip(queries, ground_truth):
retrieved = vectordb.query(query_texts=[query], n_results=n)
retrieved_ids = set(retrieved['ids'][0][:10]) # 상위 10개만 평가
true_ids = set(true_docs)

recall = len(retrieved_ids & true_ids) / len(true_ids)
recall_scores.append(recall)

results[n] = {
'recall@10': np.mean(recall_scores),
'std': np.std(recall_scores)
}

return results

# 실행 결과 예시
# n=50: recall@10=0.62
# n=100: recall@10=0.71
# n=200: recall@10=0.85
# n=500: recall@10=0.94 ← 최적점
# n=1000: recall@10=0.95 (미미한 개선, 비용 증가)

결과 및 인사이트

정량적 개선

LGE 프로젝트에서 n_results 조정 후 측정한 결과:

Metricn=100n=500개선율
Recall@100.680.91+33.8%
MRR (Mean Reciprocal Rank)0.420.73+73.8%
정답 문서 발견율71%96%+25%p

의사결정 프레임워크

다음 flowchart를 통해 n_results를 결정하세요:

데이터 밀도 분석
├─ HIGH DENSITY → base_n = 500
├─ MEDIUM DENSITY → base_n = 200
└─ LOW DENSITY → base_n = 100

쿼리 특성 평가
├─ 추상적 쿼리 → base_n * 1.5
├─ 일반 쿼리 → base_n * 1.0
└─ 구체적 쿼리 → base_n * 0.5

성능 제약 확인
├─ 지연시간 중요 → Reranking 전략
└─ 정확도 우선 → 큰 n_results 유지

최종 n_results = min(계산값, 데이터 총량)

추가 최적화 팁

# 1. HNSW ef_search 파라미터 조정 (Chroma 예시)
collection = client.create_collection(
name="optimized_collection",
metadata={
"hnsw:space": "cosine",
"hnsw:search_ef": 500, # n_results와 연동
"hnsw:M": 32 # 그래프 연결성 증가
}
)

# 2. Hybrid Search로 보완
from rank_bm25 import BM25Okapi

class HybridRetriever:
def __init__(self, vectordb, documents):
self.vectordb = vectordb
tokenized_docs = [doc.split() for doc in documents]
self.bm25 = BM25Okapi(tokenized_docs)

def search(self, query, n_results=100, alpha=0.7):
# Vector search
vec_results = self.vectordb.query(query, n_results=n_results)
vec_scores = {id: score for id, score in
zip(vec_results['ids'][0], vec_results['distances'][0])}

# BM25 search
bm25_scores = self.bm25.get_scores(query.split())

# Hybrid ranking
final_scores = {}
for doc_id in set(vec_scores.keys()):
vec_score = vec_scores.get(doc_id, 0)
bm25_score = bm25_scores[doc_id] if doc_id < len(bm25_scores) else 0
final_scores[doc_id] = alpha * vec_score + (1-alpha) * bm25_score

return sorted(final_scores.items(), key=lambda x: x[1], reverse=True)

교훈 및 베스트 프랙티스

  1. n_results는 성능 튜닝의 핵심 레버: 단순한 출력 파라미터가 아니라 검색 품질을 결정하는 하이퍼파라미터
  2. "충분히 크게, 그 다음 줄이기": 초기 개발 시 넉넉한 n_results로 시작해 reranking으로 정제하는 전략이 안전
  3. 데이터 프로파일링 필수: 밀도 분석 없이 임의로 n_results를 정하면 재앙
  4. 쿼리 타입별 분기 처리: 추상적/구체적 쿼리에 따라 동적으로 n_results 조정
  5. 모니터링 지표 설정: Recall@k, MRR 등을 지속적으로 추적해 회귀 방지

References