LabHub
시작하기
배우기 러닝패스 코스

음성 AI 에이전트 — 듣고, 찾고, 말하는 파이프라인

오인식된 질문으로 찾고, 근거를 검사한 답만 말한다

LabHub 에서 이어서 보기

목표

ASR 이 받아 적은 전화 질의로 문단 검색을 만들고, 질의 재작성·거절 문턱·인용 검증을 차례로 붙여 근거 없는 말이 입 밖으로 나가지 않는 RAG 를 만든다.

왜 중요한가

음성 질의는 오인식과 군말과 되물음을 달고 온다. 작은 LLM 은 형식도 내용도 틀린다. 그래서 '무엇을 찾을까' 와 '무엇을 말해도 될까' 를 코드가 정해야 한다. 질의 파일(/opt/lab/fixtures/voice/rag/queries.jsonl)의 asr 칸은 합성 음성을 이 이미지의 ASR 이 실제로 받아 적은 결과이고, relevant·facts 는 손으로 단 정답입니다. 채점기는 여러분의 검색·재작성·검증 함수를 불러 숨긴 사례로 시험하고, 최종 발화가 근거로 뒷받침되는지 다시 검사합니다(LLM 에는 묻지 않습니다).

단계

  1. /opt/lab/fixtures/voice/kb/*.md 를 빈 줄로 나눠 제목(#) 줄을 뺀 문단마다 {"id": "kb-hours#1", "doc": "kb-hours", "text": …} 를 /root/voice/rag/chunks.jsonl 에 적으세요.
  2. 문단을 voicekit.models.Embedder 로 임베딩해 /root/voice/rag/embed.npy((문단 수, 384))에 저장하세요.
  3. search(query, k=3) 가 [(문단 id, 코사인 점수), …] 를 돌려주는 /root/voice/rag/search.py 를 만드세요.
  4. 군말 제거·숫자 낱말 정리·되물음 풀기를 하는 rewrite(asr_text, context=None) 를 /root/voice/rag/rewrite.py 에 만드세요.
  5. 원래 질의와 재작성한 질의로 top-1·top-3 재현율과 질의별 1위 점수를 /root/voice/rag/eval.json 에 적으세요.
  6. 답이 있는 질문과 없는 질문을 가르는 점수 문턱을 /root/voice/rag/threshold.json 에 적으세요.
  7. 문턱을 넘은 질문만 LLM 에 json_schema 로 물어 답과 출처를 /root/voice/rag/answers.jsonl 에 적으세요.
  8. 답을 검사하는 verify() 를 /root/voice/rag/verify.py 에 만들고, 떨어진 답을 추출로 바꾼 최종 발화를 /root/voice/rag/final.jsonl 에 적으세요.

참고

문단으로 나눈다

/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 에서 잽니다.