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. 创建 /root/voice/rag/search.py,让 search(query, k=3) 返回 [(문단 id, 코사인 점수), …](占位符依次为段落 id、余弦分数)。
  4. 在 /root/voice/rag/rewrite.py 中创建执行去除口头赘词、规整数字词、补全追问的 rewrite(asr_text, context=None)。
  5. 把用原始查询和改写后的查询得到的 top-1、top-3 召回率以及每条查询的第 1 名分数,写入 /root/voice/rag/eval.json。
  6. 把区分有答案的问题和没有答案的问题的分数阈值,写入 /root/voice/rag/threshold.json。
  7. 只把越过阈值的问题用 json_schema 询问 LLM,把回答和出处写入 /root/voice/rag/answers.jsonl。
  8. 在 /root/voice/rag/verify.py 中创建检查回答的 verify(),并把没有通过的回答换成提取之后的最终语句,写入 /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,把查询做嵌入,按内积(=余弦)从大到小返回 k 个 [(문단 id, 점수), …](占位符依次为段落 id、分数)。模型只在第一次调用时创建一次。

一行 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 名分数按查询 id 写入 scores。

把段落 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 中测量。