벡터 검색의 정확 매칭 한계와 하이브리드 검색 도입
초기에는 벡터 검색만 사용해 "서울 청년 지원금"처럼 지역명과 대상이 명확한 질문에서도 관련 없는 결과가 상위에 노출됐습니다. 이후 키워드 검색(tsvector)을 추가하고 RRF로 순위를 결합한 뒤, 조건/지역/키워드 기반 재정렬을 더해 의미 기반 검색과 정확 매칭을 함께 반영하도록 개선했습니다.
Project
자연어 질문을 RAG형 검색 흐름으로 연결하고, 스트리밍 응답과 검색 결과를 대화형 UI로 렌더링한 프로젝트입니다.


기존 정부 복지 사이트는 메뉴 탐색 중심이라 사용자가 원하는 혜택을 빠르게 찾기 어렵습니다. CiviChat은 자연어 질문을 조건 추출, RAG 검색, LLM 요약 스트리밍, 결과 렌더링까지 하나의 대화 흐름으로 연결하고, AI 응답 렌더링, 벡터 검색 인터페이스, 접근성, 성능 최적화를 직접 설계하고 구현했습니다.
질문 입력, 대화 히스토리, 결과 카드 렌더링
검색 결과 이벤트를 먼저 보내고 LLM 요약 청크를 SSE로 스트리밍
React와 분리된 순수 TypeScript 검색/조건 추출 로직
pgvector 벡터 검색, tsvector 키워드 검색, RRF 결합 후 조건/지역/키워드 기반 재정렬
검색 결과를 쉬운 문장으로 요약하고 스트리밍 응답 생성
타이프라이터 요약, 결과 카드, 가상 스크롤 높이 추정
비즈니스 로직은 src/core에 격리하여 React/Next.js에 의존하지 않는 순수 TypeScript로 작성했습니다. CLI와 API Route가 같은 함수를 공유하며, 새 기능은 CLI에서 먼저 검증한 후 UI에 연결합니다.
초기에는 벡터 검색만 사용해 "서울 청년 지원금"처럼 지역명과 대상이 명확한 질문에서도 관련 없는 결과가 상위에 노출됐습니다. 이후 키워드 검색(tsvector)을 추가하고 RRF로 순위를 결합한 뒤, 조건/지역/키워드 기반 재정렬을 더해 의미 기반 검색과 정확 매칭을 함께 반영하도록 개선했습니다.
지역 필터를 강하게 적용하면 서울, 부산처럼 지역을 명시한 질문에서 중앙부처나 전국 공통 서비스가 후보에서 빠지는 문제가 있었습니다. 지역 필터 후보와 필터 없는 전국 후보를 함께 가져온 뒤 중복 제거와 지역 boost/penalty를 적용해 recall과 precision의 균형을 맞췄습니다.
검색 로직은 정규식, 벡터 검색, 키워드 검색, 후처리 필터가 함께 얽혀 있어 작은 수정도 다른 질의의 품질을 떨어뜨릴 수 있었습니다. eval query set을 만들고 Vitest 기반 검색 품질 테스트로 승격해 main push에서 품질 기준을 확인하도록 구성했습니다.
초기에는 LLM 요약 스트리밍이 끝난 뒤 검색 결과 카드를 보여주는 구조라 체감 응답이 늦었습니다. 서버가 검색 결과 이벤트를 먼저 보내고, 이후 요약 청크를 스트리밍하도록 바꿔 사용자가 검색 성공과 후보 결과를 더 빨리 인지할 수 있게 했습니다.
AI 요약은 길이가 매번 달라지고 타이프라이터로 점진 렌더링되기 때문에 DOM에 직접 넣은 뒤 높이를 재면 매번 reflow가 발생합니다. Canvas measureText로 DOM 밖에서 폭을 측정하고 이분 탐색으로 줄바꿈을 계산하는 방식으로 전환하여, 스트리밍 중 높이 측정의 스크립팅 비용을 85% 줄였습니다 (getBoundingClientRect 1,282ms -> Canvas 193ms). 같은 원리를 블로그 글과 플레이그라운드로도 정리했습니다.
벡터 검색만으로는 지역명이나 법령명 같은 정확 매칭이 약했습니다. 반대로 키워드 검색만으로는 서술형 질문을 처리할 수 없었습니다. 두 결과를 RRF로 합산한 뒤 서비스명/본문 키워드, 시군구/광역시도/전국형 여부, 구직자/임산부/소상공인/한부모/저소득 조건을 점수화해 최종 순위를 재정렬했습니다.
복지/법령 검색, 조건 추출, 요약 로직을 src/core에 격리해 CLI와 API Route가 같은 함수를 사용하도록 구성했습니다. UI 연결 전 터미널에서 검색 품질을 먼저 검증할 수 있고, 팀 환경에서도 프론트엔드와 백엔드/ML이 같은 core 함수를 기준으로 독립적으로 작업할 수 있는 구조입니다.