음성 AI 에이전트 — 듣고, 찾고, 말하는 파이프라인
말로 묻는 검색 — 오인식·되물음·거절, 그리고 근거를 통과한 오답
한 줄 요약
음성 질의는 짧고, 구어적이고, ASR 이 틀리게 적은 채로 들어온다. 그래서 검색 전에 질의를 다듬고(군말 제거·숫자 낱말 정리·앞 질문에 기댄 되물음 풀기), 검색 점수로 답이 없는 질문을 먼저 거절하고, 모델이 낸 답은 인용한 문서로 검사한 뒤에만 말한다. 검사는 숫자가 문서에 있는지까지만 볼 수 있다 — 근거를 통과한 오답이 남는다는 것을 알고 평가해야 한다(모듈 9).
왜 이게 필요했나
LLM 엔지니어링 코스의 RAG 는 글로 쓴 질문을 받는다. 전화는 다르다. 이 모듈의 질의 12개는 합성 음성을 이 이미지의 스트리밍 ASR 이 실제로 받아 적은 결과다(fixtures/voice/rag/queries.jsonl). "What time do you guys open on Saturday?" 는 "OHI WHAT TIME DO YOU GUISE OPEN ON SATURDAY" 로, "How long does a refill take?" 는 "HOW LONG DOES ORIFYL TAKE" 로 왔다. 그리고 두 번째 질문은 "O K AND WHAT ABOUT SUNDAY" — 앞 질문 없이는 무엇을 묻는지 모른다.
어떻게 동작하나
문단 단위 임베딩. 전화로 읽어 줄 답은 한두 문장이라, 문서를 문단으로 나눠 문단마다 임베딩한다. 이 이미지의 임베딩 모델은 all-MiniLM-L6-v2(qint8 ONNX)다. 모델 카드대로 토큰 벡터를 attention mask 로 가려 평균(mean pooling)하고 길이 1로 정규화하면, 내적이 곧 코사인 유사도다.
질의 재작성. 세 가지 규칙이면 이 자료의 대부분을 다룬다. (1) 군말 목록(um · uh · oh · like · okay · o k …)을 뺀다. 목록에는 이 ASR 이 실제로 낸 'ohi' 도 넣었다 — 관측한 오인식을 사전으로 모으는 것이 현장의 방식이다. (2) 숫자 낱말을 숫자로(twenty four → 24). 문서에는 '24 hours' 로 적혀 있다. (3) 'and what about Sunday' 같은 되물음은 앞 질문을 가져와 요일만 바꾼다. 'ORIFYL' 같은 오인식은 규칙으로 못 고친다 — 임베딩이 뜻으로 가까운 문단을 찾아 주기를 기대할 뿐이다.
측정해 보면. 이 문서 14개(문단 41개)에서는 재작성 전에도 top-1 재현율이 10개 중 10개였다. 임베딩이 'ORIFYL' 을 품은 질문을 처방 재발급 문단 가까이에 놓았다. 그런데 BM25 와 섞은 하이브리드(RRF, k=60)로 바꾸자 top-1 이 원래 질의로 10개 중 8개, 재작성한 질의로 7개로 떨어졌다 — 'VITIO'·'ORIFYL' 같은 오인식 낱말은 어떤 문서에도 없어 BM25 에 신호를 못 주고, 남은 흔한 낱말(how, long, the)이 순위를 흐렸다(이 이미지에서 잰 값). 하이브리드가 늘 이기는 것이 아니다. 재작성이 크게 도운 곳은 검색이 아니라 질문의 뜻이다. "and what about sunday" 를 그대로 모델에 주면 무엇을 물었는지 알 수 없다.
거절 문턱. 답이 없는 질문(피자 가게 추천, 와이파이 비밀번호)도 검색은 무언가를 찾아온다. 가장 가까운 문단의 점수가 문턱 τ 보다 낮으면 모델에 묻지도 않고 거절한다 — 작은 모델에게 지어낼 기회를 주지 않는 것이다. 이 자료에서 답이 있는 질문의 최저 점수는 0.345, 없는 질문의 최고 점수는 0.260 이라 그 사이에 문턱이 있다. 재작성 규칙을 조심해야 하는 이유도 여기서 보였다. 한때 'you → the clinic' 치환을 넣었더니 "can you recommend a good pizza place" 가 "can the clinic recommend…" 가 되어 점수가 0.397 로 올라, 어떤 문턱으로도 답이 있는 질문과 가를 수 없게 됐다. 그래서 그 규칙은 뺐다.
형식은 문법으로, 내용은 검사로. 0.5B 모델에게 '출처를 [kb-hours] 처럼 적어라' 라고 하면 형식부터 어긴다. llama-server 의 response_format(json_schema)로 출력을 {"answer": …, "source": …} 로 묶고, source 를 이번에 찾은 문서 id 와 none 만 허용하는 enum 으로 두면, 모델은 찾지 않은 문서를 인용할 수 없다. 그다음 코드가 검사한다: 답에 든 숫자(시각·금액·일수)가 인용한 문서에 그대로 있는가. 실제로 이 모델은 "refill takes 10 business days" 를 내면서 혈액 검사 문서를 인용했다 — 문서에는 3 business days 가 있다. 검사에서 떨어진 답은 말하지 않고, 인용 문서에서 질문과 가장 가까운 문장을 그대로 읽는다(추출). 틀릴 수가 없는 대신 덜 자연스럽다.
근거를 통과한 오답. 숫자 검사는 '토요일에 몇 시에 여느냐' 에 평일 시간(8:30 a.m. to 6 p.m.)을 답해도 통과시킨다. 숫자가 문서에 있기 때문이다. 실제로 이 파이프라인은 토요일 질문과 일요일 되물음에 평일 시간을 답했다. 이런 오답은 검증기로는 못 막고 정답표로 평가해야 보인다(모듈 9 의 사실 정확도).
현장에서 만나는 모습
음성 비서의 인용은 화면의 각주와 다르다. '[kb-hours]' 를 소리 내어 읽으면 안 된다(모듈 7 의 읽을 글 만들기에서 지운다). 대신 인용은 로그와 평가에 남겨 '무엇을 근거로 말했나' 를 나중에 따질 수 있게 한다.
다음 실습에서 할 것
KB 문서를 문단으로 나눠 임베딩하고, 코사인 검색 함수를 만든다. 질의 재작성 규칙을 숨긴 사례로 시험받고, 원래 질의와 재작성한 질의로 재현율과 점수를 잰 뒤 답이 없는 질문을 가르는 문턱을 정한다. 문턱을 넘은 질문만 LLM 에 JSON 으로 묻고, 검증기로 걸러 통과하지 못한 답은 추출로 바꿔 최종 발화를 만든다.