음성 AI 에이전트 — 듣고, 찾고, 말하는 파이프라인
오인식된 질문으로 찾고, 근거를 검사한 답만 말한다
목표
ASR 이 받아 적은 전화 질의로 문단 검색을 만들고, 질의 재작성·거절 문턱·인용 검증을 차례로 붙여 근거 없는 말이 입 밖으로 나가지 않는 RAG 를 만든다.
왜 중요한가
음성 질의는 오인식과 군말과 되물음을 달고 온다. 작은 LLM 은 형식도 내용도 틀린다. 그래서 '무엇을 찾을까' 와 '무엇을 말해도 될까' 를 코드가 정해야 한다. 질의 파일(/opt/lab/fixtures/voice/rag/queries.jsonl)의 asr 칸은 합성 음성을 이 이미지의 ASR 이 실제로 받아 적은 결과이고, relevant·facts 는 손으로 단 정답입니다. 채점기는 여러분의 검색·재작성·검증 함수를 불러 숨긴 사례로 시험하고, 최종 발화가 근거로 뒷받침되는지 다시 검사합니다(LLM 에는 묻지 않습니다).
단계
/opt/lab/fixtures/voice/kb/*.md를 빈 줄로 나눠 제목(#) 줄을 뺀 문단마다{"id": "kb-hours#1", "doc": "kb-hours", "text": …}를/root/voice/rag/chunks.jsonl에 적으세요.- 문단을
voicekit.models.Embedder로 임베딩해/root/voice/rag/embed.npy((문단 수, 384))에 저장하세요. search(query, k=3)가[(문단 id, 코사인 점수), …]를 돌려주는/root/voice/rag/search.py를 만드세요.- 군말 제거·숫자 낱말 정리·되물음 풀기를 하는
rewrite(asr_text, context=None)를/root/voice/rag/rewrite.py에 만드세요. - 원래 질의와 재작성한 질의로 top-1·top-3 재현율과 질의별 1위 점수를
/root/voice/rag/eval.json에 적으세요. - 답이 있는 질문과 없는 질문을 가르는 점수 문턱을
/root/voice/rag/threshold.json에 적으세요. - 문턱을 넘은 질문만 LLM 에 json_schema 로 물어 답과 출처를
/root/voice/rag/answers.jsonl에 적으세요. - 답을 검사하는
verify()를/root/voice/rag/verify.py에 만들고, 떨어진 답을 추출로 바꾼 최종 발화를/root/voice/rag/final.jsonl에 적으세요.
참고
- 질의 파일의 칸:
id·asr(받아 적은 글)·context(되물음이 기대는 앞 질의 id)·relevant(정답 문서)·answerable·facts. - 재현율은 답이 있는 질문 10개로 셉니다. 1위 문단의 문서가 정답 문서 가운데 하나면 top-1 적중입니다.
- LLM 호출:
from voicekit.llm import chat→chat(messages, max_tokens=80, json_schema=schema)가 (글, 첫 조각 ms, 전체 ms, timings) 를 돌려줍니다.voice-llm up이 먼저입니다. - 흔한 실수: 답이 없는 질문까지 재현율 분모에 넣기, 문턱 아래 질문도 모델에 묻기, 인용 id 를 문법으로 묶지 않아 찾지 않은 문서를 인용하게 두기.
- 문서: all-MiniLM-L6-v2 모델 카드 · llama.cpp server — response_format · Cormack et al., 2009, Reciprocal Rank Fusion
문단으로 나눈다
/opt/lab/fixtures/voice/kb/*.md 를 이름순으로 읽어 빈 줄(\n\n)로 나누고, 비었거나 # 로 시작하는 조각은 빼고, 문단마다 공백을 하나로 접은 text 와 id(문서 이름#순번, 1부터)·doc 을 /root/voice/rag/chunks.jsonl 에 한 줄씩 적으세요.
문서 이름은 파일 이름에서 .md 를 뗀 것입니다. " ".join(p.split()) 가 줄바꿈과 겹친 공백을 하나로 접습니다.
문단을 임베딩한다
chunks.jsonl 의 text 를 순서대로 voicekit.models.Embedder().embed([...]) 로 임베딩해 np.save 로 /root/voice/rag/embed.npy 에 저장하세요((문단 수, 384), 행 순서 = chunks.jsonl 순서).
Embedder 는 길이 1로 정규화한 벡터를 돌려줍니다. 행 순서가 어긋나면 검색 결과의 id 가 엉뚱한 문단을 가리킵니다.
코사인 검색 함수
/root/voice/rag/search.py 에 search(query, k=3) 를 만드세요. chunks.jsonl 과 embed.npy 를 읽어 두고, 질의를 임베딩해 내적(=코사인)이 큰 순서로 [(문단 id, 점수), …] k 개를 돌려줍니다. 모델은 처음 부를 때 한 번만 만드세요.
VEC @ q 한 줄이면 모든 문단과의 코사인이 나옵니다. np.argsort(-s)[:k]. 파일 경로는 os.path.dirname(file) 기준으로 잡으면 어디서 불러도 됩니다.
질의를 다듬는다
/root/voice/rag/rewrite.py 에 rewrite(asr_text, context=None) 를 만드세요. 소문자로 바꿔 낱말만 남기고, 군말(um · uh · oh · er · ah · like · okay · ok · o · k · hi · hello · so · well · ohi)을 빼고, 숫자 낱말을 숫자로(eight → 8, twenty four → 24) 바꿉니다. 결과가 'and what about …'·'what about …'·'how about …' 이고 context 가 있으면 context 를 같은 규칙으로 다듬은 뒤, 새 요일이 있으면 그 요일로 바꾸고 없으면 뒤에 덧붙입니다. 채점기가 숨긴 사례로 시험합니다.
되물음의 context 도 ASR 결과라 같은 rewrite 를 먼저 거칩니다. 숫자는 십의 자리(twenty…)와 일의 자리(one…nine)가 이어지면 더합니다. 'o' 와 'k' 는 'OK' 가 'O K' 로 받아 적힌 것입니다.
재현율과 점수를 잰다
질의 12개를 원래 asr 로(raw), 그리고 rewrite 로 다듬어(rewritten, context 는 앞 질의의 asr) 각각 search(…, 3) 한 뒤, 답이 있는 10개에서 recall_at_1·recall_at_3 을 /root/voice/rag/eval.json 에 적고, 재작성한 질의의 1위 점수를 scores 에 질의 id 별로 적으세요.
문단 id 를 문서 이름(doc)으로 바꿔 정답 문서와 비교합니다. q11·q12 는 답이 없는 질문이라 재현율에서 빼지만 점수는 적습니다 — 다음 단계의 문턱에 씁니다.
답이 없는 질문을 가르는 문턱
eval.json 의 scores 에서 답이 있는 질문의 최저 점수와 답이 없는 질문의 최고 점수 사이에 문턱 tau 를 정해 /root/voice/rag/threshold.json(tau·answerable_min·unanswerable_max)에 적으세요. 모든 답이 있는 질문은 tau 이상, 답이 없는 질문은 tau 미만이어야 합니다.
두 값의 한가운데가 무난합니다. 둘이 겹치면 어떤 문턱도 둘을 다 맞히지 못합니다 — 그때는 검색이 아니라 질의(재작성 규칙)를 다시 보세요.
문턱을 넘은 질문만 모델에 묻는다
질의마다 재작성·검색(top-3)을 하고, 1위 점수가 tau 미만이면 모델에 묻지 않고 거절문('I'm not sure about that. Let me connect you to the front desk.')을 answer 로, source 를 none 으로 둡니다. 넘으면 찾은 문단을 [문서 id] 를 붙여 시스템 프롬프트에 넣고, answer(문자열)·source(찾은 문서 id 들과 none 만 허용하는 enum)의 json_schema 로 물어 결과를 적습니다. /root/voice/rag/answers.jsonl 에 id·query·retrieved(찾은 문서 id, 중복 없이)·score·answer·source·gated 를 한 줄씩 적으세요.
schema = {"type": "object", "required": ["answer", "source"], "properties": {"answer": {"type": "string"}, "source": {"type": "string", "enum": retrieved + ["none"]}}}. 문법이 막아 주는 것은 형식뿐입니다 — 내용은 다음 단계가 검사합니다.
검사를 통과한 말만 한다
/root/voice/rag/verify.py 에 verify(answer, source, retrieved, kb) 를 만드세요 — kb 는 {문서 id: 문서 전체 글}, 돌려주는 것은 (통과 여부, 이유). 빈 답·source 가 none·찾지 않은 문서 인용·문서에 없는 숫자(시각 7:30 같은 형식 포함)가 있으면 떨어뜨립니다. 그다음 answers.jsonl 을 검사해, 통과한 답은 그대로(status llm), 문턱에서 거절된 질문은 거절문(refused), 떨어진 답은 인용 문서(또는 1위 문서)에서 질문과 임베딩이 가장 가까운 문장을 그대로 읽게(extractive) 바꿔 /root/voice/rag/final.jsonl(id·say·source·status)에 적으세요.
숫자는 re.findall(r"\d+(?::\d+)?", …) 로 뽑아 문서의 숫자 목록에 모두 있는지 봅니다. 문장은 (?<=[.!?])\s+ 로 나눕니다. 추출은 틀릴 수 없지만 질문에 딱 맞지 않을 수 있습니다 — 그 대가를 모듈 9 에서 잽니다.