벡터 임베딩은 텍스트를 고차원 벡터 공간에 매핑하여 의미적으로 유사한 텍스트는 벡터 공간에서 가까운 위치에 배치됩니다. 이를 통해,
- 의미적 유사도 계산 가능
- 동의어, 유의어 자동 처리
- 맥락을 고려한 검색
출처 : 엔비디아
2. 기술 스택 선택: 왜 Qdrant인가?
벡터 데이터베이스 비교
옵션
장점
단점
Pinecone
관리형 서비스, 사용 간편
비용 부담, 해외 서비스 (지연)
Weaviate
오픈소스 , GraphQL 지원
설정 복잡, 리소스 많이 필요
Qdrant
오픈소스, 가벼움, REST API
Java 클라이언트 미지원
Java + SpringBoot 기반 프로젝트에서 Qdrant를 선택한 이유
1.오픈소스 + 자체 호스팅 가능
- Pinecone 같은 클라우드 서비스는 사용량에 따라 비용이 증가
- NCP 서버에 직접 설치하여 비용 절감
2.REST API 제공
- Java 공식 클라이언트가 없어도 HTTP로 연동 가능
- 언어/프레임워크 독립적
3.HNSW 인덱스로 빠른 검색
- 대량 벡터에서도 실시간 검색 가능
- 코사인 유사도 검색 최적화
Qdrant 설정
# application-prod.yml - 운영환경
qdrant:
host: ${QDRANT_HOST:localhost}
port: 6333
grpc-port: 6334
timeout: 30
collection-name: posts_embeddings
vector-size: 1024 # NCP Clova Embedding 차원
# 저희는 네이버클라우드의 크레딧 지원을 받아 클로바를 사용하였습니다.
# 차원은 다양한 차원이 있으나, 추후 확장성을 고려하여 1024차원을 사용하였습니다.
3. 트러블슈팅: Java 클라이언트 호환성 문제
문제 상황
Qdrant 공식 Java 클라이언트를 사용하려 했지만..
( 사실 초창기에는 오픈소스라는 것에 현혹당해서 이 사실들을 몰랐습니다.. )
- Spring Boot 3.x와 호환성 문제
- gRPC 기반 클라이언트가 의존성 충돌
- 프로덕션 환경에서 연결 실패 및 타임아웃 이슈
해결: REST API 직접 구현
공식 클라이언트를 포기하고 OkHttp를 사용한 REST API 직접 구현을 선택했습니다.
VectorRepository 구현
@Slf4j
@Repository
@RequiredArgsConstructor
public class VectorRepository {
private final ObjectMapper objectMapper;
@Value("${qdrant.collection-name}")
private String collectionName;
@Value("${qdrant.vector-size}")
private int vectorSize;
@Value("${qdrant.host}")
private String host;
@Value("${qdrant.port}")
private int port;
private final OkHttpClient httpClient = new OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.build();
private String getBaseUrl() {
return String.format("http://%s:%d", host, port);
}
컬렉션 자동 생성
//컬렉션이 존재하는지 확인하고 없으면 생성
public void ensureCollectionExists() {
try {
Request request = new Request.Builder()
.url(String.format("%s/collections/%s", getBaseUrl(), collectionName))
.get()
.build();
Response response = httpClient.newCall(request).execute();
if (response.code() == 404) {
createCollection();
} else if (!response.isSuccessful()) {
log.error("컬렉션 확인 실패: {}", response.body().string());
throw new IllegalStateException("컬렉션 확인 실패");
}
response.close();
} catch (IOException e) {
log.error("컬렉션 확인 중 오류", e);
throw new IllegalStateException("컬렉션 확인 실패", e);
}
}
private void createCollection() {
try {
Map<String, Object> body = Map.of(
"vectors", Map.of(
"size", vectorSize,
"distance", "Cosine" // 코사인 유사도 사용
)
);
String json = objectMapper.writeValueAsString(body);
Request request = new Request.Builder()
.url(String.format("%s/collections/%s", getBaseUrl(), collectionName))
.put(RequestBody.create(json, MediaType.get("application/json")))
.build();
Response response = httpClient.newCall(request).execute();
if (!response.isSuccessful()) {
String errorBody = response.body() != null ? response.body().string() : "";
log.error("컬렉션 생성 실패: {}", errorBody);
throw new IllegalStateException("컬렉션 생성 실패");
}
response.close();
log.info("Qdrant 컬렉션 생성 완료: {}", collectionName);
} catch (IOException e) {
log.error("컬렉션 생성 중 오류", e);
throw new IllegalStateException("컬렉션 생성 실패", e);
}
}
벡터 검색 구현
/**
* 벡터 검색 (유사도 기반)
* @param queryEmbedding 검색 쿼리 벡터
* @param limit 결과 개수
* @return postId와 유사도 점수의 맵 (postId -> similarity score)
*/
public Map<Long, Double> searchSimilar(float[] queryEmbedding, int limit) {
try {
Map<String, Object> body = Map.of(
"vector", queryEmbedding,
"limit", limit,
"with_payload", false
);
String json = objectMapper.writeValueAsString(body);
Request request = new Request.Builder()
.url(String.format("%s/collections/%s/points/search",
getBaseUrl(), collectionName))
.post(RequestBody.create(json, MediaType.get("application/json")))
.build();
Response response = httpClient.newCall(request).execute();
if (!response.isSuccessful()) {
String errorBody = response.body() != null ? response.body().string() : "";
log.error("벡터 검색 실패: {}", errorBody);
throw new IllegalStateException("벡터 검색 실패");
}
String responseBody = response.body().string();
response.close();
// 응답 파싱 (ID와 유사도 점수 함께 추출)
JsonNode root = objectMapper.readTree(responseBody);
JsonNode result = root.get("result");
Map<Long, Double> postSimilarities = new LinkedHashMap<>();
if (result != null && result.isArray()) {
for (JsonNode point : result) {
JsonNode id = point.get("id");
JsonNode score = point.get("score");
if (id != null && id.isNumber() && score != null && score.isNumber()) {
Long postId = id.asLong();
Double similarity = score.asDouble();
postSimilarities.put(postId, similarity);
}
}
}
return postSimilarities;
} catch (IOException e) {
log.error("벡터 검색 중 오류", e);
throw new IllegalStateException("벡터 검색 실패", e);
}
}
REST API 직접 구현의 장점
-제어권 확보 : 타임아웃, 에러 핸들링 직접 관리
-안정성 향상 : 공식 클라이언트의 호환성 문제 회피
-의존성 감소 : 불필요한 라이브러리 제거
4. NCP Clova Embedding 선택 이유
1.한국어 특화 모델
- 'nlp-embedding-ko-1.0.1' 모델 사용
- 한국어 의미 이해 및 유사도 계산에 최적화
2.1024차원 벡터
- 충분한 표현력과 검색 성능의 균형
Clova Embedding Service 구현
@Slf4j
@Service
@RequiredArgsConstructor
@ConditionalOnProperty(name = "ai.provider", havingValue = "ncp", matchIfMissing = false)
public class ClovaEmbeddingServiceImpl implements EmbeddingService {
@Value("${clova.embedding.api-key:}")
private String apiKey;
@Value("${ncp.api-key:}")
private String ncpApiKey;
@Value("${clova.embedding.base-url}")
private String baseUrl;
@Value("${clova.embedding.model}")
private String model;
private final RestTemplate restTemplate;
@Override
public float[] embed(String text) {
log.info("Clova Embedding API 호출 시작");
// Embedding 키가 없으면 기본 NCP 키 사용
String actualApiKey = !apiKey.isEmpty() ? apiKey : ncpApiKey;
try {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("Authorization", "Bearer " + actualApiKey);
headers.set("X-NCP-CLOVASTUDIO-REQUEST-ID", generateRequestId());
Map<String, Object> body = Map.of("text", text);
HttpEntity<Map<String, Object>> entity = new HttpEntity<>(body, headers);
ResponseEntity<Map> response = restTemplate.postForEntity(baseUrl, entity, Map.class);
return extractEmbedding(response.getBody());
} catch (HttpClientErrorException e) {
log.error("HTTP 에러 발생! 상태 코드: {}", e.getStatusCode());
log.error("응답 본문: {}", e.getResponseBodyAsString());
throw new IllegalStateException("Clova Embedding API 호출 실패: " + e.getMessage());
} catch (Exception e) {
log.error("예외 발생", e);
throw new IllegalStateException("Clova Embedding API 처리 실패: " + e.getMessage());
}
}
트러블슈팅: 응답 구조 파싱
Clova Embedding API의 응답 구조가 문서와 다를 수 있어 Fallback 로직을 구현했습니다.
/**
* 응답에서 임베딩 벡터 추출
* OpenAI 호환 형식 지원
*/
@SuppressWarnings("unchecked")
private float[] extractEmbedding(Map<String, Object> responseBody) {
if (responseBody == null) {
throw new IllegalStateException("Clova 응답이 비어있습니다");
}
// OpenAI 호환 형식 시도
if (responseBody.containsKey("data")) {
List<Map<String, Object>> data = (List<Map<String, Object>>) responseBody.get("data");
if (data != null && !data.isEmpty()) {
Map<String, Object> firstItem = data.get(0);
List<Double> embedding = (List<Double>) firstItem.get("embedding");
if (embedding != null && !embedding.isEmpty()) {
return convertToFloatArray(embedding);
}
}
}
// Clova 고유 형식 시도
if (responseBody.containsKey("result")) {
Map<String, Object> result = (Map<String, Object>) responseBody.get("result");
if (result != null) {
List<Double> embedding = (List<Double>) result.get("embedding");
if (embedding != null && !embedding.isEmpty()) {
return convertToFloatArray(embedding);
}
}
}
// 응답 구조가 다를 수 있으므로 전체 로깅
log.error("Clova Embedding 응답 구조를 파악할 수 없습니다. 응답: {}", responseBody);
throw new IllegalStateException("Clova 응답에서 embedding을 찾을 수 없습니다");
}
private float[] convertToFloatArray(List<Double> embedding) {
float[] embeddingArray = new float[embedding.size()];
for (int i = 0; i < embedding.size(); i++) {
embeddingArray[i] = embedding.get(i).floatValue();
}
log.info("임베딩 생성 완료: {}차원", embeddingArray.length);
return embeddingArray;
}