ForPets 프로젝트에서 AI 도전 기능으로 RAG 파이프라인을 구현했다.
기존 AI 시터 추천 챗봇은 시터의 기본 정보, 리뷰 요약, 스케줄 정보를 기반으로 추천을 제공했다. 이번 작업에서는 여기서 한 단계 더 나아가, 실제 보호자 리뷰 원문을 벡터화해서 검색 가능한 지식 베이스로 만들고, 사용자의 자연어 질문과 의미적으로 유사한 리뷰를 찾아 AI 추천 응답의 근거로 활용하도록 확장했다.
즉, LLM이 그냥 “그럴듯하게” 추천하는 것이 아니라 실제 리뷰 데이터를 기반으로 응답하도록 만드는 것이 목표였다.
보호자 리뷰 데이터 조회
→ 리뷰 원문 임베딩 생성
→ Qdrant Vector DB 저장
→ 사용자 자연어 질문 임베딩
→ 유사 리뷰 시맨틱 검색
→ AI 추천 응답에 리뷰 근거 포함
시터 추천에서 중요한 건 단순 조건 검색만이 아니었다.
예를 들어 사용자는 이렇게 물어볼 수 있다.
분리불안 있는 말티즈를 차분하게 돌봐줄 시터 추천해줘
노령견을 천천히 산책시켜주는 시터 찾아줘
낯가림 있는 아이를 잘 기다려주는 시터 있어?
이런 요청은 단순히 지역, 가격, 경력만으로 판단하기 어렵다. 실제 보호자 리뷰에 “분리불안”, “천천히 산책”, “억지로 만지지 않고 기다려줌” 같은 경험이 남아 있다면, 그 리뷰를 추천 근거로 활용하는 게 훨씬 설득력 있다.
그래서 리뷰 원문을 벡터화하고, 사용자 질문과 의미적으로 가까운 리뷰를 찾아 AI 추천에 붙이는 구조를 만들었다.
이번 RAG 구현에는 아래 기술을 사용했다.
| 구분 | 사용 기술 | 이유 |
|---|---|---|
| Embedding | Gemini gemini-embedding-001 | 무료 테스트가 가능하고 리뷰 문장 임베딩에 사용 가능 |
| Vector DB | Qdrant | Docker로 로컬 실행이 쉽고 payload 기반 출처 저장이 편함 |
| Backend | Spring Boot | 기존 ForPets 백엔드 구조 유지 |
| HTTP Client | Spring RestClient | Gemini API, Qdrant API 직접 호출 |
| Similarity | Cosine Similarity | 자연어 의미 유사도 비교에 적합 |
구현한 흐름은 크게 세 단계다.
1. 리뷰 인덱싱
리뷰 원문 → Gemini Embedding → Qdrant 저장
2. RAG 검색
사용자 질문 → Gemini Embedding → Qdrant 유사 리뷰 검색
3. AI 추천 챗봇 결합
Tool Calling 추천 결과 + RAG 리뷰 근거 → 최종 응답
RAG 인덱싱 대상은 COMPLETED 예약에 연결된 보호자 리뷰다.
리뷰는 실제 케어가 완료된 예약에 대해서만 작성될 수 있기 때문에, 추천 근거로 사용하기에 적합하다고 판단했다.
테스트용으로 시터 리뷰 데이터를 조회했다.
GET /api/reviews/sitters/13?page=0&size=10&sort=createdAt&direction=desc
응답 일부는 다음과 같다.
{
"id": 130,
"reservationId": 135,
"reviewerId": 3,
"revieweeId": 16,
"reviewComment": "노령견이라 걱정했는데 이동 속도에 맞춰 천천히 산책해주시고 물 먹는 양도 확인해주셨습니다.",
"rating": 2
}
이 리뷰 원문을 임베딩해서 Qdrant에 저장한다.

