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