AWS 기술 블로그
Amazon OpenSearch Service 의 LTR 플러그인을 활용한 검색 품질 개선
개요
Amazon OpenSearch Service는 BM25(Best Match 25)라는 확률론적 순위 알고리즘을 사용하여 문서와 검색 키워드간 관련성 점수를 계산합니다. 문서에 고유 키워드가 더 자주 나타난다면 BM25 알고리즘은 해당 문서에 더 높은 점수를 부여합니다. 이는 검색 서비스에서 사용하는 인기 있는 알고리즘으로 사용자의 검색 의도에 맞게 키워드의 가중치를 조정하여 결과를 정렬합니다. 하지만, 이 알고리즘은 CTR(Click-through rate)과 같은 사용자의 행위는 고려하지 않습니다.
ML(Machine Learning) 모델을 사용하여 검색 엔진의 순위를 재 구축 하는 LTR(Learning to Rank)은 기존의 BM25와 K-NN 검색으로 반환한 검색 결과의 관련성을 개선하기 위해 2단계 Re-Ranker로서 사용됩니다. 이 기능은 문서 목록과 검색 컨텍스트를 가져와 순위가 매겨진 문서로 재 조정하여 결과를 반환합니다.
LTR(Learning to Rank)플러그인은 XGBoost및 Metarank, Ranklib 모델을 사용하여 행동 데이터를 함께 사용하여 문서의 점수를 재 지정할수 있는 오픈소스 플러그인 입니다. 이번 게시글에서는 OpenSearch Service에 LTR 플러그인의 사용 방법을 알아보기 위하여 Amazon Bedrock의 Claude 모델을 활용하여 가상의 데이터와 쿼리를 만들고 이를 평가하여 학습해 순위를 조정합니다. 마지막으로, 조정된 결과를 NDCG (Normalized Discounted Cumulative Gain) 지표로 평가해 결과가 어떻게 달라지는지 확인해 보겠습니다.
본 게시글에서 사용되는 코드는 Github에서 제공되고 있습니다. 전체 코드를 확인하고 싶으시면 Github Repo의 주피터 노트북 코드를 확인하세요.
LTR 소개
LTR은 OpenSearch 외부에서 모델이 학습되고 Feature를 생성하여 OpenSearch에 적용 됩니다. 아래 그림의 푸른 부분은 OpenSearch의 외부에서 수행됩니다.
LTR 기능을 사용하려면 재 정렬의 대상인 문서 목록 외에도 검색을 위해 사용하는 질의(검색 컨텍스트)가 필요합니다. 일반적으로 이 검색 컨텍스트에는 최소한 사용자가 제공한 검색어가 포함됩니다. 검색 컨텍스트는 재 정렬 모드에서 사용되는 추가 정보도 제공할 수 있습니다. 검색하는 사용자에 대한 정보(인구 통계 데이터, 지리적 위치 또는 나이 등), 쿼리에 대한 정보(쿼리 길이 등) 또는 쿼리의 맥락에서 문서(제목 필드에 대한 점수 등)가 될 수 있습니다.
LTR 시작하기
LTR Demo 코드를 실행하는데 앞서 아래의 사전 조건이 필요합니다.
- Amazon Bedrock api 를 호출하기위한 IAM 역할
- 코드 실행시 리전은 United States의 리전(us-east1, us-east-2, us-west-2중 택 1)으로 선택해주세요
- us-east-1, us-east-2, us-west-2 리전 Bedrock Claude 3.5 Sonnet v2 모델 접근 권한
위 두가지 설정이 완료 되면 Jupyter Notebook을 통하여 아래의 Step을 수행해주세요.
Step 1: Amazon Bedrock을 사용한 데이터셋 생성
LTR을 사용하기 전 OpenSearch Service에 대상 데이터가 있어야 합니다. 이 게시글에서 Amazon Bedrock을 통하여 인공지능과 관련된 50개의 문서를 새롭게 생성합니다. 아래의 코드는 Amazon Bedrock을 통해 문서를 생성하는 코드입니다. 모델의 성능에 따라 수분에서 수십분 소요 될 수 있습니다.
# 문서 컬렉션 생성을 위한 프롬프트
import re
import time
model_id = "us.anthropic.claude-3-5-sonnet-20241022-v2:0"
# 생성할 문서 수 설정
num_documents = 20
# 문서를 한 번에 10개씩 생성하는 프롬프트
documents = []
for batch in range(0, num_documents, 10):
end_idx = min(batch + 10, num_documents)
batch_size = end_idx - batch
print(
f"배치 {batch//10 + 1}/{(num_documents + 9)//10} 생성 시작 (문서 {batch+1}-{end_idx})")
# 한 번에 10개의 문서를 생성하는 프롬프트
batch_prompt = f"""
다음 주제에 관한 {batch_size}개의 문서를 생성해주세요: "인공지능과 기계학습"
각 문서는 다음 형식의 JSON 객체여야 합니다:
{{
"id": "doc[번호]",
"title": "문서 제목",
"content": "문서 내용 (300-400자)"
}}
문서들은 인공지능과 기계학습의 다양한 측면(기초 개념, 응용, 역사, 최신 트렌드 등)을 다루어야 합니다.
전체 응답은 유효한 JSON 배열 형식이어야 합니다.
다음 ID를 사용하세요: {', '.join([f'"doc{i+1}"' for i in range(batch, end_idx)])}
"""
# Claude 호출하여 문서 배치 생성
max_retries = 3
retry_count = 0
while retry_count < max_retries:
try:
batch_json = invoke_claude(batch_prompt, model_id, max_tokens=8192)
break
except Exception as e:
retry_count += 1
print(f"오류 발생: {str(e)}")
if retry_count < max_retries:
print(f"30초 후 재시도합니다. (시도 {retry_count}/{max_retries})")
time.sleep(30)
else:
print("최대 재시도 횟수를 초과했습니다.")
raise
# JSON 문자열에서 실제 JSON 배열 부분만 추출
json_pattern = r'\[.*\]'
json_match = re.search(json_pattern, batch_json, re.DOTALL)
if json_match:
batch_json = json_match.group(0)
try:
# JSON 파싱
batch_documents = json.loads(batch_json)
# ID 확인 및 수정
for i, doc in enumerate(batch_documents):
doc_id = f"doc{batch+i+1}"
doc["id"] = doc_id
documents.append(doc)
print(f"문서 {batch+i+1}/{num_documents} 처리 완료")
except json.JSONDecodeError:
print(f"JSON 파싱 오류 발생. 원본 응답: {batch_json}")
# 배치 완료 후 휴식
if batch + 10 < num_documents:
print(f"배치 {batch//10 + 1} 완료. 다음 배치 전 60초 대기...")
time.sleep(40)
# 데이터 저장
os.makedirs('data', exist_ok=True)
with open('data/documents.json', 'w', encoding='utf-8') as f:
json.dump(documents, f, ensure_ascii=False, indent=2)
# 생성된 문서 확인
print(f"생성된 문서 수: {len(documents)}")
print("첫 번째 문서 샘플:")
print(json.dumps(documents[0], ensure_ascii=False, indent=2))
Step 2: Amazon Bedrock을 통한 검색 쿼리 생성
LTR은 사용자가 검색한 쿼리와 클릭한 문서 데이터를 바탕으로, 어떤 문서를 더 높은 순위에 보여줄지 학습하는 기술입니다. 실제 서비스 환경에서는 사용자의 검색 기록과 클릭 데이터를 모아서 분석하고, 이를 통해 검색 결과의 순서를 더 똑똑하게 만드는 데 활용합니다. Step2의 테스트 단계에서는 LLM 같은 인공지능 모델을 이용해 다양한 검색 쿼리를 만들어보고, 이 쿼리들이 어떤 문서와 잘 맞는지 비교합니다.
model_id = "us.anthropic.claude-3-5-sonnet-20241022-v2:0"
# 검색 쿼리 생성을 위한 변수 설정
num_queries = 50
queries = []
# 10개씩 배치로 쿼리 생성
for batch in range(0, num_queries, 10):
# 현재 배치에서 생성할 쿼리 수 계산
batch_size = min(10, num_queries - batch)
end_idx = batch + batch_size
# 문서 컬렉션에서 랜덤하게 샘플링 (토큰 제한 방지)
sample_size = min(10, len(documents)) # 최대 10개 문서만 사용
random_docs = random.sample(documents, sample_size)
batch_prompt = f"""
앞서 생성한 문서 컬렉션에 대해 사용자가 검색할 만한 쿼리 {batch_size}개를 생성해주세요.
각 쿼리는 다음 형식의 JSON 객체여야 합니다:
{{{{
"id": "q[번호]",
"text": "검색 쿼리 내용"
}}}}
쿼리는 다양한 난이도와 구체성을 가져야 합니다.
쿼리는 단순 키워드일수도 있고 문장일 수도 있습니다.
전체 응답은 유효한 JSON 배열 형식이어야 합니다.
다음 ID를 사용하세요: {', '.join([f'"q{i+1}"' for i in range(batch, end_idx)])}
다음은 문서 컬렉션의 일부 샘플입니다:
```
{json.dumps(random_docs, ensure_ascii=False)}
```
"""
# Claude 호출하여 쿼리 배치 생성
max_retries = 3
retry_count = 0
while retry_count < max_retries:
try:
batch_json = invoke_claude(batch_prompt, model_id, max_tokens=8192)
break
except Exception as e:
retry_count += 1
print(f"오류 발생: {str(e)}")
if retry_count < max_retries:
print(f"30초 후 재시도합니다. (시도 {retry_count}/{max_retries})")
time.sleep(30)
else:
print("최대 재시도 횟수를 초과했습니다.")
raise
# JSON 문자열에서 실제 JSON 배열 부분만 추출
json_pattern = r'\[.*\]'
json_match = re.search(json_pattern, batch_json, re.DOTALL)
if json_match:
batch_json = json_match.group(0)
try:
# JSON 파싱
batch_queries = json.loads(batch_json)
# ID 확인 및 수정
for i, query in enumerate(batch_queries):
query_id = f"q{batch+i+1}"
query["id"] = query_id
queries.append(query)
print(f"쿼리 {batch+i+1}/{num_queries} 생성 완료")
except json.JSONDecodeError:
print(f"JSON 파싱 오류 발생. 원본 응답: {batch_json}")
# 배치 완료 후 휴식
if batch + 10 < num_queries:
print(f"배치 {batch//10 + 1} 완료. 다음 배치 전 30초 대기...")
time.sleep(40)
# 데이터 저장
with open('data/queries.json', 'w', encoding='utf-8') as f:
json.dump(queries, f, ensure_ascii=False, indent=2)
# 생성된 쿼리 확인
print(f"생성된 쿼리 수: {len(queries)}")
print("쿼리 샘플:")
for q in queries[:3]:
print(f"- {q['text']}")
Step 3: Judgement List 생성
LTR은 일반적으로 '판단 리스트(Judgement List)'라는 데이터를 사용해 모델을 훈련합니다. 이 판단 리스트는, 어떤 검색어(쿼리)에 대해 여러 문서가 얼마나 관련 있는지 등급을 매긴 목록입니다. 이 등급은 사람이 직접 정하거나, 사용자 행동(예: 클릭, 조회 등)을 분석해서 자동으로 만들 수도 있습니다. 보통은 사용자 행동 데이터를 먼저 모아 자동으로 판단 리스트를 만들고, 그 다음 사람이 한 번 더 검토해서 정확도를 높입니다. LTR의 목표는, 새로운 검색어나 문서가 들어왔을 때 모델이 예측한 순서가 이 판단 리스트의 이상적인 순서와 최대한 비슷해지도록 만드는 것입니다.
관련성은 0~4점 척도로 되어 있으며 각 점수는 아래와 같은 의미를 가지고 있습니다.
- 0 : 문서나 항목이 검색어와 거의 관련이 없습니다. 검색어에 대한 유용한 정보를 제공하지 않습니다.
- 1 : 문서나 항목이 검색어와 약간 관련이 있지만 유용하지 않습니다. 검색어와 관련된 키워드를 포함할 수 있지만, 검색어의 의도를 의미 있게 다루지 않습니다.
- 2 : 문서나 항목이 검색어와 어느 정도 관련이 있으며 유용할 수 있습니다. 부분적으로 검색어와 일치하지만 깊이나 포괄성이 부족한 정보를 포함합니다.
- 3 : 문서 또는 항목이 검색어와 관련성이 높고 유용합니다. 검색어의 의도를 직접적으로 다루는 실질적인 정보를 제공합니다.
- 4 : 문서 또는 항목이 검색어와 매우 관련성이 높고 유용합니다. 검색어의 의도를 완벽하게 충족하는 포괄적인 정보를 제공합니다.
아래는 LLM을 이용해서 생성할 Judgements.json 구조의 예입니다. 실제 생성 코드는 gitbub을 참조해주세요.
[
{
"query_id": "q1",
"doc_id": "doc34",
"relevance": 3
},
{
"query_id": "q1",
"doc_id": "doc8",
"relevance": 2
},
{
"query_id": "q1",
"doc_id": "doc13",
"relevance": 2
},
{
"query_id": "q1",
"doc_id": "doc3",
"relevance": 1
},
...
]
Step 4: Feature Set 생성
쿼리와 문서 쌍만으로는 LTR에 사용되는 ML 모델을 훈련하는 데 충분한 정보를 제공하지 못합니다. Judgment List의 관련성 점수는 여러 가지 속성 또는 특징에 따라 달라집니다. 이러한 특징을 추출하여 다양한 구성 요소가 결합되어 문서 관련성을 결정하는 방법을 파악해야 합니다. Judgment List와 추출된 특징은 LTR 모델의 훈련 데이터 세트를 구성합니다. Feature Set에 대해서 보다 자세한 내용이 궁금하시다면 OpenSearch 공식 도큐먼트를 참고하세요.
Step 5: Feature Logging을 통하여 RankLib 학습 데이터 생성
모델을 학습 시키기 위해서는 Feature의 값을 기록해야 합니다. 이는 랭킹 학습 플러그인의 중요한 구성 요소로 검색시 학습에 사용 가능하도록 Feature Set의 값이 기록됩니다. 이를 통해 각 문서와 쿼리 그리고 항목에 필요한 관련성 점수를 보다 효과적으로 수집할 수 있습니다.
# 특성 로그 생성 함수
def log_features(query_text, doc_ids):
"""특정 쿼리와 문서 ID 목록에 대한 특성 로그를 생성합니다."""
sltr_query = {
"query": {
"bool": {
"filter": [
{
"ids": {
"values": doc_ids
}
},
{
"sltr": {
"_name": "logged_featureset",
"featureset": featureset_name,
"params": {
"keywords": query_text
}
}
}
]
}
},
"ext": {
"ltr_log": {
"log_specs": {
"name": "log_entry",
"named_query": "logged_featureset"
}
}
},
"size": len(doc_ids)
}
response = os_client.search(index=index_name, body=sltr_query)
return response
# 모든 문서 ID 목록 생성
all_doc_ids = [doc['id'] for doc in documents]
# RankLib 형식의 학습 데이터 생성
ranklib_data = []
# 각 쿼리에 대해 특성 로그 생성
for query in queries:
query_id = query['id']
query_text = query['text']
# 이 쿼리에 대한 관련성 판단 가져오기
query_judgments = [j for j in all_judgments if j['query_id'] == query_id]
# 문서 ID와 관련성 점수 매핑
relevance_map = {j['doc_id']: j['relevance'] for j in query_judgments}
# 특성 로그 생성
log_response = log_features(query_text, all_doc_ids)
# 로그에서 특성 추출
for hit in log_response['hits']['hits']:
doc_id = hit['_id']
relevance = relevance_map.get(doc_id, 0)
# 특성 값 추출
features = hit['fields']['_ltrlog'][0]['log_entry']
feature_values = []
for i, feature in enumerate(features):
feature_name = feature['name']
feature_value = feature.get('value', 0)
feature_values.append(f"{i+1}:{feature_value}")
# RankLib 형식으로 변환 (qid:query_id 형식)
ranklib_line = f"{relevance} qid:{query_id[1:]} {' '.join(feature_values)} # {doc_id}"
ranklib_data.append(ranklib_line)
print(f"쿼리 {query_id}에 대한 특성 로그 생성 완료")
# RankLib 데이터 파일 저장
with open('data/ranklib_train.txt', 'w') as f:
f.write('\n'.join(ranklib_data))
print(f"RankLib 학습 데이터 생성 완료: {len(ranklib_data)}개 항목")
Step 6: RankLib 학습
이제 모든 데이터가 준비되었습니다. 샘플 문서와 쿼리, Judgement List가 준비되었으니 실제 Rank 모델을 학습하도록 하겠습니다. 이번 게시글에서 사용한 모델을 RankLib이며 이는 JVM 환경에서 학습됩니다. 따라서, 해당 코드를 수행하는 장비에 JDK가 설치 되어 있어야 합니다. 이번 테스트 환경에서는 JDK17을 사용합니다.
LTR 학습에는 다양한 모델이 활용됩니다. 아래는 각 모델별 특징에 대해서 정리한 표 입니다. 이 게시글에서는 준수한 성능을 제공하는 RankLib를 사용합니다.
XGBoost | Metarank | RankLib |
---|---|---|
Gradient Boosting 트리 앙상블로 대부분의 랭킹 문제에서 높은 Precision@K, NDCG 등을 달성함. Kaggle 등 데이터 과학 대회에서 최고의 알고리즘으로 알려져 있으며, 속도와 효율성 측면에서 뛰어남 | 기본 알고리즘으로 LambdaMART(랭킹용 LambdaGradient Boosting) 사용. NDCG@K 최적화를 목표로 하여 검색/추천에서 높은 NDCG, MRR 등의 향상을 기대할 수 있으며 경량화된 리랭킹 서버로 개발되어 지연시간이 낮음 | 다양한 알고리즘(MART, RankNet, LambdaMART 등) 구현으로 랭킹 성능 향상을 시도할 수 있음 . 특히 LambdaMART 등 사용 시 NDCG와 MRR 등에서 양호한 결과를 얻을 수 있음 |
아래의 코드는 RankLib 학습에 사용되는 Python 코드 입니다. 주피터 노트북 환경에서 수행되는 편의성을 위해 subprocess 라이브러리를 통하여 학습을 수행했습니다.
if not os.path.exists(ranklib_jar):
print("RankLib JAR 파일 다운로드 중...")
download_url = "https://sourceforge.net/projects/lemur/files/lemur/RankLib-2.18/RankLib-2.18.jar/download"
subprocess.run(['curl', '-L', download_url, '-o', ranklib_jar], check=True)
print("다운로드 완료")
# 학습 데이터 경로
train_data = 'data/ranklib_train.txt'
model_dir = 'models'
os.makedirs(model_dir, exist_ok=True)
model_output = os.path.join(model_dir, 'lambdamart_model.txt')
# LambdaMART 모델 학습
# 파라미터 설명:
# -ranker 6: LambdaMART 알고리즘 사용
# -metric2t NDCG@10: 최적화할 메트릭
# -tree 1000: 트리 개수
# -leaf 10: 리프 노드 개수
# -shrinkage 0.1: 학습률
print("LambdaMART 모델 학습 시작...")
training_command = [
'java', '-jar', ranklib_jar,
'-train', train_data,
'-ranker', '6',
'-metric2t', 'NDCG@10',
'-save', model_output,
'-tree', '1000',
'-leaf', '10',
'-shrinkage', '0.1'
]
Step 7: OpenSearch 모델 배포
학습이 완료 되면 모델이 .txt 파일로 생성됩니다. 해당 모델을 OpenSearch에 배포하기 위해선 아래와 같은 쿼리가 수행 되어야 합니다.
모델 배포가 완료되면 아래의 명령어를 통해 배포된 모델을 확인할 수 있습니다.
Step 8: LTR 테스트
LTR모델의 평가는 일반적인 BM25와 NDCG 지표로 비교하는 것으로 수행 하였습니다. NDCG는 정보 검색 및 추천 시스템 분야에서 핵심적인 평가 지표로 활용되고 있습니다. 이 지표는 검색 결과나 추천 항목의 순위 품질을 측정하는 데 있어 가장 신뢰할 수 있는 방법 중 하나로 인정받고 있습니다. NDCG의 기본 개념은 단순합니다. 사용자에게 제공되는 검색 결과나 추천 항목이 얼마나 관련성 있게 정렬되었는지를 0과 1 사이의 값으로 표현합니다.
NDCG의 가장 중요한 특징은 상위 순위에 위치한 항목에 더 큰 가중치를 부여한다는 점입니다. 이는 현실에서 사용자들이 검색 결과의 상위 항목에 더 많은 주의를 기울인다는 사실을 반영합니다. 따라서 관련성이 높은 항목이 상위에 위치할수록 NDCG 점수는 높아집니다.
NDCG@5 | NDCG@10 | |
BM25 | 0.8859 | 0.8684 |
LTR | 0.9935 | 0.984 |
LLM으로 생성되는 문서와 쿼리, Jugement List에 따라 해당 수치는 달라질 수 있습니다.
결론
이번 게시글에서는 OpenSearch Service의 검색 결과를 획기적으로 개선할 수 있는 Learning to Rank (LTR)의 구현과 활용 방법에 대해 상세히 알아보았습니다. 최근 검색 서비스 영역에서는 정확도 향상을 위해 많은 고객들이 벡터 기반의 검색을 전통적인 키워드 기반 검색과 결합한 Hybrid Search 방식을 도입하고 있습니다. 이는 분명 검색 품질을 한 단계 높이는 훌륭한 접근 방식입니다. 하지만 금융, 법률, 의료와 같이 높은 정확도가 요구되는 전문 분야나, 사용자의 검색 의도를 정확히 파악해야 하는 이커머스 등의 워크로드에서는 Hybrid Search 만으로는 해결하기 어려운 과제들이 존재합니다. 이러한 상황에서 LTR은 매우 강력한 해결책이 될 수 있습니다. 우리가 실험을 통해 확인했듯이, LTR은 다음과 같은 명확한 장점들을 제공합니다:
- 정확도 향상: 실험 결과에서 보여진 것처럼, LTR 적용 후 NDCG@5에서 평균 12.14%, NDCG@10에서 13.31%의 성능 향상을 달성했습니다. 이는 사용자들이 원하는 문서가 상위 검색결과에 더 잘 노출된다는 것을 의미합니다.
- 맞춤형 순위 조정: BM25나 벡터 검색만으로는 구현하기 어려운 비즈니스 로직이나 도메인 특화된 순위 결정 요소들을 Feature Set으로 정의하여 검색 결과에 반영할 수 있습니다.
- 유연한 확장성: 초기에는 간단한 Feature Set으로 시작하여, 점진적으로 새로운 Feature들을 추가함으로써 검색 품질을 지속적으로 개선할 수 있습니다. 예를 들어, 문서의 최신성, 인기도, 사용자 피드백 등 다양한 요소들을 순차적으로 도입할 수 있습니다.
- 데이터 기반 의사결정: LTR은 실제 사용자들의 검색 패턴과 선호도를 학습하여 이를 검색 결과에 반영합니다. 이는 주관적인 판단이 아닌, 객관적인 데이터를 기반으로 한 검색 결과 개선을 가능하게 합니다.
이번 게시글에서 소개한 구현 코드는 GitHub Repository에서 확인하실 수 있습니다. 이 코드는 LTR 도입을 위한 기본적인 프레임워크를 제공하며, 각자의 워크로드에 맞게 수정하고 확장하실 수 있습니다. 특히 다음과 같은 부분들을 고려하여 커스터마이징하시면 좋습니다:
- 도메인 특화된 Feature Set 정의
- 비즈니스 요구사항을 반영한 관련성 판단 기준 설정
- 실제 사용자 데이터를 활용한 모델 학습
- 정기적인 모델 재학습을 통한 성능 유지 관리
LTR의 도입은 단순한 기능 추가가 아닌, 검색 서비스의 품질을 한 단계 높이는 전략적 선택이 될 수 있습니다. Feature Set이 다양화되고 정교화될수록 검색 서비스는 더욱 정확하고 풍부한 결과를 제공할 수 있게 됩니다. 이는 곧 사용자 만족도 향상과 비즈니스 가치 증대로 이어질 것입니다.마지막으로, LTR은 지속적인 개선과 관리가 필요한 여정입니다. 사용자들의 피드백을 수집하고, 새로운 Feature들을 발굴하며, 모델을 주기적으로 업데이트하는 체계적인 프로세스를 구축하시기를 권장드립니다. 이러한 노력들이 모여 진정으로 사용자 중심의 검색 경험을 제공할 수 있을 것입니다.