인덱싱 API는 다음과 같다.
POST /api/ai/rag/reviews/index
처리 흐름은 다음과 같다.
1. COMPLETED 예약의 활성 리뷰 조회
2. 리뷰별 RagDocument 생성
3. Gemini Embedding API 호출
4. 리뷰 원문을 벡터로 변환
5. Qdrant collection 초기화
6. Qdrant에 vector + payload upsert
7. 성공/실패 개수 반환
Qdrant payload에는 검색 결과의 출처를 표시하기 위해 다음 정보를 저장했다.
| 필드 | 설명 |
|---|---|
| sourceType | REVIEW / REVIEW_SUMMARY |
| sourceId | 원본 데이터 ID |
| reviewId | 리뷰 ID |
| sitterId | 시터 프로필 ID |
| rating | 리뷰 평점 |
| content | 리뷰 원문 |
테스트 결과는 다음과 같았다.
{
"success": true,
"data": {
"indexedCount": 100,
"failedCount": 30
},
"error": null
}
총 130개 대상 중 100개가 인덱싱되었고, 30개는 실패 처리되었다. 실패한 문서가 있어도 전체 인덱싱을 중단하지 않고 가능한 문서만 우선 저장하도록 했다.

검색 API는 다음과 같다.
POST /api/ai/rag/search
요청 예시:
{
"query": "노령견을 천천히 산책시켜주는 시터"
}
처리 흐름은 다음과 같다.
1. 사용자 자연어 질문 수신
2. 질문을 Gemini Embedding API로 벡터화
3. Qdrant에서 cosine similarity 기반 유사 리뷰 검색
4. score_threshold 이상인 결과만 반환
5. sourceId, reviewId, sitterId, snippet, score 포함
응답 예시:
{
"sourceType": "REVIEW",
"sourceId": 100,
"reviewId": 100,
"sitterId": 10,
"rating": 5,
"snippet": "노령견이라 걱정했는데 이동 속도에 맞춰 천천히 산책해주시고 물 먹는 양도 확인해주셨습니다.",
"score": 0.8516915
}
“노령견을 천천히 산책시켜주는 시터”라는 질문에 대해 실제 리뷰 원문 중 “노령견”, “천천히 산책”, “물 먹는 양 확인”과 의미적으로 가까운 리뷰가 검색되는 것을 확인했다.

기존 AI 추천 챗봇에도 RAG 검색 결과를 연결했다.
POST /api/ai/chat
요청:
{
"message": "분리불안 있는 말티즈를 차분하게 돌봐줄 시터 추천해줘"
}
응답에는 추천 시터 목록과 함께 RAG 검색 출처가 포함된다.
{
"sourceType": "REVIEW",
"sourceId": 61,
"reviewId": 61,
"sitterId": 7,
"rating": 5,
"snippet": "말티즈가 낯을 많이 가리는데 천천히 적응시켜주시고 사진도 자주 보내주셔서 안심됐습니다.",
"score": 0.8104445
}
AI 답변에도 실제 리뷰 근거가 포함되도록 했다.
리뷰 근거: 리뷰 #61 / 시터 #7 / "말티즈가 낯을 많이 가리는데 천천히 적응시켜주시고 사진도 자주 보내주셔서 안심됐습니다."
이렇게 하면 사용자는 추천 결과가 어떤 리뷰를 근거로 만들어졌는지 확인할 수 있다.

ForPets 도메인과 관련 없는 질문도 테스트했다.
요청:
{
"query": "자동차 엔진오일을 잘 교체하는 시터"
}
응답:
{
"success": true,
"data": {
"results": []
},
"error": null
}
관련 없는 질문에 대해 억지로 리뷰를 끼워 맞추지 않고 빈 결과를 반환했다. 환각 방어 관점에서 이 동작이 중요하다고 봤다.

Qdrant 장애 상황도 고려했다.
RAG는 추천 품질을 높이는 보조 기능이지, 핵심 추천 기능 자체가 아니다. 따라서 Qdrant 검색에 실패해도 챗봇 전체가 죽으면 안 된다.
Qdrant 검색 실패
→ RAG sources 빈 리스트 반환
→ Tool Calling 기반 추천은 계속 진행
장애 상황에서도 응답 형태는 유지되도록 처리했다.
{
"success": true,
"data": {
"results": []
},
"error": null
}

