https://huggingface.co/BM-K/KoSimCSE-roberta-multitask
BM-K/KoSimCSE-roberta-multitask · Hugging Face
https://github.com/BM-K/Sentence-Embedding-is-all-you-need Korean-Sentence-Embedding 🍭 Korean sentence embedding repository. You can download the pre-trained models and inference right away, also it provides environments where individuals can train mode
huggingface.co
이번에는 위 허깅페이스 모델을 통해 상품명을 임베딩 해보겠습니다.
성능이 한국어 임베딩 모델 중 가장 우수하여 상기된 모델을 사용하기로 결정했습니다.
위 모델 또한 마찬가지로 zip으로 만들어줍니다.
이제 완료 됐다면, OpenSearch로 넘어옵니다.
POST /_plugins/_ml/model_groups/_register
{
"name": "korean_goodsNm_embedding_models",
"description": "Korean goodsNm search embedding models"
}
다음의 명령어를 통해서 model_groups를 만들어줍니다.
이를 통해서 모델들을 관리하겠습니다.
실행을 눌러주면, model_group_id가 나올텐데 이를 복사 해놓았다가, 다음 아래의 명령어에서 model_group_id field에 입력해줍니다.
{
"name": "KoSimCSE-embedding-model-v3",
"model_group_id": "{model_group_id}",
"algorithm": "TEXT_EMBEDDING",
"model_version": "3",
"description": "BM-K KoSimCSE-roberta-multitask embedding model for Korean semantic search",
"model_format": "TORCH_SCRIPT",
"model_state": "DEPLOYING",
"model_content_size_in_bytes": {zipfile_size},
"model_content_hash_value": "{SHA_256}",
"model_config": {
"model_type": "roberta",
"embedding_dimension": 768,
"framework_type": "SENTENCE_TRANSFORMERS",
"all_config": """{"model_type":"roberta","max_seq_length":128,"do_lower_case":false,"vocab_size":32000,"hidden_size":768,"num_attention_heads":12,"num_hidden_layers":12,"intermediate_size":3072,"hidden_act":"gelu","initializer_range":0.02,"layer_norm_eps":1e-12,"position_embedding_type":"absolute","use_cache":true,"model_name":"BM-K/KoSimCSE-roberta-multitask"}""",
"pooling_mode": "MEAN",
"normalize_result": true
},
"created_time": xxxxxxxxx,
"last_updated_time": xxxxxxxxx,
"last_registered_time": xxxxxxxxx,
"last_deployed_time": xxxxxxxxx,
"total_chunks": 41,
"planning_worker_node_count": 2,
"current_worker_node_count": 0,
"planning_worker_nodes": [
"xxxxxx",
"xxxxxx"
],
"deploy_to_all_nodes": true,
"is_hidden": false
}
planning_worker_nodes만 여러분들의 노드 id로 바꿔서 적용시켜 줍니다.
다음의 명령어를 OpenSearch Console에 입력해줍니다.
GET /_plugins/_ml/tasks/{task_id}
GET /_plugins/_ml/models/{model_id}
POST /_plugins/_ml/models/{model_id}/_deploy
각각의 명령어를 통해서 모델을 배포합니다.
아래의 코드를 통해서 모델의 배포된 상태를 확인해볼 수 있습니다.
GET /_plugins/_ml/models/{model_id}
아래처럼 나온다면 성공적으로 배포가 완료됐습니다!
{
"name": "KoSimCSE-embedding-model-v3",
"model_group_id": "{model_group_id}",
"algorithm": "TEXT_EMBEDDING",
"model_version": "4",
"description": "BM-K KoSimCSE-roberta-multitask embedding model for Korean semantic search",
"model_format": "TORCH_SCRIPT",
"model_state": "DEPLOYED",
...
}
이제 배포된 위 모델을 활용하여 실제 임베딩까지 진행해보도록 하겠습니다.
주의!
여기는 제가 실수를 한 부분입니다.
PUT /your-index-mapping-name/_mapping
{
"properties": {
"goodsNm_vector": {
"type": "knn_vector",
"dimension": 768,
"method": {
"name": "hnsw",
"space_type": "cosinesimil",
"engine": "lucene",
"parameters": {
"ef_construction": 512,
"m": 24
}
}
}
}
}
이렇게 먼저 인덱스를 매핑을 해줬는데,
이게 나중에 돌리고 나니 분명 type을 knn_vector로 해놓은 줄 알았는데,
나중에 확인을 해보니 float 형태로 되어있어서 Semantic Search가 제대로 진행이 되지 않아 다시 reindex를 진행했습니다.
혹여나 다른 분들도 저와 같은 실수를 하실 수 있으니 조심하라는 의미에서 실수를 한 부분이나,
글을 올리기로 했습니다.
(나중에 이렇게 실패를 하고 나서 다시 수정한 방법도 올리겠습니다.)
특히, mapping이 헷갈리는 부분이 많아서 함께 아래의 내용을 첨부합니다.
각 필드 상세 설명
1. type: "knn_vector"
"type": "knn_vector"
- 역할: 이 필드가 벡터 검색용 필드임을 명시
- 기능: k-nearest neighbor (k-NN) 검색 활성화
- 필수: 벡터 검색을 위해 반드시 반드시 설정해줘야 합니다.
2. dimension: 768
"dimension": 768
- 역할: 벡터의 차원 수 정의하는데 사용됩니다.
- 값: 768 = KoSimCSE 모델의 출력 차원 (허깅페이스 참고)
- 중요: 모델 출력과 정확히 일치해야 함
- 변경 불가: 인덱스 생성 후 수정 불가능
3. method.name: "hnsw"
"name": "hnsw"
- 역할: 벡터 인덱싱 알고리즘 선택
- HNSW: Hierarchical Navigable Small World
- 특징:
- 빠른 검색 속도
- 높은 정확도
- 메모리 효율적
- 대안: "ivf" (더 빠르지만 정확도 낮음)
- 따라서 본인의 검색 엔진의 성격에 알맞게 정해주면 됩니다.
4. space_type: "cosinesimil"
"space_type": "cosinesimil"
- 역할: 벡터 간 거리 계산 방식
- Cosine Similarity: 코사인 유사도 사용
- 특징:
- 벡터 방향의 유사성 측정
- 크기(magnitude) 무관
- 텍스트 임베딩에 최적화되어 있어 상기된 타입을 사용합니다.
- 대안?
- "l2": 유클리드 거리
- "innerproduct": 내적
5. engine: "lucene"
"engine": "lucene"
- 역할: 벡터 검색 엔진 선택
- Lucene: OpenSearch 기본 엔진
- 장점:
- 안정성 높음
- 메모리 효율적
- 중소규모 데이터에 최적화
- 대안: "faiss" (대용량 데이터용) / faiss와 lucene 속도 비교 글도 올리도록 하겠습니다.
6. ef_construction: 512
"ef_construction": 512
- 역할: 인덱스 구축 시 후보 노드 수
- 값 의미: 각 벡터당 512개 후보 검토
- 트레이드오프:
- 높음 → 정확도↑, 인덱싱 시간↑
- 낮음 → 정확도↓, 인덱싱 시간↓
- 권장값: 256~1024 (데이터 크기에 따라)
7. m: 24
"m": 24
- 역할: 각 노드의 최대 연결 수
- 값 의미: 각 벡터가 최대 24개 다른 벡터와 연결
- 트레이드오프:
- 높음 → 정확도↑, 메모리↑, 검색속도↑
- 낮음 → 정확도↓, 메모리↓, 검색속도↓
- 권장값: 16~48
이 정도로 확인해볼 수 있습니다.
PUT /your-index
{
"settings": {
"index": {
"knn": true,
"number_of_shards": 1,
"number_of_replicas": 1,
"refresh_interval": -1,
"similarity": {
"scripted_no_idf": {
"type": "scripted",
...
본인의 인덱스를 만들 때 꼭 settings에서 "refresh_interval": -1 로 설정해놓아 주셔야됩니다.
그래야 빠르게 리인덱싱을 할 수 있습니다.
goodsNm_cleaned : 이건 텍스트를 전처리하기 위해 필요한 필드입니다.
상품명에 존재하는 텍스트를 어느정도 전처리를 해줘서 더욱 정확한 임베딩이 되도록 만들기 위해 진행했습니다.
PUT _ingest/pipeline/goods-embedding-script-real
{
"description": "스크립트 기반 임베딩 파이프라인",
"processors": [
{
"script": {
"source": """
def modelId = '{model_id}';
String text = ctx.goodsNm;
if (text == null || text.isEmpty()) {
ctx.goodsNm_cleaned = "";
return;
}
// 광고성 키워드 및 특수문자 제거
def removeItems = [
"★", "☆", "최저가", "특가", "세일", "할인", "무료배송",
"당일발송", "빠른배송", "이벤트", "추천", "인기", "베스트",
"HOT", "NEW", "SALE", "EVENT", "!!", "!!!", "→", "▶",
"♥", "♡", "◆", "◇", "■", "□", "▲", "△", "▼", "▽",
"●", "○", "◎", "※", "←", "↑", "↓", "◀"
];
for (item in removeItems) {
text = text.replace(item, "");
}
// HTML 태그 제거 (간단한 방법)
text = text.replace("<b>", "").replace("</b>", "")
.replace("<i>", "").replace("</i>", "")
.replace("<u>", "").replace("</u>", "")
.replace("<em>", "").replace("</em>", "")
.replace("<strong>", "").replace("</strong>", "")
.replace("<span>", "").replace("</span>", "");
// 연속된 공백을 단일 공백으로 변환
while (text.contains(" ")) {
text = text.replace(" ", " ");
}
text = text.trim();
if (text.length() < 2) {
text = ctx.goodsNm;
}
ctx.goodsNm_cleaned = text;
"""
}
}
]
}
이를 통해서 텍스트 전처리를 진행해줍니다.
아래의 내용으로 전처리가 잘 됐는지 확인해보겠습니다.
POST _ingest/pipeline/goods-embedding-script-real/_simulate
{
"docs": [
{
"_source": {
"goodsNm": "★★★ 삼성 갤럭시 S24 Ultra 256GB !!! 최저가 특가 ★★★"
}
},
{
"_source": {
"goodsNm": "<b>LG 그램 노트북</b> 17인치 NEW 베스트 추천♥"
}
}
]
}
위의 코드를 실행해보면 아래의 결과가 나오면서 제대로 잘 진행되었음을 확인할 수 있습니다.
아래의 내용은 실제 시뮬레이션을 돌린 결과 입니다.
{
"docs": [
{
"doc": {
"_index": "_index",
"_id": "_id",
"_source": {
"goodsNm_cleaned": "삼성 갤럭시 S24 Ultra 256GB !",
"goodsNm": "★★★ 삼성 갤럭시 S24 Ultra 256GB !!! 최저가 특가 ★★★"
},
"_ingest": {
"timestamp": "2025-07-"
}
}
},
{
"doc": {
"_index": "_index",
"_id": "_id",
"_source": {
"goodsNm_cleaned": "LG 그램 노트북 17인치",
"goodsNm": "<b>LG 그램 노트북</b> 17인치 NEW 베스트 추천♥"
},
"_ingest": {
"timestamp": "2025-07-"
}
}
}
]
}
이제 임베딩까지 진행해보도록 하겠습니다.
아래의 경우 임베딩을 위한 파이프라인입니다.
PUT /_ingest/pipeline/goods-embedding-pipeline-v2
{
"description": "Korean goods name embedding pipeline",
"processors": [
{
"text_embedding": {
"model_id": "{model_id}",
"field_map": {
"goodsNm_cleaned": "goodsNm_vector"
}
}
}
]
}
다음 명령어를 통해 텍스트를 변환할 수 있습니다.
그리고 우리 출력 필드의 내용을 살펴보겠습니다.
goodsNm_vector (출력 필드)
- 역할: 생성된 벡터가 저장될 필드입니다.
- 데이터 타입: knn_vector (벡터) / 꼭 꼭!! 반드시 확인하세요!!
- 차원: 768차원 (모델에 따라 다르게 설정합니다. / 이 부분은 이미 아까 파이프라인에서 설정했습니다.)
- 내용: 텍스트를 수치화한 벡터 배열입니다.
- 예시: [0.123, -0.456, 0.789, ..., 0.321]
이제 임베딩을 위한 준비가 끝났습니다!
마지막으로 인덱싱을 통해서 마무리 짓도록 하겠습니다!
저희의 경우에는 실제 존재하는 인덱스에서 새로운 인덱스로 reindex를 진행했습니다.
이는 기존 인덱스에는 임베딩 파이프라인이 없었기때문에 새 인덱스로 복사하면서 파이프라인 적용되며,
모든 기존 데이터가 자동으로 벡터화 되기 때문에 번거롭지만 reindex라는 과정을 선택하게 됐습니다.
(물론 Update By Query 라는 과정도 있지만, 대용량 데이터를 다루는 과정에선 적합하지 않다고 판단했습니다.)
결론적으로 reindex를 사용하는 주된 이유를 깔끔하게 정리해보자면
- 기존 데이터 전체에 새로운 처리 적용
- 데이터 안전성 보장
- 새로운 인덱스 구조로 완전 이전
- 벡터 검색 성능 최적화
정도로 생각해주시면 되겠습니다.
POST /_reindex
{
"source": {
"index": "your-index"
},
"dest": {
"index": "my-index",
"pipeline": "goods-embedding-pipeline-v2"
}
}
위 과정을 통해 reindex를 할 수 있으나 메모리를 굉장히 많이 잡아먹으므로 이를 진행할 때 배치를 활용하는 것이 중요합니다.
POST /_reindex?wait_for_completion=false&requests_per_second=500&slices=4
{
"source": {
"index": "your-index",
"size": 5000
},
"dest": {
"index": "my-index",
"pipeline": "goods-embedding-pipeline-v2"
}
}
여기서 본인의 컴퓨터 사양에 따라 값을 조절하면서 인덱싱을 진행할 수 있습니다.
(이때 메모리를 항상 주의해야 됩니다.)
GET /your-new-index/_count
를 통해서 인덱스 개수가 이전 인덱스의 개수와 같다면 인덱싱 과정이 완료된 것 입니다!
자! 드디어 끝이보이기 시작합니다!
아래는 검색 쿼리문입니다.
일반 키워드 검색과 시맨틱 검색이 합쳐진 Hybrid Search 입니다.
GET /my-index/_search
{
"size": 10,
"query": {
"bool": {
"should": [
{
"match": {
"goodsNm_cleaned": {
"query": "자취용 밥솥",
"boost": 5.0,
"analyzer": "nori_analyzer"
}
}
},
{
"neural": {
"goodsNm_vector": {
"query_text": "자취용 밥솥",
"model_id": "{model_id}",
"k": 10,
"boost":3.0
}
}
}
]
}
},
"_source": "goodsNm"
}
이렇게 했을 때 이제 결과가 아래처럼 나오는 것을 볼 수 있습니다.
{
"took": 37,
"timed_out": false,
"_shards": {
"total": 1,
"successful": 1,
"skipped": 0,
"failed": 0
},
"hits": {
"total": {
"value": 479,
"relation": "eq"
},
"max_score": 26.791565,
"hits": [
{
"_index": "my-index",
"_id": "1_10000",
"_score": 26.791565,
"_source": {
"goodsNm": "전자레인지용 저당 밥솥"
}
},
{
"_index": "my-index",
"_id": "1_10001",
"_score": 25.754791,
"_source": {
"goodsNm": "1인용 2인용 미니 전기 보온 밥솥 소형 자취 원룸"
}
},
{
"_index": "my-index",
"_id": "1_10002",
"_score": 25.231916,
"_source": {
"goodsNm": "풍년 업소용 전기보온밥통 밥솥"
}
},
{
"_index": "my-index",
"_id": "1_10003",
"_score": 24.007835,
"_source": {
"goodsNm": "인블룸 전자레인지용 밥솥"
}
},
{
"_index": "my-index",
"_id": "1_10004",
"_score": 23.895649,
"_source": {
"goodsNm": "키친아트 미니 전기 밥솥 크림"
}
},
{
"_index": "my-index",
"_id": "1_10005",
"_score": 23.754526,
"_source": {
"goodsNm": "6L 압력 밥솥 내솥 밥 압력 밥솥 압력 밥솥"
}
},
{
"_index": "my-index",
"_id": "1_10006",
"_score": 23.16386,
"_source": {
"goodsNm": "자취 냉장고 소형 레트로 냉장고 원룸 자취 냉장"
}
},
다음은 이제 검색을 어떻게 하면 결과가 더 잘 나오도록 만들 수 있을까에 대한 내용으로 찾아뵙도록 하겠습니다!
'검색엔진 > Opensearch' 카테고리의 다른 글
| OpenSearch HuggingFace 연동 (1) | 2025.07.14 |
|---|---|
| OpenSearch를 활용한 형태소 분석과 중복 단어 제거 (0) | 2025.03.28 |
| [opensearch] 인덱스 snapshop 뜨는 방법 (0) | 2025.01.20 |
| 오픈서치 기본 설치 (0) | 2024.09.24 |
