본문으로 바로가기
반응형

지난 편에서 전체 아키텍처를 잡았으니, 이번 편에서는 파이프라인의 첫 두 단계 의도 분류키워드/태그 추출를 실제로 어떻게 구현했는지 다뤄보겠습니다.

 

왜 의도 분류부터 해야 했나

처음엔 "그냥 다 검색으로 처리하면 되지 않나?" 싶었습니다.

그런데 실제 사용자 발화를 가정해보니 그렇게 단순하지 않았습니다.

  • "봄에 입기 좋은 티셔츠 추천해줘" → 상품 검색
  • "나한테 어울리는 옷 좀 골라줘" → 검색이 아니라 개인화 추천 (구체적 상품 언급이 없음)
  • "제 주문 어디까지 왔어요?" → 검색이 아니라 주문 조회
  • "안녕하세요" → 그냥 인사

이 네 가지를 전부 벡터 검색으로 밀어넣으면, 인사말에도 애매한 상품이 검색 결과로 튀어나오는 이상한 경험이 생깁니다. 그래서 요청이 들어오면 가장 먼저 "이게 무슨 의도인지"부터 LLM에게 분류시키기로 했습니다.

 

 

의도 분류 설계

6가지 카테고리로 나눴습니다.

Intent판단 기준처리 방식
상품 검색 상품명·카테고리·스타일 키워드 포함 하이브리드 검색 수행
개인화 추천 구체적 상품 언급 없이 추천만 요청 주문내역 기반 추천
주문 조회 주문·구매·배송 확인 요청 DB에서 주문내역 조회
인사 인사말 AI 텍스트 응답만
질문 서비스 기능 질문 AI 텍스트 응답만
기타 위 어디에도 해당 안 함 AI 텍스트 응답만

 

여기서 실제로 헷갈렸던 케이스가 있었습니다.

"데이트룩 원피스 추천해줘"라는 문장에는 "추천"이라는 단어가 들어있지만, 이건 개인화 추천이 아니라 상품 검색으로 분류해야 맞습니다. 이미 "원피스"라는 구체적 상품과 "데이트룩"이라는 스타일이 명시되어 있기 때문입니다.

 

반대로 "나한테 어울리는 옷 골라줘"는 구체적 상품 언급이 전혀 없으니 개인화 추천으로 가야 합니다.

이 기준을 프롬프트에 명확하게 예시로 박아넣고 나서야 분류 정확도가 안정됐습니다. "추천"이라는 단어 하나로 판단하면 안 되고, "구체적 상품/스타일이 명시되어 있는가"가 진짜 기준이라는 걸 프롬프트에 반복해서 강조해야 했습니다.

// 의도 분류 호출부
public IntentResultDto classifyIntent(String userInput) {
    PromptTemplate promptTemplate = new PromptTemplate(INTENT_CLASSIFICATION_PROMPT);
    Prompt prompt = promptTemplate.create(Map.of("userInput", userInput.trim()));
    ChatResponse response = chatClient.prompt(prompt).call().chatResponse();
    return parseIntentResponse(response.getResult().getOutput().getContent(), userInput);
}

 

키워드/태그 추출: 검색이 되려면 구조화가 필요하다

의도가 "상품 검색"으로 분류되면, 다음 단계는 이 자연어 문장을 검색 시스템이 이해할 수 있는 구조로 바꾸는 작업입니다. 여기서 temperature=0으로 고정했는데, 같은 입력에 매번 다른 결과가 나오면 검색 품질이 들쭉날쭉해지기 때문입니다.

추출하는 항목은 이렇게 나눴습니다.

필드역할예시
검색용 문장(VectorQuery) 의미 기반 검색용 자연어 문장 "가정에서 세탁 가능한 편안한 루즈핏 오버사이즈 자켓"
핵심 키워드(Keywords) 정확 매칭 검색용 명사 3~5개 자켓, 루즈핏
태그(Tags) 17개 구조화 속성 카테고리, 스타일, 소재, 계절 등
부정 태그(NegativeTags) 제외할 구조화 속성 세탁방법: 드라이클리닝
필터(Filter) 정확 일치 조건 색상: 네이비블루

 

여기서 실무적으로 중요했던 부분은 "검색용 문장"과 "핵심 키워드"의 역할을 명확히 분리하는 거였습니다. 처음엔 둘을 비슷하게 만들었더니 검색 품질이 애매했는데, 다음 원칙을 세우고 나서 나아졌습니다.

  • 검색용 문장: 의미 유사도 검색에만 쓰인다. 사용자 의도를 풍부한 상품 설명 문장으로 바꾸는 것이지, 태그를 나열하는 게 아니다.
  • 핵심 키워드: 정확 매칭 검색에만 쓰인다. 상품명·카테고리를 특정하는 명사만 담는다.
// 검색용 문장이 있으면 그걸로 임베딩을 만들고, 없으면 키워드로 폴백
public List<Double> generateEmbeddingFromVectorQuery(String vectorQuery, List<String> keywords) {
    if (vectorQuery != null && !vectorQuery.isBlank()) {
        return generateEmbedding(vectorQuery);
    }
    return generateEmbeddingFromKeywords(keywords);
}

 

실제 추출 예시

"출근할 때 입을 수 있는 깔끔한 블라우스"라는 입력이 들어오면 이런 식으로 구조화됩니다.

검색용 문장: 오피스룩에 어울리는 깔끔하고 단정한 여성 블라우스
핵심 키워드: 블라우스, 출근룩, 오피스
카테고리: 블라우스
분위기: 클래식
스타일: 오피스룩
성별: 우먼
착용상황: 출근룩
패턴: 솔리드

 

이렇게 문장 하나가 검색 시스템이 다룰 수 있는 여러 필드로 쪼개지고 나서야, 뒤에 나오는 벡터 검색과 키워드 검색이 각자 자기 역할을 제대로 할 수 있게 됩니다.

 

이번 편 정리

의도 분류와 키워드/태그 추출, 둘 다 겉보기엔 간단해 보이지만 실제로는 "애매한 케이스를 어떻게 프롬프트에 명시적으로 못 박아두는가"가 정확도를 좌우했습니다. 특히 "추천"이라는 단어만 보고 판단하면 안 된다는 것처럼, 실제 사용자 발화를 여러 개 모아서 예외 케이스를 계속 프롬프트에 추가하는 반복 작업이 필요했습니다.

다음 편에서는 이렇게 추출된 정보를 가지고 벡터 검색과 키워드 검색을 어떻게 결합했는지(RRF, 동적 가중치) 다루겠습니다.

반응형