본문으로 바로가기

[자연어 검색 개발기 #4] 스트리밍, 추천, 그리고 회고

category ELK 2026. 7. 13. 11:20
반응형

3편까지 검색이 어떻게 이루어지는지 다뤘습니다. 이번 편에서는 검색 결과를 어떻게 실시간으로 보여줬는지, OpenAI를 추가로 호출하지 않고 개인화 추천을 어떻게 만들었는지, 그리고 전체 프로젝트를 돌아본 소감으로 마무리하겠습니다.

 

왜 응답을 스트리밍했나

처음엔 검색 결과와 AI 응답 문장을 한 번에 묶어서 반환했습니다. 그런데 실제로 써보니 문제가 있었습니다. AI가 응답 문장을 만드는 데 시간이 걸리다 보니, 검색 결과가 이미 준비됐는데도 사용자는 그동안 아무것도 못 보고 기다려야 했습니다.

그래서 두 단계로 나눴습니다.

  1. 검색 결과를 먼저 즉시 전송 (사용자는 상품 카드부터 바로 확인)
  2. AI 응답 문장은 토큰 단위로 이어서 스트리밍 (타이핑되듯 보여줌)

Server-Sent Events(SSE)로 이 두 이벤트를 구분해서 보냈습니다.

event: search    ← 검색 결과 JSON (즉시 전송)
data: {...}

event: token     ← AI 응답 토큰 (반복 전송)
data: 봄

event: token
data: 철 여성 캐주얼 티셔츠를 추천드려요!

event: done      ← 완료 시그널
data: [DONE]

 

체감 응답 속도가 확실히 좋아졌습니다. 실제 처리 시간은 똑같아도, 사용자가 "뭔가 진행되고 있다"는 걸 바로 느끼는지 여부가 경험을 크게 좌우한다는 걸 실감했습니다.

 
@PostMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter streamSearch(@RequestBody ChatSearchRequestDto request) {
    SseEmitter emitter = new SseEmitter(120_000L); // 2분 타임아웃

    CompletableFuture.runAsync(() -> {
        var context = naturalLanguageSearchService.searchForStreaming(...);
        emitter.send(SseEmitter.event().name("search")
            .data(objectMapper.writeValueAsString(context.getPartialResult())));

        contextualResponseService.streamContextualResponse(...)
            .toIterable().forEach(token ->
                emitter.send(SseEmitter.event().name("token").data(token))
            );

        emitter.send(SseEmitter.event().name("done").data("[DONE]"));
        emitter.complete();
    });

    return emitter;
}

 

여기서 삽질했던 부분은 타임아웃 설정이었습니다. 기본 타임아웃이 짧아서, AI 응답이 길어지는 케이스에서 스트림이 중간에 끊기는 문제가 있었습니다. 넉넉하게 잡아두는 게 안전합니다.

 

OpenAI 호출 없이 개인화 추천하기

개인화 추천을 만들 때 처음 떠올린 방식은 "사용자의 최근 본 상품들을 다시 LLM에 넣어서 추천을 물어보자"였습니다. 그런데 이러면 추천을 보여줄 때마다 매번 API 비용이 들고, 응답 속도도 느려집니다.

그래서 이미 색인 단계에서 만들어둔 임베딩을 재활용하는 방식으로 바꿨습니다.

  1. 사용자가 최근 본 상품들의 임베딩을 OpenSearch에서 그대로 조회 (추가 API 호출 없음)
  2. 여러 상품의 임베딩을 평균 내서 하나의 벡터로 만듦
  3. 그 평균 벡터로 다시 kNN 검색 수행 (시드로 쓴 상품은 결과에서 제외)
 
private PersonalizeResultDto buildRecommendation(String type, List<ProductIndexDto> seedItems, int size) {
    List<String> seedItemNos = seedItems.stream().map(ProductIndexDto::getItemNo).toList();

    // 이미 저장된 임베딩 조회 (OpenAI 호출 없음)
    Map<String, List<Double>> embeddingMap = openSearchIndexService.getEmbeddingsByItemNumbers(seedItemNos);

    // 여러 시드 상품의 임베딩 평균 벡터 계산
    List<Double> avgVector = averageVectors(new ArrayList<>(embeddingMap.values()));

    // 평균 벡터로 kNN 검색 (시드 상품 제외)
    List<ProductIndexDto> candidates = vectorSearchService.vectorSearch(avgVector, candidateSize, 0.5, ...);

    return candidates.stream()
        .filter(p -> !seedItemNoSet.contains(p.getItemNo()))
        .limit(size)
        .toList();
}

 

예를 들어 최근 본 상품이 "린넨 셔츠"와 "면 블라우스"였다면, 두 임베딩의 평균 벡터는 대략 "여름 소재 상의"라는 의미 공간을 가리키게 됩니다. 그 벡터로 다시 검색하면 비슷한 결이지만 아직 안 본 상품들이 나옵니다.

이 방식의 장점은 명확했습니다. 추천 API를 호출할 때마다 LLM을 다시 부를 필요가 없으니 속도도 빠르고 비용도 거의 안 듭니다. 대신 색인 단계에서 임베딩을 미리 만들어둔 게 전제 조건이었는데, 그건 다음 항목에서 다루는 이미지 태깅 파이프라인과 맞물려 있습니다.

 

이미지 태깅 상품 이미지에서 검색용 정보 뽑아내기

상품 데이터에는 텍스트 정보(상품명, 카테고리)는 있지만, "루즈핏인지, 크롭인지, 무드가 어떤지" 같은 정보는 텍스트만으로 파악하기 어려운 경우가 많았습니다. 그래서 GPT-4o-mini의 이미지 인식 기능으로 상품 이미지를 분석해서 태그를 자동으로 뽑아내는 단계를 색인 파이프라인에 추가했습니다.

{
  "스타일_키워드": ["캐주얼", "레이어드"],
  "질감": "매트",
  "패턴": "스트라이프",
  "실루엣": "크롭",
  "착용상황": ["데일리", "여행"],
  "무드": "걸리시",
  "계절_키워드": ["간절기", "레이어드"]
}

이건 실시간 처리가 아니라 색인 단계에서 한 번만 실행하는 관리자용 배치 작업입니다. 상품마다 이미지 분석 API를 호출해야 해서, 속도 제한 문제로 상품당 일정 시간 대기를 넣어야 했습니다. 처음엔 이 대기시간을 짧게 잡았다가 API 쪽에서 거절당하는 일이 잦아서, 넉넉하게 늘리고 나서야 안정적으로 돌아갔습니다.

 

 

전체 회고

"AI로 뭐라도 만들어봐라"로 시작한 과제였는데, 실제로 만들어보니 진짜 어려운 부분은 LLM 호출 자체가 아니라 LLM이 뱉은 결과를 기존 검색 시스템과 어떻게 이어붙이느냐였습니다.

  • 의도 분류 하나도 프롬프트에 예외 케이스를 계속 추가해야 정확도가 올라갔고
  • 벡터 검색과 키워드 검색을 그냥 반반 섞으면 안 됐고, 질의 구체성에 따라 가중치를 동적으로 바꿔야 했고
  • 개인화 추천은 오히려 LLM을 매번 안 부르는 방향으로 설계해야 실용적이었습니다

전체적으로 "AI를 얼마나 자주, 어디에 부르느냐"가 아니라 "AI 결과를 어디까지만 믿고, 나머지는 기존 시스템(검색 엔진, 캐시, DB)에 맡길 것인가"를 판단하는 게 이번 프로젝트의 핵심이었던 것 같습니다.

이번 시리즈는 여기서 마무리하겠습니다. 읽어주셔서 감사합니다.

반응형