Approach | 설명 | 장점 | 단점 | 예제/코드 참고 |
---|---|---|---|---|
LLM 기반 Query Expansion | 대규모 언어모델(LLM)을 이용하여 사용자의 원본 쿼리에서 다양한 alternative term, chain-of-thought(CoT) 방식 및 few-shot/zero-shot prompt를 통해 확장 쿼리를 생성하는 방법. PRF document를 함께 활용하는 변형도 있음. | - 다양한 키워드를 생성해 recall 향상 - prompt 설계에 따라 유연하게 확장이 가능함 |
- 모델 크기에 따라 성능 차이가 존재 - PRF 기반 문서 추가 시 창의성 제한 문제 발생 가능 |
Google 논문 “Query Expansion by Prompting Large Language Models” GitHub 예제: LLM Query Expansion Pipeline |
클래식 PRF 기반 Query Expansion | 초기 검색 결과(top-k document)를 “pseudo-relevant”로 가정하고, 해당 문서에서 term 통계(Bo1, Bo2, KL weighting 등)를 기반으로 확장 쿼리를 생성하는 방법. | - 단어 통계 기반으로 안정적인 recall 증대 - 기존 IR 시스템(BM25 등)과 쉽게 결합 가능 |
- 초기 검색 결과의 질에 의존 - 짧은 쿼리나 모호한 쿼리에 취약 |
기본 IR 라이브러리와 연계한 PRF 예제: PRF Query Expansion Example |
하이브리드 접근 방식 | LLM 기반 기법과 PRF 및 통계적 기법을 혼합하여 각 기법의 장점을 취합하는 방법. 예를 들어, LLM의 창의적인 확장과 PRF 문서의 추가 컨텍스트를 동시에 활용하거나, 계절별/유저별 사전 정보를 결합함. | - 각 기법의 강점을 조합하여 precision과 recall 모두 개선 - 다양한 도메인에 유연하게 대응 가능 |
- 파이프라인 복잡도 증가 - 시스템 튜닝에 시간이 소요됨 |
통합 파이프라인 예제: Hybrid Query Expansion Pipeline |
사전 기반 Query Expansion | 브랜드, 카테고리, 성별, 계절 등 도메인별 사전 및 룩업 테이블을 활용하여 미리 정의된 매핑 규칙에 따라 확장 쿼리를 생성. 예를 들어 “봄”에 관련된 제품 키워드, 오타 보정을 위한 유사어 매칭 등. | - 도메인 특화 정보를 반영하여 사용자의 의도에 정밀하게 매칭 - 오타 보정, 다의어 해소에 효과적 |
- 사전 구축 및 업데이트 비용 발생 - 도메인 한정적 적용 가능성 |
예제 코드: Dictionary-based Query Expansion |
오타 보정 및 계절/유저 맞춤 Query Expansion | NLP 기반 오타 교정 모듈(e.g., edit distance, transformer 기반 spell checker)과 계절별, 유저별 선호도(브랜드, 카테고리, 성별 등)를 반영한 확장 쿼리 생성. 입력 쿼리 전처리 단계에서 오타 교정 후, 사용자의 프로파일 및 최신 트렌드(계절별 이벤트, 할인 정보 등)를 추가 매핑. | - 사용자 의도와 컨텍스트에 민감한 검색 제공 - 오타, 다의어 문제를 효과적으로 해결 |
- 추가 전처리 모듈 및 사용자 프로파일 관리 필요 - 실시간 업데이트 어려울 수 있음 |
오타 보정+맞춤형 확장 예제: Typo Correction & Personalization Pipeline |
Query Expansion(QE)은 검색 시스템에서 사용자가 입력한 짧은 쿼리의 recall을 향상시키기 위해, 원본 쿼리에 추가적인 용어를 부여하여 관련 문서를 더 많이 회수하는 기법입니다.
기존 방식은 PRF (Pseudo-Relevance Feedback) 기반으로, 초기 검색 결과에서 통계적 방법(Bo1, Bo2, KL weighting 등)을 활용했지만, 최근에는 LLM의 generative 능력을 이용하여 prompt를 통해 다양한 확장 쿼리를 생성하는 방식이 연구되고 있습니다.
또한, vocabulary 문제로 인해 사용자가 입력한 단어가 검색 인덱스의 용어와 일치하지 않거나, 동의어/다의어 문제로 인해 관련 문서가 누락되는 문제를 해결하기 위해 QE가 사용됩니다.
아래는 Python과 Pytorch 또는 Hugging Face Transformers를 활용한 LLM 기반 확장 쿼리 생성 예제입니다.
import json
from transformers import AutoTokenizer, AutoModelForSeq2SeqLM
class LLMQueryExpander:
def __init__(self, model_name="google/flan-t5-base"):
self.tokenizer = AutoTokenizer.from_pretrained(model_name)
self.model = AutoModelForSeq2SeqLM.from_pretrained(model_name)
def generate_expansions(self, query, num_expansions=4, prompt_type="CoT"):
# prompt 설계: 사용자의 쿼리와 원하는 확장 방식에 따른 prompt 구성
if prompt_type == "CoT":
prompt = f"다음 쿼리에 대해 관련 키워드들을 단계별로 생성해줘:\nQuery: '{query}'\nStep-by-step 키워드 리스트:"
else:
prompt = f"Query: '{query}'\n관련된 키워드 리스트를 생성해줘:"
inputs = self.tokenizer(prompt, return_tensors="pt")
outputs = self.model.generate(
**inputs,
max_length=128,
num_return_sequences=1,
do_sample=True,
top_p=0.9,
temperature=0.7
)
response = self.tokenizer.decode(outputs[0], skip_special_tokens=True)
# 간단하게 줄바꿈이나 쉼표 기준 분할
keywords = [kw.strip() for kw in response.split('\n') if kw.strip()]
# 원본 쿼리 포함
if query not in keywords:
keywords.append(query)
return keywords
# 사용 예제
if __name__ == "__main__":
query = "open source nlp frameworks"
expander = LLMQueryExpander()
expanded_queries = expander.generate_expansions(query, prompt_type="CoT")
print("Expanded Queries:", expanded_queries)
다음은 오타 보정과 브랜드/카테고리 등 매핑을 위한 간단한 Python 예제입니다.
import difflib
# 예제 사전: 브랜드, 카테고리, 계절 키워드
domain_dict = {
"브랜드": ["Nike", "Adidas", "Puma"],
"카테고리": ["운동화", "티셔츠", "백"],
"계절": {
"봄": ["라이트 재킷", "플로럴 패턴", "맨투맨"],
"여름": ["반팔", "샌들", "쿨링 아이템"],
"가을": ["코트", "부츠", "니트"],
"겨울": ["패딩", "스웨터", "장갑"]
}
}
def correct_typo(word, dictionary):
# dictionary는 단어 리스트 형태
match = difflib.get_close_matches(word, dictionary, n=1, cutoff=0.8)
return match[0] if match else word
def expand_with_domain(query, user_profile=None, season=None):
expanded_terms = set([query])
# 오타 보정: 예를 들어 사용자가 "adids"라고 썼다면 "Adidas"로 보정
for category, items in domain_dict.items():
if category == "계절":
continue
for item in items:
corrected = correct_typo(query, items)
if corrected != query:
expanded_terms.add(corrected)
# 유저 프로파일 기반 확장 (간단한 예: 선호 카테고리 추가)
if user_profile and "선호_카테고리" in user_profile:
expanded_terms.update(user_profile["선호_카테고리"])
# 계절 기반 확장
if season and season in domain_dict["계절"]:
expanded_terms.update(domain_dict["계절"][season])
return list(expanded_terms)
# 사용 예제
if __name__ == "__main__":
query = "adids"
user_profile = {"선호_카테고리": ["운동화"]}
season = "여름"
domain_expansions = expand_with_domain(query, user_profile, season)
print("Domain-based Expansions:", domain_expansions)
위의 모듈들을 하나의 통합 파이프라인으로 구성하면, 다음과 같이 단계별로 동작할 수 있습니다.
아래는 통합 파이프라인의 의사코드(Pseudocode)입니다.
def integrated_query_expansion(query, user_profile=None, season=None):
# Step 1: 전처리 및 오타 보정
preprocessed_query = correct_typo(query, domain_dict["브랜드"] + domain_dict["카테고리"])
# Step 2: LLM 기반 확장
llm_expander = LLMQueryExpander()
llm_keywords = llm_expander.generate_expansions(preprocessed_query, prompt_type="CoT")
# Step 3: 사전 기반 확장 (도메인/유저/계절)
domain_keywords = expand_with_domain(preprocessed_query, user_profile, season)
# Step 4: 통합 및 정제
all_keywords = list(set(llm_keywords + domain_keywords))
return all_keywords
# 파이프라인 실행 예제
if __name__ == "__main__":
original_query = "adids running shoes"
user_profile = {"선호_카테고리": ["운동화"]}
season = "여름"
final_expansions = integrated_query_expansion(original_query, user_profile, season)
print("최종 확장 쿼리:", final_expansions)
관련 논문 및 자료
이와 같이 query expansion은 단순히 원본 쿼리에 키워드를 추가하는 것을 넘어서, LLM의 창의적인 키워드 생성, 통계적 PRF 기법, 그리고 도메인/유저/계절 정보를 반영한 사전 기반 매핑을 결합하여 사용자의 의도에 정밀하게 매칭되는 검색 결과를 도출할 수 있는 강력한 기법입니다. 각 모듈을 단계별로 구현하고 통합 파이프라인을 구성하면, 오타에 강건하고, 계절 및 유저별 맞춤 검색을 제공하는 AI powered search 시스템을 구축할 수 있습니다.
다음은 패션 도메인 이커머스 검색 시스템에서 Elasticsearch(ES)와 LLM 기반 Query Expansion, 그리고 도메인별 사전 매핑을 통합하는 방법을 단계별로 설계한 내용입니다.
모듈 | 주요 기능 | ES와의 연계 | 특화 전략 |
---|---|---|---|
1. 입력 전처리 및 오타 보정 모듈 | - 사용자가 입력한 쿼리의 토큰화, 정규화 및 오타 교정 - 기본 NLP 전처리 (불용어 제거, 표제어 추출 등) |
- 전처리된 쿼리를 기반으로 ES 검색 쿼리를 구성 - 쿼리 확장 전의 “클린” 데이터를 확보 |
- 패션 용어, 브랜드 이름, 카테고리 등 도메인 사전을 활용하여 오타 보정 - 예: “adids” → “Adidas” |
2. LLM 기반 Query Expansion 모듈 | - LLM(e.g., Flan-T5, GPT-3.5 등)을 활용하여 원본 쿼리에서 다양한 대체 키워드 생성 - CoT 및 few-shot prompt 기법 적용 |
- 확장 쿼리 리스트를 ES 검색 쿼리로 포함시켜 recall 극대화 - 기존 BM25 등 키워드 기반 검색에 확장된 단어들을 추가하여 질의 범위를 확장 |
- 패션 도메인 관련 예제 및 prompt 설계 - “여름” 쿼리에 “쿨링 아이템”, “라이트 패션”, “에어컨 효과” 등 관련 용어 생성 |
3. 도메인 사전 및 필드 매핑 모듈 | - 패션 상품의 브랜드, 카테고리, 계절, 성별, 색상 등 도메인 속성을 사전/룩업 테이블로 관리 - 사용자 프로파일 및 트렌드 정보와 결합한 확장 쿼리 생성 |
- ES의 각 필드와 직접 매핑: 예) brand , category , season , gender , color 등- 확장 쿼리와 ES 인덱스의 필드 간 동의어 매핑 및 분석기 설정을 통해 검색 정확도 향상 |
- 도메인 특화 사전을 구축하여 동의어, 약어, 변형 등을 포함 - 계절별, 성별 및 트렌드 기반 매핑 전략 적용 |
4. Elasticsearch 매핑 및 인덱싱 모듈 | - 패션 상품 데이터의 ES 매핑 설계: 각 속성별로 적절한 분석기(analyzer)와 다중 필드(multi-field) 설정 - 정밀 검색과 자동 완성(suggester) 기능 포함 |
- 확장된 쿼리의 각 키워드가 매핑 필드와 대응되도록, ES 매핑 설계 시 동의어 필터, n-gram, edge-ngram 분석기 등을 활용 - 색인 시 각 도메인 필드에 가중치(weight)를 부여하여 검색 우선순위 조정 |
- 패션 상품에 특화된 analyzer 및 동의어 사전을 적용 - 예) 브랜드, 카테고리, 계절, 트렌드 키워드 등 |
5. 통합 파이프라인 및 검색 쿼리 구성 | - 전처리, 확장, 매핑 결과를 통합하여 최종 ES 쿼리 생성 - 기존 검색 시스템(BM25, TF-IDF 등)과 LLM 기반 확장 결과를 결합해 최종 랭킹 수행 |
- ES의 multi-match, bool query 등을 활용하여 확장된 키워드와 기존 필드 검색을 결합 - 동적 쿼리 재구성 및 리랭킹(re-ranking) 모듈 연계 |
- 패션 도메인에 맞춰 브랜드, 카테고리, 시즌, 성별 등 필드를 우선 고려하는 쿼리 구성 - 확장 쿼리와 사전 기반 매핑 결과를 적절히 가중치 조정 |
difflib
또는 Transformer 기반 spell-checker를 사용import difflib
# 패션 도메인 사전 예시
fashion_dict = {
"브랜드": ["Adidas", "Nike", "Puma", "Reebok"],
"카테고리": ["운동화", "티셔츠", "백", "바지"],
}
def correct_typo(query, domain_list):
words = query.split()
corrected = []
for word in words:
match = difflib.get_close_matches(word, domain_list, n=1, cutoff=0.8)
corrected.append(match[0] if match else word)
return " ".join(corrected)
# 사용 예
query = "adids running shoes"
domain_terms = fashion_dict["브랜드"] + fashion_dict["카테고리"]
clean_query = correct_typo(query, domain_terms)
print("Corrected Query:", clean_query) # 예: "Adidas running shoes"
from transformers import AutoTokenizer, AutoModelForSeq2SeqLM
class FashionLLMQueryExpander:
def __init__(self, model_name="google/flan-t5-base"):
self.tokenizer = AutoTokenizer.from_pretrained(model_name)
self.model = AutoModelForSeq2SeqLM.from_pretrained(model_name)
def generate_expansions(self, query, num_expansions=4):
prompt = (
f"패션 도메인에서 사용자의 쿼리 '{query}'에 대해 관련된 대체 검색어를 단계별로 생성해줘. "
"예를 들어, '여름 운동화'라면 '쿨링 운동화', '라이트 소재 운동화' 등을 생성할 수 있어."
)
inputs = self.tokenizer(prompt, return_tensors="pt")
outputs = self.model.generate(
**inputs,
max_length=128,
num_return_sequences=1,
do_sample=True,
top_p=0.9,
temperature=0.7
)
response = self.tokenizer.decode(outputs[0], skip_special_tokens=True)
keywords = [kw.strip() for kw in response.split('\n') if kw.strip()]
if query not in keywords:
keywords.append(query)
return keywords
# 사용 예
if __name__ == "__main__":
expander = FashionLLMQueryExpander()
expansions = expander.generate_expansions("여름 운동화")
print("LLM 확장 쿼리:", expansions)
구현 예시
def domain_mapping_expansion(query, user_profile=None, season=None):
domain_expansions = set([query])
# 패션 도메인 사전 (예시)
domain_dict = {
"브랜드": ["Adidas", "Nike", "Puma", "Reebok"],
"카테고리": ["운동화", "티셔츠", "백", "바지"],
"계절": {
"봄": ["라이트 재킷", "플로럴 패턴"],
"여름": ["쿨링", "라이트 소재", "통기성"],
"가을": ["코트", "부츠", "니트"],
"겨울": ["패딩", "스웨터", "장갑"]
},
"성별": {
"남성": ["맨즈", "남성 전용"],
"여성": ["우먼", "여성 전용"]
}
}
# 브랜드, 카테고리 매핑: 단순 치환 또는 유사어 보정 적용
for key in ["브랜드", "카테고리"]:
for term in domain_dict[key]:
if term.lower() in query.lower():
domain_expansions.add(term)
# 계절 및 사용자 프로파일 반영
if season in domain_dict["계절"]:
for term in domain_dict["계절"][season]:
domain_expansions.add(term)
if user_profile and "선호_카테고리" in user_profile:
for term in user_profile["선호_카테고리"]:
domain_expansions.add(term)
return list(domain_expansions)
# 사용 예
if __name__ == "__main__":
user_profile = {"선호_카테고리": ["운동화"]}
mapped_terms = domain_mapping_expansion("여름 운동화", user_profile, season="여름")
print("도메인 기반 확장:", mapped_terms)
{
"mappings": {
"properties": {
"product_id": { "type": "keyword" },
"title": {
"type": "text",
"analyzer": "standard",
"fields": {
"raw": { "type": "keyword" },
"ngram": {
"type": "text",
"analyzer": "custom_ngram_analyzer"
}
}
},
"description": { "type": "text", "analyzer": "standard" },
"brand": {
"type": "keyword",
"normalizer": "lowercase_normalizer"
},
"category": { "type": "keyword" },
"season": { "type": "keyword" },
"gender": { "type": "keyword" },
"price": { "type": "float" }
}
},
"settings": {
"analysis": {
"analyzer": {
"custom_ngram_analyzer": {
"tokenizer": "edge_ngram_tokenizer",
"filter": [ "lowercase", "asciifolding" ]
}
},
"tokenizer": {
"edge_ngram_tokenizer": {
"type": "edge_ngram",
"min_gram": 2,
"max_gram": 20,
"token_chars": ["letter", "digit"]
}
},
"normalizer": {
"lowercase_normalizer": {
"type": "custom",
"filter": ["lowercase", "asciifolding"]
}
}
}
}
}
from elasticsearch import Elasticsearch
def integrated_search(query, user_profile=None, season=None):
# 1. 전처리
clean_query = correct_typo(query, fashion_dict["브랜드"] + fashion_dict["카테고리"])
# 2. LLM 기반 확장
llm_expander = FashionLLMQueryExpander()
llm_keywords = llm_expander.generate_expansions(clean_query)
# 3. 도메인 기반 확장
domain_keywords = domain_mapping_expansion(clean_query, user_profile, season)
# 4. 통합 키워드 생성
all_keywords = list(set(llm_keywords + domain_keywords))
# 5. Elasticsearch 쿼리 구성 (e.g., bool multi_match)
es_query = {
"query": {
"bool": {
"should": [
{
"multi_match": {
"query": " ".join(all_keywords),
"fields": [
"title^3",
"description",
"brand^2",
"category",
"season",
"gender"
],
"type": "best_fields"
}
}
]
}
}
}
# 6. ES 검색 실행
es = Elasticsearch("http://localhost:9200")
results = es.search(index="fashion_products", body=es_query)
return results
# 사용 예
if __name__ == "__main__":
user_profile = {"선호_카테고리": ["운동화"]}
search_results = integrated_search("adids running shoes", user_profile, season="여름")
print("최종 검색 결과:", search_results)
패션 도메인 이커머스에서 ES와 LLM 기반 Query Expansion, 그리고 도메인 사전 매핑을 통합하는 전략은 다음과 같이 구성할 수 있습니다.
이와 같은 모듈화된 설계와 ES 매핑 전략을 적용하면, 패션 도메인에 특화된 검색 시스템을 구축하여 사용자 쿼리에 대해 보다 정확하고 포괄적인 결과를 제공할 수 있습니다.
Query Expansion(쿼리 확장)과 Query Understanding(쿼리 이해)은 검색 시스템에서 사용자의 의도를 정확히 파악하고 관련성 높은 결과를 제공하기 위한 핵심 기술 아래 표는 다양한 접근 방법과 각각의 특징을 요약
방법 | 설명 | 장점 | 단점 | 적용 사례 |
---|---|---|---|---|
전통적 쿼리 확장 | ||||
통계 기반 쿼리 확장 (PRF) | 검색 결과의 상위 문서에서 추출한 용어로 확장 | 구현 간단, 추가 리소스 불필요 | 초기 검색 결과에 의존적 | 일반 검색 엔진 |
사전 기반 쿼리 확장 | WordNet, ConceptNet 등 사전 기반 동의어/관련어 추가 | 도메인 지식 반영 가능 | 도메인별 사전 구축 필요 | 전문 분야 검색 |
LLM 기반 쿼리 확장 | ||||
Zero-shot 확장 | 간단한 프롬프트로 LLM이 쿼리 확장 | 구현 쉬움, 추가 학습 불필요 | 일반적인 확장에 제한 | 범용 검색 |
Few-shot 확장 | 예시와 함께 LLM에게 쿼리 확장 요청 | 도메인 특화 가능 | 좋은 예시 선택 필요 | 전문 검색 시스템 |
CoT 기반 확장 | 단계적 인퍼런스으로 의미적 확장 | 깊은 의미 이해, 높은 품질 | 계산 비용 증가 | 복잡한 쿼리 처리 |
맞춤형 확장 전략 | ||||
계절별 확장 | 계절 정보를 고려한 확장 | 시간적 문맥 반영 | 지속적 업데이트 필요 | 이커머스, 여행 검색 |
사용자 프로필 기반 확장 | 이전 검색, 구매 기록 등을 활용 | 개인화된 결과 제공 | 프라이버시 문제 | 개인화 검색, 추천 |
오타 교정 확장 | 오타를 교정하여 정확한 검색어로 변환 | 사용자 입력 오류 대응 | 언어별 구현 복잡 | 모든 검색 시스템 |
하이브리드 접근 방식 | ||||
앙상블 확장 | 여러 확장 방법을 조합 | 다양한 관점 통합 | 구현 복잡, 조정 필요 | 엔터프라이즈 검색 |
AutoRAG 확장 | 자동 최적화된 RAG 파이프라인 활용 | 자동화된 성능 최적화 | 초기 설정 복잡 | 검색 시스템 |
쿼리 확장(Query Expansion)은 사용자가 입력한 원래 쿼리를 재구성하거나 추가 용어를 포함시켜 검색 결과의 품질을 향상시키는 기술 이 기술은 다음과 같은 이유로 중요합니다.
# 간단한 쿼리 확장 예시
original_query = "노트북 추천"
expanded_query = "노트북 추천 구매 랩탑 컴퓨터 맥북 윈도우 최신"
쿼리 확장은 1960년대부터 발전해왔으며, 주요 발전 단계는 다음과 같습니다.
PRF는 초기 검색 결과의 상위 문서들을 관련성 있는 것으로 가정하고, 이 문서들에서 추출한 용어로 쿼리를 확장하는 기법
from rank_bm25 import BM25Okapi
import numpy as np
from sklearn.feature_extraction.text import CountVectorizer
def pseudo_relevance_feedback(query, documents, top_k=3, expansion_terms=5):
# 초기 검색 수행
tokenized_docs = [doc.split() for doc in documents]
bm25 = BM25Okapi(tokenized_docs)
initial_scores = bm25.get_scores(query.split())
top_docs_indices = np.argsort(initial_scores)[-top_k:]
# 상위 문서에서 용어 추출
vectorizer = CountVectorizer()
term_doc_matrix = vectorizer.fit_transform([documents[i] for i in top_docs_indices])
terms = vectorizer.get_feature_names_out()
# 용어 가중치 계산 (TF-IDF 기반)
term_weights = np.sum(term_doc_matrix.toarray(), axis=0)
# 기존 쿼리 용어 제외
query_terms = set(query.split())
expansion_terms_indices = []
for i, term in enumerate(terms):
if term not in query_terms:
expansion_terms_indices.append((i, term_weights[i]))
# 상위 n개 용어 선택
expansion_terms_indices.sort(key=lambda x: x[1], reverse=True)
expanded_terms = [terms[i] for i, _ in expansion_terms_indices[:expansion_terms]]
# 확장된 쿼리 생성
expanded_query = query + " " + " ".join(expanded_terms)
return expanded_query
정보이론에 기반한 용어 가중치 모델로, 용어의 분포를 고려하여 용어를 선택
def bo1_term_weighting(term_freq_in_top_docs, term_freq_in_collection, total_terms_in_collection, top_k):
pt = term_freq_in_collection / total_terms_in_collection
ft = term_freq_in_top_docs / top_k
# Bo1 weighting
weight = ft * np.log2((1 + pt) / pt) + np.log2(1 + pt)
return weight
두 확률 분포 간의 차이를 측정하는 KL Divergence을 사용하여 확장 용어를 선택
def kl_term_weighting(term_freq_in_top_docs, term_freq_in_collection, total_terms_in_top_docs, total_terms_in_collection):
pt = term_freq_in_top_docs / total_terms_in_top_docs
qt = term_freq_in_collection / total_terms_in_collection
# KL weighting
weight = pt * np.log2(pt / qt)
return weight
WordNet은 영어 단어를 동의어 집합으로 그룹화한 어휘 데이터베이스로, 유의어, 상위어, 하위어 등의 관계를 활용할 수 있습니다.
from nltk.corpus import wordnet
def expand_query_with_wordnet(query):
expanded_terms = []
for word in query.split():
# 동의어 찾기
synsets = wordnet.synsets(word, lang='eng')
for synset in synsets[:2]: # 상위 2개 의미만 고려
for lemma in synset.lemmas():
expanded_terms.append(lemma.name().replace('_', ' '))
# 중복 제거 및 원래 쿼리와 결합
expanded_terms = list(set(expanded_terms))
expanded_query = query + " " + " ".join(expanded_terms)
return expanded_query
ConceptNet은 단어와 구문 간의 의미적 관계를 포함하는 지식 그래프
import requests
def expand_query_with_conceptnet(query, lang='ko'):
expanded_terms = []
for word in query.split():
# ConceptNet API 호출
url = f"http://api.conceptnet.io/c/{lang}/{word}"
response = requests.get(url).json()
# 관련 개념 추출
for edge in response.get('edges', [])[:10]:
if edge['rel']['label'] in ['RelatedTo', 'Synonym', 'IsA']:
target = edge['end']['label']
if target != word:
expanded_terms.append(target)
# 중복 제거 및 원래 쿼리와 결합
expanded_terms = list(set(expanded_terms))
expanded_query = query + " " + " ".join(expanded_terms)
return expanded_query
특정 도메인(e.g., 패션, 전자기기)에 대한 맞춤형 사전을 구축하여 더 정확한 쿼리 확장이 가능
class DomainSpecificDictionary:
def __init__(self):
# 도메인 특화 사전 예시 (실제로는 더 큰 규모로 구축)
self.fashion_dict = {
'티셔츠': ['반팔티', '티', '상의', '반소매'],
'청바지': ['데님', '진', '팬츠', '하의'],
'원피스': ['드레스', '롱드레스', '여성복'],
}
self.tech_dict = {
'노트북': ['랩탑', '컴퓨터', 'PC', '맥북'],
'스마트폰': ['핸드폰', '휴대폰', '모바일', '아이폰', '갤럭시'],
'이어폰': ['헤드폰', '버즈', '에어팟', '블루투스 이어폰'],
}
self.brand_dict = {
'나이키': ['nike', '스우시', '나이키'],
'애플': ['apple', '맥', '아이폰', '아이패드'],
'삼성': ['samsung', 'galaxy', '갤럭시'],
}
def expand_query(self, query, domains=['fashion', 'tech', 'brand']):
expanded_terms = []
query_terms = query.split()
for term in query_terms:
# 패션 도메인 확인
if 'fashion' in domains and term in self.fashion_dict:
expanded_terms.extend(self.fashion_dict[term])
# 기술 도메인 확인
if 'tech' in domains and term in self.tech_dict:
expanded_terms.extend(self.tech_dict[term])
# 브랜드 도메인 확인
if 'brand' in domains and term in self.brand_dict:
expanded_terms.extend(self.brand_dict[term])
# 중복 제거 및 원래 쿼리와 결합
expanded_terms = list(set(expanded_terms) - set(query_terms))
expanded_query = query + " " + " ".join(expanded_terms)
return expanded_query
사전 학습된 LLM에 별도의 예시 없이 쿼리 확장을 요청하는 방식
import openai
def expand_query_zero_shot(query, model="gpt-3.5-turbo", num_expansions=5):
prompt = f"""
당신은 검색 시스템의 일부로, 사용자 쿼리를 유사한 의미를 가진 여러 쿼리로 확장
쿼리: "{query}"
이 쿼리와 유사한 의미를 가진 {num_expansions}개의 대체 쿼리를 생성해주세요.
결과는 JSON 형식의 문자열 배열로만 반환해주세요.
"""
response = openai.ChatCompletion.create(
model=model,
messages=[{"role": "system", "content": "You are a helpful assistant that expands search queries."},
{"role": "user", "content": prompt}],
temperature=0.7
)
# JSON 파싱
import json
try:
expanded_queries = json.loads(response.choices[0].message.content)
# 원래 쿼리 추가
expanded_queries.append(query)
return expanded_queries
except:
print("JSON 파싱 오류, 텍스트 응답:", response.choices[0].message.content)
return [query] # 오류시 원래 쿼리만 반환
몇 가지 예시를 제공하여 LLM이 패턴을 학습하게 하는 방식
def expand_query_few_shot(query, model="gpt-3.5-turbo", num_expansions=5):
prompt = f"""
당신은 검색 시스템의 일부로, 사용자 쿼리를 유사한 의미를 가진 여러 쿼리로 확장
예시:
원래 쿼리: "스마트폰 추천"
확장 쿼리: ["최신 핸드폰 추천", "갤럭시 아이폰 비교", "2025년 휴대폰 추천", "고성능 모바일 기기", "가성비 스마트폰"]
원래 쿼리: "여름 바캉스 코디"
확장 쿼리: ["여름 휴가 패션", "바캉스 룩북", "해변 패션 코디", "여름 여행 의류", "피서지 복장 추천"]
원래 쿼리: "제주도 맛집"
확장 쿼리: ["제주 유명 식당", "제주도 현지인 맛집", "제주 해산물 맛집", "제주도 카페 추천", "제주 맛집 투어"]
이제 다음 쿼리를 확장해주세요:
원래 쿼리: "{query}"
확장 쿼리:
"""
response = openai.ChatCompletion.create(
model=model,
messages=[{"role": "system", "content": "You are a helpful assistant that expands search queries."},
{"role": "user", "content": prompt}],
temperature=0.7
)
# 응답 파싱
import json
import re
result = response.choices[0].message.content
# 정규식으로 대괄호 안의 내용 추출
match = re.search(r'$$(.*)$$', result, re.DOTALL)
if match:
try:
expanded_queries = json.loads(f"[{match.group(1)}]")
expanded_queries.append(query) # 원래 쿼리 추가
return expanded_queries
except:
print("JSON 파싱 오류, 텍스트 응답:", result)
return [query] # 오류시 원래 쿼리만 반환
LLM이 단계적으로 사고하여 더 깊은 의미적 확장을 수행하는 방식
def expand_query_cot(query, model="gpt-3.5-turbo", num_expansions=5):
prompt = f"""
당신은 검색 시스템의 일부로, 사용자 쿼리를 분석하고 확장하는 역할을
다음 단계를 따라 쿼리를 확장해주세요:
1. 쿼리의 핵심 개념과 의도 파악하기
2. 핵심 개념과 관련된 동의어, 관련어, 상위어 생각하기
3. 사용자의 잠재적 의도와 관련된 다양한 측면 고려하기
4. 원래 쿼리와 유사하지만 다양한 표현을 사용한 확장 쿼리 생성하기
쿼리: "{query}"
단계별로 분석한 후, 최종적으로 {num_expansions}개의 확장 쿼리를 생성해주세요.
결과는 JSON 형식의 문자열 배열로 반환해주세요.
"""
response = openai.ChatCompletion.create(
model=model,
messages=[{"role": "system", "content": "You are a helpful assistant that expands search queries."},
{"role": "user", "content": prompt}],
temperature=0.7
)
# JSON 파싱을 시도
import json
import re
result = response.choices[0].message.content
# 마지막 JSON 배열 추출 시도
matches = re.findall(r'$$(.*?)$$', result, re.DOTALL)
if matches:
try:
expanded_queries = json.loads(f"[{matches[-1]}]") # 마지막 배열 사용
expanded_queries.append(query) # 원래 쿼리 추가
return expanded_queries
except:
pass
# 텍스트에서 쿼리 추출 시도
expanded_queries = [query] # 원래 쿼리 포함
lines = result.split('\n')
for line in lines:
if '"' in line and ':' not in line and '{' not in line and '}' not in line:
# 따옴표 안의 내용 추출
match = re.search(r'"([^"]*)"', line)
if match and match.group(1) != query and len(match.group(1)) > 0:
expanded_queries.append(match.group(1))
# 중복 제거
expanded_queries = list(set(expanded_queries))
if len(expanded_queries) > 1: # 원래 쿼리 외에 추가된 것이 있으면
return expanded_queries
# 모든 방법이 실패하면 원래 쿼리만 반환
return [query]
의사 관련성 피드백으로 얻은 상위 문서를 LLM의 추가 컨텍스트로 제공하는 방식
def expand_query_prf_llm(query, documents, model="gpt-3.5-turbo", top_k=3, num_expansions=5):
# 초기 검색으로 관련 문서 찾기
tokenized_docs = [doc.split() for doc in documents]
bm25 = BM25Okapi(tokenized_docs)
initial_scores = bm25.get_scores(query.split())
top_docs_indices = np.argsort(initial_scores)[-top_k:]
prf_docs = [documents[i] for i in top_docs_indices]
# PRF 문서를 컨텍스트로 LLM에 제공
prompt = f"""
당신은 검색 시스템의 일부로, 사용자 쿼리를 확장하는 역할을
원래 쿼리: "{query}"
다음은 이 쿼리와 관련된 상위 검색 결과입니다.
{prf_docs}
이 정보를 바탕으로, 원래 쿼리와 유사하지만 다양한 의미를 포함하는 {num_expansions}개의 확장 쿼리를 생성해주세요.
결과는 JSON 형식의 문자열 배열로 반환해주세요.
"""
response = openai.ChatCompletion.create(
model=model,
messages=[{"role": "system", "content": "You are a helpful assistant that expands search queries."},
{"role": "user", "content": prompt}],
temperature=0.7
)
# JSON 파싱
import json
try:
expanded_queries = json.loads(response.choices[0].message.content)
expanded_queries.append(query) # 원래 쿼리 추가
return expanded_queries
except:
# 파싱 실패시 원래 쿼리만 반환
return [query]
시간적 문맥을 고려하여 계절에 따라 다른 확장을 적용
from datetime import datetime
class SeasonalQueryExpander:
def __init__(self):
# 계절별 확장 사전
self.seasonal_dict = {
'spring': {
'옷': ['봄옷', '봄코디', '봄패션', '가디건', '트렌치코트'],
'여행': ['봄여행', '꽃구경', '벚꽃', '봄나들이', '피크닉'],
'음식': ['봄나물', '딸기', '냉이', '취나물', '봄특선']
},
'summer': {
'옷': ['여름옷', '반팔', '린넨', '민소매', '수영복', '바캉스룩'],
'여행': ['해수욕장', '워터파크', '바다', '계곡', '섬여행'],
'음식': ['빙수', '냉면', '팥빙수', '아이스크림', '시원한']
},
'autumn': {
'옷': ['가을옷', '가을코디', '니트', '트렌치코트', '자켓'],
'여행': ['단풍구경', '가을여행', '산책', '하이킹', '등산'],
'음식': ['고구마', '밤', '사과', '버섯', '전골']
},
'winter': {
'옷': ['겨울옷', '패딩', '코트', '목도리', '장갑', '부츠'],
'여행': ['스키', '겨울여행', '눈구경', '온천', '실내'],
'음식': ['겨울간식', '핫초코', '찜', '탕', '국물요리']
}
}
def get_current_season(self):
# 현재 월을 기준으로 계절 반환
month = datetime.now().month
if 3 <= month <= 5:
return 'spring'
elif 6 <= month <= 8:
return 'summer'
elif 9 <= month <= 11:
return 'autumn'
else:
return 'winter'
def expand_query(self, query, season=None):
if season is None:
season = self.get_current_season()
# 원래 쿼리 토큰화
query_terms = query.split()
expanded_terms = []
# 계절 사전에서 관련 용어 찾기
for term in query_terms:
if term in self.seasonal_dict[season]:
expanded_terms.extend(self.seasonal_dict[season][term])
# 중복 제거 및 원래 쿼리와 결합
expanded_terms = list(set(expanded_terms))
expanded_query = query + " " + " ".join(expanded_terms)
return expanded_query
사용자의 검색 이력, 구매 이력 등을 활용하여 개인화된 확장을 제공
class UserProfileQueryExpander:
def __init__(self):
# 사용자 프로필 데이터베이스 예시
self.user_profiles = {
'user1': {
'age_group': '20대',
'gender': '여성',
'interests': ['패션', '뷰티', '여행'],
'purchase_history': ['원피스', '립스틱', '선크림'],
'search_history': ['여름 원피스', '데일리 메이크업', '제주도 여행']
},
'user2': {
'age_group': '30대',
'gender': '남성',
'interests': ['전자기기', '운동', '자동차'],
'purchase_history': ['노트북', '운동화', '블루투스 이어폰'],
'search_history': ['맥북 프로', '런닝화 추천', '전기차 비교']
}
}
# 사용자 특성별 확장 사전
self.gender_dict = {
'여성': {
'옷': ['여성복', '원피스', '블라우스', '스커트'],
'신발': ['힐', '플랫슈즈', '로퍼', '샌들'],
'가방': ['숄더백', '크로스백', '토트백']
},
'남성': {
'옷': ['남성복', '셔츠', '정장', '티셔츠'],
'신발': ['로퍼', '스니커즈', '구두', '워커'],
'가방': ['백팩', '서류가방', '크로스백']
}
# 의도별 특화 용어 추가
if has_purchase_intent:
intent_terms.extend(['구매', '주문', '배송', '결제', '할인'])
if has_comparison_intent:
intent_terms.extend(['비교', '차이점', '장단점', '특징', '스펙'])
if has_info_intent:
intent_terms.extend(['상세정보', '사양', '기능', '사용법', '제원'])
if has_review_intent:
intent_terms.extend(['리뷰', '평점', '만족도', '인기상품', '베스트'])
return intent_terms
def expand_query(self, query, user_id=None):
"""이커머스 특화 쿼리 확장"""
# Step 1: 오타 교정
corrected_query, _ = self.spell_corrector.expand_query(query)
# Step 2: 카테고리 및 브랜드 확장
category_expanded = self.category_expander.expand_query(corrected_query)
# Step 3: 계절 정보 고려
season_expanded = self.seasonal_expander.expand_query(corrected_query)
# Step 4: 사용자 의도 파악 및 관련 용어 추가
intent_terms = self.get_intent_specific_terms(corrected_query)
# Step 5: 사이즈, 색상, 가격대 변형 처리
query_terms = corrected_query.split()
variant_terms = []
for term in query_terms:
# 사이즈 변형 확인
for size_type, variants in self.size_variants.items():
if term in variants:
variant_terms.extend(variants)
# 색상 변형 확인
for color_type, variants in self.color_variants.items():
if term in variants:
variant_terms.extend(variants)
# 가격대 변형 확인
for price_type, variants in self.price_segments.items():
if term in variants:
variant_terms.extend(variants)
# Step 6: 사용자 프로필 기반 개인화 (가능한 경우)
user_terms = []
if user_id and self.user_profiles and user_id in self.user_profiles:
profile = self.user_profiles[user_id]
# 최근 검색어 활용
if 'recent_searches' in profile:
for search in profile['recent_searches'][-5:]: # 최근 5개
search_terms = search.split()
for term in query_terms:
if term in search_terms:
user_terms.extend(search_terms)
# 구매 이력 활용
if 'purchase_history' in profile:
relevant_purchases = []
for purchase in profile['purchase_history']:
for term in query_terms:
if term in purchase:
relevant_purchases.append(purchase)
user_terms.extend(relevant_purchases)
# 모든 확장 용어 결합
category_terms = category_expanded.split()
season_terms = season_expanded.split()
all_expanded_terms = list(set(
category_terms +
season_terms +
intent_terms +
variant_terms +
user_terms
) - set(query_terms))
# 최종 확장 쿼리 생성
expanded_query = corrected_query + " " + " ".join(all_expanded_terms)
return expanded_query
질의응답 시스템에서 더 정확한 답변을 얻기 위한 쿼리 확장 기법
class QAQueryExpander:
def __init__(self, model="gpt-3.5-turbo"):
"""
질의응답 시스템을 위한 쿼리 확장기
Parameters:
-----------
model : str
사용할 LLM 모델명
"""
self.model = model
# 질문 유형 분류
self.question_types = {
'factoid': ['누구', '언제', '어디', '무엇', '얼마나', '몇'],
'definition': ['뜻', '의미', '정의', '개념'],
'procedural': ['방법', '어떻게', '단계', '절차'],
'causal': ['이유', '왜', '원인', '때문에'],
'comparative': ['차이', '비교', '대비', '다른점', '같은점'],
'opinion': ['생각', '의견', '관점', '평가', '전망']
}
def classify_question_type(self, query):
"""질문 유형 분류"""
for q_type, indicators in self.question_types.items():
if any(ind in query for ind in indicators):
return q_type
return 'general'
def generate_reformulations(self, query, question_type):
"""질문 타입에 따른 재구성 생성"""
prompt_templates = {
'factoid': """
다음 사실 확인 질문을 5가지 다른 방식으로 바꿔주세요:
질문: "{query}"
1.
2.
3.
4.
5.
""",
'definition': """
다음 개념/정의 질문을 5가지 다른 방식으로 바꿔주세요:
질문: "{query}"
1.
2.
3.
4.
5.
""",
'procedural': """
다음 절차/방법 질문을 5가지 다른 방식으로 바꿔주세요:
질문: "{query}"
1.
2.
3.
4.
5.
""",
'causal': """
다음 원인/이유 질문을 5가지 다른 방식으로 바꿔주세요:
질문: "{query}"
1.
2.
3.
4.
5.
""",
'comparative': """
다음 비교 질문을 5가지 다른 방식으로 바꿔주세요:
질문: "{query}"
1.
2.
3.
4.
5.
""",
'opinion': """
다음 의견/평가 질문을 5가지 다른 방식으로 바꿔주세요:
질문: "{query}"
1.
2.
3.
4.
5.
""",
'general': """
다음 질문을 5가지 다른 방식으로 바꿔주세요:
질문: "{query}"
1.
2.
3.
4.
5.
"""
}
prompt = prompt_templates[question_type].format(query=query)
response = openai.ChatCompletion.create(
model=self.model,
messages=[
{"role": "system", "content": "You are a helpful assistant that reformulates questions."},
{"role": "user", "content": prompt}
],
temperature=0.7
)
# 응답 파싱
result = response.choices[0].message.content.strip()
lines = [line.strip() for line in result.split('\n') if line.strip()]
reformulations = []
for line in lines:
if line[0].isdigit() and '. ' in line:
# 번호 제거
reformulation = line.split('. ', 1)[1].strip()
reformulations.append(reformulation)
# 원래 쿼리 추가
reformulations.append(query)
return reformulations
def decompose_complex_question(self, query):
"""복잡한 질문을 하위 질문으로 분해"""
prompt = f"""
다음은 복잡한 질문 이 질문에 답하기 위해 필요한 하위 질문들로 분해해주세요:
복잡한 질문: "{query}"
하위 질문:
1.
"""
response = openai.ChatCompletion.create(
model=self.model,
messages=[
{"role": "system", "content": "You are a helpful assistant that decomposes complex questions."},
{"role": "user", "content": prompt}
],
temperature=0.7
)
# 응답 파싱
result = response.choices[0].message.content.strip()
lines = [line.strip() for line in result.split('\n') if line.strip()]
sub_questions = []
for line in lines:
if line[0].isdigit() and '. ' in line:
sub_question = line.split('. ', 1)[1].strip()
sub_questions.append(sub_question)
return sub_questions
def expand_query(self, query):
"""QA 특화 쿼리 확장"""
# 질문 유형 분류
question_type = self.classify_question_type(query)
# 질문 재구성
reformulations = self.generate_reformulations(query, question_type)
# 복잡한 질문인 경우 하위 질문으로 분해
is_complex = len(query.split()) > 15 or '그리고' in query or '또한' in query
if is_complex:
sub_questions = self.decompose_complex_question(query)
reformulations.extend(sub_questions)
return reformulations
여러 언어에 대응할 수 있는 다국어 쿼리 확장 시스템을 구현
class MultilingualQueryExpander:
def __init__(self, model="gpt-3.5-turbo", target_languages=None):
"""
다국어 쿼리 확장기
Parameters:
-----------
model : str
사용할 LLM 모델명
target_languages : list
목표 언어 코드 리스트 (ISO 639-1)
"""
self.model = model
self.target_languages = target_languages or ['en', 'ko', 'ja', 'zh']
# 언어 코드 매핑
self.language_names = {
'en': 'English',
'ko': 'Korean',
'ja': 'Japanese',
'zh': 'Chinese',
'es': 'Spanish',
'fr': 'French',
'de': 'German',
'ru': 'Russian'
}
def detect_language(self, text):
"""텍스트 언어 감지 (간단한 구현)"""
# 실제 구현에서는 langdetect 같은 라이브러리 사용 권장
# 여기서는 간단한 휴리스틱 사용
# 한글 확인
if any('\uac00' <= char <= '\ud7a3' for char in text):
return 'ko'
# 일본어 확인
if any('\u3040' <= char <= '\u30ff' for char in text):
return 'ja'
# 중국어 확인
if any('\u4e00' <= char <= '\u9fff' for char in text):
return 'zh'
# 기본값: 영어
return 'en'
def translate_query(self, query, source_lang, target_lang):
"""쿼리 번역"""
source_name = self.language_names.get(source_lang, source_lang)
target_name = self.language_names.get(target_lang, target_lang)
prompt = f"""
Translate the following {source_name} query into {target_name}:
Query: {query}
{target_name} translation:
"""
response = openai.ChatCompletion.create(
model=self.model,
messages=[
{"role": "system", "content": "You are a helpful assistant that translates accurately."},
{"role": "user", "content": prompt}
],
temperature=0.3
)
return response.choices[0].message.content.strip()
def expand_in_language(self, query, language):
"""특정 언어로 쿼리 확장"""
lang_name = self.language_names.get(language, language)
prompt = f"""
Expand the following {lang_name} query into 3 alternative queries in {lang_name}.
The alternatives should capture different aspects or intents of the original query.
Original query: {query}
Alternative queries in {lang_name}:
1.
2.
3.
"""
response = openai.ChatCompletion.create(
model=self.model,
messages=[
{"role": "system", "content": f"You are a helpful assistant that expands queries in {lang_name}."},
{"role": "user", "content": prompt}
],
temperature=0.7
)
# 응답 파싱
result = response.choices[0].message.content.strip()
lines = [line.strip() for line in result.split('\n') if line.strip()]
expansions = []
for line in lines:
if line[0].isdigit() and '. ' in line:
expansion = line.split('. ', 1)[1].strip()
expansions.append(expansion)
return expansions
def expand_query(self, query):
"""다국어 쿼리 확장"""
# 원본 쿼리 언어 감지
source_lang = self.detect_language(query)
all_expansions = [query] # 원본 쿼리 포함
# 1. 원본 언어로 확장
same_lang_expansions = self.expand_in_language(query, source_lang)
all_expansions.extend(same_lang_expansions)
# 2. 다른 언어로 번역 및 확장 (설정된 언어만)
for target_lang in self.target_languages:
if target_lang != source_lang:
# 번역
translated = self.translate_query(query, source_lang, target_lang)
all_expansions.append(translated)
# 번역된 쿼리로 확장
translated_expansions = self.expand_in_language(translated, target_lang)
all_expansions.extend(translated_expansions)
return all_expansions
자주 사용되는 쿼리에 대한 확장 결과를 캐싱하여 성능을 향상시킵니다.
import hashlib
import pickle
import os
import time
class QueryExpansionCache:
def __init__(self, cache_dir='./cache', ttl=86400):
"""
쿼리 확장 캐싱 시스템
Parameters:
-----------
cache_dir : str
캐시 파일 저장 디렉토리
ttl : int
캐시 항목 유효 시간 (초)
"""
self.cache_dir = cache_dir
self.ttl = ttl
# 캐시 디렉토리 생성
os.makedirs(cache_dir, exist_ok=True)
def _get_cache_key(self, query, method_name):
"""캐시 키 생성"""
key = f"{query}_{method_name}"
return hashlib.md5(key.encode('utf-8')).hexdigest()
def _get_cache_path(self, cache_key):
"""캐시 파일 경로 생성"""
return os.path.join(self.cache_dir, f"{cache_key}.pkl")
def get(self, query, method_name):
"""캐시에서 확장 쿼리 가져오기"""
cache_key = self._get_cache_key(query, method_name)
cache_path = self._get_cache_path(cache_key)
if os.path.exists(cache_path):
# 캐시 파일 존재 여부 확인
file_age = time.time() - os.path.getmtime(cache_path)
if file_age <= self.ttl:
# TTL 내에 있는 캐시
try:
with open(cache_path, 'rb') as f:
cache_data = pickle.load(f)
return cache_data
except Exception as e:
print(f"캐시 로딩 오류: {e}")
else:
# 유효 기간 지난 캐시 삭제
try:
os.remove(cache_path)
except:
pass
return None
def set(self, query, method_name, expansion_data):
"""확장 쿼리 캐시에 저장"""
cache_key = self._get_cache_key(query, method_name)
cache_path = self._get_cache_path(cache_key)
try:
with open(cache_path, 'wb') as f:
pickle.dump(expansion_data, f)
return True
except Exception as e:
print(f"캐시 저장 오류: {e}")
return False
def clear_expired(self):
"""만료된 캐시 항목 정리"""
now = time.time()
cleared_count = 0
for filename in os.listdir(self.cache_dir):
if filename.endswith('.pkl'):
file_path = os.path.join(self.cache_dir, filename)
file_age = now - os.path.getmtime(file_path)
if file_age > self.ttl:
try:
os.remove(file_path)
cleared_count += 1
except:
pass
return cleared_count
class CachedQueryExpander:
def __init__(self, expander, cache=None):
"""
캐싱을 지원하는 쿼리 확장기 래퍼
Parameters:
-----------
expander : object
실제 쿼리 확장을 수행하는 객체
cache : QueryExpansionCache
캐시 객체 (기본값: 새 캐시 생성)
"""
self.expander = expander
self.cache = cache or QueryExpansionCache()
self.method_name = expander.__class__.__name__
def expand_query(self, query, **kwargs):
"""캐싱 지원 쿼리 확장"""
# 캐시 조회
cache_key = f"{query}_{str(kwargs)}"
cached_result = self.cache.get(cache_key, self.method_name)
if cached_result is not None:
return cached_result
# 캐시 없으면 실제 확장 수행
expanded = self.expander.expand_query(query, **kwargs)
# 결과 캐싱
self.cache.set(cache_key, self.method_name, expanded)
return expanded
서로 다른 쿼리 확장 방법의 효과를 비교하는 A/B 테스트 프레임워크를 구현
import random
import uuid
import datetime
import json
class QueryExpansionABTest:
def __init__(self, expanders, test_name, test_duration_days=7):
"""
쿼리 확장 A/B 테스트 프레임워크
Parameters:
-----------
expanders : dict
테스트할 확장기 {variant_name: expander_object}
test_name : str
테스트 이름
test_duration_days : int
테스트 기간 (일)
"""
self.expanders = expanders
self.test_name = test_name
self.variants = list(expanders.keys())
# 테스트 설정
self.start_time = datetime.datetime.now()
self.end_time = self.start_time + datetime.timedelta(days=test_duration_days)
# 결과 저장소
self.results_file = f"ab_test_{test_name}_{self.start_time.strftime('%Y%m%d')}.json"
self.session_variant_map = {} # 세션별 할당된 변형
self.events = [] # 이벤트 로그
def is_active(self):
"""테스트 활성 여부 확인"""
return datetime.datetime.now() < self.end_time
def assign_variant(self, session_id):
"""세션에 변형 할당"""
if not self.is_active():
# 테스트 종료 시 기본 변형 사용
return self.variants[0]
if session_id in self.session_variant_map:
# 이미 할당된 세션은 동일 변형 유지
return self.session_variant_map[session_id]
# 새 세션에 무작위 변형 할당
variant = random.choice(self.variants)
self.session_variant_map[session_id] = variant
# 할당 이벤트 기록
self._log_event(session_id, 'assignment', {'variant': variant})
return variant
def expand_query(self, query, session_id=None):
"""세션별 변형에 따른 쿼리 확장"""
if session_id is None:
session_id = str(uuid.uuid4())
# 변형 할당
variant = self.assign_variant(session_id)
expander = self.expanders[variant]
# 쿼리 확장 실행
try:
start_time = datetime.datetime.now()
expanded = expander.expand_query(query)
duration = (datetime.datetime.now() - start_time).total_seconds()
# 확장 이벤트 기록
self._log_event(session_id, 'expansion', {
'variant': variant,
'original_query': query,
'expanded_query': expanded,
'duration': duration
})
return expanded
except Exception as e:
# 오류 이벤트 기록
self._log_event(session_id, 'error', {
'variant': variant,
'original_query': query,
'error': str(e)
})
return query # 오류 시 원래 쿼리 반환
def record_click(self, session_id, query, doc_id, position):
"""검색 결과 클릭 기록"""
self._log_event(session_id, 'click', {
'query': query,
'doc_id': doc_id,
'position': position,
'variant': self.session_variant_map.get(session_id)
})
def record_conversion(self, session_id, query, conversion_type, value=None):
"""전환 이벤트 기록"""
self._log_event(session_id, 'conversion', {
'query': query,
'conversion_type': conversion_type,
'value': value,
'variant': self.session_variant_map.get(session_id)
})
def _log_event(self, session_id, event_type, data):
"""이벤트 로깅"""
event = {
'timestamp': datetime.datetime.now().isoformat(),
'session_id': session_id,
'event_type': event_type,
'test_name': self.test_name,
'data': data
}
self.events.append(event)
# 주기적으로 이벤트 저장
if len(self.events) >= 100:
self.save_events()
def save_events(self):
"""이벤트 파일 저장"""
try:
# 기존 이벤트 로드
existing_events = []
if os.path.exists(self.results_file):
with open(self.results_file, 'r', encoding='utf-8') as f:
existing_events = json.load(f)
# 새 이벤트 추가
all_events = existing_events + self.events
# 파일 저장
with open(self.results_file, 'w', encoding='utf-8') as f:
json.dump(all_events, f, ensure_ascii=False, indent=2)
# 저장 후 이벤트 초기화
self.events = []
return True
except Exception as e:
print(f"이벤트 저장 오류: {e}")
return False
def get_results(self):
"""테스트 결과 분석"""
self.save_events() # 모든 이벤트 저장
# 파일에서 이벤트 로드
try:
with open(self.results_file, 'r', encoding='utf-8') as f:
events = json.load(f)
except:
events = []
# 변형별 통계
stats = {variant: {
'assignments': 0,
'expansions': 0,
'errors': 0,
'clicks': 0,
'conversions': 0,
'avg_duration': 0,
'ctr': 0,
'cvr': 0
} for variant in self.variants}
# 세션별 정보
sessions = {}
# 이벤트 처리
for event in events:
event_type = event['event_type']
session_id = event['session_id']
data = event['data']
variant = data.get('variant')
if not variant or variant not in stats:
continue
# 세션 정보 초기화
if session_id not in sessions:
sessions[session_id] = {'impressions': 0, 'clicks': 0, 'conversions': 0}
# 이벤트 유형별 처리
if event_type == 'assignment':
stats[variant]['assignments'] += 1
elif event_type == 'expansion':
stats[variant]['expansions'] += 1
stats[variant]['avg_duration'] += data.get('duration', 0)
sessions[session_id]['impressions'] += 1
elif event_type == 'error':
stats[variant]['errors'] += 1
elif event_type == 'click':
stats[variant]['clicks'] += 1
sessions[session_id]['clicks'] += 1
elif event_type == 'conversion':
stats[variant]['conversions'] += 1
sessions[session_id]['conversions'] += 1
# 평균 및 비율 계산
for variant, data in stats.items():
if data['expansions'] > 0:
data['avg_duration'] /= data['expansions']
if data['expansions'] > 0:
data['ctr'] = data['clicks'] / data['expansions']
if data['clicks'] > 0:
data['cvr'] = data['conversions'] / data['clicks']
return {
'test_name': self.test_name,
'start_time': self.start_time.isoformat(),
'end_time': self.end_time.isoformat(),
'active': self.is_active(),
'variants': self.variants,
'stats': stats
}
사용자 피드백을 통해 쿼리 확장을 점진적으로 개선하는 온라인 학습 시스템을 구현
class OnlineQueryExpansionLearner:
def __init__(self, base_expander, learning_rate=0.1, feedback_window=100):
"""
온라인 학습 쿼리 확장 시스템
Parameters:
-----------
base_expander : object
기본 쿼리 확장기
learning_rate : float
학습률 (0~1)
feedback_window : int
피드백 윈도우 크기
"""
self.base_expander = base_expander
self.learning_rate = learning_rate
self.feedback_window = feedback_window
# 용어 가중치 관리
self.term_weights = {} # {term: weight}
# 피드백 이력
self.feedback_history = [] # [(query, expanded_terms, clicked_doc, rating)]
# 용어 추출기
self.tokenizer = lambda text: text.lower().split()
def expand_query(self, query):
"""가중치 적용 쿼리 확장"""
# 기본 확장기로 확장
base_expanded = self.base_expander.expand_query(query)
if isinstance(base_expanded, list):
# 리스트인 경우 (여러 확장 쿼리)
expanded_terms = []
for exp_query in base_expanded:
expanded_terms.extend(self.tokenizer(exp_query))
else:
# 문자열인 경우 (단일 확장 쿼리)
expanded_terms = self.tokenizer(base_expanded)
# 원래 쿼리 용어 제외
query_terms = set(self.tokenizer(query))
expanded_terms = [term for term in expanded_terms if term not in query_terms]
# 가중치 적용 및 정렬
weighted_terms = []
for term in expanded_terms:
weight = self.term_weights.get(term, 1.0)
weighted_terms.append((term, weight))
# 가중치 기준 정렬
weighted_terms.sort(key=lambda x: x[1], reverse=True)
# 상위 10개 용어 선택
top_terms = [term for term, _ in weighted_terms[:10]]
# 최종 확장 쿼리 생성
final_expanded = query + " " + " ".join(top_terms)
return final_expanded, top_terms
def record_feedback(self, query, expanded_terms, clicked_doc=None, rating=None):
"""사용자 피드백 기록"""
# 피드백 추가
feedback = (query, expanded_terms, clicked_doc, rating)
self.feedback_history.append(feedback)
# 윈도우 크기 유지
if len(self.feedback_history) > self.feedback_window:
self.feedback_history.pop(0)
# 가중치 업데이트
self._update_weights()
return len(self.feedback_history)
def _update_weights(self):
"""피드백 기반 용어 가중치 업데이트"""
# 모든 용어의 점수 계산
term_scores = {}
for query, expanded_terms, clicked_doc, rating in self.feedback_history:
if clicked_doc is not None:
# 클릭 피드백이 있는 경우
clicked_terms = self.tokenizer(clicked_doc)
for term in expanded_terms:
# 클릭된 문서에 용어가 있으면 긍정 피드백
if term in clicked_terms:
if term not in term_scores:
term_scores[term] = 0
term_scores[term] += 1
if rating is not None:
# 명시적 평점이 있는 경우 (-1 ~ 1 범위)
for term in expanded_terms:
if term not in term_scores:
term_scores[term] = 0
term_scores[term] += rating
# 가중치 업데이트 (지수 이동 평균)
for term, score in term_scores.items():
if term not in self.term_weights:
self.term_weights[term] = 1.0
# 점수 정규화 (-1 ~ 1 범위)
normalized_score = max(min(score, 1.0), -1.0)
# 가중치 업데이트
self.term_weights[term] = (1 - self.learning_rate) * self.term_weights[term] + self.learning_rate * (1 + normalized_score)
# 가중치 범위 제한 (0.1 ~ 2.0)
self.term_weights[term] = max(min(self.term_weights[term], 2.0), 0.1)
def get_term_weights(self, top_n=20):
"""상위 n개 용어 가중치 조회"""
sorted_weights = sorted(self.term_weights.items(), key=lambda x: x[1], reverse=True)
return sorted_weights[:top_n]
def save_model(self, filename):
"""모델 저장"""
model_data = {
'term_weights': self.term_weights,
'learning_rate': self.learning_rate,
'feedback_window': self.feedback_window
}
with open(filename, 'wb') as f:
pickle.dump(model_data, f)
return True
def load_model(self, filename):
"""모델 로드"""
try:
with open(filename, 'rb') as f:
model_data = pickle.load(f)
self.term_weights = model_data['term_weights']
self.learning_rate = model_data['learning_rate']
self.feedback_window = model_data['feedback_window']
return True
except Exception as e:
print(f"모델 로딩 오류: {e}")
return False
BERT와 같은 Transformer 모델을 활용하여 의미 기반 쿼리 확장을 구현
from transformers import AutoTokenizer, AutoModel
import torch
import numpy as np
class BERTQueryExpander:
def __init__(self, model_name="klue/bert-base", device=None):
"""
BERT 기반 쿼리 확장기
Parameters:
-----------
model_name : str
사용할 BERT 모델명
device : str
실행 디바이스 ('cuda' 또는 'cpu')
"""
self.device = device or ('cuda' if torch.cuda.is_available() else 'cpu')
self.tokenizer = AutoTokenizer.from_pretrained(model_name)
self.model = AutoModel.from_pretrained(model_name).to(self.device)
# 랭킹 모델 설정
self._init_ranker()
def _init_ranker(self):
"""MLM 랭킹 모델 초기화"""
from transformers import BertForMaskedLM
self.mlm_model = BertForMaskedLM.from_pretrained("klue/bert-base").to(self.device)
self.mlm_model.eval()
def get_embeddings(self, text):
"""BERT 임베딩 생성"""
inputs = self.tokenizer(text, return_tensors="pt", padding=True, truncation=True, max_length=512)
inputs = {k: v.to(self.device) for k, v in inputs.items()}
with torch.no_grad():
outputs = self.model(**inputs)
# [CLS] 토큰 임베딩 사용
return outputs.last_hidden_state[:, 0, :].cpu().numpy()
def find_similar_terms(self, query_terms, candidate_terms, top_k=10):
"""의미적으로 유사한 용어 찾기"""
query_emb = self.get_embeddings(" ".join(query_terms))
similarities = []
for term in candidate_terms:
term_emb = self.get_embeddings(term)
# 코사인 유사도 계산
sim = np.dot(query_emb, term_emb.T) / (np.linalg.norm(query_emb) * np.linalg.norm(term_emb))
similarities.append((term, float(sim)))
# 유사도 기준 정렬
similarities.sort(key=lambda x: x[1], reverse=True)
return similarities[:top_k]
def predict_masked_terms(self, query, num_predictions=5):
"""마스크 예측으로 용어 생성"""
# 쿼리에 [MASK] 토큰 추가
masked_query = query + " " + self.tokenizer.mask_token
inputs = self.tokenizer(masked_query, return_tensors="pt")
inputs = {k: v.to(self.device) for k, v in inputs.items()}
# 마스크 토큰 위치 찾기
mask_token_index = torch.where(inputs["input_ids"] == self.tokenizer.mask_token_id)[1]
with torch.no_grad():
outputs = self.mlm_model(**inputs)
# 마스크 위치의 예측 가져오기
logits = outputs.logits
mask_token_logits = logits[0, mask_token_index, :]
# 상위 예측 선택
top_tokens = torch.topk(mask_token_logits, num_predictions, dim=1).indices[0].tolist()
# 토큰 디코딩
predicted_terms = [self.tokenizer.decode([token]) for token in top_tokens]
return predicted_terms
def expand_query(self, query, candidate_pool=None, num_terms=10):
"""BERT 기반 쿼리 확장"""
# 쿼리 토큰화
query_terms = query.lower().split()
# 후보 풀이 없으면 마스크 예측 사용
if candidate_pool is None:
predicted_terms = []
# 여러 위치에 마스크 추가하여 예측
for i in range(min(len(query_terms) + 1, 3)): # 최대 3개 위치
if i < len(query_terms):
# 기존 단어 대체
context = " ".join(query_terms[:i] + [self.tokenizer.mask_token] + query_terms[i+1:])
else:
# 문장 끝에 추가
context = query + " " + self.tokenizer.mask_token
inputs = self.tokenizer(context, return_tensors="pt")
inputs = {k: v.to(self.device) for k, v in inputs.items()}
mask_token_index = torch.where(inputs["input_ids"] == self.tokenizer.mask_token_id)[1]
with torch.no_grad():
outputs = self.mlm_model(**inputs)
logits = outputs.logits
mask_token_logits = logits[0, mask_token_index, :]
top_tokens = torch.topk(mask_token_logits, 5, dim=1).indices[0].tolist()
for token in top_tokens:
term = self.tokenizer.decode([token]).strip()
if term not in query_terms and len(term) > 1:
predicted_terms.append(term)
# 중복 제거
predicted_terms = list(set(predicted_terms))
# 유사도 기준 정렬
similar_terms = self.find_similar_terms(query_terms, predicted_terms)
# 최종 확장 용어 선택
expansion_terms = [term for term, _ in similar_terms[:num_terms]]
else:
# 후보 풀이 있으면 유사도 기반 선택
similar_terms = self.find_similar_terms(query_terms, candidate_pool)
expansion_terms = [term for term, _ in similar_terms[:num_terms]]
# 최종 확장 쿼리 생성
expanded_query = query + " " + " ".join(expansion_terms)
return expanded_query
사용자의 검색 컨텍스트를 고려하여 더 관련성 높은 확장을 제공
class ContextAwareQueryExpander:
def __init__(self, base_expander, context_window=5):
"""
컨텍스트 인식 쿼리 확장기
Parameters:
-----------
base_expander : object
기본 쿼리 확장기
context_window : int
고려할 이전 쿼리 개수
"""
self.base_expander = base_expander
self.context_window = context_window
# 세션별 검색 이력
self.session_history = {} # {session_id: [(query, timestamp, clicked_docs)]}
def _extract_context_terms(self, history):
"""검색 이력에서 컨텍스트 용어 추출"""
context_terms = []
for query, _, clicked_docs in history:
# 쿼리 용어
context_terms.extend(query.lower().split())
# 클릭한 문서 용어
for doc in clicked_docs:
context_terms.extend(doc.lower().split())
# 빈도 계산
term_freq = {}
for term in context_terms:
if len(term) > 1: # 짧은 용어 제외
term_freq[term] = term_freq.get(term, 0) + 1
# 빈도 기준 정렬
sorted_terms = sorted(term_freq.items(), key=lambda x: x[1], reverse=True)
# 상위 20개 용어 반환
return [term for term, _ in sorted_terms[:20]]
def record_query(self, session_id, query, clicked_docs=None):
"""검색 이력 기록"""
if session_id not in self.session_history:
self.session_history[session_id] = []
timestamp = datetime.datetime.now()
self.session_history[session_id].append((query, timestamp, clicked_docs or []))
# 컨텍스트 윈도우 크기 유지
if len(self.session_history[session_id]) > self.context_window:
self.session_history[session_id].pop(0)
def expand_query(self, query, session_id=None):
"""컨텍스트 인식 쿼리 확장"""
# 기본 확장 수행
base_expanded = self.base_expander.expand_query(query)
# 세션 ID가 없으면 기본 확장만 사용
if not session_id or session_id not in self.session_history:
return base_expanded
# 컨텍스트 용어 추출
context_terms = self._extract_context_terms(self.session_history[session_id])
# 컨텍스트 용어 활용
if isinstance(base_expanded, list):
# 리스트인 경우 (여러 확장 쿼리)
expanded_queries = base_expanded
else:
# 문자열인 경우 (단일 확장 쿼리)
expanded_queries = [base_expanded]
# 쿼리 재순위화
ranked_queries = []
for exp_query in expanded_queries:
# 컨텍스트 용어와의 일치도 계산
match_score = 0
for term in context_terms:
if term in exp_query.lower():
match_score += 1
ranked_queries.append((exp_query, match_score))
# 일치도 기준 정렬
ranked_queries.sort(key=lambda x: x[1], reverse=True)
# 상위 컨텍스트 용어 추가
top_context_terms = context_terms[:5]
if isinstance(base_expanded, list):
# 원래 리스트 형식 유지
result = [q for q, _ in ranked_queries]
# 컨텍스트 용어가 있는 확장 쿼리 추가
if top_context_terms:
context_query = query + " " + " ".join(top_context_terms)
result.insert(0, context_query)
return result
else:
# 문자열 형식인 경우
return ranked_queries[0][0] + " " + " ".join(top_context_terms)
대화 컨텍스트를 활용하여 쿼리를 확장하는 대화형 쿼리 확장 시스템을 구현
class ConversationalQueryExpander:
def __init__(self, model="gpt-3.5-turbo"):
"""
대화형 쿼리 확장기
Parameters:
-----------
model : str
사용할 LLM 모델명
"""
self.model = model
self.conversations = {} # {conversation_id: [messages]}
def add_message(self, conversation_id, role, content):
"""대화 이력에 메시지 추가"""
if conversation_id not in self.conversations:
self.conversations[conversation_id] = []
self.conversations[conversation_id].append({
"role": role,
"content": content
})
# 대화 이력 길이 제한 (최근 10개 메시지만 유지)
if len(self.conversations[conversation_id]) > 10:
self.conversations[conversation_id] = self.conversations[conversation_id][-10:]
def extract_entities(self, query):
"""쿼리에서 엔티티 추출"""
prompt = f"""
다음 텍스트에서 중요 엔티티(인물, 장소, 조직, 제품 등)를 추출해주세요.
텍스트: "{query}"
JSON 형식으로 반환해주세요: ["엔티티1", "엔티티2", ...]
"""
response = openai.ChatCompletion.create(
model=self.model,
messages=[
{"role": "system", "content": "You are a helpful assistant that extracts entities."},
{"role": "user", "content": prompt}
],
temperature=0.3
)
# JSON 파싱
import json
try:
entities = json.loads(response.choices[0].message.content)
return entities
except:
# 파싱 실패시 빈 리스트 반환
return []
def resolve_coreferences(self, query, conversation_id):
"""대화 이력 기반 지시 대명사 해결"""
if conversation_id not in self.conversations:
return query
# 최근 3개 메시지만 사용
recent_messages = self.conversations[conversation_id][-3:]
context = "\n".join([f"{m['role']}: {m['content']}" for m in recent_messages])
prompt = f"""
다음은 대화의 일부입니다.
{context}
가장 최근 메시지에서 대명사나 지시어(이것, 그것, 저것 등)가 있다면, 이를 명확한 표현으로 대체해주세요.
원래 의미를 유지하면서 모호함이 없는 완전한 문장을 만들어주세요.
원래 쿼리: "{query}"
명확한 쿼리:
"""
response = openai.ChatCompletion.create(
model=self.model,
messages=[
{"role": "system", "content": "You are a helpful assistant that clarifies ambiguous references."},
{"role": "user", "content": prompt}
],
temperature=0.3
)
resolved_query = response.choices[0].message.content.strip()
# "명확한 쿼리:" 같은 접두어 제거
if ":" in resolved_query:
resolved_query = resolved_query.split(":", 1)[1].strip()
return resolved_query
def expand_query(self, query, conversation_id=None):
"""대화 컨텍스트 기반 쿼리 확장"""
# 대화 컨텍스트가 없으면 단순 확장
if not conversation_id or conversation_id not in self.conversations:
return self._expand_simple(query)
# 대화 이력 추가
self.add_message(conversation_id, "user", query)
# 지시 대명사 해결
resolved_query = self.resolve_coreferences(query, conversation_id)
# 대화 컨텍스트 기반 확장
prompt = f"""
다음은 대화의 일부입니다.
{"\n".join([f"{m['role']}: {m['content']}" for m in self.conversations[conversation_id]])}
위 대화 컨텍스트를 고려하여, 다음 쿼리를 검색 시스템이 더 잘 이해할 수 있도록 확장해주세요.
원래 의미를 유지하면서, 관련 용어와 컨텍스트를 추가해주세요.
원래 쿼리: "{resolved_query}"
확장 쿼리:
"""
response = openai.ChatCompletion.create(
model=self.model,
messages=[
{"role": "system", "content": "You are a helpful assistant that expands search queries based on conversation context."},
{"role": "user", "content": prompt}
],
temperature=0.7
)
expanded_query = response.choices[0].message.content.strip()
# "확장 쿼리:" 같은 접두어 제거
if ":" in expanded_query:
expanded_query = expanded_query.split(":", 1)[1].strip()
# 시스템 응답 기록
self.add_message(conversation_id, "assistant", expanded_query)
return expanded_query
def _expand_simple(self, query):
"""단순 쿼리 확장 (대화 컨텍스트 없음)"""
prompt = f"""
다음 검색 쿼리를 더 효과적인 검색을 위해 확장해주세요.
원래 의미를 유지하면서, 동의어, 관련 용어를 추가해주세요.
원래 쿼리: "{query}"
확장 쿼리:
"""
response = openai.ChatCompletion.create(
model=self.model,
messages=[
{"role": "system", "content": "You are a helpful assistant that expands search queries."},
{"role": "user", "content": prompt}
],
temperature=0.7
)
expanded_query = response.choices[0].message.content.strip()
# "확장 쿼리:" 같은 접두어 제거
if ":" in expanded_query:
expanded_query = expanded_query.split(":", 1)[1].strip()
return expanded_query
여러 확장 방법을 조합한 종합적인 쿼리 확장 파이프라인을 구현
class ComprehensiveQueryExpansionPipeline:
def __init__(self, config=None):
"""
종합 쿼리 확장 파이프라인
Parameters:
-----------
config : dict
파이프라인 설정
"""
self.config = config or {
'spell_correction': True,
'semantic_expansion': True,
'domain_expansion': True,
'personalization': True,
'llm_model': 'gpt-3.5-turbo',
'cache_enabled': True,
'max_expansion_terms': 10
}
# 컴포넌트 초기화
self._init_components()
# 메트릭 수집기
self.metrics = {
'queries_processed': 0,
'avg_expansion_time': 0,
'component_timings': {},
'cache_hits': 0,
'cache_misses': 0
}
def _init_components(self):
"""확장 컴포넌트 초기화"""
# 오타 교정기
if self.config.get('spell_correction'):
self.spell_corrector = SpellCorrectionExpander()
else:
self.spell_corrector = None
# 의미 기반 확장기
if self.config.get('semantic_expansion'):
try:
self.semantic_expander = BERTQueryExpander()
except:
# BERT 로드 실패시 LLM 기반 확장 사용
self.semantic_expander = lambda q: expand_query_cot(
q, self.config.get('llm_model'), 5
)
else:
self.semantic_expander = None
# 도메인 특화 확장기
if self.config.get('domain_expansion'):
self.domain_expander = CategoryBrandExpander()
else:
self.domain_expander = None
# 개인화 확장기
if self.config.get('personalization'):
self.personal_expander = UserProfileQueryExpander()
else:
self.personal_expander = None
# 캐싱 설정
if self.config.get('cache_enabled'):
self.cache = QueryExpansionCache()
else:
self.cache = None
def expand_query(self, query, user_id=None, session_id=None):
"""통합 쿼리 확장"""
start_time = time.time()
# 캐시 검색
cache_key = f"{query}_{user_id}_{session_id}"
if self.cache:
cached_result = self.cache.get(cache_key, "ComprehensiveQueryExpansionPipeline")
if cached_result:
self.metrics['cache_hits'] += 1
return cached_result
self.metrics['cache_misses'] += 1
# Step 1: 오타 교정
if self.spell_corrector:
component_start = time.time()
corrected_query, _ = self.spell_corrector.expand_query(query)
component_time = time.time() - component_start
self.metrics['component_timings']['spell_correction'] = self.metrics['component_timings'].get('spell_correction', 0) + component_time
else:
corrected_query = query
# Step 2: 의미 기반 확장
if self.semantic_expander:
component_start = time.time()
semantic_expanded = self.semantic_expander.expand_query(corrected_query)
component_time = time.time() - component_start
self.metrics['component_timings']['semantic_expansion'] = self.metrics['component_timings'].get('semantic_expansion', 0) + component_time
else:
semantic_expanded = corrected_query
# Step 3: 도메인 특화 확장
if self.domain_expander:
component_start = time.time()
domain_expanded = self.domain_expander.expand_query(corrected_query)
component_time = time.time() - component_start
self.metrics['component_timings']['domain_expansion'] = self.metrics['component_timings'].get('domain_expansion', 0) + component_time
else:
domain_expanded = corrected_query
# Step 4: 사용자 프로필 기반 개인화
if self.personal_expander and user_id:
component_start = time.time()
personal_expanded = self.personal_expander.expand_query(corrected_query, user_id)
component_time = time.time() - component_start
self.metrics['component_timings']['personalization'] = self.metrics['component_timings'].get('personalization', 0) + component_time
else:
personal_expanded = corrected_query
# 모든 확장 결과 결합
all_terms = set()
# 원래 쿼리 용어 추가
original_terms = set(query.split())
all_terms.update(original_terms)
# 각 확장기 결과 추가
for expanded in [semantic_expanded, domain_expanded, personal_expanded]:
if isinstance(expanded, str):
all_terms.update(expanded.split())
elif isinstance(expanded, list):
for exp in expanded:
all_terms.update(exp.split())
# 원래 쿼리 용어 제외
expansion_terms = all_terms - original_terms
# 용어 수 제한
max_terms = self.config.get('max_expansion_terms', 10)
if len(expansion_terms) > max_terms:
# 여기서는 간단히 랜덤 선택하지만, 실제로는 용어 중요도에 따라 선택할 수 있음
expansion_terms = list(expansion_terms)[:max_terms]
# 최종 확장 쿼리 생성
final_expanded_query = query + " " + " ".join(expansion_terms)
# 실행 시간 측정 및 메트릭 업데이트
total_time = time.time() - start_time
self.metrics['queries_processed'] += 1
self.metrics['avg_expansion_time'] = (
(self.metrics['avg_expansion_time'] * (self.metrics['queries_processed'] - 1) + total_time)
/ self.metrics['queries_processed']
)
# 캐시에 결과 저장
if self.cache:
self.cache.set(cache_key, "ComprehensiveQueryExpansionPipeline", final_expanded_query)
return final_expanded_query
def get_metrics(self):
"""성능 메트릭 조회"""
return self.metrics
def reset_metrics(self):
"""메트릭 초기화"""
self.metrics = {
'queries_processed': 0,
'avg_expansion_time': 0,
'component_timings': {},
'cache_hits': 0,
'cache_misses': 0
}
Elasticsearch 또는 OpenSearch와 쿼리 확장을 통합하는 예제
from elasticsearch import Elasticsearch
import json
class ElasticsearchQueryExpander:
def __init__(self, es_host='localhost', es_port=9200, expander=None, index_name=None):
"""
Elasticsearch와 통합된 쿼리 확장기
Parameters:
-----------
es_host : str
Elasticsearch 호스트
es_port : int
Elasticsearch 포트
expander : object
쿼리 확장기 객체
index_name : str
기본 검색 인덱스
"""
self.es_client = Elasticsearch([{'host': es_host, 'port': es_port}])
self.expander = expander or ComprehensiveQueryExpansionPipeline()
self.index_name = index_name
def create_expanded_query(self, query, operator='or', boost_original=2.0):
"""확장된 쿼리로 Elasticsearch 쿼리 생성"""
# 쿼리 확장
expanded_query = self.expander.expand_query(query)
# 원래 쿼리와 확장 쿼리 분리
original_terms = query.split()
expanded_terms = [term for term in expanded_query.split() if term not in original_terms]
# Elasticsearch 쿼리 생성
es_query = {
"query": {
"bool": {
"should": [
{
"match": {
"content": {
"query": " ".join(original_terms),
"operator": operator,
"boost": boost_original
}
}
}
]
}
},
"highlight": {
"fields": {
"content": {}
}
}
}
# 확장 용어가 있으면 추가
if expanded_terms:
es_query["query"]["bool"]["should"].append({
"match": {
"content": {
"query": " ".join(expanded_terms),
"operator": operator,
"boost": 1.0
}
}
})
return es_query
def search(self, query, index=None, size=10, **kwargs):
"""확장 쿼리로 검색"""
index = index or self.index_name
if not index:
raise ValueError("검색할 인덱스를 지정해주세요")
# Elasticsearch 쿼리 생성
es_query = self.create_expanded_query(query, **kwargs)
# 검색 실행
response = self.es_client.search(
index=index,
body=es_query,
size=size
)
# 결과 형식화
results = []
for hit in response['hits']['hits']:
result = {
'id': hit['_id'],
'score': hit['_score'],
'source': hit['_source']
}
# 하이라이트가 있으면 추가
if 'highlight' in hit:
result['highlight'] = hit['highlight']
results.append(result)
return {
'query': query,
'expanded_query': " ".join(es_query["query"]["bool"]["should"][0]["match"]["content"]["query"].split() +
(es_query["query"]["bool"]["should"][1]["match"]["content"]["query"].split()
if len(es_query["query"]["bool"]["should"]) > 1 else [])),
'total': response['hits']['total']['value'] if isinstance(response['hits']['total'], dict) else response['hits']['total'],
'took': response['took'],
'results': results
}
def multi_index_search(self, query, indices, weights=None, size=10, **kwargs):
"""여러 인덱스에서 검색"""
if not indices:
raise ValueError("검색할 인덱스를 지정해주세요")
# 인덱스 가중치 설정
if weights and len(weights) == len(indices):
index_boosts = dict(zip(indices, weights))
else:
index_boosts = {index: 1.0 for index in indices}
# 확장 쿼리 생성
expanded_query = self.expander.expand_query(query)
# 원래 쿼리와 확장 쿼리 분리
original_terms = query.split()
expanded_terms = [term for term in expanded_query.split() if term not in original_terms]
# 멀티 인덱스 쿼리 생성
es_query = {
"query": {
"bool": {
"should": []
}
},
"indices_boost": [
{index: boost} for index, boost in index_boosts.items()
],
"highlight": {
"fields": {
"content": {}
}
}
}
# 원래 쿼리 추가
es_query["query"]["bool"]["should"].append({
"match": {
"content": {
"query": " ".join(original_terms),
"operator": kwargs.get('operator', 'or'),
"boost": kwargs.get('boost_original', 2.0)
}
}
})
# 확장 용어 추가
if expanded_terms:
es_query["query"]["bool"]["should"].append({
"match": {
"content": {
"query": " ".join(expanded_terms),
"operator": kwargs.get('operator', 'or'),
"boost": 1.0
}
}
})
# 검색 실행
response = self.es_client.search(
index=",".join(indices),
body=es_query,
size=size
)
# 결과 형식화
results = []
for hit in response['hits']['hits']:
result = {
'id': hit['_id'],
'score': hit['_score'],
'source': hit['_source'],
'index': hit['_index']
}
# 하이라이트가 있으면 추가
if 'highlight' in hit:
result['highlight'] = hit['highlight']
results.append(result)
return {
'query': query,
'expanded_query': expanded_query,
'total': response['hits']['total']['value'] if isinstance(response['hits']['total'], dict) else response['hits']['total'],
'took': response['took'],
'results': results
}
RESTful API로 쿼리 확장 서비스를 제공하는 웹 서버를 구현
from fastapi import FastAPI, HTTPException, Depends, Query as QueryParam
from typing import List, Dict, Any, Optional
import uvicorn
import json
import logging
# FastAPI 앱 초기화
app = FastAPI(
title="Query Expansion API",
description="다양한 쿼리 확장 방법을 제공하는 API 서비스",
version="1.0.0"
)
# 로깅 설정
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
handlers=[logging.StreamHandler()]
)
logger = logging.getLogger("query_expansion_api")
# 쿼리 확장기 초기화
expanders = {
"basic": lambda q: expand_query_zero_shot(q, "gpt-3.5-turbo", 5),
"advanced": lambda q: expand_query_cot(q, "gpt-3.5-turbo", 5),
"domain": CategoryBrandExpander().expand_query,
"spell": SpellCorrectionExpander().expand_query,
"comprehensive": ComprehensiveQueryExpansionPipeline().expand_query
}
# API 엔드포인트
@app.get("/")
async def root():
return {"message": "Query Expansion API is running"}
@app.get("/expand")
async def expand_query(
query: str = QueryParam(..., description="확장할 원본 쿼리"),
method: str = QueryParam("basic", description="사용할 확장 방법 (basic, advanced, domain, spell, comprehensive)"),
user_id: Optional[str] = QueryParam(None, description="사용자 ID (개인화 확장에 사용)"),
session_id: Optional[str] = QueryParam(None, description="세션 ID (컨텍스트 인식 확장에 사용)")
):
"""
주어진 쿼리를 확장하여 검색 성능을 향상시킵니다.
"""
try:
if method not in expanders:
raise HTTPException(status_code=400, detail=f"지원하지 않는 확장 방법: {method}")
logger.info(f"Expanding query: '{query}' using method: {method}")
# 쿼리 확장 실행
expander = expanders[method]
if method == "comprehensive":
expanded = expander(query, user_id, session_id)
else:
expanded = expander(query)
# 결과가 튜플인 경우 첫 번째 요소만 사용
if isinstance(expanded, tuple):
expanded = expanded[0]
# 응답 생성
response = {
"original_query": query,
"expanded_query": expanded,
"method": method,
"user_id": user_id,
"session_id": session_id
}
logger.info(f"Expansion result: '{expanded}'")
return response
except Exception as e:
logger.error(f"Error expanding query: {str(e)}", exc_info=True)
raise HTTPException(status_code=500, detail=f"쿼리 확장 오류: {str(e)}")
@app.get("/methods")
async def get_methods():
"""사용 가능한 확장 방법 목록을 반환"""
return {
"methods": list(expanders.keys()),
"descriptions": {
"basic": "LLM 기반 0-shot 확장",
"advanced": "LLM 기반 CoT(Chain-of-Thought) 확장",
"domain": "카테고리/브랜드 기반 도메인 특화 확장",
"spell": "오타 교정 및 확장",
"comprehensive": "여러 방법을 결합한 종합적 확장"
}
}
@app.POST("/feedback")
async def record_feedback(
query: str = QueryParam(..., description="원본 쿼리"),
expanded_query: str = QueryParam(..., description="확장 쿼리"),
rating: int = QueryParam(..., description="평가 (-1: 나쁨, 0: 중립, 1: 좋음)"),
method: str = QueryParam("basic", description="사용한 확장 방법"),
user_id: Optional[str] = QueryParam(None, description="사용자 ID"),
feedback_text: Optional[str] = QueryParam(None, description="피드백 텍스트")
):
"""사용자 피드백을 기록"""
try:
logger.info(f"Received feedback for query: '{query}', rating: {rating}")
# 실제 구현에서는 피드백을 데이터베이스에 저장
feedback_data = {
"query": query,
"expanded_query": expanded_query,
"rating": rating,
"method": method,
"user_id": user_id,
"feedback_text": feedback_text,
"timestamp": datetime.datetime.now().isoformat()
}
# 여기서는 로그로만 기록
logger.info(f"Feedback data: {json.dumps(feedback_data)}")
return {"status": "success", "message": "피드백이 성공적으로 기록되었습니다"}
except Exception as e:
logger.error(f"Error recording feedback: {str(e)}", exc_info=True)
raise HTTPException(status_code=500, detail=f"피드백 기록 오류: {str(e)}")
# 서버 실행
if __name__ == "__main__":
uvicorn.run("app:app", host="0.0.0.0", port=8000, reload=True)
쿼리 확장과 검색 개선에 관한 유용한 오픈 소스 프로젝트들
쿼리 확장과 관련된 중요 연구 논문들
쿼리 확장 구현에 유용한 Python 라이브러리 및 도구들
이 문서에서는 쿼리 확장과 쿼리 이해에 관한 다양한 방법과 구현 방법을 포괄적으로 다루었습니다. 사용자의 의도를 정확히 파악하고, 계절, 사용자 프로필, 오타, 카테고리, 브랜드 등을 고려하여 효과적인 검색 결과를 제공할 수 있는 다양한 전략을 소개했습니다. 이런 기법들은 전자상거래, 정보 검색, 질의응답 시스템 등 다양한 분야에서 활용될 수 있으며, 최신 LLM 기술과 결합하여 더욱 강력한 결과를 얻을 수 있습니다.
아래 코드는 패션 도메인 이커머스 검색 시스템을 위한 고도화된 모듈 기반 아키텍처입니다.
이 코드는 사용자의 숨겨진 발화(e.g., “편안한”, “세련된”, “트렌디한” 등)를 포함한 다양한 속성을 robust하게 처리하며,
브랜드 및 동의어 사전을 기반으로 오타 보정과 확장 쿼리 생성을 수행한 후, Elasticsearch(ES) 필드(e.g., title, description, brand, category, season, gender 등)와 정교하게 매핑하는 전체 파이프라인을 구성합니다.
또한, 사용자의 클릭 피드백을 로그로 기록하고 학습 업데이트(e.g., ES boost 파라미터 파인튜닝)를 위한 모듈도 포함되어 있습니다.
import difflib
import logging
import json
import numpy as np
import re
from datetime import datetime
from transformers import AutoTokenizer, AutoModelForSeq2SeqLM
from elasticsearch import Elasticsearch
# ---------------------------------------------------------------------------------
# 1. Configuration: 도메인 사전, 사용자 세그먼트, 계절/유행 설정, 초기 ES 필드 boost 값 및 학습 파라미터
# ---------------------------------------------------------------------------------
CONFIG = {
"domain_dict": {
"브랜드": ["Adidas", "Nike", "Puma", "Reebok", "Under Armour"],
"카테고리": ["운동화", "티셔츠", "백", "바지", "재킷", "드레스"],
"계절": {
"봄": ["라이트 재킷", "플로럴 패턴", "산뜻한"],
"여름": ["쿨링", "라이트 소재", "통기성", "시원한"],
"가을": ["코트", "부츠", "니트", "따뜻한"],
"겨울": ["패딩", "스웨터", "장갑", "보온"]
},
"동의어": {
"운동화": ["스니커즈", "러닝화"],
"티셔츠": ["반팔티", "셔츠"],
"백": ["가방", "핸드백"],
},
# 숨겨진 발화에서 자주 나타나는 속성 (사용자의 감성 및 스타일 관련)
"hidden_attributes": ["편안한", "세련된", "트렌디한", "캐주얼", "포멀", "스타일리시", "럭셔리"]
},
"segment_config": {
"young_adult": {"선호_카테고리": ["운동화", "티셔츠", "백"], "hidden_keywords": ["트렌디한", "캐주얼"]},
"middle_aged": {"선호_카테고리": ["바지", "재킷", "드레스"], "hidden_keywords": ["포멀", "세련된"]}
},
# ES 쿼리 빌더에 사용할 각 필드에 대한 초기 boost 값 (추후 학습/피드백 기반 업데이트)
"es_boost": {
"title": 3.0,
"description": 1.0,
"brand": 2.0,
"category": 1.5,
"season": 1.0,
"gender": 1.0
},
# 학습 관련 파라미터
"learning_rate": 0.05
}
# ---------------------------------------------------------------------------------
# 2. Preprocessor: 오타 보정 및 동의어 확장 모듈
# ---------------------------------------------------------------------------------
class Preprocessor:
"""
사용자의 입력 쿼리에 대해 오타 보정 및 동의어 확장을 수행합니다.
"""
def __init__(self, domain_dict):
self.domain_dict = domain_dict
def correct_typo(self, query, reference_list):
words = query.split()
corrected_words = []
for word in words:
match = difflib.get_close_matches(word, reference_list, n=1, cutoff=0.8)
corrected_words.append(match[0] if match else word)
corrected_query = " ".join(corrected_words)
logging.debug(f"[Preprocessor] 오타 보정 결과: {corrected_query}")
return corrected_query
def apply_synonyms(self, query):
words = query.split()
expanded = set(words)
for word in words:
for key, synonyms in self.domain_dict.get("동의어", {}).items():
if word.lower() == key.lower() or word.lower() in [s.lower() for s in synonyms]:
expanded.add(key)
expanded.update(synonyms)
expanded_query = " ".join(expanded)
logging.debug(f"[Preprocessor] 동의어 확장 결과: {expanded_query}")
return expanded_query
def preprocess(self, query):
ref_list = self.domain_dict["브랜드"] + self.domain_dict["카테고리"]
query_corrected = self.correct_typo(query, ref_list)
query_synonyms = self.apply_synonyms(query_corrected)
logging.info(f"[Preprocessor] 최종 전처리 쿼리: {query_synonyms}")
return query_synonyms
# ---------------------------------------------------------------------------------
# 3. HiddenIntentExtractor: 사용자의 숨겨진 발화 및 감성, 스타일 등 은닉 속성 추출
# ---------------------------------------------------------------------------------
class HiddenIntentExtractor:
"""
사용자의 발화에서 명시적으로 표현되지 않은 숨은 의도(e.g., '편안한', '세련된' 등)를 추출합니다.
(실제 서비스에서는 LLM 기반의 감성 분석이나 transformer 분류기를 적용할 수 있음)
"""
def __init__(self, domain_dict):
self.hidden_keywords = domain_dict.get("hidden_attributes", [])
# 정규식 패턴 생성 (단어 경계로 매칭)
self.pattern = re.compile(r'\b(' + '|'.join(map(re.escape, self.hidden_keywords)) + r')\b', re.IGNORECASE)
def extract(self, query):
matches = self.pattern.findall(query)
extracted = list(set([match.lower() for match in matches]))
logging.info(f"[HiddenIntentExtractor] 추출된 숨은 속성: {extracted}")
return extracted
# ---------------------------------------------------------------------------------
# 4. AdvancedLLMQueryExpander: LLM 기반 쿼리 확장 (CoT, few-shot 적용)
# ---------------------------------------------------------------------------------
class AdvancedLLMQueryExpander:
"""
패션 도메인에 특화된 LLM 기반 쿼리 확장 모듈.
입력 쿼리에 대해 chain-of-thought 혹은 few-shot prompt를 활용하여 대체 검색어(확장어)를 생성합니다.
"""
def __init__(self, model_name="google/flan-t5-base"):
self.tokenizer = AutoTokenizer.from_pretrained(model_name)
self.model = AutoModelForSeq2SeqLM.from_pretrained(model_name)
def generate_expansions(self, query, num_expansions=4):
prompt = (
f"패션 이커머스에서 사용자의 쿼리 '{query}'에 대해 관련된 대체 검색어를 생성해줘. "
"예를 들어, '여름 운동화'라면 '쿨링 운동화', '라이트 소재 운동화', '통기성 좋은 운동화' 등을 생성할 수 있어. "
"단, 사용자의 숨겨진 발화(e.g., '편안한', '세련된')와 브랜드, 카테고리 정보를 고려해서 다양한 확장어를 생성해줘."
)
inputs = self.tokenizer(prompt, return_tensors="pt")
outputs = self.model.generate(
**inputs,
max_length=150,
num_return_sequences=1,
do_sample=True,
top_p=0.9,
temperature=0.7
)
response = self.tokenizer.decode(outputs[0], skip_special_tokens=True)
# 응답을 줄바꿈, 쉼표, 또는 기타 구분자로 분리
keywords = re.split(r'[\n,;]+', response)
keywords = [kw.strip() for kw in keywords if kw.strip()]
if query.lower() not in [k.lower() for k in keywords]:
keywords.append(query)
logging.info(f"[AdvancedLLMQueryExpander] 생성된 확장어: {keywords}")
return keywords
# ---------------------------------------------------------------------------------
# 5. DomainMapper: 도메인 사전 및 사용자 세그먼트/계절 정보 반영
# ---------------------------------------------------------------------------------
class DomainMapper:
"""
브랜드, 카테고리, 계절, 그리고 사용자 세그먼트(e.g., young_adult, middle_aged)를 반영하여
도메인 확장어를 생성합니다.
"""
def __init__(self, config):
self.config = config
def map_domain(self, query, segment=None, season=None):
domain_expansions = set([query])
domain_dict = self.config["domain_dict"]
# 브랜드 및 카테고리 매핑 (문자열 포함 여부 체크)
for key in ["브랜드", "카테고리"]:
for term in domain_dict[key]:
if term.lower() in query.lower():
domain_expansions.add(term)
# 계절 기반 확장
if season and season in domain_dict["계절"]:
for term in domain_dict["계절"][season]:
domain_expansions.add(term)
# 사용자 세그먼트 기반 확장
if segment and segment in self.config["segment_config"]:
segment_info = self.config["segment_config"][segment]
if "선호_카테고리" in segment_info:
for term in segment_info["선호_카테고리"]:
domain_expansions.add(term)
# 추가적으로 숨은 속성(e.g., 선호 키워드)을 포함
if "hidden_keywords" in segment_info:
for term in segment_info["hidden_keywords"]:
domain_expansions.add(term)
mapped = list(domain_expansions)
logging.info(f"[DomainMapper] 도메인 매핑 확장어: {mapped}")
return mapped
# ---------------------------------------------------------------------------------
# 6. AttributeEnricher: HiddenIntentExtractor와 결합해 숨은 발화 및 감성 정보를 쿼리에 반영
# ---------------------------------------------------------------------------------
class AttributeEnricher:
"""
사용자의 숨겨진 발화(e.g., '편안한', '세련된' 등)를 기반으로 추가 확장어를 생성하고,
기존 확장어 세트에 결합하여 더욱 정교한 쿼리 집합을 만듭니다.
"""
def __init__(self, domain_dict):
self.hidden_extractor = HiddenIntentExtractor(domain_dict)
def enrich(self, query):
hidden_attributes = self.hidden_extractor.extract(query)
# 추가 로직: hidden_attributes가 존재하면, 추가 관련 용어를 결합할 수 있음.
enriched = set(hidden_attributes)
logging.info(f"[AttributeEnricher] Enriched attributes: {enriched}")
return list(enriched)
# ---------------------------------------------------------------------------------
# 7. ESQueryBuilder: ES 필드에 매핑되는 템플릿 쿼리 생성
# ---------------------------------------------------------------------------------
class ESQueryBuilder:
"""
통합 확장어와 학습된 boost 파라미터를 바탕으로 Elasticsearch 전용 쿼리 템플릿을 생성합니다.
"""
def __init__(self, es_boost):
self.es_boost = es_boost
def build_query(self, keywords):
query_str = " ".join(keywords)
# 각 필드별 boost 값을 문자열에 반영 (ex: "title^3.0")
fields = [f"{field}^{boost}" for field, boost in self.es_boost.items()]
es_query = {
"query": {
"bool": {
"should": [
{
"multi_match": {
"query": query_str,
"fields": fields,
"type": "best_fields",
"operator": "or"
}
}
]
}
}
}
logging.info(f"[ESQueryBuilder] 생성된 ES 쿼리: {json.dumps(es_query, ensure_ascii=False)}")
return es_query
# ---------------------------------------------------------------------------------
# 8. QueryUnderstandingPipeline: 전체 파이프라인 통합 (전처리 → LLM 확장 → 도메인 매핑 → 속성 Enrich → ES 쿼리 생성)
# ---------------------------------------------------------------------------------
class QueryUnderstandingPipeline:
def __init__(self, config, segment=None, season=None, llm_model_name="google/flan-t5-base"):
self.config = config
self.segment = segment
self.season = season
self.preprocessor = Preprocessor(config["domain_dict"])
self.llm_expander = AdvancedLLMQueryExpander(model_name=llm_model_name)
self.domain_mapper = DomainMapper(config)
self.attribute_enricher = AttributeEnricher(config["domain_dict"])
self.es_query_builder = ESQueryBuilder(config["es_boost"])
self.es_client = Elasticsearch("http://localhost:9200")
def process_query(self, query):
# 1. 전처리: 오타 보정 및 동의어 확장
preprocessed_query = self.preprocessor.preprocess(query)
# 2. LLM 기반 확장: 다양한 대체 검색어 생성
llm_keywords = self.llm_expander.generate_expansions(preprocessed_query)
# 3. 도메인 매핑: 브랜드, 카테고리, 계절, 세그먼트 정보 반영
domain_keywords = self.domain_mapper.map_domain(preprocessed_query, self.segment, self.season)
# 4. Hidden Attribute Enrichment: 사용자의 숨겨진 발화 추출 및 결합
enriched_attributes = self.attribute_enricher.enrich(preprocessed_query)
# 5. 모든 확장어 통합 및 중복 제거
all_keywords = list(set(llm_keywords + domain_keywords + enriched_attributes))
logging.info(f"[Pipeline] 최종 통합 확장어: {all_keywords}")
return all_keywords
def search(self, query, index="fashion_products"):
keywords = self.process_query(query)
es_query = self.es_query_builder.build_query(keywords)
results = self.es_client.search(index=index, body=es_query)
return results
# ---------------------------------------------------------------------------------
# 9. FeedbackLogger: 사용자 클릭 피드백 로깅 (실제 환경에서는 DB 또는 로그 시스템에 저장)
# ---------------------------------------------------------------------------------
class FeedbackLogger:
def __init__(self):
self.feedback_log = [] # (query, clicked_doc, es_query, timestamp, weight 등)
def log_click(self, query, clicked_doc, es_query):
log_entry = {
"query": query,
"clicked_doc": clicked_doc,
"es_query": es_query,
"timestamp": datetime.now().isoformat(),
"weight": np.random.rand() # 예: 클릭 가중치 (실제 피드백 점수)
}
self.feedback_log.append(log_entry)
logging.info(f"[FeedbackLogger] 피드백 로그: {log_entry}")
def get_feedback(self):
return self.feedback_log
# ---------------------------------------------------------------------------------
# 10. TrainingUpdater: 피드백 로그 기반 ES boost 파라미터 업데이트 (간단한 학습 예제)
# ---------------------------------------------------------------------------------
class TrainingUpdater:
def __init__(self, config):
self.config = config
def update_boosts(self, feedback_logs):
updated_boost = self.config["es_boost"].copy()
learning_rate = self.config["learning_rate"]
for field in updated_boost:
field_weights = []
for log in feedback_logs:
es_query_str = json.dumps(log["es_query"])
if field in es_query_str:
field_weights.append(log["weight"])
if field_weights:
avg_weight = np.mean(field_weights)
# 단순 업데이트: 기존 boost 값에서 avg_weight와의 차이를 보정
updated_boost[field] += learning_rate * (avg_weight - updated_boost[field])
logging.info(f"[TrainingUpdater] 업데이트된 ES boost: {updated_boost}")
return updated_boost
# ---------------------------------------------------------------------------------
# 11. Main: 전체 파이프라인 실행 및 피드백 기반 학습 시뮬레이션
# ---------------------------------------------------------------------------------
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
# 사용자 세그먼트 및 현재 계절 설정 (e.g., 젊은층 / 여름)
user_segment = "young_adult"
current_season = "여름"
# 파이프라인 및 피드백, 학습 업데이트 모듈 초기화
pipeline = QueryUnderstandingPipeline(CONFIG, segment=user_segment, season=current_season)
feedback_logger = FeedbackLogger()
trainer = TrainingUpdater(CONFIG)
# training dataset(시뮬레이션 예제): 사용자 쿼리와 클릭한 제품 정보
training_data = [
{"query": "adids running shoes", "relevant_product": "Adidas Running Shoes - 편안한 러닝화"},
{"query": "여름 운동화", "relevant_product": "쿨링 Adidas 운동화 - 시원한 통기성"},
{"query": "nike 티셔츠", "relevant_product": "Nike 티셔츠 컬렉션 - 트렌디한 디자인"}
]
# 시뮬레이션: 각 쿼리에 대해 검색을 실행하고 피드백 로그 기록
for data in training_data:
query = data["query"]
results = pipeline.search(query)
# ES 쿼리 재생성을 위해 process_query 호출
es_query_used = pipeline.es_query_builder.build_query(pipeline.process_query(query))
if results["hits"]["hits"]:
# 결과 중 첫 번째 문서를 클릭했다고 가정
clicked_doc = results["hits"]["hits"][0]["_source"]
feedback_logger.log_click(query, clicked_doc, es_query_used)
else:
logging.info(f"[Main] '{query}'에 대해 검색 결과가 없습니다.")
# 피드백 로그를 이용하여 ES boost 파라미터 업데이트
feedback_logs = feedback_logger.get_feedback()
updated_boost = trainer.update_boosts(feedback_logs)
# 업데이트된 boost를 반영하여 파이프라인의 ESQueryBuilder 업데이트
pipeline.es_query_builder.es_boost = updated_boost
# 업데이트 후 테스트 쿼리 실행 (e.g., "nike running shoes")
test_query = "nike running shoes"
test_results = pipeline.search(test_query)
logging.info(f"[Main] 최종 검색 결과 (Test Query: '{test_query}'):")
for hit in test_results["hits"]["hits"]:
logging.info(json.dumps(hit["_source"], ensure_ascii=False))
Configuration
– CONFIG 딕셔너리에 패션 도메인 사전(브랜드, 카테고리, 계절, 동의어, 숨겨진 속성)과 사용자 세그먼트(young_adult, middle_aged) 및 초기 ES boost 값과 학습률을 정의합니다.
Preprocessor
– 입력 쿼리에 대해 오타 보정(e.g., “adids” → “Adidas”)과 동의어 확장을 적용하여 기본 쿼리를 정제합니다.
HiddenIntentExtractor
– 사용자가 명시적으로 입력하지 않은 숨은 속성(e.g., “편안한”, “세련된”)을 정규식 기반으로 추출합니다.
AdvancedLLMQueryExpander
– LLM(Flan-T5 등)을 사용하여 사용자의 전처리된 쿼리로부터 Chain-of-Thought 방식 및 few-shot prompt 기법을 활용해 대체 검색어(확장어)를 생성합니다.
DomainMapper
– 브랜드, 카테고리, 계절, 사용자 세그먼트 정보를 기반으로 도메인 확장어를 추가합니다.
AttributeEnricher
– HiddenIntentExtractor를 통해 추출된 숨은 속성을 활용하여 쿼리에 추가적인 확장어를 결합합니다.
ESQueryBuilder
– 통합 확장어와 현재 ES boost 파라미터를 바탕으로 Elasticsearch 쿼리 템플릿(다중 필드, boost 적용)을 생성합니다.
QueryUnderstandingPipeline
– 전체 모듈(전처리, LLM 확장, 도메인 매핑, 속성 Enrich)을 순차적으로 호출해 최종 검색 쿼리를 생성하고, ES에 요청하는 통합 파이프라인을 구성합니다.
FeedbackLogger 및 TrainingUpdater
– 사용자 클릭 피드백을 로그로 기록하고, 이를 바탕으로 간단한 규칙 기반 업데이트를 통해 ES boost 파라미터를 학습합니다.
Main
– 시뮬레이션 데이터를 통해 전체 파이프라인을 실행하고, 피드백 로그를 수집한 후 업데이트된 boost 파라미터를 반영하여 테스트 쿼리의 결과를 출력합니다.
이와 같이 고도화된 모듈화 아키텍처와 학습 코드를 통해, 사용자의 숨겨진 발화와 다양한 속성을 robust하게 처리하며 패션 도메인에 특화된 정밀 검색 시스템을 구축할 수 있습니다.
아래는 각 클래스별로 분리된 패션 이커머스 도메인 쿼리 확장 시스템 구현입니다.
class FashionCategory:
"""패션 카테고리 계층 구조 클래스"""
def __init__(self, data_path="data/fashion_categories.json"):
"""
패션 카테고리 데이터 로드
Parameters:
-----------
data_path : str
카테고리 JSON 파일 경로
"""
if os.path.exists(data_path):
with open(data_path, 'r', encoding='utf-8') as f:
self.categories = json.load(f)
else:
# 샘플 데이터
self.categories = {
"상의": {
"티셔츠": ["반팔티", "긴팔티", "민소매티", "크롭티"],
"셔츠": ["옥스포드", "린넨셔츠", "데님셔츠", "체크셔츠"],
"니트웨어": ["스웨터", "가디건", "풀오버", "니트"],
"후드": ["후드티", "후드집업", "맨투맨", "스웻셔츠"]
},
"하의": {
"청바지": ["슬림진", "와이드진", "스트레이트진", "스키니진"],
"슬랙스": ["치노팬츠", "슬랙스", "정장바지", "와이드팬츠"],
"반바지": ["데님쇼츠", "트레이닝쇼츠", "버뮤다팬츠", "카고쇼츠"],
"스커트": ["미니스커트", "미디스커트", "롱스커트", "플리츠스커트"]
},
"아우터": {
"자켓": ["데님자켓", "가죽자켓", "봄버자켓", "테일러드자켓"],
"코트": ["트렌치코트", "피코트", "더플코트", "롱코트"],
"패딩": ["숏패딩", "롱패딩", "경량패딩", "다운자켓"],
"가디건": ["오버사이즈가디건", "집업가디건", "롱가디건", "크롭가디건"]
},
"신발": {
"운동화": ["스니커즈", "캔버스화", "러닝화", "테니스화"],
"구두": ["옥스포드", "로퍼", "펌프스", "윙팁"],
"부츠": ["앵클부츠", "첼시부츠", "워커", "레인부츠"],
"샌들": ["플랫샌들", "스트랩샌들", "슬리퍼", "에스파드리유"]
},
"가방": {
"백팩": ["데이백", "student백팩", "여행백팩", "롤탑백팩"],
"크로스백": ["미니크로스백", "메신저백", "새들백", "바디백"],
"토트백": ["캔버스토트", "레더토트", "쇼퍼백", "스트럭처드토트"],
"클러치": ["이브닝클러치", "파우치", "월렛클러치", "엔벨롭클러치"]
}
}
# 디렉토리 생성 및 저장
os.makedirs(os.path.dirname(data_path), exist_ok=True)
with open(data_path, 'w', encoding='utf-8') as f:
json.dump(self.categories, f, ensure_ascii=False, indent=4)
# 역방향 매핑 (서브카테고리/아이템 -> 상위 카테고리)
self.reverse_mapping = {}
for main_cat, sub_cats in self.categories.items():
for sub_cat, items in sub_cats.items():
self.reverse_mapping[sub_cat.lower()] = main_cat
for item in items:
self.reverse_mapping[item.lower()] = sub_cat
def get_parent_category(self, term):
"""용어의 상위 카테고리 찾기"""
return self.reverse_mapping.get(term.lower())
def get_related_terms(self, term, max_terms=5):
"""용어와 관련된 다른 용어들 찾기"""
term_lower = term.lower()
# 직접 매칭되는 경우
if term_lower in self.reverse_mapping:
parent = self.reverse_mapping[term_lower]
# 서브카테고리인 경우
for main_cat, sub_cats in self.categories.items():
if parent == main_cat:
# 같은 메인 카테고리의 다른 서브카테고리 반환
return list(sub_cats.keys())[:max_terms]
for sub_cat, items in sub_cats.items():
if parent == sub_cat:
# 같은 서브카테고리의 다른 아이템 반환
return items[:max_terms]
# 부분 매칭 시도
related = []
for cat in self.reverse_mapping:
if term_lower in cat or cat in term_lower:
related.append(cat)
if len(related) >= max_terms:
break
return related
class SeasonTrendManager:
"""계절 및 트렌드 관리자"""
def __init__(self, data_path="data/season_trends.json"):
"""
계절 및 트렌드 데이터 로드
Parameters:
-----------
data_path : str
계절/트렌드 JSON 파일 경로
"""
if os.path.exists(data_path):
with open(data_path, 'r', encoding='utf-8') as f:
self.data = json.load(f)
else:
# 샘플 데이터
self.data = {
"seasons": {
"spring": {
"months": [3, 4, 5],
"keywords": ["봄", "가디건", "트렌치코트", "블라우스", "가벼운", "산뜻한", "파스텔"],
"colors": ["파스텔", "라이트블루", "라벤더", "민트", "코랄"],
"fabrics": ["면", "린넨", "데님", "가벼운니트"]
},
"summer": {
"months": [6, 7, 8],
"keywords": ["여름", "반팔", "민소매", "티셔츠", "반바지", "시원한", "얇은", "여름휴가"],
"colors": ["화이트", "네이비", "하늘색", "네온", "비비드"],
"fabrics": ["면", "린넨", "시어서커", "매쉬"]
},
"fall": {
"months": [9, 10, 11],
"keywords": ["가을", "니트", "트렌치코트", "자켓", "카디건", "따뜻한", "레이어드"],
"colors": ["브라운", "버건디", "카키", "머스타드", "다크그린"],
"fabrics": ["울", "코듀로이", "니트", "스웨이드"]
},
"winter": {
"months": [12, 1, 2],
"keywords": ["겨울", "패딩", "코트", "니트", "목도리", "장갑", "따뜻한", "두꺼운"],
"colors": ["블랙", "그레이", "화이트", "다크네이비", "버건디"],
"fabrics": ["울", "캐시미어", "벨벳", "퍼"]
}
},
"trends": {
"2023": {
"keywords": ["Y2K", "오버사이즈", "가죽", "빈티지", "하이웨이스트", "크롭탑"],
"colors": ["버터크림", "라벤더", "에메랄드", "테라코타"],
"patterns": ["체크", "타이다이", "플로럴", "애니멀프린트"]
},
"2024": {
"keywords": ["서스테이너블", "젠더리스", "미니멀", "복고풍", "테크웨어"],
"colors": ["딥블루", "세이지그린", "체리레드", "코코아브라운"],
"patterns": ["지오메트릭", "컬러블로킹", "추상적", "베이직"]
}
},
"last_updated": datetime.datetime.now().isoformat()
}
# 디렉토리 생성 및 저장
os.makedirs(os.path.dirname(data_path), exist_ok=True)
with open(data_path, 'w', encoding='utf-8') as f:
json.dump(self.data, f, ensure_ascii=False, indent=4)
def get_current_season(self):
"""현재 계절 반환"""
month = datetime.datetime.now().month
for season, data in self.data["seasons"].items():
if month in data["months"]:
return season
return "fall" # 기본값
def get_season_keywords(self, season=None):
"""특정 계절 키워드 반환"""
if season is None:
season = self.get_current_season()
season_data = self.data["seasons"].get(season, {})
keywords = season_data.get("keywords", [])
colors = season_data.get("colors", [])
fabrics = season_data.get("fabrics", [])
return keywords + colors + fabrics
def get_current_year_trends(self):
"""현재 연도 트렌드 반환"""
year = str(datetime.datetime.now().year)
if year in self.data["trends"]:
trend_data = self.data["trends"][year]
keywords = trend_data.get("keywords", [])
colors = trend_data.get("colors", [])
patterns = trend_data.get("patterns", [])
return keywords + colors + patterns
# 가장 최신 트렌드 반환
years = sorted(self.data["trends"].keys())
if years:
latest_year = years[-1]
trend_data = self.data["trends"][latest_year]
keywords = trend_data.get("keywords", [])
colors = trend_data.get("colors", [])
patterns = trend_data.get("patterns", [])
return keywords + colors + patterns
return []
def update_trend(self, year, trend_data):
"""트렌드 데이터 업데이트"""
self.data["trends"][str(year)] = trend_data
self.data["last_updated"] = datetime.datetime.now().isoformat()
class FashionSynonymsManager:
"""패션 동의어 관리자"""
def __init__(self, data_path="data/fashion_synonyms.json"):
"""
패션 동의어 데이터 로드
Parameters:
-----------
data_path : str
동의어 JSON 파일 경로
"""
self.data_path = data_path
if os.path.exists(data_path):
with open(data_path, 'r', encoding='utf-8') as f:
self.synonyms = json.load(f)
else:
# 샘플 데이터
self.synonyms = {
"티셔츠": ["티", "티셔츠", "T셔츠", "Tee", "T-shirt"],
"맨투맨": ["맨투맨", "스웻셔츠", "스웨트셔츠", "mtm"],
"청바지": ["청바지", "진", "데님", "진즈", "jeans"],
"스니커즈": ["스니커즈", "운동화", "스니커", "스닉커즈"],
"자켓": ["자켓", "재킷", "점퍼", "jacket"],
"블라우스": ["블라우스", "여성셔츠", "블라우져", "blouse"],
"가디건": ["가디건", "카디건", "cardigan"],
"스커트": ["스커트", "치마", "skirt"],
"원피스": ["원피스", "드레스", "dress", "one-piece"],
"패딩": ["패딩", "다운재킷", "패딩재킷", "padding"],
"플랫슈즈": ["플랫슈즈", "플랫", "플랫슈", "flat shoes", "flats"],
"슬랙스": ["슬랙스", "정장바지", "면바지", "슬렉스"],
"니트": ["니트", "스웨터", "knit", "sweater"],
"코트": ["코트", "외투", "coat"],
"후드티": ["후드티", "후드", "후디", "hoody", "hoodie"]
}
# 디렉토리 생성 및 저장
os.makedirs(os.path.dirname(data_path), exist_ok=True)
with open(data_path, 'w', encoding='utf-8') as f:
json.dump(self.synonyms, f, ensure_ascii=False, indent=4)
# 역방향 매핑 구성
self.reverse_mapping = {}
for standard, synonyms in self.synonyms.items():
for synonym in synonyms:
self.reverse_mapping[synonym.lower()] = standard
def get_standard_term(self, term):
"""입력 용어의 표준 용어 반환"""
return self.reverse_mapping.get(term.lower(), term)
def get_synonyms(self, term):
"""표준 용어의 동의어 리스트 반환"""
# 표준 용어로 변환
standard_term = self.get_standard_term(term)
# 동의어 반환
return self.synonyms.get(standard_term, [term])
def add_synonym(self, standard_term, synonym):
"""동의어 추가"""
if standard_term in self.synonyms:
if synonym not in self.synonyms[standard_term]:
self.synonyms[standard_term].append(synonym)
self.reverse_mapping[synonym.lower()] = standard_term
else:
self.synonyms[standard_term] = [standard_term, synonym]
self.reverse_mapping[standard_term.lower()] = standard_term
self.reverse_mapping[synonym.lower()] = standard_term
# 변경사항 저장
with open(self.data_path, 'w', encoding='utf-8') as f:
json.dump(self.synonyms, f, ensure_ascii=False, indent=4)
class TypoCorrector:
"""오타 교정기"""
def __init__(self, data_path="data/fashion_typos.json", dictionary=None):
"""
오타 교정기 초기화
Parameters:
-----------
data_path : str
오타 교정 데이터 파일 경로
dictionary : dict
기존 표준 용어 사전
"""
self.data_path = data_path
self.dictionary = dictionary or {}
if os.path.exists(data_path):
with open(data_path, 'r', encoding='utf-8') as f:
self.typo_corrections = json.load(f)
else:
# 샘플 데이터
self.typo_corrections = {
"티셔트": "티셔츠",
"티셔슽": "티셔츠",
"티셔숳": "티셔츠",
"청파지": "청바지",
"쳥바지": "청바지",
"싀커트": "스커트",
"스컷트": "스커트",
"원피슷": "원피스",
"원피수": "원피스",
"자켓트": "자켓",
"자캣": "자켓",
"카디칸": "가디건",
"가디칸": "가디건",
"슬랙슷": "슬랙스",
"슬랙수": "슬랙스",
"패팅": "패딩",
"패듕": "패딩",
"후드틔": "후드티",
"후드티셔츠": "후드티"
}
# 디렉토리 생성 및 저장
os.makedirs(os.path.dirname(data_path), exist_ok=True)
with open(data_path, 'w', encoding='utf-8') as f:
json.dump(self.typo_corrections, f, ensure_ascii=False, indent=4)
# 표준 용어 사전 통합
self.valid_terms = set(self.typo_corrections.values())
if dictionary:
for standard, synonyms in dictionary.items():
self.valid_terms.add(standard)
self.valid_terms.update(synonyms)
def _levenshtein_distance(self, s1, s2):
"""편집 거리 계산"""
if len(s1) < len(s2):
return self._levenshtein_distance(s2, s1)
if len(s2) == 0:
return len(s1)
previous_row = range(len(s2) + 1)
for i, c1 in enumerate(s1):
current_row = [i + 1]
for j, c2 in enumerate(s2):
insertions = previous_row[j + 1] + 1
deletions = current_row[j] + 1
substitutions = previous_row[j] + (c1 != c2)
current_row.append(min(insertions, deletions, substitutions))
previous_row = current_row
return previous_row[-1]
def correct(self, term):
"""용어 오타 교정"""
# 직접 매핑된 오타인 경우
if term.lower() in self.typo_corrections:
return self.typo_corrections[term.lower()]
# 이미 유효한 용어인 경우
if term.lower() in self.valid_terms:
return term
# 편집 거리를 이용한 가장 가까운 용어 찾기
min_distance = float('inf')
best_match = term
for valid_term in self.valid_terms:
distance = self._levenshtein_distance(term.lower(), valid_term.lower())
if distance < min_distance and distance <= max(1, len(term) // 3): # 거리가 단어 길이의 1/3 이하인 경우만
min_distance = distance
best_match = valid_term
# 오타 매핑 업데이트
if best_match != term:
self.typo_corrections[term.lower()] = best_match
self._save_corrections()
return best_match
return term
def add_correction(self, typo, correction):
"""오타 교정 매핑 추가"""
self.typo_corrections[typo.lower()] = correction
self.valid_terms.add(correction)
self._save_corrections()
def _save_corrections(self):
"""오타 교정 데이터 저장"""
with open(self.data_path, 'w', encoding='utf-8') as f:
json.dump(self.typo_corrections, f, ensure_ascii=False, indent=4)
class UserSegmentManager:
"""사용자 세그먼트 관리자"""
def __init__(self, data_path="data/user_segments.json"):
"""
사용자 세그먼트 데이터 로드
Parameters:
-----------
data_path : str
사용자 세그먼트 JSON 파일 경로
"""
self.data_path = data_path
if os.path.exists(data_path):
with open(data_path, 'r', encoding='utf-8') as f:
self.segments = json.load(f)
else:
# 샘플 데이터
self.segments = {
"demographics": {
"gender": {
"male": {
"categories": ["남성복", "남성신발", "남성가방", "남성액세서리"],
"keywords": ["남성", "남자", "남성용", "맨즈"]
},
"female": {
"categories": ["여성복", "여성신발", "여성가방", "여성액세서리"],
"keywords": ["여성", "여자", "여성용", "우먼즈"]
},
"unisex": {
"categories": ["유니섹스", "공용"],
"keywords": ["공용", "유니섹스", "남녀공용"]
}
},
"age": {
"teens": {
"categories": ["청소년의류", "student복"],
"keywords": ["청소년", "틴에이저", "student", "주니어"]
},
"20s": {
"categories": ["20대의류", "영어덜트"],
"keywords": ["20대", "대student", "신입사원", "영어덜트"]
},
"30s": {
"categories": ["30대의류", "어덜트"],
"keywords": ["30대", "직장인", "사회초년생", "어덜트"]
},
"40s_plus": {
"categories": ["중장년의류", "시니어"],
"keywords": ["40대", "50대", "중년", "시니어"]
}
}
},
"style_preferences": {
"casual": {
"categories": ["캐주얼", "데일리룩", "스트릿"],
"keywords": ["캐주얼", "데일리", "편안한", "스트릿", "일상"]
},
"formal": {
"categories": ["포멀", "정장", "비즈니스", "오피스룩"],
"keywords": ["정장", "포멀", "비즈니스", "오피스", "격식있는"]
},
"vintage": {
"categories": ["빈티지", "레트로", "올드스쿨"],
"keywords": ["빈티지", "레트로", "올드스쿨", "클래식", "중고감성"]
},
"minimalist": {
"categories": ["미니멀", "베이직", "심플"],
"keywords": ["미니멀", "심플", "베이직", "기본", "절제된"]
},
"athleisure": {
"categories": ["애슬레저", "스포티", "액티브"],
"keywords": ["스포티", "애슬레저", "운동", "트레이닝", "액티브"]
}
},
"purchase_behavior": {
"price_sensitive": {
"categories": ["세일", "할인", "가성비"],
"keywords": ["세일", "할인", "특가", "가성비", "저렴한", "바겐"]
},
"premium": {
"categories": ["프리미엄", "명품", "디자이너"],
"keywords": ["명품", "프리미엄", "고급", "디자이너", "럭셔리"]
},
"trend_follower": {
"categories": ["트렌디", "신상", "인기상품"],
"keywords": ["트렌드", "유행", "인기", "핫", "신상"]
},
"eco_conscious": {
"categories": ["친환경", "지속가능", "업사이클"],
"keywords": ["친환경", "지속가능", "업사이클", "오가닉", "에코"]
}
}
}
# 디렉토리 생성 및 저장
os.makedirs(os.path.dirname(data_path), exist_ok=True)
with open(data_path, 'w', encoding='utf-8') as f:
json.dump(self.segments, f, ensure_ascii=False, indent=4)
def get_segment_keywords(self, segment_path):
"""특정 세그먼트의 키워드 반환"""
segments = self.segments
path_parts = segment_path.split(".")
for part in path_parts:
if part in segments:
segments = segments[part]
else:
return []
if isinstance(segments, dict) and "keywords" in segments:
return segments["keywords"]
return []
def get_user_segment_keywords(self, user_profile):
"""사용자 프로필 기반 세그먼트 키워드 반환"""
keywords = []
# 성별 키워드
if "gender" in user_profile:
gender = user_profile["gender"].lower()
if gender in ["male", "female", "unisex"]:
keywords.extend(self.get_segment_keywords(f"demographics.gender.{gender}"))
# 연령대 키워드
if "age" in user_profile:
age = user_profile["age"]
if age < 20:
keywords.extend(self.get_segment_keywords("demographics.age.teens"))
elif 20 <= age < 30:
keywords.extend(self.get_segment_keywords("demographics.age.20s"))
elif 30 <= age < 40:
keywords.extend(self.get_segment_keywords("demographics.age.30s"))
else:
keywords.extend(self.get_segment_keywords("demographics.age.40s_plus"))
# 스타일 선호도 키워드
if "style_preferences" in user_profile:
for style in user_profile["style_preferences"]:
if style in self.segments["style_preferences"]:
keywords.extend(self.get_segment_keywords(f"style_preferences.{style}"))
# 구매 행동 키워드
if "purchase_behavior" in user_profile:
for behavior in user_profile["purchase_behavior"]:
if behavior in self.segments["purchase_behavior"]:
keywords.extend(self.get_segment_keywords(f"purchase_behavior.{behavior}"))
return list(set(keywords)) # 중복 제거하여 반환
def update_segment(self, segment_path, data):
"""세그먼트 데이터 업데이트"""
segments = self.segments
path_parts = segment_path.split(".")
# 마지막 부분을 제외한 경로로 이동
current = segments
for i, part in enumerate(path_parts[:-1]):
if part not in current:
current[part] = {}
current = current[part]
# 마지막 부분 업데이트
current[path_parts[-1]] = data
# 변경사항 저장
with open(self.data_path, 'w', encoding='utf-8') as f:
json.dump(self.segments, f, ensure_ascii=False, indent=4)
class UserProfileStore:
"""사용자 프로필 저장소"""
def __init__(self, data_path="data/user_profiles.json"):
"""
사용자 프로필 데이터 로드
Parameters:
-----------
data_path : str
사용자 프로필 JSON 파일 경로
"""
self.data_path = data_path
if os.path.exists(data_path):
with open(data_path, 'r', encoding='utf-8') as f:
self.profiles = json.load(f)
else:
# 샘플 데이터
self.profiles = {
"user1": {
"gender": "female",
"age": 28,
"style_preferences": ["casual", "minimalist"],
"purchase_behavior": ["price_sensitive"],
"recent_searches": ["여름 원피스", "스트라이프 셔츠", "린넨 팬츠"],
"recent_purchases": ["플로럴 미니 원피스", "화이트 티셔츠", "데님 쇼츠"]
},
"user2": {
"gender": "male",
"age": 35,
"style_preferences": ["formal", "minimalist"],
"purchase_behavior": ["premium"],
"recent_searches": ["남성 정장", "가죽 구두", "코튼 셔츠"],
"recent_purchases": ["네이비 수트", "옥스포드 셔츠", "브라운 로퍼"]
},
"user3": {
"gender": "female",
"age": 22,
"style_preferences": ["vintage", "casual"],
"purchase_behavior": ["trend_follower"],
"recent_searches": ["오버사이즈 후드티", "Y2K 패션", "카고팬츠"],
"recent_purchases": ["크롭 티셔츠", "와이드 데님", "버킷햇"]
}
}
# 디렉토리 생성 및 저장
os.makedirs(os.path.dirname(data_path), exist_ok=True)
with open(data_path, 'w', encoding='utf-8') as f:
json.dump(self.profiles, f, ensure_ascii=False, indent=4)
def get_profile(self, user_id):
"""사용자 프로필 반환"""
return self.profiles.get(user_id, {})
def update_profile(self, user_id, profile_data):
"""사용자 프로필 업데이트"""
if user_id in self.profiles:
self.profiles[user_id].update(profile_data)
else:
self.profiles[user_id] = profile_data
# 변경사항 저장
with open(self.data_path, 'w', encoding='utf-8') as f:
json.dump(self.profiles, f, ensure_ascii=False, indent=4)
def add_search_query(self, user_id, query):
"""사용자 검색 기록 추가"""
if user_id not in self.profiles:
self.profiles[user_id] = {"recent_searches": []}
if "recent_searches" not in self.profiles[user_id]:
self.profiles[user_id]["recent_searches"] = []
# 중복 검색어 제거
if query in self.profiles[user_id]["recent_searches"]:
self.profiles[user_id]["recent_searches"].remove(query)
# 최근 검색어 추가 (최대 10개 유지)
self.profiles[user_id]["recent_searches"].insert(0, query)
self.profiles[user_id]["recent_searches"] = self.profiles[user_id]["recent_searches"][:10]
# 변경사항 저장
with open(self.data_path, 'w', encoding='utf-8') as f:
json.dump(self.profiles, f, ensure_ascii=False, indent=4)
def add_purchase(self, user_id, product_name):
"""사용자 구매 기록 추가"""
if user_id not in self.profiles:
self.profiles[user_id] = {"recent_purchases": []}
if "recent_purchases" not in self.profiles[user_id]:
self.profiles[user_id]["recent_purchases"] = []
# 중복 구매 제거
if product_name in self.profiles[user_id]["recent_purchases"]:
self.profiles[user_id]["recent_purchases"].remove(product_name)
# 최근 구매 추가 (최대 10개 유지)
self.profiles[user_id]["recent_purchases"].insert(0, product_name)
self.profiles[user_id]["recent_purchases"] = self.profiles[user_id]["recent_purchases"][:10]
# 변경사항 저장
with open(self.data_path, 'w', encoding='utf-8') as f:
json.dump(self.profiles, f, ensure_ascii=False, indent=4)
class QueryExpansionCache:
"""쿼리 확장 캐시"""
def __init__(self, cache_dir="cache", ttl=86400): # 기본 TTL: 1일
"""
캐시 초기화
Parameters:
-----------
cache_dir : str
캐시 디렉토리 경로
ttl : int
캐시 항목 유효 시간 (초)
"""
self.cache_dir = cache_dir
self.ttl = ttl
# 캐시 디렉토리 생성
os.makedirs(cache_dir, exist_ok=True)
def _get_cache_key(self, query, user_id=None, method=None):
"""캐시 키 생성"""
key_parts = [query.lower()]
if user_id:
key_parts.append(str(user_id))
if method:
key_parts.append(str(method))
key_str = "_".join(key_parts)
return hashlib.md5(key_str.encode('utf-8')).hexdigest()
def _get_cache_path(self, cache_key):
"""캐시 파일 경로 생성"""
return os.path.join(self.cache_dir, f"{cache_key}.pkl")
def get(self, query, user_id=None, method=None):
"""캐시에서 확장 쿼리 가져오기"""
cache_key = self._get_cache_key(query, user_id, method)
cache_path = self._get_cache_path(cache_key)
if os.path.exists(cache_path):
file_age = time.time() - os.path.getmtime(cache_path)
if file_age <= self.ttl:
try:
with open(cache_path, 'rb') as f:
return pickle.load(f)
except Exception as e:
logger.error(f"캐시 로딩 오류: {e}")
else:
# 유효기간 지난 캐시 삭제
try:
os.remove(cache_path)
except:
pass
return None
def set(self, query, expansion_result, user_id=None, method=None):
"""확장 결과 캐시에 저장"""
cache_key = self._get_cache_key(query, user_id, method)
cache_path = self._get_cache_path(cache_key)
try:
with open(cache_path, 'wb') as f:
pickle.dump(expansion_result, f)
return True
except Exception as e:
logger.error(f"캐시 저장 오류: {e}")
return False
def clear_expired(self):
"""만료된 캐시 항목 정리"""
now = time.time()
cleared_count = 0
for filename in os.listdir(self.cache_dir):
if filename.endswith('.pkl'):
file_path = os.path.join(self.cache_dir, filename)
file_age = now - os.path.getmtime(file_path)
if file_age > self.ttl:
try:
os.remove(file_path)
cleared_count += 1
except:
pass
return cleared_count
class BERTQueryExpander:
"""BERT 기반 의미적 쿼리 확장"""
def __init__(self, model_name="klue/bert-base", device=None):
"""
BERT 쿼리 확장기 초기화
Parameters:
-----------
model_name : str
사용할 BERT 모델명
device : str
실행 디바이스 ('cuda' 또는 'cpu')
"""
self.device = device or ('cuda' if torch.cuda.is_available() else 'cpu')
logger.info(f"BERT 모델 로딩: {model_name} (device: {self.device})")
try:
self.tokenizer = AutoTokenizer.from_pretrained(model_name)
self.model = AutoModelForMaskedLM.from_pretrained(model_name).to(self.device)
self.model.eval()
except Exception as e:
logger.error(f"BERT 모델 로딩 실패: {e}")
self.tokenizer = None
self.model = None
def _is_model_loaded(self):
"""모델 로딩 확인"""
return self.tokenizer is not None and self.model is not None
def expand_with_mask(self, query, num_terms=5):
"""마스크 토큰 예측으로 쿼리 확장"""
if not self._is_model_loaded():
logger.warning("BERT 모델이 로딩되지 않았습니다.")
return []
# 쿼리 토큰화
query_tokens = query.split()
# 마스크 토큰을 쿼리의 시작과 끝에 추가하여 예측
masked_queries = [
f"{self.tokenizer.mask_token} {query}", # 앞에 마스크
f"{query} {self.tokenizer.mask_token}" # 뒤에 마스크
]
expansion_terms = []
for masked_query in masked_queries:
inputs = self.tokenizer(masked_query, return_tensors="pt").to(self.device)
# 마스크 토큰 위치 찾기
mask_token_index = torch.where(inputs["input_ids"] == self.tokenizer.mask_token_id)[1]
with torch.no_grad():
outputs = self.model(**inputs)
# 마스크 위치의 예측 가져오기
logits = outputs.logits
mask_token_logits = logits[0, mask_token_index, :]
# 상위 예측 선택
top_tokens = torch.topk(mask_token_logits, num_terms, dim=1).indices[0].tolist()
# 토큰 디코딩 및 필터링
for token in top_tokens:
term = self.tokenizer.decode([token]).strip()
# 한 글자 이상이고 쿼리에 없는 용어만 추가
if len(term) > 1 and term not in query_tokens:
expansion_terms.append(term)
# 중복 제거
return list(set(expansion_terms))
def expand_query(self, query, num_terms=5):
"""BERT 기반 쿼리 확장"""
expansion_terms = self.expand_with_mask(query, num_terms=num_terms)
return expansion_terms
class FashionQueryExpander:
"""패션 도메인 특화 쿼리 확장기"""
def __init__(self,
use_bert=True,
bert_model_name="klue/bert-base",
use_cache=True,
cache_ttl=86400,
config=None):
"""
패션 쿼리 확장기 초기화
Parameters:
-----------
use_bert : bool
BERT 모델 사용 여부
bert_model_name : str
사용할 BERT 모델명
use_cache : bool
캐시 사용 여부
cache_ttl : int
캐시 유효 시간 (초)
config : dict
확장 설정
"""
self.config = config or {
'spell_correction': True,
'synonym_expansion': True,
'category_expansion': True,
'season_expansion': True,
'user_segment_expansion': True,
'bert_expansion': use_bert,
'max_expansion_terms': 10,
'field_weights': { # ES 필드별 가중치 설정
'product_name': 3.0,
'brand': 2.0,
'category': 2.0,
'description': 1.0,
'tags': 1.5
}
}
# 구성 요소 초기화
self.category_manager = FashionCategory()
self.season_manager = SeasonTrendManager()
self.synonym_manager = FashionSynonymsManager()
self.typo_corrector = TypoCorrector(dictionary=self.synonym_manager.synonyms)
self.user_manager = UserSegmentManager()
self.user_profile_store = UserProfileStore()
# BERT 모델 초기화 (optional)
self.bert_expander = None
if use_bert and self.config['bert_expansion']:
self.bert_expander = BERTQueryExpander(model_name=bert_model_name)
# 캐시 초기화 (optional)
self.cache = None
if use_cache:
self.cache = QueryExpansionCache(ttl=cache_ttl)
# 메트릭
self.metrics = {
'queries_processed': 0,
'avg_expansion_time': 0,
'component_timings': {},
'cache_hits': 0,
'cache_misses': 0
}
def preprocess_query(self, query):
"""쿼리 전처리 (오타 교정)"""
if not self.config['spell_correction']:
return query
start_time = time.time()
# 쿼리 토큰화
tokens = query.split()
corrected_tokens = [self.typo_corrector.correct(token) for token in tokens]
corrected_query = " ".join(corrected_tokens)
component_time = time.time() - start_time
self.metrics['component_timings']['spell_correction'] = self.metrics['component_timings'].get('spell_correction', 0) + component_time
return corrected_query
def expand_with_synonyms(self, query_terms):
"""동의어 확장"""
if not self.config['synonym_expansion']:
return []
start_time = time.time()
synonyms = []
for term in query_terms:
term_synonyms = self.synonym_manager.get_synonyms(term)
synonyms.extend([s for s in term_synonyms if s != term])
component_time = time.time() - start_time
self.metrics['component_timings']['synonym_expansion'] = self.metrics['component_timings'].get('synonym_expansion', 0) + component_time
return list(set(synonyms))
def expand_with_categories(self, query_terms):
"""카테고리 기반 확장"""
if not self.config['category_expansion']:
return []
start_time = time.time()
category_terms = []
for term in query_terms:
related_terms = self.category_manager.get_related_terms(term)
category_terms.extend(related_terms)
component_time = time.time() - start_time
self.metrics['component_timings']['category_expansion'] = self.metrics['component_timings'].get('category_expansion', 0) + component_time
return list(set(category_terms) - set(query_terms))
def expand_with_season(self, query_terms):
"""계절 및 트렌드 기반 확장"""
if not self.config['season_expansion']:
return []
start_time = time.time()
# 현재 계절 키워드
season = self.season_manager.get_current_season()
season_keywords = self.season_manager.get_season_keywords(season)
# 현재 트렌드 키워드
trend_keywords = self.season_manager.get_current_year_trends()
# 쿼리와 관련있는 키워드만 선택 (간단한 관련성 판단)
related_keywords = []
all_keywords = season_keywords + trend_keywords
# 1. 쿼리에 계절 단어가 있는지 확인
seasons = ["봄", "여름", "가을", "겨울", "spring", "summer", "fall", "winter"]
has_season_term = any(s in " ".join(query_terms).lower() for s in seasons)
if has_season_term:
# 쿼리에 계절 단어가 있으면 관련 계절 키워드 추가
for term in query_terms:
if term.lower() in [s.lower() for s in seasons]:
# 계절 확인
if term.lower() in ["봄", "spring"]:
related_keywords.extend(self.season_manager.get_season_keywords("spring"))
elif term.lower() in ["여름", "summer"]:
related_keywords.extend(self.season_manager.get_season_keywords("summer"))
elif term.lower() in ["가을", "fall"]:
related_keywords.extend(self.season_manager.get_season_keywords("fall"))
elif term.lower() in ["겨울", "winter"]:
related_keywords.extend(self.season_manager.get_season_keywords("winter"))
else:
# 없으면 현재 계절 키워드 추가
for keyword in all_keywords:
for term in query_terms:
# 카테고리 연관성 확인
term_category = self.category_manager.get_parent_category(term)
keyword_category = self.category_manager.get_parent_category(keyword)
if term_category and keyword_category and term_category == keyword_category:
related_keywords.append(keyword)
# 현재 계절의 일반적인 키워드 몇 개 추가
related_keywords.extend(season_keywords[:3])
component_time = time.time() - start_time
self.metrics['component_timings']['season_expansion'] = self.metrics['component_timings'].get('season_expansion', 0) + component_time
return list(set(related_keywords) - set(query_terms))
def expand_with_user_segment(self, query_terms, user_id):
"""사용자 세그먼트 기반 확장"""
if not user_id or not self.config['user_segment_expansion']:
return []
start_time = time.time()
# 사용자 프로필
user_profile = self.user_profile_store.get_profile(user_id)
if not user_profile:
return []
# 세그먼트 키워드
segment_keywords = self.user_manager.get_user_segment_keywords(user_profile)
# 최근 검색어 및 구매 관련 키워드
recent_terms = []
if "recent_searches" in user_profile:
for search in user_profile["recent_searches"]:
search_terms = search.split()
for term in query_terms:
if term in search_terms:
recent_terms.extend([t for t in search_terms if t != term])
if "recent_purchases" in user_profile:
for purchase in user_profile["recent_purchases"]:
purchase_terms = purchase.split()
for term in query_terms:
if term in purchase_terms:
recent_terms.extend([t for t in purchase_terms if t != term])
# 관련 키워드 선택
related_keywords = segment_keywords + recent_terms
component_time = time.time() - start_time
self.metrics['component_timings']['user_segment_expansion'] = self.metrics['component_timings'].get('user_segment_expansion', 0) + component_time
return list(set(related_keywords) - set(query_terms))
def expand_with_bert(self, query):
"""BERT 모델 기반 의미적 확장"""
if not self.bert_expander or not self.config['bert_expansion']:
return []
start_time = time.time()
expansion_terms = self.bert_expander.expand_query(query)
component_time = time.time() - start_time
self.metrics['component_timings']['bert_expansion'] = self.metrics['component_timings'].get('bert_expansion', 0) + component_time
return expansion_terms
def expand_query(self, query, user_id=None):
"""종합 쿼리 확장"""
start_time = time.time()
# 캐시 확인
if self.cache:
cached_result = self.cache.get(query, user_id)
if cached_result:
self.metrics['cache_hits'] += 1
return cached_result
self.metrics['cache_misses'] += 1
# 1. 쿼리 전처리 (오타 교정)
corrected_query = self.preprocess_query(query)
# 2. 쿼리 토큰화
query_terms = corrected_query.split()
# 3. 각 확장 방법 적용
expansion_terms = {
'synonyms': self.expand_with_synonyms(query_terms),
'categories': self.expand_with_categories(query_terms),
'season': self.expand_with_season(query_terms),
'user_segment': self.expand_with_user_segment(query_terms, user_id) if user_id else [],
'bert': self.expand_with_bert(corrected_query)
}
# 4. 모든 확장 용어 결합
all_expansion_terms = []
for source, terms in expansion_terms.items():
all_expansion_terms.extend(terms)
# 5. 중복 제거 및 최대 개수 제한
unique_expansion_terms = list(set(all_expansion_terms) - set(query_terms))
if len(unique_expansion_terms) > self.config['max_expansion_terms']:
unique_expansion_terms = unique_expansion_terms[:self.config['max_expansion_terms']]
# 6. 결과 포맷팅
result = {
'original_query': query,
'corrected_query': corrected_query,
'expansion_terms': unique_expansion_terms,
'expansion_sources': expansion_terms,
'field_weights': self.config.get('field_weights', {})
}
# 7. 캐시에 저장
if self.cache:
self.cache.set(query, result, user_id)
# 8. 메트릭 업데이트
total_time = time.time() - start_time
self.metrics['queries_processed'] += 1
self.metrics['avg_expansion_time'] = (
(self.metrics['avg_expansion_time'] * (self.metrics['queries_processed'] - 1) + total_time)
/ self.metrics['queries_processed']
)
return result
def create_elasticsearch_query(self, expansion_result, boost_original=2.0, operator='OR'):
"""Elasticsearch 쿼리 생성"""
original_query = expansion_result['original_query']
corrected_query = expansion_result['corrected_query']
expansion_terms = expansion_result['expansion_terms']
field_weights = expansion_result['field_weights']
# 필드별 가중치 설정
fields = []
for field, weight in field_weights.items():
fields.append(f"{field}^{weight}")
# 원래 쿼리와 확장 쿼리 분리
es_query = {
"query": {
"bool": {
"should": [
# 원래/교정된 쿼리 (높은 가중치)
{
"multi_match": {
"query": corrected_query,
"fields": fields,
"type": "cross_fields",
"operator": operator,
"boost": boost_original
}
}
]
}
},
"highlight": {
"fields": {field.split('^')[0]: {} for field in fields}
}
}
# 확장 용어가 있으면 추가
if expansion_terms:
expanded_query = " ".join(expansion_terms)
es_query["query"]["bool"]["should"].append({
"multi_match": {
"query": expanded_query,
"fields": fields,
"type": "cross_fields",
"operator": operator,
"boost": 1.0
}
})
# 원래 쿼리와 교정된 쿼리가 다르면, 원래 쿼리도 낮은 가중치로 추가
if original_query != corrected_query:
es_query["query"]["bool"]["should"].append({
"multi_match": {
"query": original_query,
"fields": fields,
"type": "cross_fields",
"operator": operator,
"boost": 0.8
}
})
return es_query
"review_count": {"type": "integer"}
}
}
# 인덱스 생성
try:
self.client.indices.create(
index=index_name,
body={
"settings": settings,
"mappings": mappings
}
)
logger.info(f"인덱스 '{index_name}' 생성 완료")
return True
except Exception as e:
logger.error(f"인덱스 생성 실패: {e}")
return False
def index_product(self, index_name, product):
"""상품 데이터 인덱싱"""
if not self.is_connected():
return False
try:
response = self.client.index(
index=index_name,
id=product.get("product_id"),
body=product
)
return response['result'] in ['created', 'updated']
except Exception as e:
logger.error(f"상품 인덱싱 실패: {e}")
return False
def bulk_index_products(self, index_name, products):
"""다수 상품 일괄 인덱싱"""
if not self.is_connected():
return False
bulk_data = []
for product in products:
bulk_data.append({
"index": {
"_index": index_name,
"_id": product.get("product_id")
}
})
bulk_data.append(product)
try:
response = self.client.bulk(body=bulk_data, refresh=True)
return not response.get('errors', False)
except Exception as e:
logger.error(f"대량 인덱싱 실패: {e}")
return False
def search(self, index_name, es_query, size=10):
"""ES 쿼리로 검색"""
if not self.is_connected():
return []
try:
response = self.client.search(
index=index_name,
body=es_query,
size=size
)
# 검색 결과 포맷팅
results = []
for hit in response['hits']['hits']:
result = {
'id': hit['_id'],
'score': hit['_score'],
'source': hit['_source']
}
# 하이라이트가 있으면 추가
if 'highlight' in hit:
result['highlight'] = hit['highlight']
results.append(result)
return {
'total': response['hits']['total']['value'] if isinstance(response['hits']['total'], dict) else response['hits']['total'],
'took': response['took'],
'results': results
}
except Exception as e:
logger.error(f"검색 실패: {e}")
return {
'total': 0,
'took': 0,
'results': []
}
class QueryFeedbackStore:
"""쿼리 피드백 저장소"""
def __init__(self, data_path="data/query_feedback.json"):
"""
쿼리 피드백 데이터 로드
Parameters:
-----------
data_path : str
피드백 데이터 JSON 파일 경로
"""
self.data_path = data_path
if os.path.exists(data_path):
with open(data_path, 'r', encoding='utf-8') as f:
self.feedback_data = json.load(f)
else:
# 초기 구조
self.feedback_data = {
"term_feedback": {}, # 용어별 피드백
"query_feedback": {}, # 쿼리별 피드백
"session_feedback": {} # 세션별 피드백
}
# 디렉토리 생성 및 저장
os.makedirs(os.path.dirname(data_path), exist_ok=True)
with open(data_path, 'w', encoding='utf-8') as f:
json.dump(self.feedback_data, f, ensure_ascii=False, indent=4)
def add_feedback(self, query, expansion_terms, clicked_product_ids=None, user_id=None, session_id=None, rating=None):
"""
피드백 추가
Parameters:
-----------
query : str
원래 쿼리
expansion_terms : list
확장 용어 리스트
clicked_product_ids : list
클릭한 상품 ID 리스트
user_id : str
사용자 ID
session_id : str
세션 ID
rating : int
명시적 평점 (-1, 0, 1)
"""
# 타임스탬프
timestamp = datetime.datetime.now().isoformat()
# 기본 피드백 데이터
feedback = {
"query": query,
"expansion_terms": expansion_terms,
"clicked_product_ids": clicked_product_ids or [],
"user_id": user_id,
"session_id": session_id,
"timestamp": timestamp,
"rating": rating
}
# 쿼리별 피드백 저장
query_key = query.lower()
if query_key not in self.feedback_data["query_feedback"]:
self.feedback_data["query_feedback"][query_key] = []
self.feedback_data["query_feedback"][query_key].append(feedback)
# 용어별 피드백 저장
for term in expansion_terms:
term_key = term.lower()
if term_key not in self.feedback_data["term_feedback"]:
self.feedback_data["term_feedback"][term_key] = []
self.feedback_data["term_feedback"][term_key].append({
"query": query,
"clicked_product_ids": clicked_product_ids or [],
"timestamp": timestamp,
"rating": rating
})
# 세션별 피드백 저장
if session_id:
if session_id not in self.feedback_data["session_feedback"]:
self.feedback_data["session_feedback"][session_id] = []
self.feedback_data["session_feedback"][session_id].append(feedback)
# 변경사항 저장
with open(self.data_path, 'w', encoding='utf-8') as f:
json.dump(self.feedback_data, f, ensure_ascii=False, indent=4)
return True
def get_term_feedback(self, term):
"""특정 용어에 대한 피드백 반환"""
term_key = term.lower()
return self.feedback_data["term_feedback"].get(term_key, [])
def get_query_feedback(self, query):
"""특정 쿼리에 대한 피드백 반환"""
query_key = query.lower()
return self.feedback_data["query_feedback"].get(query_key, [])
def get_session_feedback(self, session_id):
"""특정 세션에 대한 피드백 반환"""
return self.feedback_data["session_feedback"].get(session_id, [])
def calculate_term_scores(self):
"""용어별 점수 계산"""
term_scores = {}
for term, feedbacks in self.feedback_data["term_feedback"].items():
clicks = 0
explicit_ratings = 0
for feedback in feedbacks:
# 클릭 수 집계
clicks += len(feedback["clicked_product_ids"])
# 명시적 평점 집계
if feedback["rating"] is not None:
explicit_ratings += feedback["rating"]
# 종합 점수 계산
term_scores[term] = {
"clicks": clicks,
"explicit_ratings": explicit_ratings,
"feedback_count": len(feedbacks),
"score": clicks + explicit_ratings # 간단한 합산 점수
}
return term_scores
class OnlineQueryExpansionLearner:
"""온라인 학습 쿼리 확장 시스템"""
def __init__(self, base_expander, feedback_store, learning_rate=0.1):
"""
온라인 학습 시스템 초기화
Parameters:
-----------
base_expander : FashionQueryExpander
기본 쿼리 확장기
feedback_store : QueryFeedbackStore
피드백 저장소
learning_rate : float
학습률
"""
self.base_expander = base_expander
self.feedback_store = feedback_store
self.learning_rate = learning_rate
# 용어 가중치
self.term_weights = {}
# 초기 가중치 계산
self._update_term_weights()
def _update_term_weights(self):
"""피드백 기반 용어 가중치 업데이트"""
# 용어별 점수 계산
term_scores = self.feedback_store.calculate_term_scores()
# 가중치 업데이트
for term, score_data in term_scores.items():
score = score_data["score"]
feedback_count = score_data["feedback_count"]
# 피드백이 충분한 경우에만 가중치 조정
if feedback_count >= 3:
normalized_score = max(min(score / feedback_count, 1.0), -1.0)
if term not in self.term_weights:
self.term_weights[term] = 1.0
# 지수 이동 평균으로 가중치 업데이트
self.term_weights[term] = (1 - self.learning_rate) * self.term_weights[term] + self.learning_rate * (1 + normalized_score)
# 가중치 범위 제한 (0.1 ~ 2.0)
self.term_weights[term] = max(min(self.term_weights[term], 2.0), 0.1)
def expand_query(self, query, user_id=None):
"""가중치 적용 쿼리 확장"""
# 기본 확장 실행
expansion_result = self.base_expander.expand_query(query, user_id)
# 원래 확장 용어
original_terms = expansion_result["expansion_terms"]
# 가중치 적용
weighted_terms = []
for term in original_terms:
weight = self.term_weights.get(term.lower(), 1.0)
weighted_terms.append((term, weight))
# 가중치 기준 정렬
weighted_terms.sort(key=lambda x: x[1], reverse=True)
# 상위 용어 선택
max_terms = self.base_expander.config.get('max_expansion_terms', 10)
top_terms = [term for term, _ in weighted_terms[:max_terms]]
# 결과 갱신
expansion_result["expansion_terms"] = top_terms
expansion_result["weighted_terms"] = weighted_terms
return expansion_result
def record_feedback(self, query, expansion_terms, clicked_product_ids=None, user_id=None, session_id=None, rating=None):
"""피드백 기록 및 학습"""
# 피드백 저장
self.feedback_store.add_feedback(query, expansion_terms, clicked_product_ids, user_id, session_id, rating)
# 가중치 업데이트
self._update_term_weights()
def get_term_weights(self, top_n=None):
"""용어 가중치 조회"""
sorted_weights = sorted(self.term_weights.items(), key=lambda x: x[1], reverse=True)
if top_n is not None:
return sorted_weights[:top_n]
return sorted_weights
# FastAPI 모델 정의
class QueryExpansionRequest(BaseModel):
query: str
user_id: Optional[str] = None
session_id: Optional[str] = None
use_online_learning: bool = True
class SearchRequest(BaseModel):
query: str
user_id: Optional[str] = None
session_id: Optional[str] = None
index_name: str
size: int = 10
use_online_learning: bool = True
class FeedbackRequest(BaseModel):
query: str
expansion_terms: List[str]
clicked_product_ids: Optional[List[str]] = None
user_id: Optional[str] = None
session_id: Optional[str] = None
rating: Optional[int] = None
# FastAPI 앱 초기화
app = FastAPI(
title="패션 이커머스 쿼리 확장 API",
description="패션 도메인에 특화된 쿼리 확장 및 검색 API",
version="1.0.0"
)
# 시스템 컴포넌트 초기화
@app.on_event("startup")
async def startup_event():
app.state.fashion_expander = FashionQueryExpander(
use_bert=True,
bert_model_name="klue/bert-base",
use_cache=True
)
app.state.feedback_store = QueryFeedbackStore()
app.state.online_learner = OnlineQueryExpansionLearner(
base_expander=app.state.fashion_expander,
feedback_store=app.state.feedback_store,
learning_rate=0.1
)
app.state.es_manager = ElasticsearchManager(
host='localhost',
port=9200
)
# API 엔드포인트
@app.get("/")
async def root():
return {"message": "패션 이커머스 쿼리 확장 API"}
@app.POST("/expand", response_model=dict)
async def expand_query(request: QueryExpansionRequest):
"""쿼리 확장 API"""
try:
logger.info(f"쿼리 확장 요청: {request.query} (사용자: {request.user_id}, 세션: {request.session_id})")
if request.use_online_learning:
# 온라인 학습 확장
expansion_result = app.state.online_learner.expand_query(request.query, request.user_id)
else:
# 기본 확장
expansion_result = app.state.fashion_expander.expand_query(request.query, request.user_id)
# 사용자 검색 기록 업데이트
if request.user_id:
app.state.fashion_expander.user_profile_store.add_search_query(request.user_id, request.query)
return expansion_result
except Exception as e:
logger.error(f"쿼리 확장 오류: {str(e)}", exc_info=True)
raise HTTPException(status_code=500, detail=f"쿼리 확장 오류: {str(e)}")
@app.POST("/search", response_model=dict)
async def search(request: SearchRequest):
"""확장 쿼리로 검색 API"""
try:
logger.info(f"검색 요청: {request.query} (인덱스: {request.index_name}, 사용자: {request.user_id})")
# 쿼리 확장
if request.use_online_learning:
expansion_result = app.state.online_learner.expand_query(request.query, request.user_id)
else:
expansion_result = app.state.fashion_expander.expand_query(request.query, request.user_id)
# Elasticsearch 쿼리 생성
es_query = app.state.fashion_expander.create_elasticsearch_query(expansion_result)
# 검색 실행
search_result = app.state.es_manager.search(request.index_name, es_query, request.size)
# 결과 포맷팅
result = {
'query': request.query,
'expansion_result': expansion_result,
'search_result': search_result
}
# 사용자 검색 기록 업데이트
if request.user_id:
app.state.fashion_expander.user_profile_store.add_search_query(request.user_id, request.query)
return result
except Exception as e:
logger.error(f"검색 오류: {str(e)}", exc_info=True)
raise HTTPException(status_code=500, detail=f"검색 오류: {str(e)}")
@app.POST("/feedback", response_model=dict)
async def record_feedback(request: FeedbackRequest):
"""피드백 기록 API"""
try:
logger.info(f"피드백 요청: {request.query} (사용자: {request.user_id}, 평점: {request.rating})")
# 피드백 기록
app.state.online_learner.record_feedback(
request.query,
request.expansion_terms,
request.clicked_product_ids,
request.user_id,
request.session_id,
request.rating
)
# 구매 기록 업데이트 (클릭된 상품이 있는 경우)
if request.user_id and request.clicked_product_ids:
# 여기서는 간단히 첫 번째 클릭 상품만 구매로 가정
app.state.fashion_expander.user_profile_store.add_purchase(
request.user_id,
f"Product ID: {request.clicked_product_ids[0]}"
)
return {"status": "success", "message": "피드백이 성공적으로 기록되었습니다"}
except Exception as e:
logger.error(f"피드백 기록 오류: {str(e)}", exc_info=True)
raise HTTPException(status_code=500, detail=f"피드백 기록 오류: {str(e)}")
@app.get("/health")
async def health_check():
"""시스템 상태 확인 API"""
es_status = "connected" if app.state.es_manager.is_connected() else "disconnected"
return {
"status": "healthy",
"elasticsearch": es_status,
"metrics": {
"queries_processed": app.state.fashion_expander.metrics.get('queries_processed', 0),
"avg_expansion_time": app.state.fashion_expander.metrics.get('avg_expansion_time', 0),
"cache_hits": app.state.fashion_expander.metrics.get('cache_hits', 0),
"cache_misses": app.state.fashion_expander.metrics.get('cache_misses', 0)
}
}
def load_sample_products(file_path="data/sample_products.csv"):
"""샘플 상품 데이터 로드"""
if os.path.exists(file_path):
return pd.read_csv(file_path)
# 샘플 데이터 생성
categories = [
"티셔츠", "셔츠", "블라우스", "스웨터", "가디건", "후드티",
"청바지", "슬랙스", "치마", "반바지", "레깅스",
"원피스", "자켓", "코트", "패딩", "점퍼",
"스니커즈", "구두", "샌들", "부츠", "슬리퍼",
"백팩", "크로스백", "토트백", "클러치", "지갑"
]
brands = [
"나이키", "아디다스", "자라", "H&M", "유니클로", "갭", "무신사", "COS",
"블랙야크", "노스페이스", "코오롱스포츠", "MLB", "구찌", "프라다", "루이비통"
]
colors = [
"블랙", "화이트", "네이비", "그레이", "베이지", "카키", "레드", "블루",
"핑크", "퍼플", "옐로우", "그린", "오렌지", "브라운", "버건디"
]
sizes = ["XS", "S", "M", "L", "XL", "XXL", "FREE"]
genders = ["남성", "여성", "공용"]
seasons = ["봄", "여름", "가을", "겨울", "사계절"]
# 1000개 샘플 상품 생성
products = []
for i in range(1, 1001):
category = random.choice(categories)
brand = random.choice(brands)
color = random.choice(colors)
size = random.choice(sizes)
gender = random.choice(genders)
season = random.choice(seasons)
price = round(random.uniform(10000, 300000), -3) # 1만원 ~ 30만원, 천원 단위로 반올림
product = {
"product_id": f"P{i:05d}",
"product_name": f"{brand} {gender} {color} {category} {size}",
"brand": brand,
"category": category,
"price": price,
"color": color,
"size": size,
"gender": gender,
"season": season,
"stock": random.randint(0, 100),
"rating": round(random.uniform(3.0, 5.0), 1),
"review_count": random.randint(0, 500),
"created_at": (datetime.datetime.now() - datetime.timedelta(days=random.randint(0, 365))).isoformat()
}
products.append(product)
# DataFrame 생성 및 저장
df = pd.DataFrame(products)
os.makedirs(os.path.dirname(file_path), exist_ok=True)
df.to_csv(file_path, index=False)
return df
def initialize_es_index(es_manager, index_name="fashion_products", sample_data=True):
"""Elasticsearch 인덱스 초기화 및 샘플 데이터 로드"""
# 인덱스 생성
if not es_manager.create_fashion_index(index_name, overwrite=True):
logger.error(f"인덱스 {index_name} 생성 실패")
return False
# 샘플 데이터 로드 및 인덱싱
if sample_data:
df = load_sample_products()
products = df.to_dict('records')
if not es_manager.bulk_index_products(index_name, products):
logger.error("샘플 상품 인덱싱 실패")
return False
logger.info(f"{len(products)}개의 샘플 상품 인덱싱 완료")
return True
def evaluate_query_expansion(expander, test_queries, es_manager, index_name, gold_standard=None):
"""쿼리 확장 성능 평가"""
results = {}
for query in test_queries:
# 원래 쿼리로 검색
original_es_query = {
"query": {
"multi_match": {
"query": query,
"fields": ["product_name^3.0", "brand^2.0", "category^2.0", "description^1.0"],
"type": "cross_fields",
"operator": "OR"
}
}
}
original_result = es_manager.search(index_name, original_es_query, size=20)
# 확장 쿼리로 검색
expansion_result = expander.expand_query(query)
es_query = expander.create_elasticsearch_query(expansion_result)
expanded_result = es_manager.search(index_name, es_query, size=20)
# 결과 분석
query_metrics = {
'original_query': query,
'expansion_terms': expansion_result['expansion_terms'],
'original_hit_count': original_result['total'],
'expanded_hit_count': expanded_result['total'],
'original_took': original_result['took'],
'expanded_took': expanded_result['took'],
'hit_diff': expanded_result['total'] - original_result['total']
}
# 골드 스탠다드가 있는 경우 정확도 계산
if gold_standard and query in gold_standard:
relevant_ids = gold_standard[query]
# 원래 쿼리 정확도
original_hits = [hit['id'] for hit in original_result['results']]
original_relevant = [id for id in original_hits if id in relevant_ids]
# 확장 쿼리 정확도
expanded_hits = [hit['id'] for hit in expanded_result['results']]
expanded_relevant = [id for id in expanded_hits if id in relevant_ids]
# 메트릭 계산
query_metrics.update({
'original_precision': len(original_relevant) / len(original_hits) if original_hits else 0,
'expanded_precision': len(expanded_relevant) / len(expanded_hits) if expanded_hits else 0,
'original_recall': len(original_relevant) / len(relevant_ids) if relevant_ids else 0,
'expanded_recall': len(expanded_relevant) / len(relevant_ids) if relevant_ids else 0,
'original_relevant': len(original_relevant),
'expanded_relevant': len(expanded_relevant)
})
results[query] = query_metrics
# 전체 메트릭 계산
avg_metrics = {
'avg_hit_diff': sum(m['hit_diff'] for m in results.values()) / len(results),
'avg_original_took': sum(m['original_took'] for m in results.values()) / len(results),
'avg_expanded_took': sum(m['expanded_took'] for m in results.values()) / len(results)
}
if gold_standard:
avg_metrics.update({
'avg_original_precision': sum(m.get('original_precision', 0) for m in results.values()) / len(results),
'avg_expanded_precision': sum(m.get('expanded_precision', 0) for m in results.values()) / len(results),
'avg_original_recall': sum(m.get('original_recall', 0) for m in results.values()) / len(results),
'avg_expanded_recall': sum(m.get('expanded_recall', 0) for m in results.values()) / len(results),
})
return {
'queries': results,
'avg_metrics': avg_metrics
}
if __name__ == "__main__":
import uvicorn
import argparse
parser = argparse.ArgumentParser(description="패션 이커머스 쿼리 확장 시스템")
parser.add_argument("--mode", choices=["api", "init", "eval"], default="api",
help="실행 모드 (api: API 서버 실행, init: ES 초기화, eval: 성능 평가)")
parser.add_argument("--host", default="0.0.0.0", help="API 서버 호스트")
parser.add_argument("--port", type=int, default=8000, help="API 서버 포트")
args = parser.parse_args()
if args.mode == "api":
# API 서버 실행
uvicorn.run("app:app", host=args.host, port=args.port, reload=True)
elif args.mode == "init":
# Elasticsearch 초기화
logger.info("Elasticsearch 초기화 중...")
es_manager = ElasticsearchManager()
if initialize_es_index(es_manager):
logger.info("Elasticsearch 초기화 완료")
else:
logger.error("Elasticsearch 초기화 실패")
elif args.mode == "eval":
# 성능 평가
logger.info("쿼리 확장 성능 평가 중...")
# 테스트 쿼리
test_queries = [
"검은색 티셔츠",
"여름용 원피스",
"남성 정장",
"스포티한 운동화",
"가을 코트",
"가디건",
"청바지",
"여성 백팩",
"화이트 셔츠",
"겨울 패딩"
]
# 확장기 및 ES 매니저 초기화
fashion_expander = FashionQueryExpander()
es_manager = ElasticsearchManager()
# 성능 평가
results = evaluate_query_expansion(
fashion_expander,
test_queries,
es_manager,
"fashion_products"
)
print("\n=== 쿼리 확장 성능 평가 결과 ===")
print(f"평균 히트 증가율: {results['avg_metrics']['avg_hit_diff']}")
print(f"평균 원본 쿼리 실행 시간: {results['avg_metrics']['avg_original_took']}ms")
print(f"평균 확장 쿼리 실행 시간: {results['avg_metrics']['avg_expanded_took']}ms")
if 'avg_original_precision' in results['avg_metrics']:
print(f"평균 원본 쿼리 Precision: {results['avg_metrics']['avg_original_precision']:.4f}")
print(f"평균 확장 쿼리 Precision: {results['avg_metrics']['avg_expanded_precision']:.4f}")
print(f"평균 원본 쿼리 재현율: {results['avg_metrics']['avg_original_recall']:.4f}")
print(f"평균 확장 쿼리 재현율: {results['avg_metrics']['avg_expanded_recall']:.4f}")
print("\n개별 쿼리 결과:")
for query, metrics in results['queries'].items():
print(f"\n쿼리: {query}")
print(f" 확장 용어: {', '.join(metrics['expansion_terms'])}")
print(f" 원본 히트: {metrics['original_hit_count']}, 확장 히트: {metrics['expanded_hit_count']}")
if 'original_precision' in metrics:
print(f" 원본 Precision: {metrics['original_precision']:.4f}, 확장 Precision: {metrics['expanded_precision']:.4f}")
print(f" 원본 재현율: {metrics['original_recall']:.4f}, 확장 재현율: {metrics['expanded_recall']:.4f}")
다음은 이 시스템을 사용하는 예제 코드입니다.
import requests
import json
# API 설정
API_BASE_URL = "http://localhost:8000"
def expand_query(query, user_id=None, session_id=None):
"""쿼리 확장 API 호출"""
url = f"{API_BASE_URL}/expand"
data = {
"query": query,
"user_id": user_id,
"session_id": session_id
}
response = requests.POST(url, json=data)
if response.status_code == 200:
return response.json()
else:
print(f"오류: {response.status_code} - {response.text}")
return None
def search(query, index_name="fashion_products", user_id=None, session_id=None, size=10):
"""검색 API 호출"""
url = f"{API_BASE_URL}/search"
data = {
"query": query,
"index_name": index_name,
"user_id": user_id,
"session_id": session_id,
"size": size
}
response = requests.POST(url, json=data)
if response.status_code == 200:
return response.json()
else:
print(f"오류: {response.status_code} - {response.text}")
return None
def record_feedback(query, expansion_terms, clicked_product_ids=None, user_id=None, session_id=None, rating=None):
"""피드백 API 호출"""
url = f"{API_BASE_URL}/feedback"
data = {
"query": query,
"expansion_terms": expansion_terms,
"clicked_product_ids": clicked_product_ids,
"user_id": user_id,
"session_id": session_id,
"rating": rating
}
response = requests.POST(url, json=data)
if response.status_code == 200:
return response.json()
else:
print(f"오류: {response.status_code} - {response.text}")
return None
def demo():
"""시스템 데모"""
# 1. 쿼리 확장
query = "여름 원피스"
user_id = "user1"
session_id = "session123"
print(f"\n1. 쿼리 확장: '{query}'")
expansion_result = expand_query(query, user_id, session_id)
if expansion_result:
print(f"원본 쿼리: {expansion_result['original_query']}")
print(f"교정된 쿼리: {expansion_result['corrected_query']}")
print(f"확장 용어: {', '.join(expansion_result['expansion_terms'])}")
for source, terms in expansion_result['expansion_sources'].items():
if terms:
print(f" - {source}: {', '.join(terms)}")
# 2. 검색
print(f"\n2. 검색: '{query}'")
search_result = search(query, "fashion_products", user_id, session_id)
if search_result and 'search_result' in search_result:
results = search_result['search_result']['results']
print(f"총 {search_result['search_result']['total']}개 결과 ({search_result['search_result']['took']}ms)")
for i, result in enumerate(results[:5], 1): # 상위 5개만 출력
product = result['source']
print(f" {i}. {product['product_name']} (브랜드: {product['brand']}, 가격: {product['price']}원)")
# 3. 피드백 제공
if expansion_result and search_result and 'search_result' in search_result and search_result['search_result']['results']:
# 첫 번째 상품 클릭 시뮬레이션
clicked_product = search_result['search_result']['results'][0]
clicked_product_ids = [clicked_product['id']]
print(f"\n3. 피드백 제공: 클릭한 상품 ID {clicked_product_ids}")
feedback_result = record_feedback(
query,
expansion_result['expansion_terms'],
clicked_product_ids,
user_id,
session_id,
rating=1 # 긍정적 피드백
)
if feedback_result:
print(f"피드백 상태: {feedback_result['status']}")
print(f"메시지: {feedback_result['message']}")
# 4. 계절별 쿼리 테스트
seasonal_queries = ["봄 아우터", "여름 티셔츠", "가을 코트", "겨울 패딩"]
print("\n4. 계절별 쿼리 테스트")
for seasonal_query in seasonal_queries:
expansion_result = expand_query(seasonal_query, user_id, session_id)
if expansion_result:
print(f"\n쿼리: '{seasonal_query}'")
print(f"확장 용어: {', '.join(expansion_result['expansion_terms'])}")
# 계절 관련 용어만 확인
season_terms = expansion_result['expansion_sources'].get('season', [])
if season_terms:
print(f"계절 관련 용어: {', '.join(season_terms)}")
# 5. 사용자 세그먼트별 테스트
user_queries = {
"user1": "캐주얼 의류", # 여성, 20대, 캐주얼/미니멀 스타일, 가격민감
"user2": "정장", # 남성, 30대, 포멀/미니멀 스타일, 프리미엄
"user3": "트렌디한 상의" # 여성, 20대, 빈티지/캐주얼 스타일, 트렌드 팔로워
}
print("\n5. 사용자 세그먼트별 테스트")
for test_user_id, test_query in user_queries.items():
expansion_result = expand_query(test_query, test_user_id, session_id)
if expansion_result:
print(f"\n사용자: {test_user_id}, 쿼리: '{test_query}'")
print(f"확장 용어: {', '.join(expansion_result['expansion_terms'])}")
# 사용자 세그먼트 관련 용어 확인
user_terms = expansion_result['expansion_sources'].get('user_segment', [])
if user_terms:
print(f"사용자 세그먼트 용어: {', '.join(user_terms)}")
if __name__ == "__main__":
demo()
다음은 시스템을 초기화하고 필요한 의존성을 설치하는 스크립트입니다.
import os
import subprocess
import argparse
def install_dependencies():
"""필요한 패키지 설치"""
print("필요한 패키지 설치 중...")
packages = [
"fastapi", "uvicorn", "elasticsearch", "torch", "transformers",
"pandas", "numpy", "scikit-learn", "requests"
]
# 설치 명령 실행
for package in packages:
print(f"패키지 설치: {package}")
subprocess.run(["pip", "install", package])
print("패키지 설치 완료")
def create_directories():
"""필요한 디렉토리 생성"""
print("디렉토리 생성 중...")
directories = ["data", "cache", "logs"]
for directory in directories:
os.makedirs(directory, exist_ok=True)
print(f"디렉토리 생성됨: {directory}")
def initialize_system():
"""시스템 초기화 (샘플 데이터 생성 등)"""
print("시스템 초기화 중...")
# 모듈 임포트
from elasticsearch import Elasticsearch
import logging
# 로깅 설정
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
handlers=[logging.FileHandler("logs/initialization.log"), logging.StreamHandler()]
)
logger = logging.getLogger("initialization")
# 샘플 데이터 생성
try:
# Elasticsearch 연결 확인
es = Elasticsearch(["localhost:9200"])
if not es.ping():
logger.warning("Elasticsearch가 실행 중이 아닙니다. 먼저 Elasticsearch를 실행해주세요.")
print("주의: Elasticsearch가 실행 중이 아닙니다. 먼저 Elasticsearch를 실행해주세요.")
else:
# 모듈 임포트
from fashion_query_expansion import ElasticsearchManager, initialize_es_index, load_sample_products
# ES 인덱스 초기화
logger.info("Elasticsearch 인덱스 초기화 중...")
es_manager = ElasticsearchManager()
if initialize_es_index(es_manager):
logger.info("Elasticsearch 인덱스 초기화 완료")
else:
logger.error("Elasticsearch 인덱스 초기화 실패")
except Exception as e:
logger.error(f"초기화 중 오류 발생: {e}", exc_info=True)
print(f"오류: {e}")
print("시스템 초기화 완료")
def main():
parser = argparse.ArgumentParser(description="패션 이커머스 쿼리 확장 시스템 초기화")
parser.add_argument("--skip-deps", action="store_true", help="패키지 설치 건너뛰기")
args = parser.parse_args()
if not args.skip_deps:
install_dependencies()
create_directories()
initialize_system()
print("\n모든 초기화 작업이 완료되었습니다.")
print("API 서버를 실행하려면 다음 명령을 실행하세요: python fashion_query_expansion.py --mode api")
if __name__ == "__main__":
main()
각 클래스별로 LLM을 통합하고, 세그먼트별 분석을 강화하며, 필드를 상세화한 고도화된 시스템 코드를 작성했습니다. 골든 데이터셋을 활용한 학습 및 평가 방식도 포함했습니다.
class FashionCategory:
"""패션 카테고리 계층 구조 및 관계 관리 클래스"""
def __init__(self, data_path="data/fashion_categories.json", load_expanded=True):
"""
패션 카테고리 데이터 로드 및 초기화
Parameters:
-----------
data_path : str
카테고리 JSON 파일 경로
load_expanded : bool
확장된 카테고리 데이터 로드 여부
"""
if os.path.exists(data_path):
with open(data_path, 'r', encoding='utf-8') as f:
self.categories = json.load(f)
else:
# 기본 카테고리 구조 (실제 시스템에서는 더 상세한 카테고리 체계 사용)
self.categories = {
"상의": {
"티셔츠": {
"items": ["반팔티", "긴팔티", "민소매티", "크롭티", "오버사이즈티", "슬림핏티", "그래픽티"],
"attributes": {
"소재": ["면", "폴리에스터", "레이온", "혼방", "린넨", "텐셀"],
"디테일": ["라운드넥", "브이넥", "유넥", "보트넥", "터틀넥", "폴로넥"],
"핏": ["오버사이즈", "슬림핏", "스탠다드핏", "박시핏"]
}
},
"셔츠": {
"items": ["옥스포드", "린넨셔츠", "데님셔츠", "체크셔츠", "솔리드셔츠", "스트라이프셔츠", "플란넬셔츠"],
"attributes": {
"소재": ["면", "린넨", "데님", "플란넬", "옥스퍼드", "실크"],
"디테일": ["버튼다운", "스프레드", "밴드", "클래식", "윙팁"],
"핏": ["레귤러핏", "슬림핏", "오버사이즈"]
}
},
"니트웨어": {
"items": ["스웨터", "가디건", "풀오버", "케이블니트", "터틀넥니트", "브이넥니트", "모크넥니트"],
"attributes": {
"소재": ["울", "캐시미어", "아크릴", "면", "모헤어", "혼방"],
"디테일": ["라운드넥", "브이넥", "터틀넥", "모크넥", "케이블"],
"두께": ["얇은", "중간", "두꺼운"]
}
},
"후드": {
"items": ["후드티", "후드집업", "맨투맨", "스웻셔츠", "크루넥", "오버사이즈후드", "크롭후드"],
"attributes": {
"소재": ["면", "폴리에스터", "기모", "쭈리", "혼방"],
"디테일": ["포켓", "드로우스트링", "프린팅", "자수"],
"핏": ["오버사이즈", "스탠다드", "슬림"]
}
},
"블라우스": {
"items": ["쉬폰블라우스", "새틴블라우스", "실크블라우스", "오프숄더", "퍼프슬리브", "프릴블라우스"],
"attributes": {
"소재": ["쉬폰", "새틴", "실크", "레이온", "면", "폴리에스터"],
"디테일": ["리본", "프릴", "러플", "레이스", "퍼프슬리브"],
"핏": ["루즈핏", "슬림핏", "크롭"]
}
}
},
"하의": {
"청바지": {
"items": ["슬림진", "와이드진", "스트레이트진", "스키니진", "몸바", "배기진", "보이프렌드진", "부츠컷진"],
"attributes": {
"워싱": ["라이트", "미디엄", "다크", "블랙", "생지", "워싱", "디스트로이드"],
"소재": ["면", "스트레치", "데님", "코튼"],
"핏": ["스키니", "슬림", "레귤러", "와이드", "배기", "부츠컷"]
}
},
"슬랙스": {
"items": ["치노팬츠", "슬랙스", "정장바지", "와이드팬츠", "스트레이트팬츠", "크롭팬츠", "핀턱팬츠"],
"attributes": {
"소재": ["울", "폴리에스터", "면", "혼방", "린넨"],
"핏": ["와이드", "슬림", "스트레이트", "테이퍼드"],
"디테일": ["핀턱", "플리츠", "크리스", "밴딩", "플랫"]
}
},
"반바지": {
"items": ["데님쇼츠", "트레이닝쇼츠", "버뮤다팬츠", "카고쇼츠", "스윔쇼츠", "치노쇼츠"],
"attributes": {
"소재": ["면", "데님", "린넨", "폴리에스터", "나일론"],
"길이": ["초미니", "미니", "미드", "버뮤다"],
"핏": ["슬림", "레귤러", "와이드", "루즈"]
}
},
"스커트": {
"items": ["미니스커트", "미디스커트", "롱스커트", "플리츠스커트", "펜슬스커트", "에이라인스커트", "랩스커트"],
"attributes": {
"소재": ["면", "데님", "울", "쉬폰", "새틴", "가죽"],
"길이": ["미니", "미디", "롱", "맥시"],
"실루엣": ["에이라인", "플레어", "펜슬", "플리츠", "랩"]
}
},
"트레이닝": {
"items": ["조거팬츠", "트랙팬츠", "스웨트팬츠", "레깅스", "요가팬츠"],
"attributes": {
"소재": ["면", "폴리에스터", "나일론", "스판덱스", "플리스"],
"핏": ["루즈", "테이퍼드", "스키니", "레귤러"],
"디테일": ["밴딩", "조거", "드로스트링", "지퍼", "사이드라인"]
}
}
},
"아우터": {
"자켓": {
"items": ["데님자켓", "가죽자켓", "봄버자켓", "테일러드자켓", "트랙자켓", "윈드브레이커", "블레이저"],
"attributes": {
"소재": ["데님", "가죽", "면", "나일론", "폴리에스터", "트윌", "울"],
"핏": ["오버사이즈", "크롭", "슬림", "박시"],
"디테일": ["포켓", "패치", "지퍼", "버튼", "퀼팅"]
}
},
"코트": {
"items": ["트렌치코트", "피코트", "더플코트", "롱코트", "발마칸코트", "체스터필드코트", "카코트"],
"attributes": {
"소재": ["울", "캐시미어", "혼방", "면", "폴리에스터"],
"길이": ["크롭", "미드", "롱", "맥시"],
"핏": ["오버사이즈", "슬림", "레귤러"]
}
},
"패딩": {
"items": ["숏패딩", "롱패딩", "경량패딩", "다운자켓", "푸퍼", "덕다운", "벤치코트"],
"attributes": {
"소재": ["나일론", "폴리에스터", "다운", "웰론", "폴리"],
"길이": ["크롭", "힙", "니", "앵클"],
"충전재": ["오리털", "거위털", "웰론", "솜", "폴리"]
}
},
"가디건": {
"items": ["오버사이즈가디건", "집업가디건", "롱가디건", "크롭가디건", "버튼가디건", "볼레로"],
"attributes": {
"소재": ["울", "캐시미어", "면", "아크릴", "혼방"],
"디테일": ["버튼", "지퍼", "포켓", "벨트"],
"길이": ["크롭", "힙", "니", "롱"]
}
},
"점퍼": {
"items": ["MA-1", "항공점퍼", "스타디움점퍼", "바시티점퍼", "코치자켓"],
"attributes": {
"소재": ["나일론", "폴리에스터", "면", "울", "혼방"],
"디테일": ["리브", "패치", "자수", "프린트"],
"핏": ["오버사이즈", "레귤러", "슬림"]
}
}
},
"신발": {
"운동화": {
"items": ["스니커즈", "캔버스화", "러닝화", "테니스화", "트레이닝화", "농구화", "어글리슈즈"],
"attributes": {
"브랜드": ["나이키", "아디다스", "컨버스", "뉴발란스", "푸마", "반스"],
"소재": ["가죽", "캔버스", "니트", "메쉬", "고어텍스"],
"밑창": ["고무", "파일론", "에어", "부스트", "리액트"]
}
},
"구두": {
"items": ["옥스포드", "로퍼", "펌프스", "윙팁", "더비", "몽크스트랩", "첼시부츠"],
"attributes": {
"소재": ["가죽", "스웨이드", "에나멜", "합성피혁"],
"굽높이": ["플랫", "로우", "미드", "하이"],
"스타일": ["캐주얼", "정장", "세미포멀"]
}
},
"부츠": {
"items": ["앵클부츠", "첼시부츠", "워커", "레인부츠", "라이딩부츠", "데저트부츠", "미들부츠"],
"attributes": {
"소재": ["가죽", "스웨이드", "고어텍스", "러버", "니트"],
"길이": ["앵클", "미드", "니", "사이하이"],
"굽형태": ["플랫", "초커", "블록", "웨지", "스틸레토"]
}
},
"샌들": {
"items": ["플랫샌들", "스트랩샌들", "슬리퍼", "에스파드리유", "웨지샌들", "글래디에이터", "버켄스탁"],
"attributes": {
"소재": ["가죽", "직물", "합성피혁", "고무", "EVA"],
"굽높이": ["플랫", "로우", "미드", "하이", "웨지"],
"디테일": ["스트랩", "버클", "태슬", "플랫폼", "오픈토"]
}
}
},
"가방": {
"백팩": {
"items": ["데이백", "student백팩", "여행백팩", "롤탑백팩", "미니백팩", "노트북백팩", "아웃도어백팩"],
"attributes": {
"소재": ["나일론", "폴리에스터", "가죽", "캔버스", "코듀라"],
"용량": ["소형", "중형", "대형"],
"기능": ["노트북수납", "USB포트", "방수", "여행용", "다수포켓"]
}
},
"크로스백": {
"items": ["미니크로스백", "메신저백", "새들백", "바디백", "숄더백", "플랩백"],
"attributes": {
"소재": ["가죽", "캔버스", "나일론", "합성피혁"],
"스타일": ["미니멀", "클래식", "캐주얼", "포멀"],
"사이즈": ["미니", "스몰", "미디엄", "라지"]
}
},
"토트백": {
"items": ["캔버스토트", "레더토트", "쇼퍼백", "스트럭처드토트", "비치토트", "오픈토트"],
"attributes": {
"소재": ["캔버스", "가죽", "코튼", "합성피혁", "린넨"],
"스타일": ["오픈", "지퍼", "버클", "스냅"],
"사이즈": ["미니", "스몰", "미디엄", "라지"]
}
},
"클러치": {
"items": ["이브닝클러치", "파우치", "월렛클러치", "엔벨롭클러치", "박스클러치", "웨딩클러치"],
"attributes": {
"소재": ["가죽", "새틴", "벨벳", "비즈", "메탈릭"],
"스타일": ["엔벨롭", "박스", "미니멀", "스터드"],
"용도": ["파티", "웨딩", "데일리", "비즈니스"]
}
}
}
}
# 디렉토리 생성 및 저장
os.makedirs(os.path.dirname(data_path), exist_ok=True)
with open(data_path, 'w', encoding='utf-8') as f:
json.dump(self.categories, f, ensure_ascii=False, indent=4)
# 확장된 카테고리 데이터 로드
if load_expanded:
self._build_expanded_mappings()
# 역방향 매핑 구성 (하위 카테고리 -> 상위 카테고리)
self.reverse_mapping = {}
self._build_reverse_mapping()
# 속성 매핑 구성 (속성 -> 카테고리)
self.attribute_mapping = {}
self._build_attribute_mapping()
# 관련 카테고리 매핑 구성 (카테고리 간 연관성)
self.related_categories = {}
self._build_related_categories()
def _build_expanded_mappings(self):
"""확장된 카테고리 매핑 구성"""
expanded_path = "data/expanded_categories.json"
if os.path.exists(expanded_path):
with open(expanded_path, 'r', encoding='utf-8') as f:
self.expanded_categories = json.load(f)
else:
# 기본 확장 매핑 (실제 시스템에서는 자동으로 구성)
self.expanded_categories = {
"스타일": {
"캐주얼": ["티셔츠", "후드", "청바지", "스니커즈", "백팩"],
"포멀": ["셔츠", "블라우스", "슬랙스", "정장바지", "구두"],
"스트릿": ["그래픽티", "후드티", "조거팬츠", "스니커즈", "볼캡"],
"미니멀": ["솔리드티셔츠", "니트웨어", "슬랙스", "첼시부츠"],
"빈티지": ["데님자켓", "체크셔츠", "와이드진", "워커"],
"애슬레저": ["트레이닝복", "레깅스", "스포츠브라", "운동화"]
},
"시즌": {
"봄": ["트렌치코트", "가디건", "청바지", "가벼운니트"],
"여름": ["티셔츠", "반바지", "샌들", "선글라스", "비치백"],
"가을": ["니트웨어", "가디건", "트렌치코트", "청바지", "앵클부츠"],
"겨울": ["패딩", "코트", "스웨터", "부츠", "목도리", "장갑"]
},
"핏": {
"오버사이즈": ["오버사이즈티", "와이드팬츠", "오버핏셔츠", "박시니트"],
"슬림핏": ["슬림티셔츠", "스키니진", "슬림슬랙스", "피티드셔츠"],
"레귤러핏": ["기본티셔츠", "스트레이트진", "일자바지"]
},
"소재": {
"면": ["티셔츠", "셔츠", "면바지", "후드티"],
"울": ["스웨터", "니트웨어", "코트", "울슬랙스"],
"데님": ["청바지", "데님자켓", "데님셔츠", "데님스커트"],
"가죽": ["가죽자켓", "가죽스커트", "가죽팬츠", "가죽백"],
"리넨": ["리넨셔츠", "리넨팬츠", "리넨원피스", "리넨블라우스"]
}
}
with open(expanded_path, 'w', encoding='utf-8') as f:
json.dump(self.expanded_categories, f, ensure_ascii=False, indent=4)
def _build_reverse_mapping(self):
"""역방향 매핑 구성 (하위 -> 상위 카테고리)"""
for main_cat, sub_cats in self.categories.items():
for sub_cat, details in sub_cats.items():
self.reverse_mapping[sub_cat.lower()] = main_cat
if "items" in details:
for item in details["items"]:
self.reverse_mapping[item.lower()] = sub_cat
def _build_attribute_mapping(self):
"""속성 매핑 구성 (속성 -> 카테고리)"""
for main_cat, sub_cats in self.categories.items():
for sub_cat, details in sub_cats.items():
if "attributes" in details:
for attr_type, attrs in details["attributes"].items():
for attr in attrs:
if attr.lower() not in self.attribute_mapping:
self.attribute_mapping[attr.lower()] = []
self.attribute_mapping[attr.lower()].append((main_cat, sub_cat, attr_type))
def _build_related_categories(self):
"""관련 카테고리 매핑 구성 (의미적 연관성 기반)"""
# 기본 상호 관련성
related_pairs = [
("티셔츠", "셔츠"), ("티셔츠", "맨투맨"), ("후드", "맨투맨"),
("청바지", "슬랙스"), ("청바지", "면바지"), ("스커트", "원피스"),
("자켓", "코트"), ("패딩", "코트"), ("운동화", "스니커즈"),
("백팩", "크로스백"), ("토트백", "숄더백")
]
# 스타일 기반 관련성 (확장 카테고리 활용)
for style, items in self.expanded_categories.get("스타일", {}).items():
for i, item1 in enumerate(items):
for item2 in items[i+1:]:
related_pairs.append((item1, item2))
# 매핑 구성
for item1, item2 in related_pairs:
if item1.lower() not in self.related_categories:
self.related_categories[item1.lower()] = []
if item2.lower() not in self.related_categories:
self.related_categories[item2.lower()] = []
if item2.lower() not in self.related_categories[item1.lower()]:
self.related_categories[item1.lower()].append(item2.lower())
if item1.lower() not in self.related_categories[item2.lower()]:
self.related_categories[item2.lower()].append(item1.lower())
def get_parent_category(self, term):
"""용어의 상위 카테고리 찾기"""
return self.reverse_mapping.get(term.lower())
def get_categories_for_attribute(self, attribute):
"""속성에 해당하는 카테고리 찾기"""
return self.attribute_mapping.get(attribute.lower(), [])
def get_related_terms(self, term, max_terms=5, include_attributes=True):
"""용어와 관련된 다른 용어들 찾기"""
term_lower = term.lower()
related = []
# 1. 직접 매핑 - 같은 카테고리 내 다른 아이템
if term_lower in self.reverse_mapping:
parent = self.reverse_mapping[term_lower]
# 메인 카테고리에서 하위 카테고리 찾기
for main_cat, sub_cats in self.categories.items():
if parent == main_cat:
# 같은 메인 카테고리의 다른 서브카테고리 반환
related.extend(list(sub_cats.keys()))
break
# 서브카테고리에서 아이템 찾기
for sub_cat, details in sub_cats.items():
if parent == sub_cat and "items" in details:
# 같은 서브카테고리의 다른 아이템 반환
related.extend([item for item in details["items"] if item.lower() != term_lower])
# 해당 카테고리의 주요 속성 추가
if include_attributes and "attributes" in details:
for attr_type, attrs in details["attributes"].items():
# 대표 속성 몇 개만 추가
related.extend(attrs[:2])
break
# 2. 의미적 연관성 기반 관련 용어
if term_lower in self.related_categories:
for related_term in self.related_categories[term_lower]:
if related_term not in [r.lower() for r in related]:
# 원래 형태 찾기
for main_cat, sub_cats in self.categories.items():
found = False
for sub_cat, details in sub_cats.items():
if sub_cat.lower() == related_term:
related.append(sub_cat)
found = True
break
if "items" in details:
for item in details["items"]:
if item.lower() == related_term:
related.append(item)
found = True
break
if found:
break
if found:
break
# 찾지 못한 경우 그대로 추가
if not found:
related.append(related_term)
# 3. 확장 카테고리에서 관련 용어 찾기
for category_type, categories in self.expanded_categories.items():
for category, items in categories.items():
if term_lower in [item.lower() for item in items]:
# 같은 확장 카테고리에 속한 다른 아이템 추가
related.extend([item for item in items if item.lower() != term_lower])
break
# 중복 제거 및 최대 개수 제한
unique_related = []
for item in related:
if item.lower() not in [r.lower() for r in unique_related] and item.lower() != term_lower:
unique_related.append(item)
return unique_related[:max_terms]
def get_attributes_for_category(self, category):
"""카테고리에 대한 속성 목록 반환"""
for main_cat, sub_cats in self.categories.items():
if main_cat.lower() == category.lower():
# 메인 카테고리인 경우 모든 서브카테고리의 속성 수집
all_attributes = {}
for sub_cat, details in sub_cats.items():
if "attributes" in details:
for attr_type, attrs in details["attributes"].items():
if attr_type not in all_attributes:
all_attributes[attr_type] = []
all_attributes[attr_type].extend(attrs)
# 중복 제거
for attr_type in all_attributes:
all_attributes[attr_type] = list(set(all_attributes[attr_type]))
return all_attributes
# 서브카테고리 확인
for sub_cat, details in sub_cats.items():
if sub_cat.lower() == category.lower() and "attributes" in details:
return details["attributes"]
# 아이템 확인
if "items" in details and category.lower() in [item.lower() for item in details["items"]]:
return details.get("attributes", {})
return {}
def categorize_query_terms(self, query_terms):
"""쿼리 용어를 카테고리/속성으로 분류"""
categorized = {
"main_categories": [],
"sub_categories": [],
"items": [],
"attributes": [],
"unknown": []
}
for term in query_terms:
term_lower = term.lower()
# 메인 카테고리 확인
if term_lower in [cat.lower() for cat in self.categories.keys()]:
categorized["main_categories"].append(term)
continue
# 서브 카테고리 확인
is_subcategory = False
for main_cat, sub_cats in self.categories.items():
if term_lower in [sub.lower() for sub in sub_cats.keys()]:
categorized["sub_categories"].append(term)
is_subcategory = True
break
if is_subcategory:
continue
# 아이템 확인
is_item = False
for main_cat, sub_cats in self.categories.items():
for sub_cat, details in sub_cats.items():
if "items" in details and term_lower in [item.lower() for item in details["items"]]:
categorized["items"].append(term)
is_item = True
break
if is_item:
break
if is_item:
continue
# 속성 확인
if term_lower in self.attribute_mapping:
categorized["attributes"].append(term)
continue
# 확장 카테고리 확인
found_in_expanded = False
for category_type, categories in self.expanded_categories.items():
for category, items in categories.items():
if term_lower == category.lower() or term_lower in [item.lower() for item in items]:
# 카테고리 자체가 속성일 수 있음
categorized["attributes"].append(term)
found_in_expanded = True
break
if found_in_expanded:
break
if found_in_expanded:
continue
# 분류 불가
categorized["unknown"].append(term)
return categorized
def extract_category_intent(self, query_terms):
"""쿼리에서 카테고리 의도 추출"""
categorized = self.categorize_query_terms(query_terms)
# 카테고리 의도 결정 (우선순위: 아이템 > 서브카테고리 > 메인카테고리)
for key in ["items", "sub_categories", "main_categories"]:
if categorized[key]:
primary_intent = categorized[key][0]
# 상위 카테고리 찾기
if key == "items":
parent = self.get_parent_category(primary_intent)
grand_parent = self.get_parent_category(parent) if parent else None
return {
"primary": primary_intent,
"parent": parent,
"grand_parent": grand_parent,
"attributes": categorized["attributes"]
}
elif key == "sub_categories":
parent = self.get_parent_category(primary_intent)
return {
"primary": primary_intent,
"parent": parent,
"attributes": categorized["attributes"]
}
else: # main_categories
return {
"primary": primary_intent,
"attributes": categorized["attributes"]
}
# 속성만 있는 경우
if categorized["attributes"]:
# 속성으로부터 가장 연관성 높은 카테고리 인퍼런스
attribute = categorized["attributes"][0]
relevant_categories = self.get_categories_for_attribute(attribute)
if relevant_categories:
# 가장 빈도가 높은 카테고리 선택
category_counts = {}
for main_cat, sub_cat, _ in relevant_categories:
if sub_cat not in category_counts:
category_counts[sub_cat] = 0
category_counts[sub_cat] += 1
primary_intent = max(category_counts.items(), key=lambda x: x[1])[0]
parent = self.get_parent_category(primary_intent)
return {
"primary": primary_intent,
"parent": parent,
"inferred_from_attribute": attribute,
"attributes": categorized["attributes"]
}
# 아무것도 찾지 못한 경우
return None
class SeasonTrendManager:
"""계절 및 트렌드 관리자"""
def __init__(self, data_path="data/season_trends.json", trend_update_interval=30):
"""
계절 및 트렌드 데이터 로드
Parameters:
-----------
data_path : str
계절/트렌드 JSON 파일 경로
trend_update_interval : int
트렌드 데이터 업데이트 주기 (일)
"""
self.data_path = data_path
self.trend_update_interval = trend_update_interval
if os.path.exists(data_path):
with open(data_path, 'r', encoding='utf-8') as f:
self.data = json.load(f)
# 데이터 최신성 확인
if self._needs_update():
self._update_trend_data()
else:
# 샘플 데이터
self.data = {
"seasons": {
"spring": {
"months": [3, 4, 5],
"keywords": ["봄", "가디건", "트렌치코트", "블라우스", "가벼운", "산뜻한", "파스텔"],
"colors": ["파스텔", "라이트블루", "라벤더", "민트", "코랄", "피치", "베이지"],
"fabrics": ["면", "린넨", "데님", "가벼운니트", "쉬폰", "실크", "트윌"],
"patterns": ["플로럴", "스트라이프", "도트", "체크", "무지"],
"items": {
"상의": ["가디건", "블라우스", "티셔츠", "셔츠", "니트", "맨투맨"],
"하의": ["청바지", "면바지", "치마", "플리츠스커트", "테니스스커트"],
"아우터": ["트렌치코트", "데님자켓", "가디건", "윈드브레이커", "블레이저"],
"신발": ["로퍼", "스니커즈", "플랫슈즈", "뮬", "샌들"],
"가방": ["토트백", "크로스백", "숄더백"]
},
"style_keywords": ["화사한", "산뜻한", "내추럴", "포근한", "캐주얼", "러블리"]
},
"summer": {
"months": [6, 7, 8],
"keywords": ["여름", "반팔", "민소매", "티셔츠", "반바지", "시원한", "얇은", "여름휴가", "바캉스"],
"colors": ["화이트", "네이비", "하늘색", "네온", "비비드", "옐로우", "오렌지", "그린"],
"fabrics": ["면", "린넨", "시어서커", "매쉬", "나일론", "테리", "저지"],
"patterns": ["스트라이프", "마린", "트로피컬", "타이다이", "체크"],
"items": {
"상의": ["반팔티", "민소매", "크롭티", "나시", "반팔셔츠", "하와이안셔츠"],
"하의": ["반바지", "숏팬츠", "데님쇼츠", "스커트", "버뮤다팬츠", "린넨팬츠"],
"아우터": ["얇은가디건", "쉬어셔츠", "리넨자켓", "데님자켓"],
"신발": ["샌들", "슬리퍼", "에스파드리유", "스니커즈", "보트슈즈"],
"가방": ["라탄백", "스트로백", "캔버스토트", "미니백", "클러치"]
},
"style_keywords": ["시원한", "경쾌한", "상큼한", "활동적인", "비치", "리조트"]
},
"fall": {
"months": [9, 10, 11],
"keywords": ["가을", "니트", "트렌치코트", "자켓", "카디건", "따뜻한", "레이어드", "단풍"],
"colors": ["브라운", "버건디", "카키", "머스타드", "다크그린", "네이비", "카멜", "오렌지"],
"fabrics": ["울", "코듀로이", "니트", "스웨이드", "트위드", "캐시미어", "플란넬"],
"patterns": ["체크", "헤링본", "아가일", "플레이드", "스트라이프"],
"items": {
"상의": ["니트", "스웨터", "터틀넥", "셔츠", "맨투맨", "후드티"],
"하의": ["코듀로이팬츠", "슬랙스", "와이드팬츠", "스트레이트진", "플리츠스커트"],
"아우터": ["트렌치코트", "레더자켓", "울코트", "카디건", "블레이저", "니트가디건"],
"신발": ["첼시부츠", "앵클부츠", "로퍼", "옥스포드", "스니커즈"],
"가방": ["토트백", "숄더백", "버킷백", "크로스백"]
},
"style_keywords": ["차분한", "클래식", "시크한", "레이어드", "내추럴", "고급스러운"]
},
"winter": {
"months": [12, 1, 2],
"keywords": ["겨울", "패딩", "코트", "니트", "목도리", "장갑", "따뜻한", "두꺼운", "보온"],
"colors": ["블랙", "그레이", "화이트", "다크네이비", "버건디", "딥그린", "카멜", "베이지"],
"fabrics": ["울", "캐시미어", "벨벳", "퍼", "다운", "플리스", "기모", "니트"],
"patterns": ["체크", "헤링본", "무지", "하운드투스", "페이즐리"],
"items": {
"상의": ["두꺼운니트", "터틀넥", "후드티", "맨투맨", "기모티셔츠"],
"하의": ["울슬랙스", "기모청바지", "코듀로이팬츠", "트윌팬츠", "레깅스"],
"아우터": ["패딩", "롱코트", "무스탕", "다운재킷", "헤비코트", "퍼코트"],
"신발": ["부츠", "방한화", "부티", "워커", "어그부츠"],
"가방": ["토트백", "숄더백", "백팩", "크로스백"]
},
"style_keywords": ["따뜻한", "포근한", "아늑한", "어두운", "고급스러운", "클래식"]
}
},
"trends": {
"2023": {
"keywords": ["Y2K", "오버사이즈", "가죽", "빈티지", "하이웨이스트", "크롭탑", "바이커룩"],
"colors": ["버터크림", "라벤더", "에메랄드", "테라코타", "세이지그린", "베이비블루"],
"patterns": ["체크", "타이다이", "플로럴", "애니멀프린트", "지오메트릭", "마블"],
"items": {
"상의": ["크롭티", "브라톱", "백리스톱", "Y2K탑", "코르셋", "오버사이즈셔츠"],
"하의": ["카고팬츠", "와이드진", "로우라이즈진", "플리츠스커트", "미니스커트"],
"아우터": ["가죽자켓", "오버사이즈코트", "봄버자켓", "크롭자켓", "퍼자켓"],
"신발": ["청키슈즈", "플랫폼슈즈", "스트랩샌들", "패딩부츠", "고프코어"]
},
"brands": ["마르지엘라", "발렌시아가", "프라다", "구찌", "미우미우", "스톤아일랜드"],
"influences": ["TikTok", "Instagram", "K-Pop", "스트리트스타일", "지속가능성"]
},
"2024": {
"keywords": ["서스테이너블", "젠더리스", "미니멀", "복고풍", "테크웨어", "유틸리티"],
"colors": ["딥블루", "세이지그린", "체리레드", "코코아브라운", "라일락", "시나몬"],
"patterns": ["지오메트릭", "컬러블로킹", "추상적", "베이직", "핸드페인티드"],
"items": {
"상의": ["오버사이즈티셔츠", "린넨블라우스", "니트탑", "홀터넥", "루즈핏셔츠"],
"하의": ["테일러드쇼츠", "와이드레그팬츠", "사틴스커트", "유틸리티팬츠", "하이웨이스트진"],
"아우터": ["오버사이즈블레이저", "유틸리티자켓", "데님트렌치", "테크재킷", "롱가디건"],
"신발": ["러그솔슈즈", "풋베드샌들", "볼륨스니커즈", "슬링백", "스퀘어토"]
},
"brands": ["로에베", "바즐", "보테가베네타", "셀린", "디올", "자크뮈스"],
"influences": ["지속가능한 패션", "유니버설디자인", "웰빙", "디지털패션", "메타버스"]
},
"2025": {
"keywords": ["퓨처리즘", "뉴테일러링", "네오클래식", "생물공학", "바이오필릭", "디지털노마드"],
"colors": ["테크니컬블루", "바이오그린", "네오코랄", "디지털퍼플", "그레이니지", "포레스트"],
"patterns": ["디지털웨이브", "네오아가일", "바이오미메틱", "테크글리치", "하이브리드"],
"items": {
"상의": ["스마트티셔츠", "모듈러블라우스", "테크니컬니트", "바이오데리브드탑", "퓨전셔츠"],
"하의": ["어댑티브팬츠", "네오카고", "디지털프린트스커트", "바이오텍스쇼츠", "모듈러진"],
"아우터": ["스마트재킷", "퓨처테일러링코트", "어댑티브블레이저", "테크니컬트렌치", "바이오파브릭점퍼"],
"신발": ["3D프린팅슈즈", "바이오데리브드스니커즈", "스마트샌들", "어댑티브부츠", "모듈러플랫"]
},
"brands": ["아크네스튜디오", "메종마르지엘라", "프라다", "디올", "스텔라매카트니"],
"influences": ["바이오디자인", "웨어러블기술", "메타버스", "디지털노마드", "기후적응패션"]
}
},
"last_updated": datetime.datetime.now().isoformat()
}
# 디렉토리 생성 및 저장
os.makedirs(os.path.dirname(data_path), exist_ok=True)
with open(data_path, 'w', encoding='utf-8') as f:
json.dump(self.data, f, ensure_ascii=False, indent=4)
# 계절별 스타일 사전 구축
self._build_seasonal_style_dict()
def _needs_update(self):
"""트렌드 데이터 업데이트 필요 여부 확인"""
try:
last_updated = datetime.datetime.fromisoformat(self.data.get("last_updated", "2000-01-01"))
days_since_update = (datetime.datetime.now() - last_updated).days
return days_since_update >= self.trend_update_interval
except (ValueError, TypeError):
return True
def _update_trend_data(self):
"""트렌드 데이터 업데이트 (실제 구현에서는 외부 API 호출 또는 크롤링)"""
# 여기서는 샘플 구현으로 현재 날짜만 업데이트
self.data["last_updated"] = datetime.datetime.now().isoformat()
# 변경사항 저장
with open(self.data_path, 'w', encoding='utf-8') as f:
json.dump(self.data, f, ensure_ascii=False, indent=4)
def _build_seasonal_style_dict(self):
"""계절별 스타일 사전 구축"""
self.seasonal_style_dict = {}
for season, data in self.data["seasons"].items():
# 각 계절에 대한 모든 관련 용어 수집
all_terms = []
all_terms.extend(data.get("keywords", []))
all_terms.extend(data.get("colors", []))
all_terms.extend(data.get("fabrics", []))
all_terms.extend(data.get("patterns", []))
all_terms.extend(data.get("style_keywords", []))
# 아이템 추가
for category, items in data.get("items", {}).items():
all_terms.extend(items)
# 중복 제거 및 소문자 변환
self.seasonal_style_dict[season] = [term.lower() for term in set(all_terms)]
def get_current_season(self):
"""현재 계절 반환"""
month = datetime.datetime.now().month
for season, data in self.data["seasons"].items():
if month in data["months"]:
return season
return "fall" # 기본값
def get_season_for_month(self, month):
"""특정 월에 해당하는 계절 반환"""
if not isinstance(month, int) or month < 1 or month > 12:
return None
for season, data in self.data["seasons"].items():
if month in data["months"]:
return season
return None
def get_season_keywords(self, season=None):
"""특정 계절 키워드 반환"""
if season is None:
season = self.get_current_season()
season_data = self.data["seasons"].get(season, {})
keywords = season_data.get("keywords", [])
colors = season_data.get("colors", [])
fabrics = season_data.get("fabrics", [])
patterns = season_data.get("patterns", [])
return {
"all": keywords + colors + fabrics + patterns,
"keywords": keywords,
"colors": colors,
"fabrics": fabrics,
"patterns": patterns
}
def get_season_items(self, season=None, category=None):
"""특정 계절의 아이템 반환 (optional으로 카테고리별)"""
if season is None:
season = self.get_current_season()
season_data = self.data["seasons"].get(season, {})
items_by_category = season_data.get("items", {})
# 특정 카테고리만 요청한 경우
if category:
return items_by_category.get(category, [])
# 모든 카테고리
all_items = []
for cat_items in items_by_category.values():
all_items.extend(cat_items)
return all_items
def get_current_year_trends(self):
"""현재 연도 트렌드 반환"""
year = str(datetime.datetime.now().year)
if year in self.data["trends"]:
trend_data = self.data["trends"][year]
return trend_data
# 가장 최신 트렌드 반환
years = sorted(self.data["trends"].keys())
if years:
latest_year = years[-1]
return self.data["trends"][latest_year]
return {}
def get_seasonal_keywords_for_query(self, query_terms, expanded=True):
"""쿼리와 관련된 계절 키워드 반환"""
query_text = " ".join([term.lower() for term in query_terms])
# 계절 언급 확인
seasons = {
"spring": ["봄", "spring"],
"summer": ["여름", "summer"],
"fall": ["가을", "autumn", "fall"],
"winter": ["겨울", "winter"]
}
mentioned_season = None
for season, season_terms in seasons.items():
if any(term in query_text for term in season_terms):
mentioned_season = season
break
# 계절이 언급된 경우
if mentioned_season:
season_data = self.get_season_keywords(mentioned_season)
if expanded:
return {
"season": mentioned_season,
"keywords": season_data["all"][:10], # 상위 10개만
"is_explicit": True
}
else:
return {
"season": mentioned_season,
"keywords": season_data["keywords"][:5], # 상위 5개만
"is_explicit": True
}
# 계절이 언급되지 않은 경우 현재 계절 사용
current_season = self.get_current_season()
# 쿼리 용어와 계절별 스타일 용어 비교
season_scores = {}
for season, terms in self.seasonal_style_dict.items():
# 각 계절의 용어가 쿼리에 얼마나 많이 포함되어 있는지 계산
matches = sum(1 for term in terms if term in query_text)
season_scores[season] = matches
# 가장 관련성 높은 계절 선택
if any(score > 0 for score in season_scores.values()):
best_season = max(season_scores.items(), key=lambda x: x[1])[0]
season_data = self.get_season_keywords(best_season)
if expanded:
return {
"season": best_season,
"keywords": season_data["all"][:10],
"is_explicit": False,
"score": season_scores[best_season]
}
else:
return {
"season": best_season,
"keywords": season_data["keywords"][:5],
"is_explicit": False,
"score": season_scores[best_season]
}
# 아무 관련성도 없으면 현재 계절의 키워드
season_data = self.get_season_keywords(current_season)
if expanded:
return {
"season": current_season,
"keywords": season_data["all"][:10],
"is_explicit": False,
"is_default": True
}
else:
return {
"season": current_season,
"keywords": season_data["keywords"][:5],
"is_explicit": False,
"is_default": True
}
def get_trend_keywords_for_query(self, query_terms, max_keywords=5):
"""쿼리와 관련된 트렌드 키워드 반환"""
query_text = " ".join([term.lower() for term in query_terms])
# 현재 트렌드
current_trends = self.get_current_year_trends()
# 관련 키워드 찾기
related_keywords = []
trend_keywords = current_trends.get("keywords", [])
# 쿼리에 트렌드 키워드가 포함되어 있는지 확인
mentioned_trends = [keyword for keyword in trend_keywords if keyword.lower() in query_text]
if mentioned_trends:
# 명시적으로 언급된 트렌드가 있는 경우
for trend in mentioned_trends:
# 관련 항목 수집
if "items" in current_trends:
for category, items in current_trends["items"].items():
related_keywords.extend(items)
# 색상, 패턴 추가
related_keywords.extend(current_trends.get("colors", []))
related_keywords.extend(current_trends.get("patterns", []))
else:
# 부분적으로 일치하는 트렌드 찾기
partial_matches = []
for keyword in trend_keywords:
if any(term.lower() in keyword.lower() for term in query_terms):
partial_matches.append(keyword)
if partial_matches:
related_keywords.extend(partial_matches)
# 관련 항목 일부 추가
if "items" in current_trends:
for category in ["상의", "하의", "아우터", "신발"][:2]: # 상위 2개 카테고리만
if category in current_trends["items"]:
related_keywords.extend(current_trends["items"][category][:2]) # 각 2개씩
else:
# 관련 트렌드가 없으면 일반적인 트렌드 키워드 추가
related_keywords.extend(trend_keywords[:3])
# 아이템 카테고리 하나당 하나씩 추가
if "items" in current_trends:
for category, items in current_trends["items"].items():
if items:
related_keywords.append(items[0])
# 중복 제거 및 최대 개수 제한
unique_keywords = []
for keyword in related_keywords:
if keyword.lower() not in [k.lower() for k in unique_keywords] and keyword.lower() not in query_text:
unique_keywords.append(keyword)
if len(unique_keywords) >= max_keywords:
break
return unique_keywords
def update_trend(self, year, trend_data):
"""트렌드 데이터 업데이트"""
self.data["trends"][str(year)] = trend_data
self.data["last_updated"] = datetime.datetime.now().isoformat()
# 변경사항 저장
with open(self.data_path, 'w', encoding='utf-8') as f:
json.dump(self.data, f, ensure_ascii=False, indent=4)
class FashionSynonymsManager:
"""패션 동의어 관리자"""
def __init__(self, data_path="data/fashion_synonyms.json", llm_expander=None):
"""
패션 동의어 데이터 로드
Parameters:
-----------
data_path : str
동의어 JSON 파일 경로
llm_expander : object
LLM 기반 동의어 확장기 (선택 사항)
"""
self.data_path = data_path
self.llm_expander = llm_expander
if os.path.exists(data_path):
with open(data_path, 'r', encoding='utf-8') as f:
self.synonyms = json.load(f)
else:
# 샘플 데이터
self.synonyms = {
"티셔츠": ["티", "티셔츠", "T셔츠", "Tee", "T-shirt", "티셔트", "반팔티", "탑", "top", "tshirt"],
"맨투맨": ["맨투맨", "스웻셔츠", "스웨트셔츠", "mtm", "sweatshirt", "맨투맨티셔츠"],
"청바지": ["청바지", "진", "데님", "진즈", "jeans", "denim", "청", "데님팬츠", "청팬츠"],
"스니커즈": ["스니커즈", "운동화", "스니커", "스닉커즈", "sneakers", "sneaker", "러닝화"],
"자켓": ["자켓", "재킷", "점퍼", "jacket", "아우터", "outer", "자켓", "jumper"],
"블라우스": ["블라우스", "여성셔츠", "블라우져", "blouse", "여성상의", "셔츠", "shirts", "탑", "tops"],
"가디건": ["가디건", "카디건", "cardigan", "니트가디건", "오픈가디건", "집업가디건"],
"스커트": ["스커트", "치마", "skirt", "스킷", "케이스커트", "플리츠스커트", "테니스스커트", "미니스커트"],
"원피스": ["원피스", "드레스", "dress", "one-piece", "원피", "캐주얼드레스", "롱원피스", "미니원피스"],
"패딩": ["패딩", "다운재킷", "패딩재킷", "padding", "다운", "down", "점퍼", "푸퍼", "패딩점퍼"],
"플랫슈즈": ["플랫슈즈", "플랫", "플랫슈", "flat shoes", "flats", "로퍼", "플랫폼", "로퍼"],
"슬랙스": ["슬랙스", "정장바지", "면바지", "슬렉스", "슬랙", "슬렉", "slacks", "정장팬츠", "드레스팬츠"],
"니트": ["니트", "스웨터", "knit", "sweater", "풀오버", "니트웨어", "knitwear", "울니트", "니트탑"],
"코트": ["코트", "외투", "coat", "오버코트", "롱코트", "겨울코트", "트렌치코트", "트렌치"],
"후드티": ["후드티", "후드", "후디", "hoody", "hoodie", "후드티셔츠", "후드맨투맨", "후드스웨트셔츠"],
"레깅스": ["레깅스", "타이즈", "스타킹", "leggings", "tights", "레깅", "요가팬츠", "타이츠"],
"바지": ["바지", "팬츠", "pants", "하의", "슬랙스", "진", "청바지", "트라우저", "trousers"],
"셔츠": ["셔츠", "남방", "shirt", "남자셔츠", "드레스셔츠", "옥스포드셔츠", "캐주얼셔츠"],
"크롭탑": ["크롭탑", "크롭티", "crop top", "배꼽티", "반티", "탑", "탑티", "크롭"],
"부츠": ["부츠", "boots", "부띠", "워커", "앵클부츠", "첼시부츠", "롱부츠", "미들부츠"],
"샌들": ["샌들", "sandals", "슬리퍼", "slipper", "쪼리", "플립플랍", "flip-flop", "뮬", "mule"],
"백팩": ["백팩", "가방", "backpack", "bag", "배낭", "책가방", "student가방", "등에 메는 가방"],
"크로스백": ["크로스백", "크로스백", "쌕", "crossbody", "메신저백", "숄더백", "어깨가방"],
"토트백": ["토트백", "tote", "토트", "숄더백", "쇼퍼백", "핸드백", "손가방"],
"모자": ["모자", "hat", "캡", "cap", "볼캡", "보넷", "비니", "bucket hat", "버킷햇", "패션모자"],
"안경": ["안경", "선글라스", "glasses", "sunglasses", "아이웨어", "eyewear", "고글"],
"양말": ["양말", "삭스", "socks", "스타킹", "발목양말", "롱삭스", "니삭스", "미드삭스"],
"벨트": ["벨트", "belt", "혁대", "가죽벨트", "패션벨트", "웨스트벨트", "레더벨트"],
"귀걸이": ["귀걸이", "이어링", "earring", "귀고리", "후프", "스터드", "드롭", "피어싱"],
"목걸이": ["목걸이", "넥클리스", "necklace", "초커", "펜던트", "체인", "목걸이"],
"반지": ["반지", "링", "ring", "핑거링", "레이어드링", "커플링", "실버링", "골드링"],
"팔찌": ["팔찌", "브레이슬릿", "bracelet", "뱅글", "체인팔찌", "참팔찌", "커프"],
"스카프": ["스카프", "scarf", "머플러", "숄", "목도리", "넥워머", "스톨", "반다나"],
"장갑": ["장갑", "gloves", "가죽장갑", "니트장갑", "패션장갑", "골프장갑", "손가락장갑"]
}
# 디렉토리 생성 및 저장
os.makedirs(os.path.dirname(data_path), exist_ok=True)
with open(data_path, 'w', encoding='utf-8') as f:
json.dump(self.synonyms, f, ensure_ascii=False, indent=4)
# 역방향 매핑 및 변형어 매핑 구성
self.reverse_mapping = {}
self.variant_mapping = {}
self._build_mappings()
def _build_mappings(self):
"""역방향 매핑 및 변형어 매핑 구성"""
# 역방향 매핑 (동의어 -> 표준어)
for standard, synonyms in self.synonyms.items():
for synonym in synonyms:
self.reverse_mapping[synonym.lower()] = standard
# 변형어 매핑 (철자 변형, 공백 제거 등)
for standard, synonyms in self.synonyms.items():
self.variant_mapping[standard.lower()] = standard
self.variant_mapping[standard.lower().replace(" ", "")] = standard
for synonym in synonyms:
self.variant_mapping[synonym.lower()] = standard
self.variant_mapping[synonym.lower().replace(" ", "")] = standard
# 영문 동의어의 경우 한글 발음 변형도 추가
if any(ord(char) < 128 for char in synonym): # 영문 문자 포함 여부
# 간단한 발음 변형 예시 (실제로는 더 복잡한 알고리즘 필요)
variants = self._generate_variants(synonym)
for variant in variants:
self.variant_mapping[variant.lower()] = standard
def _generate_variants(self, term):
"""영문 용어의 한글 발음 변형 생성 (간단한 구현)"""
# 실제 구현에서는 더 정교한 알고리즘 사용
variants = []
# 기본 변형 규칙 몇 가지 적용
term_lower = term.lower()
# 공백 및 하이픈 변형
variants.append(term_lower.replace(" ", ""))
variants.append(term_lower.replace("-", ""))
variants.append(term_lower.replace(" ", "-"))
variants.append(term_lower.replace("-", " "))
# 발음 변형 예시
if "shirt" in term_lower:
variants.append("셔츠")
variants.append("샤쓰")
if "pants" in term_lower:
variants.append("팬츠")
variants.append("펜츠")
if "jacket" in term_lower:
variants.append("자켓")
variants.append("재킷")
if "shoes" in term_lower:
variants.append("슈즈")
variants.append("슈")
return variants
def get_standard_term(self, term):
"""입력 용어의 표준 용어 반환"""
term_lower = term.lower()
# 정확한 매핑
if term_lower in self.reverse_mapping:
return self.reverse_mapping[term_lower]
# 변형어 매핑
if term_lower in self.variant_mapping:
return self.variant_mapping[term_lower]
# 공백 제거 후 재시도
if term_lower.replace(" ", "") in self.variant_mapping:
return self.variant_mapping[term_lower.replace(" ", "")]
# 부분 매칭 시도
for key, standard in self.reverse_mapping.items():
if term_lower in key or key in term_lower:
return standard
# LLM 동의어 확장 시도
if self.llm_expander:
try:
synonyms = self.llm_expander.get_synonyms(term, max_synonyms=1)
if synonyms and synonyms[0] in self.reverse_mapping:
return self.reverse_mapping[synonyms[0].lower()]
except:
pass
return term
def get_synonyms(self, term, include_variants=True, max_synonyms=None):
"""표준 용어의 동의어 리스트 반환"""
# 표준 용어로 변환
standard_term = self.get_standard_term(term)
# 동의어 목록
synonyms = self.synonyms.get(standard_term, [term])
# 변형어도 포함할 경우
if include_variants:
# term_lower = term.lower()
standard_lower = standard_term.lower()
variants = []
for variant, std in self.variant_mapping.items():
if std.lower() == standard_lower and variant != standard_lower:
# 원래 형태 찾기
for syn in self.synonyms.get(std, []):
if syn.lower() == variant:
variants.append(syn)
break
else:
variants.append(variant)
# 중복 제거하여 추가
for variant in variants:
if variant not in synonyms:
synonyms.append(variant)
# 최대 개수 제한
if max_synonyms is not None and len(synonyms) > max_synonyms:
return synonyms[:max_synonyms]
return synonyms
def add_synonym(self, standard_term, synonym):
"""동의어 추가"""
if standard_term in self.synonyms:
if synonym not in self.synonyms[standard_term]:
self.synonyms[standard_term].append(synonym)
self.reverse_mapping[synonym.lower()] = standard_term
# 변형어 매핑 추가
self.variant_mapping[synonym.lower()] = standard_term
self.variant_mapping[synonym.lower().replace(" ", "")] = standard_term
else:
self.synonyms[standard_term] = [standard_term, synonym]
self.reverse_mapping[standard_term.lower()] = standard_term
self.reverse_mapping[synonym.lower()] = standard_term
# 변형어 매핑 추가
self.variant_mapping[standard_term.lower()] = standard_term
self.variant_mapping[standard_term.lower().replace(" ", "")] = standard_term
self.variant_mapping[synonym.lower()] = standard_term
self.variant_mapping[synonym.lower().replace(" ", "")] = standard_term
# 변경사항 저장
with open(self.data_path, 'w', encoding='utf-8') as f:
json.dump(self.synonyms, f, ensure_ascii=False, indent=4)
def expand_with_llm(self, term, max_synonyms=5):
"""LLM을 사용한 동의어 확장"""
if not self.llm_expander:
return []
try:
# LLM으로 동의어 확장
llm_synonyms = self.llm_expander.get_synonyms(term, max_synonyms=max_synonyms)
# 기존 동의어와 중복 제거
standard_term = self.get_standard_term(term)
existing_synonyms = self.get_synonyms(standard_term)
new_synonyms = [syn for syn in llm_synonyms
if syn.lower() not in [s.lower() for s in existing_synonyms]]
return new_synonyms
except:
return []
def update_with_llm_synonyms(self, terms, threshold=2):
"""LLM으로 생성한 동의어로 사전 업데이트"""
if not self.llm_expander:
return 0
count_added = 0
for term in terms:
# 표준 용어 확인
standard_term = self.get_standard_term(term)
# LLM으로 동의어 생성
new_synonyms = self.expand_with_llm(term)
# 새 동의어 중 일정 개수 이상 추가 시 사전 업데이트
if len(new_synonyms) >= threshold:
for synonym in new_synonyms:
if standard_term in self.synonyms:
if synonym not in self.synonyms[standard_term]:
self.synonyms[standard_term].append(synonym)
count_added += 1
else:
self.synonyms[standard_term] = [standard_term, synonym]
count_added += 1
# 매핑 재구성
self._build_mappings()
# 변경사항 저장
if count_added > 0:
with open(self.data_path, 'w', encoding='utf-8') as f:
json.dump(self.synonyms, f, ensure_ascii=False, indent=4)
return count_added
class TypoCorrector:
"""오타 교정기"""
def __init__(self, data_path="data/fashion_typos.json", dictionary=None, use_typo_model=True):
"""
오타 교정기 초기화
Parameters:
-----------
data_path : str
오타 교정 데이터 파일 경로
dictionary : dict
기존 표준 용어 사전 (선택 사항)
use_typo_model : bool
오타 교정 모델 사용 여부
"""
self.data_path = data_path
self.dictionary = dictionary or {}
self.use_typo_model = use_typo_model
# 오타 교정 모델 초기화 (옵션)
self.typo_model = None
if use_typo_model:
try:
from symspellpy import SymSpell, Verbosity
self.typo_model = SymSpell(max_dictionary_edit_distance=2, prefix_length=7)
self.typo_model_loaded = False
except ImportError:
print("SymSpellPy 모듈을 찾을 수 없습니다. pip install symspellpy로 설치하세요.")
self.use_typo_model = False
if os.path.exists(data_path):
with open(data_path, 'r', encoding='utf-8') as f:
self.typo_corrections = json.load(f)
else:
# 샘플 데이터
self.typo_corrections = {
"티셔트": "티셔츠",
"티셔슽": "티셔츠",
"티셔숳": "티셔츠",
"티샤스": "티셔츠",
"티샤쓰": "티셔츠",
"청파지": "청바지",
"쳥바지": "청바지",
"청빠지": "청바지",
"쳥빠지": "청바지",
"싀커트": "스커트",
"스컷트": "스커트",
"스커츠": "스커트",
"스컷": "스커트",
"원피슷": "원피스",
"원피수": "원피스",
"웡피스": "원피스",
"워피스": "원피스",
"자켓트": "자켓",
"자캣": "자켓",
"쟈켓": "자켓",
"쟈켓트": "자켓",
"카디칸": "가디건",
"가디칸": "가디건",
"카디건": "가디건",
"카디간": "가디건",
"슬랙슷": "슬랙스",
"슬랙수": "슬랙스",
"스랙스": "슬랙스",
"슬랙쓰": "슬랙스",
"패팅": "패딩",
"패듕": "패딩",
"패링": "패딩",
"페딩": "패딩",
"후드틔": "후드티",
"후드티셔츠": "후드티",
"후드티셔슽": "후드티",
"후드탑": "후드티",
"레깅쓰": "레깅스",
"레깅": "레깅스",
"레기스": "레깅스",
"레긴스": "레깅스",
"니트웨어": "니트",
"니트웨얼": "니트",
"니트웨아": "니트",
"빗트": "니트",
"스니커즈": "스니커즈",
"스니커스": "스니커즈",
"스니컬스": "스니커즈",
"스니컼즈": "스니커즈",
"스니커즈": "스니커즈",
"백팽": "백팩",
"벡팩": "백팩",
"백엑": "백팩",
"밸팩": "백팩",
"크로스팩": "크로스백",
"크로스뱅": "크로스백",
"크로스배": "크로스백",
"크로스벡": "크로스백",
"토트백": "토트백",
"토트벡": "토트백",
"토트뱅": "토트백",
"토트뱅": "토트백",
"블라우쓰": "블라우스",
"브라우스": "블라우스",
"블라무스": "블라우스",
"블라우쯔": "블라우스",
"반티": "반팔티",
"반탑티": "반팔티",
"반팔탑": "반팔티",
"멘투멘": "맨투맨",
"맨투멘": "맨투맨",
"멘투맨": "맨투맨",
"맨투먼": "맨투맨"
}
# 디렉토리 생성 및 저장
os.makedirs(os.path.dirname(data_path), exist_ok=True)
with open(data_path, 'w', encoding='utf-8') as f:
json.dump(self.typo_corrections, f, ensure_ascii=False, indent=4)
# 표준 용어 사전 통합
self.valid_terms = set(self.typo_corrections.values())
if dictionary:
for standard, synonyms in dictionary.items():
self.valid_terms.add(standard)
self.valid_terms.update(synonyms)
# 오타 교정 모델 학습
if self.use_typo_model and self.typo_model:
self._initialize_typo_model()
def _initialize_typo_model(self):
"""오타 교정 모델 초기화"""
try:
# 기존 사전으로부터 단어 로드
for term in self.valid_terms:
self.typo_model.create_dictionary_entry(term, 1)
# 오타 교정 매핑 로드
for typo, correction in self.typo_corrections.items():
self.typo_model.create_dictionary_entry(correction, 2) # 정확한 단어에 더 높은 가중치
self.typo_model_loaded = True
except Exception as e:
print(f"오타 교정 모델 초기화 중 오류 발생: {e}")
self.use_typo_model = False
def _levenshtein_distance(self, s1, s2):
"""편집 거리 계산"""
if len(s1) < len(s2):
return self._levenshtein_distance(s2, s1)
if len(s2) == 0:
return len(s1)
previous_row = range(len(s2) + 1)
for i, c1 in enumerate(s1):
current_row = [i + 1]
for j, c2 in enumerate(s2):
insertions = previous_row[j + 1] + 1
deletions = current_row[j] + 1
substitutions = previous_row[j] + (c1 != c2)
current_row.append(min(insertions, deletions, substitutions))
previous_row = current_row
return previous_row[-1]
def _correct_with_model(self, term):
"""오타 교정 모델로 교정"""
if not self.typo_model or not self.typo_model_loaded:
return None
try:
from symspellpy import Verbosity
suggestions = self.typo_model.lookup(term, Verbosity.CLOSEST, max_edit_distance=2)
if suggestions:
return suggestions[0].term
except:
pass
return None
def correct(self, term):
"""용어 오타 교정"""
# 짧은 용어는 건너뛰기 (주로 조사, 접속사 등)
if len(term) <= 1:
return term
# 영어 또는 숫자만 있는 경우 그대로 반환
if term.isalnum() and all(ord(char) < 128 for char in term):
return term
# 소문자 변환
term_lower = term.lower()
# 1. 직접 매핑된 오타인 경우
if term_lower in self.typo_corrections:
return self.typo_corrections[term_lower]
# 2. 이미 유효한 용어인 경우
if term_lower in self.valid_terms:
return term
# 3. 오타 교정 모델 사용
if self.use_typo_model:
model_correction = self._correct_with_model(term_lower)
if model_correction and (model_correction in self.valid_terms or model_correction in self.typo_corrections.values()):
# 오타 매핑 업데이트
self.typo_corrections[term_lower] = model_correction
self._save_corrections()
return model_correction
# 4. 편집 거리를 이용한 가장 가까운 용어 찾기
min_distance = float('inf')
best_match = term
# 유효 용어와 비교
for valid_term in self.valid_terms:
distance = self._levenshtein_distance(term_lower, valid_term.lower())
if distance < min_distance and distance <= max(1, len(term) // 3): # 거리가 단어 길이의 1/3 이하인 경우만
min_distance = distance
best_match = valid_term
# 오타 교정 매핑과 비교
for typo, correction in self.typo_corrections.items():
distance = self._levenshtein_distance(term_lower, typo.lower())
if distance < min_distance and distance <= max(1, len(term) // 3):
min_distance = distance
best_match = correction
# 충분히 가까운 매칭이 있으면 오타 매핑 업데이트
if best_match != term and min_distance <= max(1, len(term) // 3):
self.typo_corrections[term_lower] = best_match
self._save_corrections()
return best_match
return term
def correct_query(self, query):
"""쿼리 전체 오타 교정"""
tokens = query.split()
corrected_tokens = [self.correct(token) for token in tokens]
corrected_query = " ".join(corrected_tokens)
# 전체 쿼리가 변경되었는지 여부
is_corrected = (corrected_query != query)
return corrected_query, is_corrected
def add_correction(self, typo, correction):
"""오타 교정 매핑 추가"""
self.typo_corrections[typo.lower()] = correction
self.valid_terms.add(correction)
# 오타 교정 모델 업데이트
if self.use_typo_model and self.typo_model and self.typo_model_loaded:
try:
self.typo_model.create_dictionary_entry(correction, 2)
except:
pass
self._save_corrections()
def _save_corrections(self):
"""오타 교정 데이터 저장"""
with open(self.data_path, 'w', encoding='utf-8') as f:
json.dump(self.typo_corrections, f, ensure_ascii=False, indent=4)
def update_from_user_feedback(self, typo_corrections, min_frequency=2):
"""사용자 피드백으로부터 오타 교정 데이터 업데이트"""
# 빈도수 기반 필터링
filtered_corrections = {}
for typo, corrections in typo_corrections.items():
if len(corrections) >= min_frequency:
# 가장 빈번한 교정 선택
correction_counts = {}
for correction in corrections:
if correction not in correction_counts:
correction_counts[correction] = 0
correction_counts[correction] += 1
best_correction = max(correction_counts.items(), key=lambda x: x[1])[0]
filtered_corrections[typo] = best_correction
# 데이터 업데이트
update_count = 0
for typo, correction in filtered_corrections.items():
if typo.lower() not in self.typo_corrections or self.typo_corrections[typo.lower()] != correction:
self.typo_corrections[typo.lower()] = correction
self.valid_terms.add(correction)
update_count += 1
# 변경사항 저장
if update_count > 0:
self._save_corrections()
# 오타 교정 모델 재학습
if self.use_typo_model and self.typo_model:
self._initialize_typo_model()
return update_count
class UserSegmentManager:
"""사용자 세그먼트 관리자"""
def __init__(self, data_path="data/user_segments.json", llm_analyzer=None):
"""
사용자 세그먼트 데이터 로드
Parameters:
-----------
data_path : str
사용자 세그먼트 JSON 파일 경로
llm_analyzer : object
LLM 기반 사용자 세그먼트 분석기 (선택 사항)
"""
self.data_path = data_path
self.llm_analyzer = llm_analyzer
if os.path.exists(data_path):
with open(data_path, 'r', encoding='utf-8') as f:
self.segments = json.load(f)
else:
# 샘플 데이터 (더 상세한 세그먼트 정의)
self.segments = {
"demographics": {
"gender": {
"male": {
"categories": ["남성복", "남성신발", "남성가방", "남성액세서리"],
"keywords": ["남성", "남자", "남성용", "맨즈", "남자옷", "신사복", "남성패션"],
"attributes": {
"fit": ["레귤러핏", "슬림핏", "오버사이즈", "와이드핏", "테이퍼드"],
"style": ["포멀", "캐주얼", "스포티", "스트릿", "댄디"]
},
"age_segments": {
"10s": ["청소년", "student", "틴에이저", "힙합", "스트릿", "캐주얼"],
"20s": ["대student", "사회초년생", "트렌디", "포멀캐주얼", "데이트룩"],
"30s": ["직장인", "비즈니스", "세미포멀", "미니멀", "댄디"],
"40s_plus": ["중년", "클래식", "고급", "럭셔리", "편안함"]
}
},
"female": {
"categories": ["여성복", "여성신발", "여성가방", "여성액세서리"],
"keywords": ["여성", "여자", "여성용", "우먼즈", "여자옷", "여성패션", "레이디스"],
"attributes": {
"fit": ["슬림핏", "루즈핏", "크롭", "하이웨이스트", "오버사이즈"],
"style": ["페미닌", "캐주얼", "모던", "로맨틱", "포멀"]
},
"age_segments": {
"10s": ["청소년", "student", "틴에이저", "걸리시", "스트릿", "캐주얼"],
"20s": ["대student", "사회초년생", "트렌디", "오피스룩", "데이트룩"],
"30s": ["직장인", "오피스웨어", "모던시크", "미니멀", "세련된"],
"40s_plus": ["중년", "우아한", "고급", "럭셔리", "편안함"]
}
},
"unisex": {
"categories": ["유니섹스", "공용", "젠더리스"],
"keywords": ["공용", "유니섹스", "남녀공용", "젠더리스", "젠더프리", "논젠더"],
"attributes": {
"fit": ["오버사이즈", "레귤러핏", "루즈핏", "박시핏", "릴렉스드"],
"style": ["캐주얼", "스트릿", "미니멀", "베이직", "모던"]
}
}
},
"age": {
"teens": {
"categories": ["청소년의류", "student복", "틴에이저패션"],
"keywords": ["청소년", "틴에이저", "student", "주니어", "Z세대", "영캐주얼"],
"attributes": {
"style": ["스트릿", "캐주얼", "스포티", "트렌디", "빈티지"],
"price": ["저가", "중저가", "합리적", "가성비"]
},
"preferences": {
"brands": ["나이키", "아디다스", "스투시", "컨버스", "반스", "챔피온"],
"items": ["후드티", "티셔츠", "조거팬츠", "스니커즈", "백팩", "볼캡"]
}
},
"20s": {
"categories": ["20대의류", "영어덜트", "트렌디패션"],
"keywords": ["20대", "대student", "신입사원", "영어덜트", "밀레니얼", "트렌디"],
"attributes": {
"style": ["캐주얼", "오피스캐주얼", "데이트룩", "트렌디", "스트릿"],
"price": ["중저가", "중가", "가성비", "합리적"]
},
"preferences": {
"brands": ["자라", "H&M", "유니클로", "무신사", "나이키", "아디다스"],
"items": ["셔츠", "블라우스", "슬랙스", "청바지", "원피스", "미니멀백"]
}
},
"30s": {
"categories": ["30대의류", "어덜트", "프로페셔널"],
"keywords": ["30대", "직장인", "사회초년생", "어덜트", "프로페셔널", "세련된"],
"attributes": {
"style": ["포멀캐주얼", "비즈니스캐주얼", "오피스웨어", "세미포멀", "미니멀"],
"price": ["중가", "중고가", "프리미엄", "투자"]
},
"preferences": {
"brands": ["COS", "마시모두띠", "타미힐피거", "폴로", "메종키츠네", "띠어리"],
"items": ["블레이저", "셔츠", "슬랙스", "코트", "가디건", "로퍼"]
}
},
"40s_plus": {
"categories": ["중장년의류", "시니어", "클래식패션"],
"keywords": ["40대", "50대", "중년", "시니어", "클래식", "고급"],
"attributes": {
"style": ["클래식", "우아한", "세련된", "컨템포러리", "럭셔리"],
"price": ["고가", "프리미엄", "럭셔리", "투자"]
},
"preferences": {
"brands": ["막스마라", "브루넬로쿠치넬리", "톰포드", "에르메스", "구찌", "프라다"],
"items": ["코트", "자켓", "니트", "슬랙스", "블라우스", "정장"]
}
}
},
"location": {
"metropolitan": {
"categories": ["도시패션", "트렌디", "모던시티"],
"keywords": ["도시", "대도시", "메트로폴리탄", "서울", "부산", "도시적인"],
"attributes": {
"style": ["모던", "시크", "세련된", "트렌디", "미니멀"],
"price": ["중고가", "고가", "프리미엄"]
}
},
"suburban": {
"categories": ["서브어반", "캐주얼", "실용적"],
"keywords": ["교외", "지방", "서브어반", "실용적인", "편안한"],
"attributes": {
"style": ["캐주얼", "실용적", "베이직", "편안한", "데일리"],
"price": ["중저가", "중가", "합리적"]
}
},
"rural": {
"categories": ["컨트리", "아웃도어", "내추럴"],
"keywords": ["시골", "지방", "컨트리", "아웃도어", "내추럴"],
"attributes": {
"style": ["컨트리", "아웃도어", "내추럴", "캐주얼", "실용적"],
"price": ["저가", "중저가", "가성비"]
}
}
}
},
"style_preferences": {
"casual": {
"categories": ["캐주얼", "데일리룩", "스트릿"],
"keywords": ["캐주얼", "데일리", "편안한", "스트릿", "일상", "기본", "꾸안꾸"],
"items": {
"상의": ["티셔츠", "맨투맨", "후드티", "니트", "면셔츠", "데님셔츠"],
"하의": ["청바지", "조거팬츠", "치노팬츠", "면바지", "트레이닝팬츠"],
"아우터": ["데님자켓", "후드집업", "봄버자켓", "가디건", "코치자켓"],
"신발": ["스니커즈", "슬립온", "컨버스", "샌들", "로퍼"],
"가방": ["백팩", "크로스백", "숄더백", "토트백", "에코백"]
},
"brands": ["유니클로", "자라", "H&M", "갭", "무신사 스탠다드", "에잇세컨즈"]
},
"formal": {
"categories": ["포멀", "정장", "비즈니스", "오피스룩"],
"keywords": ["정장", "포멀", "비즈니스", "오피스", "격식있는", "세미정장", "정통"],
"items": {
"상의": ["셔츠", "블라우스", "니트", "재킷", "블레이저"],
"하의": ["슬랙스", "정장바지", "스커트", "펜슬스커트", "울팬츠"],
"아우터": ["테일러드코트", "트렌치코트", "수트자켓", "캐시미어코트"],
"신발": ["구두", "로퍼", "옥스포드", "펌프스", "앵클부츠"],
"가방": ["서류가방", "토트백", "클러치", "숄더백", "브리프케이스"]
},
"brands": ["브룩스브라더스", "타미힐피거", "폴로", "COS", "띠어리", "막스마라"]
},
"vintage": {
"categories": ["빈티지", "레트로", "올드스쿨"],
"keywords": ["빈티지", "레트로", "올드스쿨", "클래식", "중고감성", "유행지난", "복고"],
"items": {
"상의": ["플란넬셔츠", "오버사이즈티셔츠", "레트로니트", "뱅울자켓", "청재킷"],
"하의": ["와이드진", "카고팬츠", "벨보텀", "코듀로이팬츠", "플리츠스커트"],
"아우터": ["데님자켓", "레더자켓", "트랙자켓", "베이스볼자켓", "코듀로이자켓"],
"신발": ["청키스니커즈", "워커", "컨버스", "닥터마틴", "로퍼"],
"가방": ["새들백", "버킷백", "키스톤백", "버클백"]
},
"brands": ["리바이스", "칼하트", "챔피온", "닥터마틴", "프레드페리", "반스"]
},
"minimalist": {
"categories": ["미니멀", "베이직", "심플"],
"keywords": ["미니멀", "심플", "베이직", "기본", "절제된", "모던", "깔끔한", "단정한"],
"items": {
"상의": ["화이트티셔츠", "블랙티셔츠", "그레이티셔츠", "베이직셔츠", "미니멀블라우스"],
"하의": ["블랙진", "네이비슬랙스", "베이직스커트", "테이퍼드팬츠", "크롭팬츠"],
"아우터": ["베이직코트", "심플자켓", "미니멀블레이저", "모던트렌치코트"],
"신발": ["로퍼", "화이트스니커즈", "블랙플랫", "미니멀부츠", "슬립온"],
"가방": ["토트백", "미니멀크로스백", "심플클러치", "모던숄더백"]
},
"brands": ["COS", "아크네스튜디오", "애크미드라비", "에센셜", "메종마르지엘라"]
},
"athleisure": {
"categories": ["애슬레저", "스포티", "액티브"],
"keywords": ["스포티", "애슬레저", "운동", "트레이닝", "액티브", "스포츠", "헬스", "레깅스"],
"items": {
"상의": ["스포츠브라", "래쉬가드", "트레이닝티셔츠", "후드티", "크롭탑"],
"하의": ["레깅스", "트레이닝팬츠", "조거팬츠", "반바지", "트랙팬츠"],
"아우터": ["트랙자켓", "윈드브레이커", "후드집업", "스포츠재킷", "다운베스트"],
"신발": ["런닝화", "트레이닝화", "스니커즈", "슬립온", "스포츠샌들"],
"가방": ["짐백", "러닝백", "백팩", "크로스백", "더플백"]
},
"brands": ["나이키", "아디다스", "언더아머", "룰루레몬", "뉴발란스", "푸마"]
},
"streetwear": {
"categories": ["스트릿웨어", "힙합", "어반"],
"keywords": ["스트릿", "힙합", "어반", "스케이트", "그래피티", "오버사이즈", "박시"],
"items": {
"상의": ["그래픽티셔츠", "오버사이즈후드", "스트릿맨투맨", "프린트티", "크롭탑"],
"하의": ["카고팬츠", "와이드팬츠", "트랙팬츠", "배기진", "조거팬츠"],
"아우터": ["오버사이즈자켓", "트랙자켓", "플라이트자켓", "코치자켓", "윈드브레이커"],
"신발": ["하이탑스니커즈", "에어포스", "조던", "이지부스트", "척테일러"],
"가방": ["크로스백", "슬링백", "힙색", "메신저백", "더플백"]
},
"brands": ["슈프림", "스투시", "오프화이트", "베이프", "팔라스", "카하트WIP"]
}
},
"purchase_behavior": {
"price_sensitive": {
"categories": ["세일", "할인", "가성비"],
"keywords": ["세일", "할인", "특가", "가성비", "저렴한", "바겐", "프로모션", "타임세일", "아울렛"],
"attributes": {
"price_range": ["저가", "중저가", "할인품", "세일품"],
"shopping_pattern": ["계절말세일", "타임세일", "아울렛쇼핑", "직구", "쿠폰헌터"]
},
"preferred_channels": ["아울렛", "할인몰", "소셜커머스", "직구", "이월상품", "재고처분"]
},
"premium": {
"categories": ["프리미엄", "명품", "디자이너"],
"keywords": ["명품", "프리미엄", "고급", "디자이너", "럭셔리", "하이엔드", "부티크", "익스클루시브"],
"attributes": {
"price_range": ["고가", "명품", "프리미엄", "럭셔리"],
"shopping_pattern": ["백화점", "부티크", "편집샵", "시즌오픈", "프리오더"]
},
"preferred_channels": ["백화점", "명품관", "편집샵", "플래그십스토어", "공식몰", "프리미엄몰"]
},
"trend_follower": {
"categories": ["트렌디", "신상", "인기상품"],
"keywords": ["트렌드", "유행", "인기", "핫", "신상", "베스트셀러", "트렌디", "인플루언서픽"],
"attributes": {
"price_range": ["중가", "중고가", "신상가"],
"shopping_pattern": ["시즌오픈", "신상입고", "베스트셀러", "인기검색"]
},
"preferred_channels": ["편집샵", "SPA브랜드", "온라인편집몰", "인플루언서추천", "트렌드포털"]
},
"eco_conscious": {
"categories": ["친환경", "지속가능", "업사이클"],
"keywords": ["친환경", "지속가능", "업사이클", "오가닉", "에코", "비건", "공정무역", "윤리적"],
"attributes": {
"price_range": ["중고가", "프리미엄", "가치소비"],
"shopping_pattern": ["지속가능브랜드", "업사이클제품", "윤리적소비", "오가닉소재"]
},
"preferred_channels": ["친환경브랜드", "지속가능패션", "비건패션", "업사이클브랜드", "중고거래"]
},
"impulse_buyer": {
"categories": ["충동구매", "즉흥쇼핑", "쾌락쇼핑"],
"keywords": ["임팩트", "독특한", "단독", "한정판", "특별한", "즉시구매", "지금당장"],
"attributes": {
"price_range": ["다양함", "중저가~고가", "가격불문"],
"shopping_pattern": ["타임세일", "펄스마케팅", "플래시세일", "한정판", "단독상품"]
},
"preferred_channels": ["소셜커머스", "타임커머스", "라이브커머스", "팝업스토어", "한정판매"]
},
"value_shopper": {
"categories": ["가치소비", "투자소비", "장기소유"],
"keywords": ["투자", "가치", "품질", "내구성", "클래식", "스테디셀러", "베이직", "타임리스"],
"attributes": {
"price_range": ["중고가", "고가", "적정가"],
"shopping_pattern": ["품질중시", "내구성", "클래식디자인", "시즌리스"]
},
"preferred_channels": ["공식몰", "백화점", "편집샵", "브랜드스토어", "공식아울렛"]
}
},
"fashion_persona": {
"classic_formal": {
"description": "클래식하고 포멀한 스타일을 선호하는 사용자",
"keywords": ["클래식", "포멀", "타임리스", "비즈니스", "정통", "우아한"],
"typical_segments": {
"demographics": ["30s", "40s_plus", "male", "female"],
"style": ["formal", "minimalist"],
"behavior": ["premium", "value_shopper"]
},
"preferred_items": ["정장", "셔츠", "블라우스", "슬랙스", "코트", "구두", "로퍼"],
"avoid_items": ["후드티", "조거팬츠", "스트릿웨어", "오버사이즈", "그래픽티"]
},
"trendy_youngster": {
"description": "최신 트렌드를 중시하는 젊은 사용자",
"keywords": ["트렌디", "힙", "최신", "개성있는", "감각적인", "세련된"],
"typical_segments": {
"demographics": ["teens", "20s", "male", "female"],
"style": ["streetwear", "vintage", "casual"],
"behavior": ["trend_follower", "impulse_buyer", "price_sensitive"]
},
"preferred_items": ["그래픽티", "오버사이즈", "크롭탑", "조거팬츠", "스니커즈", "볼캡"],
"avoid_items": ["정장", "클래식코트", "드레스셔츠", "포멀웨어"]
},
"active_lifestyle": {
"description": "활동적인 라이프스타일을 가진 사용자",
"keywords": ["활동적", "스포티", "건강한", "애슬레저", "아웃도어", "기능성"],
"typical_segments": {
"demographics": ["20s", "30s", "male", "female"],
"style": ["athleisure", "casual", "streetwear"],
"behavior": ["price_sensitive", "value_shopper"]
},
"preferred_items": ["레깅스", "트레이닝팬츠", "후드티", "스포츠브라", "런닝화", "윈드브레이커"],
"avoid_items": ["정장", "슬랙스", "블라우스", "하이힐", "코르셋"]
},
"conscious_consumer": {
"description": "윤리적 소비를 중시하는 사용자",
"keywords": ["윤리적", "지속가능한", "환경", "가치", "의식있는", "미니멀"],
"typical_segments": {
"demographics": ["20s", "30s", "40s_plus", "unisex"],
"style": ["minimalist", "casual", "vintage"],
"behavior": ["eco_conscious", "value_shopper"]
},
"preferred_items": ["오가닉코튼", "친환경소재", "업사이클", "베이직아이템", "중고의류"],
"avoid_items": ["패스트패션", "초저가의류", "합성소재", "트렌디아이템"]
},
"luxury_enthusiast": {
"description": "럭셔리 패션과 브랜드를 선호하는 사용자",
"keywords": ["럭셔리", "명품", "고급", "프리미엄", "디자이너", "퀄리티"],
"typical_segments": {
"demographics": ["30s", "40s_plus", "male", "female"],
"style": ["formal", "minimalist", "vintage"],
"behavior": ["premium", "value_shopper"]
},
"preferred_items": ["명품백", "디자이너의류", "프리미엄소재", "시그니처아이템", "한정판"],
"avoid_items": ["저가브랜드", "가성비아이템", "패스트패션", "복제품"]
}
}
}
# 디렉토리 생성 및 저장
os.makedirs(os.path.dirname(data_path), exist_ok=True)
with open(data_path, 'w', encoding='utf-8') as f:
json.dump(self.segments, f, ensure_ascii=False, indent=4)
# 완전한 키워드 집합 구성
self.all_keywords = {}
self._build_keyword_mappings()
def _build_keyword_mappings(self):
"""모든 세그먼트 키워드 매핑 구성"""
# 인구통계학적 키워드
self.all_keywords["demographics"] = {}
for demo_type, segments in self.segments["demographics"].items():
self.all_keywords["demographics"][demo_type] = {}
for segment, details in segments.items():
self.all_keywords["demographics"][demo_type][segment] = details.get("keywords", [])
# 스타일 선호도 키워드
self.all_keywords["style"] = {}
for style, details in self.segments["style_preferences"].items():
self.all_keywords["style"][style] = details.get("keywords", [])
# 구매 행동 키워드
self.all_keywords["behavior"] = {}
for behavior, details in self.segments["purchase_behavior"].items():
self.all_keywords["behavior"][behavior] = details.get("keywords", [])
# 패션 페르소나 키워드
self.all_keywords["persona"] = {}
for persona, details in self.segments["fashion_persona"].items():
self.all_keywords["persona"][persona] = details.get("keywords", [])
def get_segment_keywords(self, segment_path):
"""특정 세그먼트의 키워드 반환"""
segments = self.segments
path_parts = segment_path.split(".")
for part in path_parts:
if part in segments:
segments = segments[part]
else:
return []
if isinstance(segments, dict) and "keywords" in segments:
return segments["keywords"]
return []
def get_segment_items(self, segment_path):
"""특정 세그먼트의 아이템 반환"""
segments = self.segments
path_parts = segment_path.split(".")
for part in path_parts:
if part in segments:
segments = segments[part]
else:
return {}
if isinstance(segments, dict) and "items" in segments:
return segments["items"]
return {}
def get_user_segment_keywords(self, user_profile):
"""사용자 프로필 기반 세그먼트 키워드 반환"""
keywords = []
# 성별 키워드
if "gender" in user_profile:
gender = user_profile["gender"].lower()
if gender in ["male", "female", "unisex"]:
keywords.extend(self.get_segment_keywords(f"demographics.gender.{gender}"))
# 연령대 키워드
if "age" in user_profile:
age = user_profile["age"]
age_segment = None
if age < 20:
age_segment = "teens"
elif 20 <= age < 30:
age_segment = "20s"
elif 30 <= age < 40:
age_segment = "30s"
else:
age_segment = "40s_plus"
keywords.extend(self.get_segment_keywords(f"demographics.age.{age_segment}"))
# 성별-연령 특화 키워드
if "gender" in user_profile:
gender = user_profile["gender"].lower()
if gender in ["male", "female"] and f"age_segments.{age_segment}" in self.segments["demographics"]["gender"][gender]:
age_specific = self.segments["demographics"]["gender"][gender]["age_segments"][age_segment]
keywords.extend(age_specific)
# 지역 키워드
if "location" in user_profile:
location_type = user_profile["location"].lower()
if location_type in ["metropolitan", "suburban", "rural"]:
keywords.extend(self.get_segment_keywords(f"demographics.location.{location_type}"))
# 스타일 선호도 키워드
if "style_preferences" in user_profile:
for style in user_profile["style_preferences"]:
if style in self.segments["style_preferences"]:
keywords.extend(self.get_segment_keywords(f"style_preferences.{style}"))
# 구매 행동 키워드
if "purchase_behavior" in user_profile:
for behavior in user_profile["purchase_behavior"]:
if behavior in self.segments["purchase_behavior"]:
keywords.extend(self.get_segment_keywords(f"purchase_behavior.{behavior}"))
# 패션 페르소나 키워드 (선택 사항)
if "fashion_persona" in user_profile:
for persona in user_profile["fashion_persona"]:
if persona in self.segments["fashion_persona"]:
keywords.extend(self.get_segment_keywords(f"fashion_persona.{persona}"))
# 페르소나 자동 인퍼런스
if "fashion_persona" not in user_profile and self.llm_analyzer:
try:
inferred_persona = self.llm_analyzer.infer_fashion_persona(user_profile)
if inferred_persona and inferred_persona in self.segments["fashion_persona"]:
keywords.extend(self.get_segment_keywords(f"fashion_persona.{inferred_persona}"))
except:
pass
return list(set(keywords)) # 중복 제거하여 반환
def get_user_segment_items(self, user_profile, category=None):
"""사용자 프로필 기반 세그먼트 아이템 반환"""
all_items = {}
# 스타일 선호도 기반 아이템
if "style_preferences" in user_profile:
for style in user_profile["style_preferences"]:
if style in self.segments["style_preferences"]:
items = self.get_segment_items(f"style_preferences.{style}")
for cat, cat_items in items.items():
if category and cat != category:
continue
if cat not in all_items:
all_items[cat] = []
all_items[cat].extend(cat_items)
# 페르소나 기반 추천 아이템
if "fashion_persona" in user_profile:
for persona in user_profile["fashion_persona"]:
if persona in self.segments["fashion_persona"]:
preferred_items = self.segments["fashion_persona"][persona].get("preferred_items", [])
if not category:
if "추천" not in all_items:
all_items["추천"] = []
all_items["추천"].extend(preferred_items)
# 각 카테고리 내 중복 제거
for cat in all_items:
all_items[cat] = list(set(all_items[cat]))
# 특정 카테고리만 요청한 경우
if category and category in all_items:
return all_items[category]
return all_items
def identify_user_segments(self, query_terms, user_profile=None):
"""쿼리 및 사용자 프로필에서 세그먼트 식별"""
query_text = " ".join([term.lower() for term in query_terms])
# 세그먼트 점수 초기화
segment_scores = {
"demographics": {
"gender": {},
"age": {},
"location": {}
},
"style": {},
"behavior": {},
"persona": {}
}
# 쿼리에서 키워드 매칭으로 세그먼트 식별
for category, subcategories in self.all_keywords.items():
for subcategory, segments in subcategories.items():
for segment, keywords in segments.items():
score = 0
for keyword in keywords:
if keyword.lower() in query_text:
score += 1
if score > 0:
segment_scores[category][subcategory][segment] = score
# 사용자 프로필이 있으면 가중치 부여
if user_profile:
# 성별
if "gender" in user_profile:
gender = user_profile["gender"].lower()
if gender in segment_scores["demographics"]["gender"]:
segment_scores["demographics"]["gender"][gender] += 5
else:
segment_scores["demographics"]["gender"][gender] = 5
# 연령대
if "age" in user_profile:
age = user_profile["age"]
age_segment = None
if age < 20:
age_segment = "teens"
elif 20 <= age < 30:
age_segment = "20s"
elif 30 <= age < 40:
age_segment = "30s"
else:
age_segment = "40s_plus"
if age_segment in segment_scores["demographics"]["age"]:
segment_scores["demographics"]["age"][age_segment] += 5
else:
segment_scores["demographics"]["age"][age_segment] = 5
# 지역
if "location" in user_profile:
location = user_profile["location"].lower()
if location in segment_scores["demographics"]["location"]:
segment_scores["demographics"]["location"][location] += 5
else:
segment_scores["demographics"]["location"][location] = 5
# 스타일 선호도
if "style_preferences" in user_profile:
for style in user_profile["style_preferences"]:
if style in segment_scores["style"]:
segment_scores["style"][style] += 5
else:
segment_scores["style"][style] = 5
# 구매 행동
if "purchase_behavior" in user_profile:
for behavior in user_profile["purchase_behavior"]:
if behavior in segment_scores["behavior"]:
segment_scores["behavior"][behavior] += 5
else:
segment_scores["behavior"][behavior] = 5
# 패션 페르소나
if "fashion_persona" in user_profile:
for persona in user_profile["fashion_persona"]:
if persona in segment_scores["persona"]:
segment_scores["persona"][persona] += 5
else:
segment_scores["persona"][persona] = 5
# 최종 세그먼트 선택 (각 카테고리별 최고 점수)
identified_segments = {}
for category, subcategories in segment_scores.items():
if category == "demographics":
identified_segments[category] = {}
for subcategory, segments in subcategories.items():
if segments: # 빈 세그먼트가 아닌 경우
top_segment = max(segments.items(), key=lambda x: x[1])
if top_segment[1] > 0: # 점수가 0보다 크면
identified_segments[category][subcategory] = top_segment[0]
else:
if subcategories: # 빈 세그먼트가 아닌 경우
top_segments = sorted(subcategories.items(), key=lambda x: x[1], reverse=True)
if top_segments and top_segments[0][1] > 0: # 점수가 0보다 크면
identified_segments[category] = [s[0] for s in top_segments if s[1] > 0][:2] # 상위 2개
return identified_segments
def get_segment_expansion_keywords(self, identified_segments, max_keywords=5):
"""식별된 세그먼트를 기반으로 확장 키워드 추출"""
expansion_keywords = []
# 인구통계학적 세그먼트
if "demographics" in identified_segments:
for subcategory, segment in identified_segments["demographics"].items():
keywords = self.get_segment_keywords(f"demographics.{subcategory}.{segment}")
expansion_keywords.extend(keywords[:2]) # 상위 2개만
# 스타일 세그먼트
if "style" in identified_segments:
for style in identified_segments["style"]:
keywords = self.get_segment_keywords(f"style_preferences.{style}")
expansion_keywords.extend(keywords[:2]) # 상위 2개만
# 구매 행동 세그먼트
if "behavior" in identified_segments:
for behavior in identified_segments["behavior"]:
keywords = self.get_segment_keywords(f"purchase_behavior.{behavior}")
expansion_keywords.extend(keywords[:2]) # 상위 2개만
# 페르소나 세그먼트
if "persona" in identified_segments:
for persona in identified_segments["persona"]:
keywords = self.get_segment_keywords(f"fashion_persona.{persona}")
expansion_keywords.extend(keywords[:2]) # 상위 2개만
# 중복 제거 및 최대 개수 제한
unique_keywords = list(set(expansion_keywords))
if len(unique_keywords) > max_keywords:
return unique_keywords[:max_keywords]
return unique_keywords
def update_segment(self, segment_path, data):
"""세그먼트 데이터 업데이트"""
segments = self.segments
path_parts = segment_path.split(".")
# 마지막 부분을 제외한 경로로 이동
current = segments
for i, part in enumerate(path_parts[:-1]):
if part not in current:
current[part] = {}
current = current[part]
# 마지막 부분 업데이트
current[path_parts[-1]] = data
# 변경사항 저장
with open(self.data_path, 'w', encoding='utf-8') as f:
json.dump(self.segments, f, ensure_ascii=False, indent=4)
# 키워드 매핑 재구성
self._build_keyword_mappings()
class UserProfileStore:
"""사용자 프로필 저장소"""
def __init__(self, data_path="data/user_profiles.json", llm_segmenter=None):
"""
사용자 프로필 데이터 로드
Parameters:
-----------
data_path : str
사용자 프로필 JSON 파일 경로
llm_segmenter : object
LLM 기반 사용자 세그먼트 분석기 (선택 사항)
"""
self.data_path = data_path
self.llm_segmenter = llm_segmenter
if os.path.exists(data_path):
with open(data_path, 'r', encoding='utf-8') as f:
self.profiles = json.load(f)
else:
# 샘플 데이터 (더 상세한 사용자 프로필)
self.profiles = {
"user1": {
"gender": "female",
"age": 28,
"location": "metropolitan",
"style_preferences": ["casual", "minimalist"],
"purchase_behavior": ["price_sensitive", "trend_follower"],
"fashion_persona": ["trendy_youngster"],
"recent_searches": [
"여름 원피스", "스트라이프 셔츠", "린넨 팬츠",
"미니멀 백", "화이트 스니커즈", "크롭티 코디"
],
"recent_purchases": [
"플로럴 미니 원피스", "화이트 티셔츠", "데님 쇼츠",
"리넨 블라우스", "스트랩 샌들"
],
"favorite_brands": ["자라", "H&M", "유니클로", "무신사 스탠다드"],
"favorite_categories": ["원피스", "블라우스", "스니커즈", "가방"],
"size_preferences": {
"상의": "M",
"하의": "28",
"신발": "240"
},
"color_preferences": ["화이트", "베이지", "블랙", "파스텔"],
"browsing_history": {
"viewed_products": [
"P12345", "P23456", "P34567", "P45678", "P56789"
],
"viewed_categories": [
"블라우스", "원피스", "스니커즈", "가방", "청바지"
]
},
"purchase_history_stats": {
"avg_price_range": "중저가",
"frequent_purchase_categories": ["상의", "신발", "원피스"],
"seasonal_preferences": {
"spring": ["블라우스", "청바지", "트렌치코트"],
"summer": ["원피스", "반바지", "샌들"],
"fall": ["니트", "자켓", "롱스커트"],
"winter": ["코트", "부츠", "스웨터"]
}
},
"last_activity": "2025-02-25T14:22:11"
},
"user2": {
"gender": "male",
"age": 35,
"location": "metropolitan",
"style_preferences": ["formal", "minimalist"],
"purchase_behavior": ["premium", "value_shopper"],
"fashion_persona": ["classic_formal"],
"recent_searches": [
"남성 정장", "가죽 구두", "코튼 셔츠",
"캐시미어 코트", "비즈니스 캐주얼", "니트 베스트"
],
"recent_purchases": [
"네이비 수트", "옥스포드 셔츠", "브라운 로퍼",
"캐시미어 니트", "트렌치코트"
],
"favorite_brands": ["브룩스브라더스", "타미힐피거", "폴로", "COS"],
"favorite_categories": ["수트", "셔츠", "구두", "코트"],
"size_preferences": {
"상의": "L",
"하의": "32",
"신발": "275"
},
"color_preferences": ["네이비", "그레이", "블랙", "브라운"],
"browsing_history": {
"viewed_products": [
"P67890", "P78901", "P89012", "P90123", "P01234"
],
"viewed_categories": [
"수트", "셔츠", "구두", "니트", "코트"
]
},
"purchase_history_stats": {
"avg_price_range": "중고가",
"frequent_purchase_categories": ["수트", "셔츠", "코트"],
"seasonal_preferences": {
"spring": ["라이트코트", "가디건", "치노팬츠"],
"summer": ["린넨셔츠", "드레스셔츠", "로퍼"],
"fall": ["니트", "블레이저", "트렌치코트"],
"winter": ["코트", "울자켓", "캐시미어"]
}
},
"last_activity": "2025-02-26T09:13:44"
},
"user3": {
"gender": "female",
"age": 22,
"location": "metropolitan",
"style_preferences": ["vintage", "casual", "streetwear"],
"purchase_behavior": ["trend_follower", "price_sensitive"],
"fashion_persona": ["trendy_youngster"],
"recent_searches": [
"오버사이즈 후드티", "Y2K 패션", "카고팬츠",
"크롭 맨투맨", "복고풍 안경", "레트로 자켓"
],
"recent_purchases": [
"크롭 티셔츠", "와이드 데님", "버킷햇",
"레트로 선글라스", "청자켓", "플랫폼 스니커즈"
],
"favorite_brands": ["스투시", "칼하트", "나이키", "컨버스", "반스"],
"favorite_categories": ["맨투맨", "후드티", "데님", "스니커즈"],
"size_preferences": {
"상의": "S",
"하의": "26",
"신발": "235"
},
"color_preferences": ["블랙", "그레이", "블루", "그린"],
"browsing_history": {
"viewed_products": [
"P12121", "P23232", "P34343", "P45454", "P56565"
],
"viewed_categories": [
"후드티", "청바지", "스니커즈", "자켓", "모자"
]
},
"purchase_history_stats": {
"avg_price_range": "중저가",
"frequent_purchase_categories": ["상의", "스니커즈", "모자"],
"seasonal_preferences": {
"spring": ["청자켓", "후드티", "청바지"],
"summer": ["그래픽티", "반바지", "버킷햇"],
"fall": ["후드티", "맨투맨", "자켓"],
"winter": ["패딩", "니트", "후드집업"]
}
},
"last_activity": "2025-02-25T21:34:09"
}
}
# 디렉토리 생성 및 저장
os.makedirs(os.path.dirname(data_path), exist_ok=True)
with open(data_path, 'w', encoding='utf-8') as f:
json.dump(self.profiles, f, ensure_ascii=False, indent=4)
def get_profile(self, user_id):
"""사용자 프로필 반환"""
return self.profiles.get(user_id, {})
def update_profile(self, user_id, profile_data):
"""사용자 프로필 업데이트"""
if user_id in self.profiles:
self.profiles[user_id].update(profile_data)
else:
self.profiles[user_id] = profile_data
# 마지막 활동 시간 업데이트
self.profiles[user_id]["last_activity"] = datetime.datetime.now().isoformat()
# 변경사항 저장
with open(self.data_path, 'w', encoding='utf-8') as f:
json.dump(self.profiles, f, ensure_ascii=False, indent=4)
def add_search_query(self, user_id, query):
"""사용자 검색 기록 추가"""
if user_id not in self.profiles:
# 새 사용자 프로필 생성
self.profiles[user_id] = {
"recent_searches": [],
"browsing_history": {
"viewed_categories": [],
"viewed_products": []
},
"last_activity": datetime.datetime.now().isoformat()
}
if "recent_searches" not in self.profiles[user_id]:
self.profiles[user_id]["recent_searches"] = []
# 중복 검색어 제거
if query in self.profiles[user_id]["recent_searches"]:
self.profiles[user_id]["recent_searches"].remove(query)
# 최근 검색어 추가 (최대 20개 유지)
self.profiles[user_id]["recent_searches"].insert(0, query)
self.profiles[user_id]["recent_searches"] = self.profiles[user_id]["recent_searches"][:20]
# 마지막 활동 시간 업데이트
self.profiles[user_id]["last_activity"] = datetime.datetime.now().isoformat()
# LLM 세그먼트 분석기가 있으면 사용자 세그먼트 업데이트
if self.llm_segmenter and "style_preferences" not in self.profiles[user_id]:
try:
search_history = self.profiles[user_id]["recent_searches"]
if len(search_history) >= 3: # 최소 3번의 검색이 있을 때
segments = self.llm_segmenter.analyze_search_history(search_history)
if segments:
# 세그먼트 정보 업데이트
for key, value in segments.items():
if key not in self.profiles[user_id]:
self.profiles[user_id][key] = value
except:
pass
# 변경사항 저장
with open(self.data_path, 'w', encoding='utf-8') as f:
json.dump(self.profiles, f, ensure_ascii=False, indent=4)
def add_purchase(self, user_id, product_data):
"""사용자 구매 기록 추가"""
if not isinstance(product_data, dict):
# 문자열인 경우 간단한 제품명으로 처리
product_data = {"product_name": product_data}
if user_id not in self.profiles:
# 새 사용자 프로필 생성
self.profiles[user_id] = {
"recent_purchases": [],
"last_activity": datetime.datetime.now().isoformat()
}
if "recent_purchases" not in self.profiles[user_id]:
self.profiles[user_id]["recent_purchases"] = []
# 중복 구매 처리
product_name = product_data.get("product_name", "")
for i, purchase in enumerate(self.profiles[user_id]["recent_purchases"]):
if isinstance(purchase, dict) and purchase.get("product_name") == product_name:
self.profiles[user_id]["recent_purchases"].pop(i)
break
elif isinstance(purchase, str) and purchase == product_name:
self.profiles[user_id]["recent_purchases"].pop(i)
break
# 최근 구매 추가 (최대 20개 유지)
self.profiles[user_id]["recent_purchases"].insert(0, product_data)
self.profiles[user_id]["recent_purchases"] = self.profiles[user_id]["recent_purchases"][:20]
# 구매 통계 업데이트
if "purchase_history_stats" not in self.profiles[user_id]:
self.profiles[user_id]["purchase_history_stats"] = {
"avg_price_range": "중저가",
"frequent_purchase_categories": [],
"seasonal_preferences": {
"spring": [], "summer": [], "fall": [], "winter": []
}
}
# 카테고리 빈도 업데이트
if "category" in product_data:
category = product_data["category"]
frequent_cats = self.profiles[user_id]["purchase_history_stats"]["frequent_purchase_categories"]
if category in frequent_cats:
# 이미 있는 카테고리면 앞으로 이동
frequent_cats.remove(category)
frequent_cats.insert(0, category)
# 최대 5개 유지
self.profiles[user_id]["purchase_history_stats"]["frequent_purchase_categories"] = frequent_cats[:5]
# 계절별 선호도 업데이트
if "season" in product_data:
season = product_data["season"].lower()
if season in ["spring", "summer", "fall", "winter"]:
if "category" in product_data:
category = product_data["category"]
seasonal_prefs = self.profiles[user_id]["purchase_history_stats"]["seasonal_preferences"][season]
if category in seasonal_prefs:
seasonal_prefs.remove(category)
seasonal_prefs.insert(0, category)
# 최대 5개 유지
self.profiles[user_id]["purchase_history_stats"]["seasonal_preferences"][season] = seasonal_prefs[:5]
# 마지막 활동 시간 업데이트
self.profiles[user_id]["last_activity"] = datetime.datetime.now().isoformat()
# LLM 세그먼트 분석기가 있고 구매 이력이 충분하면 사용자 세그먼트 업데이트
if self.llm_segmenter and len(self.profiles[user_id]["recent_purchases"]) >= 5:
try:
purchases = self.profiles[user_id]["recent_purchases"]
segments = self.llm_segmenter.analyze_purchase_history(purchases)
if segments:
# 세그먼트 정보 업데이트
for key, value in segments.items():
if key in ["style_preferences", "purchase_behavior", "fashion_persona"]:
if key not in self.profiles[user_id]:
self.profiles[user_id][key] = value
else:
# 기존 세그먼트와 병합 (중복 제거)
current = set(self.profiles[user_id][key])
new = set(value)
self.profiles[user_id][key] = list(current.union(new))
except:
pass
# 변경사항 저장
with open(self.data_path, 'w', encoding='utf-8') as f:
json.dump(self.profiles, f, ensure_ascii=False, indent=4)
def add_view(self, user_id, product_id, category=None):
"""사용자 상품 조회 기록 추가"""
if user_id not in self.profiles:
# 새 사용자 프로필 생성
self.profiles[user_id] = {
"browsing_history": {
"viewed_products": [],
"viewed_categories": []
},
"last_activity": datetime.datetime.now().isoformat()
}
if "browsing_history" not in self.profiles[user_id]:
self.profiles[user_id]["browsing_history"] = {
"viewed_products": [],
"viewed_categories": []
}
# 상품 ID 추가
viewed_products = self.profiles[user_id]["browsing_history"]["viewed_products"]
if product_id in viewed_products:
viewed_products.remove(product_id)
viewed_products.insert(0, product_id)
self.profiles[user_id]["browsing_history"]["viewed_products"] = viewed_products[:50] # 최대 50개
# 카테고리 정보가 있으면 추가
if category:
viewed_categories = self.profiles[user_id]["browsing_history"]["viewed_categories"]
if category in viewed_categories:
viewed_categories.remove(category)
viewed_categories.insert(0, category)
self.profiles[user_id]["browsing_history"]["viewed_categories"] = viewed_categories[:20] # 최대 20개
# 마지막 활동 시간 업데이트
self.profiles[user_id]["last_activity"] = datetime.datetime.now().isoformat()
# 변경사항 저장 (빈도 줄이기 위해 20번마다 저장)
if sum(1 for profile in self.profiles.values()
for product in profile.get("browsing_history", {}).get("viewed_products", [])
if product == product_id) % 20 == 0:
with open(self.data_path, 'w', encoding='utf-8') as f:
json.dump(self.profiles, f, ensure_ascii=False, indent=4)
def get_user_preferences(self, user_id, preference_type=None):
"""사용자 선호도 정보 반환"""
if user_id not in self.profiles:
return {}
profile = self.profiles[user_id]
if preference_type == "style":
return profile.get("style_preferences", [])
elif preference_type == "brand":
return profile.get("favorite_brands", [])
elif preference_type == "category":
return profile.get("favorite_categories", [])
elif preference_type == "color":
return profile.get("color_preferences", [])
elif preference_type == "size":
return profile.get("size_preferences", {})
elif preference_type == "season":
return profile.get("purchase_history_stats", {}).get("seasonal_preferences", {})
# 모든 선호도 반환
preferences = {}
if "style_preferences" in profile:
preferences["style"] = profile["style_preferences"]
if "favorite_brands" in profile:
preferences["brand"] = profile["favorite_brands"]
if "favorite_categories" in profile:
preferences["category"] = profile["favorite_categories"]
if "color_preferences" in profile:
preferences["color"] = profile["color_preferences"]
if "size_preferences" in profile:
preferences["size"] = profile["size_preferences"]
if "purchase_history_stats" in profile and "seasonal_preferences" in profile["purchase_history_stats"]:
preferences["season"] = profile["purchase_history_stats"]["seasonal_preferences"]
return preferences
def get_user_preference_keywords(self, user_id, max_keywords=10):
"""사용자 선호도 기반 키워드 추출"""
if user_id not in self.profiles:
return []
profile = self.profiles[user_id]
keywords = []
# 스타일 선호도 추가
if "style_preferences" in profile:
keywords.extend(profile["style_preferences"])
# 브랜드 선호도 추가
if "favorite_brands" in profile:
keywords.extend(profile["favorite_brands"][:3]) # 상위 3개만
# 카테고리 선호도 추가
if "favorite_categories" in profile:
keywords.extend(profile["favorite_categories"][:3]) # 상위 3개만
# 색상 선호도 추가
if "color_preferences" in profile:
keywords.extend(profile["color_preferences"][:3]) # 상위 3개만
# 최근 검색어에서 키워드 추출
if "recent_searches" in profile:
# 간단한 키워드 추출 (실제 구현에서는 더 정교한 알고리즘 사용)
for query in profile["recent_searches"][:5]: # 최근 5개 검색어
# 한 단어 이상의 용어 추출
terms = [term for term in query.split() if len(term) > 1]
keywords.extend(terms[:2]) # 각 검색어에서 최대 2개 용어
# 최근 구매에서 키워드 추출
if "recent_purchases" in profile:
for purchase in profile["recent_purchases"][:5]: # 최근
if isinstance(purchase, dict):
if "product_name" in purchase:
# 제품명에서 단어 추출
terms = [term for term in purchase["product_name"].split() if len(term) > 1]
keywords.extend(terms[:2])
if "category" in purchase:
keywords.append(purchase["category"])
elif isinstance(purchase, str):
# 제품명에서 단어 추출
terms = [term for term in purchase.split() if len(term) > 1]
keywords.extend(terms[:2])
# 중복 제거 및 최대 개수 제한
unique_keywords = []
for keyword in keywords:
if keyword.lower() not in [k.lower() for k in unique_keywords]:
unique_keywords.append(keyword)
if len(unique_keywords) > max_keywords:
return unique_keywords[:max_keywords]
return unique_keywords
class LLMUserAnalyzer:
"""LLM 기반 사용자 분석기"""
def __init__(self, model="gpt-3.5-turbo", user_segment_manager=None):
"""
LLM 기반 사용자 분석기 초기화
Parameters:
-----------
model : str
사용할 LLM 모델명
user_segment_manager : UserSegmentManager
사용자 세그먼트 관리자 (선택 사항)
"""
self.model = model
self.user_segment_manager = user_segment_manager
def infer_fashion_persona(self, user_profile):
"""사용자 프로필에서 패션 페르소나 인퍼런스"""
try:
import openai
# 프로필에서 주요 정보 추출
age = user_profile.get("age", "")
gender = user_profile.get("gender", "")
style_prefs = user_profile.get("style_preferences", [])
purchase_behavior = user_profile.get("purchase_behavior", [])
recent_searches = user_profile.get("recent_searches", [])[:5] # 최근 5개
recent_purchases = user_profile.get("recent_purchases", [])[:5] # 최근 5개
# 페르소나 목록
personas = ["classic_formal", "trendy_youngster", "active_lifestyle",
"conscious_consumer", "luxury_enthusiast"]
# 페르소나 설명
if self.user_segment_manager:
persona_descriptions = {
persona: self.user_segment_manager.segments["fashion_persona"][persona]["description"]
for persona in personas
}
else:
persona_descriptions = {
"classic_formal": "클래식하고 포멀한 스타일을 선호하는 사용자",
"trendy_youngster": "최신 트렌드를 중시하는 젊은 사용자",
"active_lifestyle": "활동적인 라이프스타일을 가진 사용자",
"conscious_consumer": "윤리적 소비를 중시하는 사용자",
"luxury_enthusiast": "럭셔리 패션과 브랜드를 선호하는 사용자"
}
# 프롬프트 구성
prompt = f"""
다음은 이커머스 패션 사이트 사용자의 프로필 정보입니다.
- 나이: {age}
- 성별: {gender}
- 스타일 선호도: {', '.join(style_prefs) if style_prefs else '정보 없음'}
- 구매 행동: {', '.join(purchase_behavior) if purchase_behavior else '정보 없음'}
- 최근 검색어: {', '.join(recent_searches) if recent_searches else '정보 없음'}
- 최근 구매: {', '.join(recent_purchases) if recent_purchases else '정보 없음'}
다음 패션 페르소나 중에서 이 사용자에게 가장 적합한 것을 하나만 선택하세요:
{', '.join([f"{persona}: {desc}" for persona, desc in persona_descriptions.items()])}
가장 적합한 패션 페르소나 하나만 응답해주세요. 다른 설명은 필요 없습니다.
"""
# LLM 호출
response = openai.ChatCompletion.create(
model=self.model,
messages=[
{"role": "system", "content": "당신은 패션 스타일링 전문가입니다."},
{"role": "user", "content": prompt}
],
temperature=0.3,
max_tokens=50
)
# 응답 처리
result = response.choices[0].message.content.strip().lower()
# 페르소나 추출
for persona in personas:
if persona.lower() in result:
return persona
return None
except Exception as e:
print(f"패션 페르소나 인퍼런스 오류: {e}")
return None
def analyze_search_history(self, search_history, num_segments=2):
"""검색 기록 분석으로 사용자 세그먼트 인퍼런스"""
try:
import openai
# 세그먼트 정보
if self.user_segment_manager:
style_segments = list(self.user_segment_manager.segments["style_preferences"].keys())
behavior_segments = list(self.user_segment_manager.segments["purchase_behavior"].keys())
# 스타일 세그먼트 설명
style_descriptions = {
style: ", ".join(self.user_segment_manager.segments["style_preferences"][style].get("keywords", [])[:3])
for style in style_segments
}
# 구매 행동 세그먼트 설명
behavior_descriptions = {
behavior: ", ".join(self.user_segment_manager.segments["purchase_behavior"][behavior].get("keywords", [])[:3])
for behavior in behavior_segments
}
else:
# 기본 세그먼트
style_segments = ["casual", "formal", "vintage", "minimalist", "athleisure", "streetwear"]
behavior_segments = ["price_sensitive", "premium", "trend_follower", "eco_conscious"]
# 간단한 설명
style_descriptions = {
"casual": "캐주얼, 데일리, 편안한",
"formal": "정장, 포멀, 비즈니스",
"vintage": "빈티지, 레트로, 올드스쿨",
"minimalist": "미니멀, 심플, 기본",
"athleisure": "스포티, 애슬레저, 액티브",
"streetwear": "스트릿, 힙합, 어반"
}
behavior_descriptions = {
"price_sensitive": "세일, 할인, 가성비",
"premium": "프리미엄, 명품, 고급",
"trend_follower": "트렌드, 유행, 인기",
"eco_conscious": "친환경, 지속가능, 윤리적"
}
# 검색어 목록 문자열 생성
searches = "\n".join([f"- {search}" for search in search_history[:10]]) # 최근 10개만
# 프롬프트 구성
prompt = f"""
다음은 패션 이커머스 사이트에서 사용자의 최근 검색 기록입니다.
{searches}
이 검색 기록을 바탕으로 사용자의 패션 스타일 선호도와 구매 행동 패턴을 분석해주세요.
스타일 선호도 옵션 (설명 포함):
{', '.join([f"{style}({desc})" for style, desc in style_descriptions.items()])}
구매 행동 패턴 옵션 (설명 포함):
{', '.join([f"{behavior}({desc})" for behavior, desc in behavior_descriptions.items()])}
위 검색 기록에 가장 적합한 스타일 선호도 {num_segments}개와 구매 행동 패턴 1개를 JSON 형식으로 응답해주세요.
예시: style_preferences
"""
# LLM 호출
response = openai.ChatCompletion.create(
model=self.model,
messages=[
{"role": "system", "content": "당신은 패션 소비자 행동 분석 전문가입니다."},
{"role": "user", "content": prompt}
],
temperature=0.3,
max_tokens=150
)
# 응답 처리
result = response.choices[0].message.content.strip()
# JSON 파싱
import json
import re
# JSON 형식 추출 시도
json_match = re.search(r'\{.*\}', result, re.DOTALL)
if json_match:
try:
segments = json.loads(json_match.group(0))
# 유효성 검사
if "style_preferences" in segments and "purchase_behavior" in segments:
# 유효한 세그먼트만 필터링
valid_styles = [style for style in segments["style_preferences"]
if style in style_segments]
valid_behaviors = [behavior for behavior in segments["purchase_behavior"]
if behavior in behavior_segments]
return {
"style_preferences": valid_styles[:num_segments],
"purchase_behavior": valid_behaviors[:1] # 최대 1개
}
except:
pass
return None
except Exception as e:
print(f"검색 기록 분석 오류: {e}")
return None
def analyze_purchase_history(self, purchase_history, num_segments=2):
"""구매 기록 분석으로 사용자 세그먼트 인퍼런스"""
try:
import openai
# 구매 기록 문자열 생성
if all(isinstance(p, str) for p in purchase_history):
purchases = "\n".join([f"- {purchase}" for purchase in purchase_history[:10]])
else:
purchases = "\n".join([f"- {p.get('product_name', '')}" for p in purchase_history[:10]
if isinstance(p, dict) and 'product_name' in p])
# 세그먼트 정보
if self.user_segment_manager:
style_segments = list(self.user_segment_manager.segments["style_preferences"].keys())
behavior_segments = list(self.user_segment_manager.segments["purchase_behavior"].keys())
persona_segments = list(self.user_segment_manager.segments["fashion_persona"].keys())
# 간단한 설명
style_descriptions = {
style: ", ".join(self.user_segment_manager.segments["style_preferences"][style].get("keywords", [])[:3])
for style in style_segments
}
behavior_descriptions = {
behavior: ", ".join(self.user_segment_manager.segments["purchase_behavior"][behavior].get("keywords", [])[:3])
for behavior in behavior_segments
}
persona_descriptions = {
persona: self.user_segment_manager.segments["fashion_persona"][persona]["description"]
for persona in persona_segments
}
else:
# 기본 세그먼트
style_segments = ["casual", "formal", "vintage", "minimalist", "athleisure", "streetwear"]
behavior_segments = ["price_sensitive", "premium", "trend_follower", "eco_conscious"]
persona_segments = ["classic_formal", "trendy_youngster", "active_lifestyle",
"conscious_consumer", "luxury_enthusiast"]
# 간단한 설명
style_descriptions = {
"casual": "캐주얼, 데일리, 편안한",
"formal": "정장, 포멀, 비즈니스",
"vintage": "빈티지, 레트로, 올드스쿨",
"minimalist": "미니멀, 심플, 기본",
"athleisure": "스포티, 애슬레저, 액티브",
"streetwear": "스트릿, 힙합, 어반"
}
behavior_descriptions = {
"price_sensitive": "세일, 할인, 가성비",
"premium": "프리미엄, 명품, 고급",
"trend_follower": "트렌드, 유행, 인기",
"eco_conscious": "친환경, 지속가능, 윤리적"
}
persona_descriptions = {
"classic_formal": "클래식하고 포멀한 스타일 선호",
"trendy_youngster": "최신 트렌드를 중시하는 젊은 층",
"active_lifestyle": "활동적인 라이프스타일",
"conscious_consumer": "윤리적 소비 중시",
"luxury_enthusiast": "럭셔리 브랜드 선호"
}
# 프롬프트 구성
prompt = f"""
다음은 패션 이커머스 사이트에서 사용자의 최근 구매 기록입니다.
{purchases}
이 구매 기록을 바탕으로 사용자의 패션 스타일 선호도, 구매 행동 패턴, 패션 페르소나를 분석해주세요.
스타일 선호도 옵션 (설명 포함):
{', '.join([f"{style}({desc})" for style, desc in style_descriptions.items()])}
구매 행동 패턴 옵션 (설명 포함):
{', '.join([f"{behavior}({desc})" for behavior, desc in behavior_descriptions.items()])}
패션 페르소나 옵션 (설명 포함):
{', '.join([f"{persona}({desc})" for persona, desc in persona_descriptions.items()])}
위 구매 기록에 가장 적합한 스타일 선호도 {num_segments}개, 구매 행동 패턴 1개, 패션 페르소나 1개를 JSON 형식으로 응답해주세요.
예시: style_preferences
"""
# LLM 호출
response = openai.ChatCompletion.create(
model=self.model,
messages=[
{"role": "system", "content": "당신은 패션 소비자 행동 분석 전문가입니다."},
{"role": "user", "content": prompt}
],
temperature=0.3,
max_tokens=150
)
# 응답 처리
result = response.choices[0].message.content.strip()
# JSON 파싱
import json
import re
# JSON 형식 추출 시도
json_match = re.search(r'\{.*\}', result, re.DOTALL)
if json_match:
try:
segments = json.loads(json_match.group(0))
# 유효성 검사 및 필터링
valid_segments = {}
if "style_preferences" in segments:
valid_styles = [style for style in segments["style_preferences"]
if style in style_segments]
if valid_styles:
valid_segments["style_preferences"] = valid_styles[:num_segments]
if "purchase_behavior" in segments:
valid_behaviors = [behavior for behavior in segments["purchase_behavior"]
if behavior in behavior_segments]
if valid_behaviors:
valid_segments["purchase_behavior"] = valid_behaviors[:1]
if "fashion_persona" in segments:
valid_personas = [persona for persona in segments["fashion_persona"]
if persona in persona_segments]
if valid_personas:
valid_segments["fashion_persona"] = valid_personas[:1]
return valid_segments
except:
pass
return None
except Exception as e:
print(f"구매 기록 분석 오류: {e}")
return None
class LLMSynonymExpander:
"""LLM 기반 동의어 확장기"""
def __init__(self, model="gpt-3.5-turbo"):
"""
LLM 기반 동의어 확장기 초기화
Parameters:
-----------
model : str
사용할 LLM 모델명
"""
self.model = model
def get_synonyms(self, term, max_synonyms=5, include_variations=True, domain="fashion"):
"""특정 용어의 동의어 생성"""
try:
import openai
# 도메인별 프롬프트 조정
domain_context = ""
if domain == "fashion":
domain_context = "패션 이커머스 도메인에서 사용되는"
# 변형어 생성 옵션
variations_prompt = ""
if include_variations:
variations_prompt = "철자 변형, 약어, 영어-한글 표기 변형도 포함해주세요."
# 프롬프트 구성
prompt = f"""
"{term}"의 {domain_context} 동의어를 {max_synonyms}개 생성해주세요. {variations_prompt}
응답은 배열 형태의 동의어 목록만 제공해주세요. 예: ["동의어1", "동의어2", ...]
"""
# LLM 호출
response = openai.ChatCompletion.create(
model=self.model,
messages=[
{"role": "system", "content": "당신은 패션 용어 전문가입니다."},
{"role": "user", "content": prompt}
],
temperature=0.3,
max_tokens=150
)
# 응답 처리
result = response.choices[0].message.content.strip()
# JSON 배열 추출 시도
import json
import re
# 배열 형식 추출
array_match = re.search(r'$$.*$$', result, re.DOTALL)
if array_match:
try:
synonyms = json.loads(array_match.group(0))
# 유효성 검사 및 필터링
valid_synonyms = []
for synonym in synonyms:
if isinstance(synonym, str) and synonym.strip() and synonym.strip().lower() != term.lower():
valid_synonyms.append(synonym.strip())
# 중복 제거 및 최대 개수 제한
unique_synonyms = []
for synonym in valid_synonyms:
if synonym.lower() not in [s.lower() for s in unique_synonyms]:
unique_synonyms.append(synonym)
return unique_synonyms[:max_synonyms]
except:
pass
# 일반 텍스트 파싱 시도
lines = result.split("\n")
synonyms = []
for line in lines:
# 번호나 불릿으로 시작하는 라인 처리
clean_line = re.sub(r'^[0-9.-]+\s*', '', line).strip()
# 따옴표 제거
clean_line = re.sub(r'[\'"]', '', clean_line)
# 콜론 이후 텍스트 추출
if ":" in clean_line:
clean_line = clean_line.split(":", 1)[1].strip()
# 빈 라인이 아니고 원래 용어와 다른 경우 추가
if clean_line and clean_line.lower() != term.lower():
synonyms.append(clean_line)
# 중복 제거 및 최대 개수 제한
unique_synonyms = []
for synonym in synonyms:
if synonym.lower() not in [s.lower() for s in unique_synonyms]:
unique_synonyms.append(synonym)
return unique_synonyms[:max_synonyms]
except Exception as e:
print(f"동의어 생성 오류: {e}")
return []
def expand_query_terms(self, query_terms, max_per_term=2, domain="fashion"):
"""쿼리 용어 각각에 대한 동의어 확장"""
expanded_terms = []
for term in query_terms:
# 기본 용어 추가
expanded_terms.append(term)
# 동의어 생성
synonyms = self.get_synonyms(term, max_synonyms=max_per_term, domain=domain)
expanded_terms.extend(synonyms)
return expanded_terms
class LLMQueryExpander:
"""LLM 기반 쿼리 확장기"""
def __init__(self, model="gpt-3.5-turbo"):
"""
LLM 기반 쿼리 확장기 초기화
Parameters:
-----------
model : str
사용할 LLM 모델명
"""
self.model = model
def expand_query(self, query, num_queries=5, domain="fashion"):
"""쿼리 확장 수행"""
try:
import openai
# 도메인별 프롬프트 조정
domain_context = ""
if domain == "fashion":
domain_context = "패션 이커머스 검색에서"
# 프롬프트 구성
prompt = f"""
당신은 {domain_context} 사용자 검색 의도를 정확히 이해하는 검색 전문가입니다.
아래 사용자 쿼리를 다양한 관점에서 해석하여 {num_queries}개의 대체 쿼리로 확장해주세요.
동의어, 관련 패션 용어, 브랜드, 소재, 스타일 등을 고려해서 다양하게 표현해주세요.
사용자 쿼리: "{query}"
응답은 확장된 쿼리 목록만 배열 형태로 제공해주세요. 예: ["확장 쿼리1", "확장 쿼리2", ...]
"""
# LLM 호출
response = openai.ChatCompletion.create(
model=self.model,
messages=[
{"role": "system", "content": "당신은 패션 검색 전문가입니다."},
{"role": "user", "content": prompt}
],
temperature=0.7,
max_tokens=200
)
# 응답 처리
result = response.choices[0].message.content.strip()
# JSON 배열 추출 시도
import json
import re
# 배열 형식 추출
array_match = re.search(r'$$.*$$', result, re.DOTALL)
if array_match:
try:
expanded_queries = json.loads(array_match.group(0))
# 유효성 검사 및 필터링
valid_queries = []
for exp_query in expanded_queries:
if isinstance(exp_query, str) and exp_query.strip():
valid_queries.append(exp_query.strip())
# 원래 쿼리 추가
valid_queries.append(query)
# 중복 제거
unique_queries = []
for exp_query in valid_queries:
if exp_query.lower() not in [q.lower() for q in unique_queries]:
unique_queries.append(exp_query)
return unique_queries
except:
pass
# 일반 텍스트 파싱 시도
lines = result.split("\n")
expanded_queries = []
for line in lines:
# 번호나 불릿으로 시작하는 라인 처리
clean_line = re.sub(r'^[0-9.-]+\s*', '', line).strip()
# 따옴표 제거
clean_line = re.sub(r'[\'"]', '', clean_line)
# 콜론 이후 텍스트 추출
if ":" in clean_line:
clean_line = clean_line.split(":", 1)[1].strip()
# 빈 라인이 아닌 경우 추가
if clean_line:
expanded_queries.append(clean_line)
# 원래 쿼리 추가
expanded_queries.append(query)
# 중복 제거
unique_queries = []
for exp_query in expanded_queries:
if exp_query.lower() not in [q.lower() for q in unique_queries]:
unique_queries.append(exp_query)
return unique_queries
except Exception as e:
print(f"쿼리 확장 오류: {e}")
return [query] # 오류 시 원래 쿼리만 반환
def analyze_query_intent(self, query):
"""쿼리 의도 분석"""
try:
import openai
# 프롬프트 구성
prompt = f"""
패션 이커머스 사이트에서 다음 검색 쿼리의 사용자 의도를 분석해주세요:
쿼리: "{query}"
아래 형식의 JSON으로 응답해주세요:
primary_intent
"""
# LLM 호출
response = openai.ChatCompletion.create(
model=self.model,
messages=[
{"role": "system", "content": "당신은 패션 검색 의도 분석 전문가입니다."},
{"role": "user", "content": prompt}
],
temperature=0.3,
max_tokens=200
)
# 응답 처리
result = response.choices[0].message.content.strip()
# JSON 파싱
import json
import re
# JSON 형식 추출
json_match = re.search(r'\{.*\}', result, re.DOTALL)
if json_match:
try:
intent_data = json.loads(json_match.group(0))
return intent_data
except:
pass
return None
except Exception as e:
print(f"쿼리 의도 분석 오류: {e}")
return None
class FashionQueryExpander:
"""패션 도메인 특화 쿼리 확장기"""
def __init__(self,
use_bert=True,
use_llm=True,
bert_model_name="klue/bert-base",
llm_model_name="gpt-3.5-turbo",
use_cache=True,
cache_ttl=86400,
config=None):
"""
패션 쿼리 확장기 초기화
Parameters:
-----------
use_bert : bool
BERT 모델 사용 여부
use_llm : bool
LLM 사용 여부
bert_model_name : str
사용할 BERT 모델명
llm_model_name : str
사용할 LLM 모델명
use_cache : bool
캐시 사용 여부
cache_ttl : int
캐시 유효 시간 (초)
config : dict
확장 설정
"""
self.config = config or {
'spell_correction': True,
'synonym_expansion': True,
'category_expansion': True,
'season_expansion': True,
'user_segment_expansion': True,
'bert_expansion': use_bert,
'llm_expansion': use_llm,
'max_expansion_terms': 15,
'field_weights': { # ES 필드별 가중치 설정
'product_name': 5.0,
'brand': 4.0,
'category': 4.0,
'subcategory': 3.5,
'description': 2.0,
'attributes.color': 3.0,
'attributes.material': 3.0,
'attributes.style': 3.0,
'attributes.pattern': 3.0,
'attributes.occasion': 3.0,
'attributes.season': 3.0,
'attributes.fit': 3.0,
'tags': 4.0,
'keywords': 4.0,
'search_keywords': 4.5
}
}
# 기본 구성 요소 초기화
self.category_manager = FashionCategory()
self.season_manager = SeasonTrendManager()
self.synonym_manager = FashionSynonymsManager()
self.typo_corrector = TypoCorrector(dictionary=self.synonym_manager.synonyms)
self.user_manager = UserSegmentManager()
self.user_profile_store = UserProfileStore()
# LLM 기반 컴포넌트 초기화
self.llm_query_expander = None
self.llm_synonym_expander = None
self.llm_user_analyzer = None
if use_llm and self.config['llm_expansion']:
self.llm_query_expander = LLMQueryExpander(model=llm_model_name)
self.llm_synonym_expander = LLMSynonymExpander(model=llm_model_name)
self.llm_user_analyzer = LLMUserAnalyzer(model=llm_model_name, user_segment_manager=self.user_manager)
# 동의어 관리자에 LLM 확장기 연결
self.synonym_manager.llm_expander = self.llm_synonym_expander
# 사용자 프로필 저장소에 LLM 분석기 연결
self.user_profile_store.llm_segmenter = self.llm_user_analyzer
# BERT 모델 초기화 (optional)
self.bert_expander = None
if use_bert and self.config['bert_expansion']:
try:
from transformers import AutoTokenizer, AutoModelForMaskedLM
import torch
# BERT 기반 쿼리 확장기 초기화
self.bert_expander = BERTQueryExpander(model_name=bert_model_name)
except Exception as e:
print(f"BERT 모델 초기화 오류: {e}")
self.config['bert_expansion'] = False
# 캐시 초기화 (optional)
self.cache = None
if use_cache:
self.cache = QueryExpansionCache(ttl=cache_ttl)
# 메트릭
self.metrics = {
'queries_processed': 0,
'avg_expansion_time': 0,
'component_timings': {},
'cache_hits': 0,
'cache_misses': 0,
'expansion_stats': {
'spell_correction': 0,
'synonym_expansion': 0,
'category_expansion': 0,
'season_expansion': 0,
'user_segment_expansion': 0,
'bert_expansion': 0,
'llm_expansion': 0
}
}
def preprocess_query(self, query):
"""쿼리 전처리 (오타 교정)"""
if not self.config['spell_correction']:
return query, False
start_time = time.time()
# 쿼리 토큰화 및 교정
corrected_query, is_corrected = self.typo_corrector.correct_query(query)
# 교정된 경우 통계 업데이트
if is_corrected:
self.metrics['expansion_stats']['spell_correction'] += 1
component_time = time.time() - start_time
self.metrics['component_timings']['spell_correction'] = self.metrics['component_timings'].get('spell_correction', 0) + component_time
return corrected_query, is_corrected
def expand_with_synonyms(self, query_terms):
"""동의어 확장"""
if not self.config['synonym_expansion']:
return []
start_time = time.time()
synonyms = []
for term in query_terms:
term_synonyms = self.synonym_manager.get_synonyms(term)
synonyms.extend([s for s in term_synonyms if s != term])
# 결과가 있는 경우 통계 업데이트
if synonyms:
self.metrics['expansion_stats']['synonym_expansion'] += 1
component_time = time.time() - start_time
self.metrics['component_timings']['synonym_expansion'] = self.metrics['component_timings'].get('synonym_expansion', 0) + component_time
return list(set(synonyms))
def expand_with_categories(self, query_terms):
"""카테고리 기반 확장"""
if not self.config['category_expansion']:
return []
start_time = time.time()
# 쿼리 용어 카테고리 분류
categorized = self.category_manager.categorize_query_terms(query_terms)
# 카테고리 의도 추출
category_intent = self.category_manager.extract_category_intent(query_terms)
category_terms = []
# 카테고리 의도가 있는 경우
if category_intent:
# 주요 카테고리에 대한 관련 용어 추가
primary = category_intent.get("primary")
if primary:
related_terms = self.category_manager.get_related_terms(primary, max_terms=5)
category_terms.extend(related_terms)
# 상위 카테고리에 대한 관련 용어 추가
parent = category_intent.get("parent")
if parent:
related_terms = self.category_manager.get_related_terms(parent, max_terms=3)
category_terms.extend(related_terms)
# 속성 정보가 있는 경우 관련 속성 추가
attributes = category_intent.get("attributes", [])
if attributes:
for attr in attributes[:2]: # 상위 2개 속성만
category_attrs = self.category_manager.get_categories_for_attribute(attr)
for _, sub_cat, _ in category_attrs[:2]: # 상위 2개 카테고리만
if sub_cat != primary and sub_cat != parent:
related_terms = self.category_manager.get_related_terms(sub_cat, max_terms=2)
category_terms.extend(related_terms)
else:
# 각 쿼리 용어에 대해 관련 용어 찾기
for term in query_terms:
related_terms = self.category_manager.get_related_terms(term, max_terms=3)
category_terms.extend(related_terms)
# 결과가 있는 경우 통계 업데이트
if category_terms:
self.metrics['expansion_stats']['category_expansion'] += 1
component_time = time.time() - start_time
self.metrics['component_timings']['category_expansion'] = self.metrics['component_timings'].get('category_expansion', 0) + component_time
return list(set(category_terms) - set(query_terms))
def expand_with_season(self, query_terms):
"""계절 및 트렌드 기반 확장"""
if not self.config['season_expansion']:
return []
start_time = time.time()
# 쿼리와 관련된 계절 키워드 확인
season_info = self.season_manager.get_seasonal_keywords_for_query(query_terms)
# 트렌드 키워드 확인
trend_keywords = self.season_manager.get_trend_keywords_for_query(query_terms, max_keywords=5)
# 계절 키워드
seasonal_keywords = season_info.get("keywords", [])
# 통계 업데이트
if seasonal_keywords or trend_keywords:
self.metrics['expansion_stats']['season_expansion'] += 1
component_time = time.time() - start_time
self.metrics['component_timings']['season_expansion'] = self.metrics['component_timings'].get('season_expansion', 0) + component_time
# 모든 키워드 결합 및 중복 제거
all_keywords = list(set(seasonal_keywords + trend_keywords))
return all_keywords
def expand_with_user_segment(self, query_terms, user_id):
"""사용자 세그먼트 기반 확장"""
if not user_id or not self.config['user_segment_expansion']:
return []
start_time = time.time()
# 사용자 프로필
user_profile = self.user_profile_store.get_profile(user_id)
if not user_profile:
return []
# 사용자 프로필에서 세그먼트 식별
identified_segments = self.user_manager.identify_user_segments(query_terms, user_profile)
# 세그먼트 기반 확장 키워드
segment_keywords = self.user_manager.get_segment_expansion_keywords(identified_segments, max_keywords=7)
# 사용자 선호도 키워드
preference_keywords = self.user_profile_store.get_user_preference_keywords(user_id, max_keywords=5)
# 결과가 있는 경우 통계 업데이트
if segment_keywords or preference_keywords:
self.metrics['expansion_stats']['user_segment_expansion'] += 1
component_time = time.time() - start_time
self.metrics['component_timings']['user_segment_expansion'] = self.metrics['component_timings'].get('user_segment_expansion', 0) + component_time
# 모든 키워드 결합 및 중복 제거
all_keywords = list(set(segment_keywords + preference_keywords))
return all_keywords
def expand_with_bert(self, query):
"""BERT 모델 기반 의미적 확장"""
if not self.bert_expander or not self.config['bert_expansion']:
return []
start_time = time.time()
try:
expansion_terms = self.bert_expander.expand_query(query, num_terms=5)
# 결과가 있는 경우 통계 업데이트
if expansion_terms:
self.metrics['expansion_stats']['bert_expansion'] += 1
component_time = time.time() - start_time
self.metrics['component_timings']['bert_expansion'] = self.metrics['component_timings'].get('bert_expansion', 0) + component_time
return expansion_terms
except Exception as e:
print(f"BERT 확장 오류: {e}")
component_time = time.time() - start_time
self.metrics['component_timings']['bert_expansion'] = self.metrics['component_timings'].get('bert_expansion', 0) + component_time
return []
def expand_with_llm(self, query, query_terms, user_id=None):
"""LLM 기반 확장"""
if not self.llm_query_expander or not self.config['llm_expansion']:
return []
start_time = time.time()
try:
# 쿼리 확장
expanded_queries = self.llm_query_expander.expand_query(query, num_queries=3)
# 쿼리 의도 분석
query_intent = self.llm_query_expander.analyze_query_intent(query)
# 확장 용어 추출
expansion_terms = []
# 확장 쿼리에서 용어 추출
if expanded_queries:
for expanded_query in expanded_queries:
if expanded_query != query:
# 원래 쿼리 용어 제외
expanded_terms = expanded_query.split()
new_terms = [term for term in expanded_terms if term.lower() not in [t.lower() for t in query_terms]]
expansion_terms.extend(new_terms)
# 쿼리 의도에서 용어 추출
if query_intent:
# 타겟 카테고리
if "target_category" in query_intent and query_intent["target_category"]:
category = query_intent["target_category"]
if category.lower() not in [t.lower() for t in query_terms]:
expansion_terms.append(category)
# 스타일 선호도
if "style_preferences" in query_intent and query_intent["style_preferences"]:
styles = query_intent["style_preferences"]
if isinstance(styles, list):
for style in styles:
if style.lower() not in [t.lower() for t in query_terms]:
expansion_terms.append(style)
# 속성
if "attributes" in query_intent and query_intent["attributes"]:
attrs = query_intent["attributes"]
if isinstance(attrs, list):
for attr in attrs:
if attr.lower() not in [t.lower() for t in query_terms]:
expansion_terms.append(attr)
# 계절
if "season" in query_intent and query_intent["season"]:
season = query_intent["season"]
if season.lower() not in [t.lower() for t in query_terms]:
expansion_terms.append(season)
# 중복 제거
unique_terms = []
for term in expansion_terms:
if term.lower() not in [t.lower() for t in unique_terms]:
unique_terms.append(term)
# 결과가 있는 경우 통계 업데이트
if unique_terms:
self.metrics['expansion_stats']['llm_expansion'] += 1
component_time = time.time() - start_time
self.metrics['component_timings']['llm_expansion'] = self.metrics['component_timings'].get('llm_expansion', 0) + component_time
return unique_terms
except Exception as e:
print(f"LLM 확장 오류: {e}")
component_time = time.time() - start_time
self.metrics['component_timings']['llm_expansion'] = self.metrics['component_timings'].get('llm_expansion', 0) + component_time
return []
def expand_query(self, query, user_id=None):
"""종합 쿼리 확장"""
start_time = time.time()
# 캐시 확인
if self.cache:
cached_result = self.cache.get(query, user_id)
if cached_result:
self.metrics['cache_hits'] += 1
return cached_result
self.metrics['cache_misses'] += 1
# 1. 쿼리 전처리 (오타 교정)
corrected_query, is_corrected = self.preprocess_query(query)
# 2. 쿼리 토큰화
query_terms = corrected_query.split()
# 3. 각 확장 방법 적용
expansion_terms = {
'synonyms': self.expand_with_synonyms(query_terms),
'categories': self.expand_with_categories(query_terms),
'season': self.expand_with_season(query_terms),
'user_segment': self.expand_with_user_segment(query_terms, user_id) if user_id else [],
'bert': self.expand_with_bert(corrected_query),
'llm': self.expand_with_llm(corrected_query, query_terms, user_id) if self.config['llm_expansion'] else []
}
# 4. 모든 확장 용어 결합
all_expansion_terms = []
for source, terms in expansion_terms.items():
all_expansion_terms.extend(terms)
# 5. 중복 제거 및 최대 개수 제한
unique_expansion_terms = []
for term in all_expansion_terms:
term_lower = term.lower()
if term_lower not in [t.lower() for t in query_terms] and term_lower not in [t.lower() for t in unique_expansion_terms]:
unique_expansion_terms.append(term)
if len(unique_expansion_terms) > self.config['max_expansion_terms']:
unique_expansion_terms = unique_expansion_terms[:self.config['max_expansion_terms']]
# 6. 쿼리 의도 분석 (LLM이 있는 경우)
query_intent = None
if self.llm_query_expander and self.config['llm_expansion']:
try:
query_intent = self.llm_query_expander.analyze_query_intent(query)
except:
pass
# 7. 결과 포맷팅
result = {
'original_query': query,
'corrected_query': corrected_query,
'is_corrected': is_corrected,
'expansion_terms': unique_expansion_terms,
'expansion_sources': expansion_terms,
'field_weights': self.config.get('field_weights', {}),
'query_intent': query_intent
}
# 8. 캐시에 저장
if self.cache:
self.cache.set(query, result, user_id)
# 9. 메트릭 업데이트
total_time = time.time() - start_time
self.metrics['queries_processed'] += 1
self.metrics['avg_expansion_time'] = (
(self.metrics['avg_expansion_time'] * (self.metrics['queries_processed'] - 1) + total_time)
/ self.metrics['queries_processed']
)
# 10. 사용자 검색 기록 업데이트 (선택 사항)
if user_id:
try:
self.user_profile_store.add_search_query(user_id, query)
except:
pass
return result
def create_elasticsearch_query(self, expansion_result, boost_original=3.0, operator='OR'):
"""Elasticsearch 쿼리 생성"""
original_query = expansion_result['original_query']
corrected_query = expansion_result['corrected_query']
expansion_terms = expansion_result['expansion_terms']
field_weights = expansion_result['field_weights']
query_intent = expansion_result.get('query_intent')
# 필드별 가중치 설정
fields = []
for field, weight in field_weights.items():
fields.append(f"{field}^{weight}")
# 기본 쿼리 구조
es_query = {
"query": {
"bool": {
"should": [
# 원래/교정된 쿼리 (높은 가중치)
{
"multi_match": {
"query": corrected_query,
"fields": fields,
"type": "cross_fields",
"operator": operator,
"boost": boost_original,
"tie_breaker": 0.3
}
}
],
"minimum_should_match": 1
}
},
"highlight": {
"fields": {field.split('^')[0]: {} for field in fields}
}
}
# 확장 용어가 있으면 추가
if expansion_terms:
expanded_query = " ".join(expansion_terms)
es_query["query"]["bool"]["should"].append({
"multi_match": {
"query": expanded_query,
"fields": fields,
"type": "cross_fields",
"operator": operator,
"boost": 1.0,
"tie_breaker": 0.3
}
})
# 원래 쿼리와 교정된 쿼리가 다르면, 원래 쿼리도 낮은 가중치로 추가
if original_query != corrected_query:
es_query["query"]["bool"]["should"].append({
"multi_match": {
"query": original_query,
"fields": fields,
"type": "cross_fields",
"operator": operator,
"boost": 0.8,
"tie_breaker": 0.3
}
})
# 쿼리 의도가 있는 경우 필터 및 가중치 조정
if query_intent:
# 카테고리 필터
if "target_category" in query_intent and query_intent["target_category"]:
category = query_intent["target_category"]
# 정확한 매칭으로 필터링하진 않고, 점수 가중치 부여
es_query["query"]["bool"]["should"].append({
"multi_match": {
"query": category,
"fields": ["category^4.0", "subcategory^3.5"],
"type": "phrase",
"boost": 2.0
}
})
# 속성 필터
if "attributes" in query_intent and query_intent["attributes"]:
for attr in query_intent["attributes"]:
if attr:
es_query["query"]["bool"]["should"].append({
"multi_match": {
"query": attr,
"fields": ["attributes.*^3.0", "tags^2.0", "keywords^2.0"],
"type": "best_fields",
"boost": 1.5
}
})
# 계절 필터
if "season" in query_intent and query_intent["season"]:
season = query_intent["season"]
es_query["query"]["bool"]["should"].append({
"term": {
"attributes.season": {
"value": season,
"boost": 2.0
}
}
})
# 가격 필터 (가격 민감도)
if "price_sensitivity" in query_intent and query_intent["price_sensitivity"]:
sensitivity = query_intent["price_sensitivity"].lower()
if sensitivity == "저" or sensitivity == "low":
es_query["query"]["bool"]["should"].append({
"range": {
"price": {
"lt": 50000,
"boost": 1.5
}
}
})
elif sensitivity == "중" or sensitivity == "medium":
es_query["query"]["bool"]["should"].append({
"range": {
"price": {
"gte": 50000,
"lt": 150000,
"boost": 1.5
}
}
})
elif sensitivity == "고" or sensitivity == "high":
es_query["query"]["bool"]["should"].append({
"range": {
"price": {
"gte": 150000,
"boost": 1.5
}
}
})
}
# 정렬 옵션 (기본적으로 스코어 기준)
es_query["sort"] = [
"_score",
{"popularity": {"order": "desc"}}, # 인기도
{"review_count": {"order": "desc"}} # 리뷰 수
]
return es_query
class ElasticsearchManager:
"""Elasticsearch 관리자"""
def __init__(self, host='localhost', port=9200, username=None, password=None, use_ssl=False):
"""
Elasticsearch 연결 설정
Parameters:
-----------
host : str
Elasticsearch 호스트
port : int
Elasticsearch 포트
username : str
Elasticsearch 사용자명 (선택 사항)
password : str
Elasticsearch 비밀번호 (선택 사항)
use_ssl : bool
SSL 사용 여부
"""
# 연결 설정
es_config = {
'host': host,
'port': port
}
if username and password:
es_config['http_auth'] = (username, password)
if use_ssl:
es_config['use_ssl'] = True
es_config['verify_certs'] = True
try:
self.client = Elasticsearch([es_config])
info = self.client.info()
logger.info(f"Elasticsearch 연결 성공: {info['version']['number']}")
except Exception as e:
logger.error(f"Elasticsearch 연결 실패: {e}")
self.client = None
def is_connected(self):
"""Elasticsearch 연결 상태 확인"""
return self.client is not None and self.client.ping()
def create_fashion_index(self, index_name, overwrite=False):
"""패션 상품용 인덱스 생성"""
if not self.is_connected():
return False
# 인덱스 존재 확인
if self.client.indices.exists(index=index_name):
if overwrite:
self.client.indices.delete(index=index_name)
else:
logger.info(f"인덱스 '{index_name}'가 이미 존재합니다.")
return True
# 인덱스 설정
settings = {
"analysis": {
"analyzer": {
"korean": {
"type": "custom",
"tokenizer": "nori_tokenizer",
"filter": ["nori_posfilter", "lowercase", "synonym_filter"]
},
"korean_exact": {
"type": "custom",
"tokenizer": "standard",
"filter": ["lowercase"]
},
"korean_ngram": {
"type": "custom",
"tokenizer": "nori_tokenizer",
"filter": ["nori_posfilter", "lowercase", "ngram_filter"]
}
},
"filter": {
"nori_posfilter": {
"type": "nori_part_of_speech",
"stoptags": ["E", "J", "SC", "SE", "SF", "SP", "SSC", "SSO", "SY"]
},
"synonym_filter": {
"type": "synonym",
"synonyms_path": "analysis/synonym.txt"
},
"ngram_filter": {
"type": "ngram",
"min_gram": 2,
"max_gram": 3
}
}
},
"index": {
"max_ngram_diff": 2
}
}
# 매핑 설정 (더 상세한 필드 정의)
mappings = {
"properties": {
"product_id": {"type": "keyword"},
"product_name": {
"type": "text",
"analyzer": "korean",
"fields": {
"keyword": {"type": "keyword"},
"ngram": {"type": "text", "analyzer": "korean_ngram"},
"exact": {"type": "text", "analyzer": "korean_exact"}
}
},
"brand": {
"type": "text",
"analyzer": "korean",
"fields": {
"keyword": {"type": "keyword"},
"exact": {"type": "text", "analyzer": "korean_exact"}
}
},
"category": {
"type": "text",
"analyzer": "korean",
"fields": {
"keyword": {"type": "keyword"},
"exact": {"type": "text", "analyzer": "korean_exact"}
}
},
"subcategory": {
"type": "text",
"analyzer": "korean",
"fields": {
"keyword": {"type": "keyword"},
"exact": {"type": "text", "analyzer": "korean_exact"}
}
},
"description": {
"type": "text",
"analyzer": "korean"
},
"price": {"type": "float"},
"sale_price": {"type": "float"},
"discount_rate": {"type": "float"},
"attributes": {
"properties": {
"color": {
"type": "text",
"analyzer": "korean",
"fields": {
"keyword": {"type": "keyword"}
}
},
"size": {"type": "keyword"},
"material": {
"type": "text",
"analyzer": "korean",
"fields": {
"keyword": {"type": "keyword"}
}
},
"pattern": {
"type": "text",
"analyzer": "korean",
"fields": {
"keyword": {"type": "keyword"}
}
},
"style": {
"type": "text",
"analyzer": "korean",
"fields": {
"keyword": {"type": "keyword"}
}
},
"fit": {
"type": "text",
"analyzer": "korean",
"fields": {
"keyword": {"type": "keyword"}
}
},
"season": {"type": "keyword"},
"gender": {"type": "keyword"},
"occasion": {
"type": "text",
"analyzer": "korean",
"fields": {
"keyword": {"type": "keyword"}
}
},
"feature": {
"type": "text",
"analyzer": "korean"
}
}
},
"tags": {
"type": "text",
"analyzer": "korean",
"fields": {
"keyword": {"type": "keyword"}
}
},
"keywords": {
"type": "text",
"analyzer": "korean",
"fields": {
"keyword": {"type": "keyword"}
}
},
"search_keywords": {
"type": "text",
"analyzer": "korean"
},
"created_at": {"type": "date"},
"updated_at": {"type": "date"},
"image_url": {"type": "keyword"},
"additional_images": {"type": "keyword"},
"stock": {"type": "integer"},
"is_in_stock": {"type": "boolean"},
"rating": {"type": "float"},
"review_count": {"type": "integer"},
"popularity": {"type": "float"},
"is_new": {"type": "boolean"},
"is_sale": {"type": "boolean"},
"season_tag": {"type": "keyword"},
"style_tag": {"type": "keyword"},
"target_gender": {"type": "keyword"},
"target_age": {"type": "keyword"}
}
}
# 인덱스 생성
try:
self.client.indices.create(
index=index_name,
body={
"settings": settings,
"mappings": mappings
}
)
logger.info(f"인덱스 '{index_name}' 생성 완료")
return True
except Exception as e:
logger.error(f"인덱스 생성 실패: {e}")
return False
def index_product(self, index_name, product):
"""상품 데이터 인덱싱"""
if not self.is_connected():
return False
try:
response = self.client.index(
index=index_name,
id=product.get("product_id"),
body=product
)
return response['result'] in ['created', 'updated']
except Exception as e:
logger.error(f"상품 인덱싱 실패: {e}")
return False
def bulk_index_products(self, index_name, products):
"""다수 상품 일괄 인덱싱"""
if not self.is_connected():
return False
bulk_data = []
for product in products:
bulk_data.append({
"index": {
"_index": index_name,
"_id": product.get("product_id")
}
})
bulk_data.append(product)
try:
response = self.client.bulk(body=bulk_data, refresh=True)
return not response.get('errors', False)
except Exception as e:
logger.error(f"대량 인덱싱 실패: {e}")
return False
def update_synonyms(self, index_name, synonyms):
"""동의어 사전 업데이트"""
if not self.is_connected():
return False
# 동의어 형식 변환 (Elasticsearch 형식)
synonym_lines = []
for standard, variants in synonyms.items():
synonym_line = ", ".join([standard] + [v for v in variants if v != standard])
synonym_lines.append(synonym_line)
# 동의어 파일 내용
synonyms_content = "\n".join(synonym_lines)
try:
# 동의어 파일 업데이트 (실제 환경에서는 파일 시스템 접근 필요)
# 여기서는 간단히 로그만 남김
logger.info(f"동의어 사전 업데이트: {len(synonym_lines)}개 항목")
# 인덱스 설정 업데이트 (실제로는 인덱스 닫고 업데이트 필요)
# 여기서는 간단한 구현만 제공
return True
except Exception as e:
logger.error(f"동의어 사전 업데이트 실패: {e}")
return False
def search(self, index_name, es_query, size=20, from_=0, include_explanation=False):
"""ES 쿼리로 검색"""
if not self.is_connected():
return {
'total': 0,
'took': 0,
'results': []
}
try:
# 검색 요청
search_args = {
"index": index_name,
"body": es_query,
"size": size,
"from_": from_
}
if include_explanation:
search_args["explain"] = True
response = self.client.search(**search_args)
# 검색 결과 포맷팅
results = []
for hit in response['hits']['hits']:
result = {
'id': hit['_id'],
'score': hit['_score'],
'source': hit['_source']
}
# 하이라이트가 있으면 추가
if 'highlight' in hit:
result['highlight'] = hit['highlight']
# 설명이 있으면 추가
if include_explanation and '_explanation' in hit:
result['explanation'] = hit['_explanation']
results.append(result)
return {
'total': response['hits']['total']['value'] if isinstance(response['hits']['total'], dict) else response['hits']['total'],
'took': response['took'],
'results': results
}
except Exception as e:
logger.error(f"검색 실패: {e}")
return {
'total': 0,
'took': 0,
'results': [],
'error': str(e)
}
def suggest(self, index_name, query, size=5):
"""검색어 자동완성 추천"""
if not self.is_connected():
return []
# 자동완성 쿼리
suggest_query = {
"suggest": {
"product-suggest": {
"prefix": query,
"completion": {
"field": "suggest",
"size": size,
"skip_duplicates": True,
"fuzzy": {
"fuzziness": "AUTO"
}
}
}
}
}
try:
response = self.client.search(index=index_name, body=suggest_query)
suggestions = []
for suggestion in response['suggest']['product-suggest'][0]['options']:
suggestions.append({
'text': suggestion['text'],
'score': suggestion['_score']
})
return suggestions
except Exception as e:
logger.error(f"자동완성 추천 실패: {e}")
return []
def get_popular_searches(self, index_name="search_logs", time_range="7d", size=10):
"""인기 검색어 조회"""
if not self.is_connected():
return []
# 인기 검색어 집계 쿼리
aggs_query = {
"size": 0,
"query": {
"range": {
"timestamp": {
"gte": f"now-{time_range}"
}
}
},
"aggs": {
"popular_searches": {
"terms": {
"field": "query.keyword",
"size": size
}
}
}
}
try:
response = self.client.search(index=index_name, body=aggs_query)
popular_searches = []
for bucket in response['aggregations']['popular_searches']['buckets']:
popular_searches.append({
'query': bucket['key'],
'count': bucket['doc_count']
})
return popular_searches
except Exception as e:
logger.error(f"인기 검색어 조회 실패: {e}")
return []
def log_search(self, index_name="search_logs", query=None, user_id=None, session_id=None,
expansion_result=None, search_result=None):
"""검색 로그 저장"""
if not self.is_connected() or not query:
return False
log_data = {
"query": query,
"user_id": user_id,
"session_id": session_id,
"timestamp": datetime.datetime.now().isoformat(),
"result_count": search_result.get('total', 0) if search_result else 0,
"took_ms": search_result.get('took', 0) if search_result else 0
}
# 확장 결과 정보 추가
if expansion_result:
log_data["corrected_query"] = expansion_result.get('corrected_query')
log_data["is_corrected"] = expansion_result.get('is_corrected', False)
log_data["expansion_terms"] = expansion_result.get('expansion_terms', [])
try:
self.client.index(index=index_name, body=log_data)
return True
except Exception as e:
logger.error(f"검색 로그 저장 실패: {e}")
return False
class QueryFeedbackStore:
"""쿼리 피드백 저장소"""
def __init__(self, data_path="data/query_feedback.json", es_manager=None):
"""
쿼리 피드백 데이터 로드
Parameters:
-----------
data_path : str
피드백 데이터 JSON 파일 경로
es_manager : ElasticsearchManager
Elasticsearch 관리자 (선택 사항)
"""
self.data_path = data_path
self.es_manager = es_manager
if os.path.exists(data_path):
with open(data_path, 'r', encoding='utf-8') as f:
self.feedback_data = json.load(f)
else:
# 초기 구조
self.feedback_data = {
"term_feedback": {}, # 용어별 피드백
"query_feedback": {}, # 쿼리별 피드백
"session_feedback": {}, # 세션별 피드백
"user_feedback": {}, # 사용자별 피드백
"typo_corrections": {} # 오타 수정 기록
}
# 디렉토리 생성 및 저장
os.makedirs(os.path.dirname(data_path), exist_ok=True)
with open(data_path, 'w', encoding='utf-8') as f:
json.dump(self.feedback_data, f, ensure_ascii=False, indent=4)
def add_feedback(self, query, expansion_terms, clicked_product_ids=None, user_id=None,
session_id=None, rating=None, search_result=None):
"""
피드백 추가
Parameters:
-----------
query : str
원래 쿼리
expansion_terms : list
확장 용어 리스트
clicked_product_ids : list
클릭한 상품 ID 리스트
user_id : str
사용자 ID
session_id : str
세션 ID
rating : int
명시적 평점 (-1, 0, 1)
search_result : dict
검색 결과 정보
"""
# 타임스탬프
timestamp = datetime.datetime.now().isoformat()
# 기본 피드백 데이터
feedback = {
"query": query,
"expansion_terms": expansion_terms,
"clicked_product_ids": clicked_product_ids or [],
"user_id": user_id,
"session_id": session_id,
"timestamp": timestamp,
"rating": rating
}
# 검색 결과 정보 추가
if search_result:
feedback["result_count"] = search_result.get('total', 0)
feedback["search_time"] = search_result.get('took', 0)
# 검색 결과 분석 (상위 5개만)
top_results = []
for i, result in enumerate(search_result.get('results', [])[:5]):
top_results.append({
"position": i + 1,
"id": result.get('id'),
"score": result.get('score'),
"product_name": result.get('source', {}).get('product_name', '')
})
feedback["top_results"] = top_results
# 쿼리별 피드백 저장
query_key = query.lower()
if query_key not in self.feedback_data["query_feedback"]:
self.feedback_data["query_feedback"][query_key] = []
self.feedback_data["query_feedback"][query_key].append(feedback)
# 용어별 피드백 저장
for term in expansion_terms:
term_key = term.lower()
if term_key not in self.feedback_data["term_feedback"]:
self.feedback_data["term_feedback"][term_key] = []
term_feedback = {
"query": query,
"clicked_product_ids": clicked_product_ids or [],
"timestamp": timestamp,
"rating": rating
}
self.feedback_data["term_feedback"][term_key].append(term_feedback)
# 세션별 피드백 저장
if session_id:
if session_id not in self.feedback_data["session_feedback"]:
self.feedback_data["session_feedback"][session_id] = []
self.feedback_data["session_feedback"][session_id].append(feedback)
# 사용자별 피드백 저장
if user_id:
if user_id not in self.feedback_data["user_feedback"]:
self.feedback_data["user_feedback"][user_id] = []
self.feedback_data["user_feedback"][user_id].append(feedback)
# 오타 기록 저장 (교정된 쿼리인 경우)
if "corrected_query" in feedback and feedback["corrected_query"] != query:
corrected_query = feedback["corrected_query"]
# 오타 수정 기록 형식
typo_key = query.lower()
if typo_key not in self.feedback_data["typo_corrections"]:
self.feedback_data["typo_corrections"][typo_key] = []
self.feedback_data["typo_corrections"][typo_key].append({
"corrected_query": corrected_query,
"timestamp": timestamp,
"user_id": user_id
})
# Elasticsearch에 저장 (선택 사항)
if self.es_manager:
try:
self.es_manager.client.index(
index="search_feedback",
body=feedback
)
except:
pass
# 변경사항 저장
with open(self.data_path, 'w', encoding='utf-8') as f:
json.dump(self.feedback_data, f, ensure_ascii=False, indent=4)
return True
def get_term_feedback(self, term):
"""특정 용어에 대한 피드백 반환"""
term_key = term.lower()
return self.feedback_data["term_feedback"].get(term_key, [])
def get_query_feedback(self, query):
"""특정 쿼리에 대한 피드백 반환"""
query_key = query.lower()
return self.feedback_data["query_feedback"].get(query_key, [])
def get_session_feedback(self, session_id):
"""특정 세션에 대한 피드백 반환"""
return self.feedback_data["session_feedback"].get(session_id, [])
def get_user_feedback(self, user_id):
"""특정 사용자에 대한 피드백 반환"""
return self.feedback_data["user_feedback"].get(user_id, [])
def calculate_term_scores(self):
"""용어별 점수 계산"""
term_scores = {}
for term, feedbacks in self.feedback_data["term_feedback"].items():
clicks = 0
explicit_ratings = 0
for feedback in feedbacks:
# 클릭 수 집계
clicks += len(feedback["clicked_product_ids"])
# 명시적 평점 집계
if feedback["rating"] is not None:
explicit_ratings += feedback["rating"]
# 종합 점수 계산
term_scores[term] = {
"clicks": clicks,
"explicit_ratings": explicit_ratings,
"feedback_count": len(feedbacks),
"score": clicks + explicit_ratings # 간단한 합산 점수
}
return term_scores
def calculate_query_performance(self):
"""쿼리별 성능 분석"""
query_performance = {}
for query, feedbacks in self.feedback_data["query_feedback"].items():
total_clicks = 0
total_ratings = 0
total_searches = len(feedbacks)
queries_with_clicks = 0
for feedback in feedbacks:
clicks = len(feedback.get("clicked_product_ids", []))
total_clicks += clicks
if clicks > 0:
queries_with_clicks += 1
if feedback.get("rating") is not None:
total_ratings += feedback["rating"]
# 성능 지표 계산
query_performance[query] = {
"total_searches": total_searches,
"total_clicks": total_clicks,
"click_through_rate": queries_with_clicks / total_searches if total_searches > 0 else 0,
"average_clicks": total_clicks / total_searches if total_searches > 0 else 0,
"average_rating": total_ratings / total_searches if total_searches > 0 else 0
}
return query_performance
def get_typo_corrections(self):
"""오타 수정 기록 반환"""
typo_stats = {}
for typo, corrections in self.feedback_data["typo_corrections"].items():
# 가장 흔한 수정 찾기
correction_counts = {}
for entry in corrections:
correction = entry["corrected_query"]
if correction not in correction_counts:
correction_counts[correction] = 0
correction_counts[correction] += 1
if correction_counts:
best_correction = max(correction_counts.items(), key=lambda x: x[1])
typo_stats[typo] = {
"best_correction": best_correction[0],
"correction_count": best_correction[1],
"total_occurrences": len(corrections)
}
return typo_stats
def get_user_segments_by_behavior(self, min_feedback=5):
"""사용자 검색 행동 기반 세그먼트화"""
user_segments = {}
for user_id, feedbacks in self.feedback_data["user_feedback"].items():
if len(feedbacks) < min_feedback:
continue
# 사용자 행동 분석
total_searches = len(feedbacks)
total_clicks = sum(len(f.get("clicked_product_ids", [])) for f in feedbacks)
clicked_searches = sum(1 for f in feedbacks if len(f.get("clicked_product_ids", [])) > 0)
# 행동 지표 계산
click_ratio = clicked_searches / total_searches if total_searches > 0 else 0
query_complexity = sum(len(f.get("query", "").split()) for f in feedbacks) / total_searches if total_searches > 0 else 0
# 세그먼트 결정
segments = []
if click_ratio > 0.7:
segments.append("high_engagement")
elif click_ratio < 0.3:
segments.append("low_engagement")
if query_complexity > 3:
segments.append("complex_queries")
else:
segments.append("simple_queries")
# 결과 저장
user_segments[user_id] = {
"segments": segments,
"click_ratio": click_ratio,
"query_complexity": query_complexity,
"total_searches": total_searches
}
return user_segments
class GoldenDatasetManager:
"""골든 데이터셋 관리자"""
def __init__(self, data_path="data/golden_dataset.json"):
"""
골든 데이터셋 로드
Parameters:
-----------
data_path : str
골든 데이터셋 JSON 파일 경로
"""
self.data_path = data_path
if os.path.exists(data_path):
with open(data_path, 'r', encoding='utf-8') as f:
self.dataset = json.load(f)
else:
# 초기 구조
self.dataset = {
"queries": {}, # 쿼리별 골든 데이터
"categories": {}, # 카테고리별 대표 쿼리
"evaluations": [], # 평가 결과
"metadata": {
"created_at": datetime.datetime.now().isoformat(),
"updated_at": datetime.datetime.now().isoformat(),
"version": "1.0.0",
"description": "패션 이커머스 검색 시스템 평가용 골든 데이터셋"
}
}
# 디렉토리 생성 및 저장
os.makedirs(os.path.dirname(data_path), exist_ok=True)
with open(data_path, 'w', encoding='utf-8') as f:
json.dump(self.dataset, f, ensure_ascii=False, indent=4)
def add_golden_query(self, query, relevant_product_ids, category=None, subcategory=None, difficulty=None, description=None):
"""골든 쿼리 추가"""
query_key = query.lower()
# 쿼리 정보
query_data = {
"query": query,
"relevant_product_ids": relevant_product_ids,
"category": category,
"subcategory": subcategory,
"difficulty": difficulty or "medium", # easy, medium, hard
"description": description,
"created_at": datetime.datetime.now().isoformat()
}
# 쿼리 추가
self.dataset["queries"][query_key] = query_data
# 카테고리 매핑 업데이트
if category:
if category not in self.dataset["categories"]:
self.dataset["categories"][category] = {}
if subcategory:
if subcategory not in self.dataset["categories"][category]:
self.dataset["categories"][category][subcategory] = []
if query not in self.dataset["categories"][category][subcategory]:
self.dataset["categories"][category][subcategory].append(query)
else:
if "general" not in self.dataset["categories"][category]:
self.dataset["categories"][category]["general"] = []
if query not in self.dataset["categories"][category]["general"]:
self.dataset["categories"][category]["general"].append(query)
# 메타데이터 업데이트
self.dataset["metadata"]["updated_at"] = datetime.datetime.now().isoformat()
# 변경사항 저장
with open(self.data_path, 'w', encoding='utf-8') as f:
json.dump(self.dataset, f, ensure_ascii=False, indent=4)
return True
def get_golden_query(self, query):
"""특정 쿼리의 골든 데이터 반환"""
query_key = query.lower()
return self.dataset["queries"].get(query_key)
def get_category_queries(self, category, subcategory=None):
"""특정 카테고리의 골든 쿼리 목록 반환"""
if category not in self.dataset["categories"]:
return []
if subcategory:
return self.dataset["categories"][category].get(subcategory, [])
else:
# 모든 서브카테고리의 쿼리 합치기
all_queries = []
for queries in self.dataset["categories"][category].values():
all_queries.extend(queries)
return all_queries
def get_all_golden_queries(self, difficulty=None):
"""모든 골든 쿼리 반환 (optional으로 난이도 필터링)"""
if difficulty:
return {
query: data for query, data in self.dataset["queries"].items()
if data.get("difficulty") == difficulty
}
else:
return self.dataset["queries"]
def add_evaluation_result(self, model_name, query_expander_config, results):
"""평가 결과 저장"""
evaluation = {
"model_name": model_name,
"query_expander_config": query_expander_config,
"timestamp": datetime.datetime.now().isoformat(),
"results": results,
"summary": {
"mean_precision": sum(r.get("precision", 0) for r in results.values()) / len(results) if results else 0,
"mean_recall": sum(r.get("recall", 0) for r in results.values()) / len(results) if results else 0,
"mean_ndcg": sum(r.get("ndcg", 0) for r in results.values()) / len(results) if results else 0,
"queries_evaluated": len(results)
}
}
self.dataset["evaluations"].append(evaluation)
# 메타데이터 업데이트
self.dataset["metadata"]["updated_at"] = datetime.datetime.now().isoformat()
# 변경사항 저장
with open(self.data_path, 'w', encoding='utf-8') as f:
json.dump(self.dataset, f, ensure_ascii=False, indent=4)
return
[참고자료]