처음에는 text-embedding-004 모델을 사용했다.
그런데 Gemini API 호출 시 다음 오류가 발생했다.
models/text-embedding-004 is not found for API version v1beta
현재 호출 방식에서는 해당 모델을 사용할 수 없었다.
그래서 embedding model을 다음과 같이 변경했다.
forpets:
ai:
gemini:
embedding-model: gemini-embedding-001
인덱싱 API를 여러 번 호출하니 Qdrant에서 collection이 이미 존재한다는 오류가 발생했다.
Collection `forpets_reviews` already exists
인덱싱 API는 여러 번 호출해도 안전해야 한다. 그래서 collection이 이미 존재하면 실패로 보지 않고 재사용하도록 처리했다.
collection 없음 → 새로 생성
collection 있음 → 재사용
가장 오래 잡아먹은 문제는 벡터 차원 불일치였다.
Gemini gemini-embedding-001은 3072차원 벡터를 반환하는데, Qdrant collection은 처음에 768차원으로 생성되어 있었다.
그 결과 다음 오류가 발생했다.
Vector dimension error: expected dim: 768, got 3072
해결 방법은 두 가지였다.
먼저 설정을 3072로 변경했다.
forpets:
ai:
rag:
vector-size: 3072
그리고 기존 collection이 이미 생성되어 있는 경우를 위해, collection의 vector size를 확인한 뒤 설정값과 다르면 RAG 전용 collection을 삭제 후 재생성하도록 처리했다.
collection 있음 + vector size 일치 → 재사용
collection 있음 + vector size 불일치 → 삭제 후 재생성
이렇게 해서 로컬 테스트 중에도 이전 설정으로 만들어진 collection 때문에 계속 실패하는 문제를 해결했다.
최종적으로 Postman에서 6개 시나리오를 테스트했다.
| 번호 | 테스트 항목 | 결과 |
|---|---|---|
| 1 | 리뷰 데이터 확인 | 시터별 리뷰 정상 조회 |
| 2 | RAG 인덱싱 | indexedCount=100, failedCount=30 |
| 3 | RAG 검색 | 자연어 질문과 유사한 리뷰 검색 성공 |
| 4 | AI 챗봇 + RAG 근거 확인 | 추천 응답에 sources와 리뷰 근거 포함 |
| 5 | 검색 결과 없음 fallback | 도메인과 무관한 질문에 빈 결과 반환 |
| 6 | Qdrant 장애 fallback | 장애 시 빈 결과 반환, 서비스 응답 정상 유지 |
| 요구사항 | 구현 내용 |
|---|---|
| 도메인 데이터를 임베딩하여 벡터 DB에 저장 | 보호자 리뷰 원문을 Gemini Embedding으로 벡터화 후 Qdrant 저장 |
| 자연어 질의 기반 시맨틱 검색 | 사용자 질문을 임베딩하여 Qdrant에서 유사 리뷰 검색 |
| 검색 결과 출처 포함 | sourceId, reviewId, sitterId, snippet, score 반환 |
| 유사도 임계값 설정 | score-threshold: 0.7 적용 |
| 검색 결과 없음 fallback | 관련 없는 질문에 results: [] 반환 |
| RAG + Tool Calling 결합 | AI 추천 챗봇 응답에 RAG sources 포함 |
| 장애 fallback | Qdrant 실패 시 빈 sources 반환, 추천 기능은 유지 |
이번 RAG 작업을 통해 ForPets의 AI 추천 기능은 단순 조건 검색 기반 추천에서 한 단계 확장되었다.
최종 구조는 다음과 같다.
리뷰 원문
→ Gemini Embedding
→ Qdrant 저장
→ 자연어 질문 임베딩
→ 유사 리뷰 검색
→ AI 추천 응답에 리뷰 근거 포함
이제 사용자는 “분리불안 있는 말티즈”, “노령견 산책”, “낯가림 있는 아이”처럼 정확한 키워드가 아니더라도, 실제 리뷰 원문을 기반으로 더 신뢰할 수 있는 시터 추천을 받을 수 있다.
가장 크게 느낀 점은 RAG는 단순히 “벡터 DB 붙이기”가 아니라는 것이다. 모델의 임베딩 차원, Vector DB collection 설정, 검색 threshold, 출처 payload, 장애 fallback까지 함께 맞아야 실제 서비스 기능으로 쓸 수 있었다.