news 2026/10/27 11:00:51

GPT-4 + Whisper + Weaviate 构建语音知识问答助手:RAG 工程实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
GPT-4 + Whisper + Weaviate 构建语音知识问答助手:RAG 工程实践

做 AI 应用最怕的其实不是调 API,而是不知道怎么把几个 API 拼成一个真正能用的产品。想象一个这样的场景:你在手机上按住说话,问它“根据上季度的销售数据,哪个区域的哪种产品增速最快?”它先把你这句话转成文本,然后在你的文档库里找到相关段落,最后用自然语言给出结论。这个交互背后,Whisper 负责听写,Weaviate 负责检索,GPT-4 负责生成最终答案。三种能力缺一不可。

这篇是这个系列的第三篇。前两篇我们把 GPT-4 的 Chat API、Whisper 的转写能力、Weaviate 的基础概念分别讲透了,所以这一篇不再重复单个模型的用法,而是把它们放进同一个项目里,讲一套可落地的工程做法。内容包括环境搭建、代码结构、参数调优,以及那些只有真正跑过才会踩到的坑。如果你已经会独立调用这些 API,正想把它们揉进真实产品,这篇文章就是按着头带你走一遍全程。

1. 三个工具放在一起,究竟解决什么问题

1.1 各自扮演的角色

先理清分工。GPT-4 是生成层,负责把检索到的信息组织成通顺、有依据的回答;Whisper 是输入层,把语音转换成文本,让系统能“听懂”你说的话;Weaviate 是知识层,保存文档切片后的向量,让系统能在几亿条片段里快速找到和问题语义相关的部分。

这不是随意拼凑,而是一套典型的 RAG(检索增强生成)结构。RAG 的核心思路是:大模型的内部知识有限,也不一定包含你的业务文档,所以在生成前先去做一次检索,把“外部记忆”拿出来拼进 prompt。这里的“外部记忆”就是 Weaviate 里存的文档向量。你可以把文档切片想象成一本书的书签,每张书签上都写了一批关键词和语义编码。用户的问题虽然和文档原文不完全一样,但不影响找到含义相近的内容。

1.2 一个具体的需求:给文档装上“耳朵”

我们用一个非常具体的项目来贯穿全文:做一个“语音知识问答助手”。用户的输入是一段语音,输出是一个带出处的自然语言答案。系统内部链路是这样:

用户说话 -> Whisper 转写文本 -> 文本向量化 -> Weaviate 查询 top3 相关片段 -> 把片段和问题拼进 GPT-4 prompt -> 返回答案。

为什么要做成语音输入?因为很多工具类应用的使用场景是手在忙、眼在看,没法打字。比如仓库管理员问库存规则、维修师傅问设备参数,语音比键盘方便得多。而 Whisper 对中文、方言口音和嘈杂环境的鲁棒性都不错,选它作为输入前端是合理的。

1.3 为什么不用传统搜索引擎

有人会问:我直接用 Elasticsearch 做关键词匹配不行吗?行,但对“口语化问题”很吃力。你问“上季度哪个区域卖得最好”,文档里写的是“华东区销售额环比增长 12%”,两者之间没有任何共同的实词,BM25 很难召回。向量检索把语义映射到高维空间,问题与文档即使字面不同,也能在语义上接近。Weaviate 比较难得的是,它同时支持向量检索、BM25 关键词检索和两者的混合检索,还能附带 metadata 过滤。这样我们既可以用向量实现模糊语义匹配,也可以在必要时用 filter 限制文档范围。

2. 准备工作:环境、依赖和数据

2.1 安装依赖和本地服务

建议使用 Python 3.10 或更高版本,并且一定用虚拟环境隔离。我习惯把项目拆成两部分:代码和基础设施。代码这边只需要几个包,执行以下命令:

python -m venv .venv source .venv/bin/activate # Windows 用户用 .venv\Scripts\activate pip install openai weaviate-client python-dotenv tiktoken

openai是官方 SDK,目前在 1.x 版本,接口比老版更简洁;weaviate-client用于连接 Weaviate;tiktoken用来估算 token 数量,后面会用到;python-dotenv方便读取密钥配置。

Weaviate 推荐用 Docker 跑本地单机实例。在项目根目录放一个docker-compose.yml:

services: weaviate: image: semitechnologies/weaviate:1.24.6 restart: on-failure ports: - "8080:8080" environment: PERSISTENCE_DATA_PATH: '/var/lib/weaviate' QUERY_DEFAULTS_LIMIT: 20 volumes: - weaviate_data:/var/lib/weaviate volumes: weaviate_data:

然后执行docker compose up -d。等服务启动后,访问http://localhost:8080能看到 Weaviate 的首页说明,再继续下一步。

2.2 准备示例文档与音频

准备一个纯文本文件当作知识库,比如knowledge_base.txt,里面放几段产品说明、流程规范或会议纪要。如果没有现成内容,可以先放一段虚构的“产品 FAQ”,内容随意,关键是里面要有可以被检索到的信息单元。

音频准备其实很自由,你也可以用自己的手机录音直接测试。如果只想快速验证链路,建议用一段 10 秒到 1 分钟的中文语音,内容例如“华东区上季度销售额是多少”。文件格式用 mp3 或 wav 都可以。尽量不要用超过 20 分钟的音频,因为容易踩到请求体大小限制和时间过长的坑,后面会细说。

2.3 配置 OpenAI 和 Weaviate

在项目里创建.env文件:

OPENAI_API_KEY=你的真实key WEAVIATE_URL=http://localhost:8080

然后写一个工具模块,统一初始化客户端:

import os from dotenv import load_dotenv import weaviate from openai import OpenAI load_dotenv() openai_client = OpenAI() weaviate_client = weaviate.Client(os.getenv("WEAVIATE_URL"))

注意:OpenAI 的OpenAI()会自动读取OPENAI_API_KEY,所以我们不需要显式传参。Weaviate 的客户端则必须传url。

接下来,在 Weaviate 里创建一个 Class。这步非常重要,Class 相当于数据库表,DocumentChunk就是我们用来存放文档切片的“表”:

schema_config = { "class": "DocumentChunk", "vectorizer": "none", "properties": [ {"name": "content", "dataType": ["text"]}, {"name": "source", "dataType": ["string"]}, {"name": "chunk_index", "dataType": ["int"]}, {"name": "created_at", "dataType": ["date"]} ] } if not weaviate_client.schema.exists("DocumentChunk"): weaviate_client.schema.create_class(schema_config)

我把vectorizer设成了"none",意思是向量完全由我们自己生成,再传给 Weaviate。这么做的好处是可控性强:我们可以用同一个 OpenAI embedding 模型把所有文档和查询都向量化,隔离了 Weaviate 对第三方模型 Provider 的配置依赖。如果你不想管向量化,也可以配置 Weaviate 内置 OpenAPI vectorizer,但那样会引入额外的网络请求和环境变量,调试起来没那么直接。

3. 核心实现:Whisper 转写,Embedding 入库,GPT-4 回答

3.1 用 Whisper 把语音变成文本

我直接用 OpenAI 托管的whisper-1模型,不用本地部署。官方 API 的上限相对宽松,而且不需要管理音频转写的推理服务器,这对大多数项目来说性价比很高。

def transcribe(audio_path: str, language: str = "zh") -> str: with open(audio_path, "rb") as audio_file: response = openai_client.audio.transcriptions.create( model="whisper-1", file=audio_file, response_format="verbose_json", language=language ) return response.text

这里有一个关键参数:response_format="verbose_json"。它不仅仅返回文本,还会返回各分段的时间戳,后续如果你想做语音对齐、字幕生成,会非常有用。另一个参数是language="zh"。在中文环境里,不指定语言时 Whsiper 偶尔会自动识别成英文,导致输出变成带口音的英文拼音,强制指定可以显著降低这种概率。

如果你的音频超过 25MB,API 会直接拒绝。稳妥的做法是先用 ffmpeg 做分段或重采样:

ffmpeg -i input.mp3 -ar 16000 -ac 1 output.wav ffmpeg -i input.wav -f segment -segment_time 600 -c copy segment_%03d.wav

第一条命令把采样率统一到 16kHz、单声道,Whisper 对这种干净音频的转写效果最好;第二条命令按 10 分钟切分,每段独立送 API。

3.2 文档切块与向量化

把文档原封不动丢给 Weaviate 是不现实的。一方面 GPT-4 上下文有限,另一方面检索粒度太粗,一条文档动辄几千字,Query 可能只能命中其中一小段。所以先做切块。

切块最简单的做法是按固定字数硬切,但更好的方式是按 token 切分,因为 embedding 模型和 GPT-4 都基于 token 做计算。这里用tiktoken实现:

import tiktoken encoding = tiktoken.get_encoding("cl100k_base") def split_text_by_tokens(text, max_tokens=500, overlap_tokens=50): tokens = encoding.encode(text) chunks = [] pos = 0 while pos < len(tokens): chunk = tokens[pos:pos + max_tokens] chunks.append(encoding.decode(chunk)) pos += max_tokens - overlap_tokens return chunks

overlap_tokens是重叠长度。为什么需要重叠?因为文档在自然边界处可能一句话被切成两半,导致任意一半语义都不完整。重叠 50 个 token 相当于给相邻切片留了个“接口”,能很大程度减少信息丢失。

切完后,对每个 chunk 调用 embedding 接口,再把向量和数据写进 Weaviate:

def embed_text(text: str): resp = openai_client.embeddings.create( model="text-embedding-3-small", input=text ) return resp.data[0].embedding def index_document(file_path: str): with open(file_path, "r", encoding="utf-8") as f: text = f.read() chunks = split_text_by_tokens(text) for idx, chunk in enumerate(chunks): vector = embed_text(chunk) weaviate_client.data_object.create( data_object={ "content": chunk, "source": file_path, "chunk_index": idx, "created_at": "2025-01-01T00:00:00Z" }, class_name="DocumentChunk", vector=vector )

这里用的是text-embedding-3-small。它是 OpenAI 目前性价比最高的 embedding 模型,默认维度 1536,足够表达语义。需要注意:所有入库的向量必须来自同一个模型,否则维度或语义空间不一致,检索结果会一团糟。如果你的数据量很大,单条循环写入会非常慢,建议用 Weaviate 的批量写入接口,类似with weaviate_client.batch as batch: batch.add_data_object(...),能快十倍以上。

3.3 查询链路:把用户问题转成检索条件

用户问题不一定和文档原文用词一样,所以要把问题也向量化,再在 Weaviate 里做近似度检索:

def search_docs(query: str, top_k: int = 3): q_vector = embed_text(query) result = weaviate_client.query.get( "DocumentChunk", ["content", "source", "chunk_index"] ).with_near_vector( {"vector": q_vector, "distance": 0.2} ).with_limit(top_k).do() return result["data"]["Get"]["DocumentChunk"]

distance代表向量距离,Weaviate 默认用余弦距离,数值越小表示语义越接近。0.2 是一个比较严格的起始点,能保证召回的内容大概率相关。但也可能因为太严格而返回空结果,这点在“常见问题”里会专门讲。

另一个需要强调的是top_k。设成 3 比较适合问答场景。太大会把不相关的内容塞进 prompt,引入噪声;太小又会漏掉关键信息。如果你拿到的文档本身就是 FAQ 型,每一条都很独立,可以适当提高到 5。

如果文档很多,还想限定某个文件名称,可以在查询时加where条件:

where_filter = { "path": ["source"], "operator": "Equal", "valueString": "knowledge_base.txt" }

这相当于 SQL 里的 WHERE 语句,非常实用。

3.4 组装 GPT-4 的上下文问答

检索到相关片段后,把它们拼进 prompt 交给 GPT-4。这里最考验工程判断的是“上下文怎么组织”。我把代码写完整:

def ask_with_voice(audio_path: str): question = transcribe(audio_path) hits = search_docs(question, top_k=3) context = "\n\n".join( [f"[{h['source']}] {h['content']}" for h in hits] ) messages = [ {"role": "system", "content": "你是一名知识库助手。请严格根据参考资料回答问题,不要编造。如果资料不足以回答,请说:根据现有文档,我无法回答。最后需要标注引用的来源。"}, {"role": "user", "content": f"参考资料:\n{context}\n\n用户问题:{question}"} ] response = openai_client.chat.completions.create( model="gpt-4", messages=messages, temperature=0.2, max_tokens=800 ) return response.choices[0].message.content

temperature=0.2是关键。问答应用里我们并不追求创意,而是追求准确和稳定。温度太高,模型容易自由发挥,在知识库问答场景里就是“幻觉”。system prompt 里也明确写了“不要编造”,并强制要求“如果资料不足就说不知道”,这个设计非常重要。

max_tokens=800是输出上限。知识库问答通常不需要洋洋洒洒写论文,800 token 足够覆盖大部分回答,还能防止模型过度啰嗦导致费用失控。

到这里,一条完整的语音问答链路已经跑通了。

4. 进阶调优:真正好用的 AI 驱动应用都是调出来的

4.1 向量检索的阈值到底怎么设

很多读者第一次跑通都会遇到两个极端:要么distance设太严,查不到结果;要么太松,召回一堆无关内容,GPT-4 被噪声带偏。我自己的调法很简单,先跑一批真实问题,把search_docs返回的 distance 值打印出来,看分布。

for h in hits: print(h["_additional"]["distance"], h["content"][:50])

然后在日志里观察:正确的候选片段 distance 通常在哪个区间,错误的候选片段又在哪个区间。以我自己的经验,中文 FAQ 类文档,text-embedding-3-small的余弦距离下,0.15 以内通常高度相关,0.15-0.3 是模糊相关,超过 0.3 基本没戏。但这只是经验,不同文档集差异很大,一定要测自己的数据。

如果你的业务场景里很多问题会用到同义词,建议直接用 Weaviate 的 hybrid 搜索:同时跑向量检索和 BM25 关键词检索,再把结果合并排序。实现方式是把with_near_vector换成with_hybrid(query=question, alpha=0.5),alpha=0.5表示向量和关键词各占一半权重。这样能兼顾语义和字面,召回稳定性会高很多。

4.2 Token 预算与模型输出控制

GPT-4 的上下文是 8k 或 32k,但并不是说我们每次都把整 8k 塞满。上下文越长,延迟越高、成本越贵,模型注意力也可能被无关内容稀释。我更推荐的做法是:在拼 prompt 之前用 tiktoken 统计每个 chunk 的 token,按MAX_CONTEXT_TOKENS总量动态选择要带几个 chunk。

MAX_CONTEXT_TOKENS = 2000 def select_chunks(query: str, top_k: int = 5): q_vector = embed_text(query) result = weaviate_client.query.get( "DocumentChunk", ["content", "source"] ).with_near_vector( {"vector": q_vector, "distance": 0.3} ).with_limit(top_k).do() hits = result["data"]["Get"]["DocumentChunk"] selected = [] total_tokens = 0 for h in hits: tokens = len(encoding.encode(h["content"])) if total_tokens + tokens > MAX_CONTEXT_TOKENS: break selected.append(h["content"]) total_tokens += tokens return selected

这样做的实际价值是:当知识库里的切片长度差异很大时(比如会议纪要段落长、FAQ 段落短),固定按数量带 top3 可能会漏掉真正相关的长文,固定按 token 选能让模型始终在一个可控的窗口里作答。后期调试更稳,也更容易估算每次调用的成本。

还有一个小技巧:在chat.completions.create里加seed参数。OpenAI 对同 seed 的请求会尽量输出同样的结果,这样你做回归测试时不会因为微小的随机性而误判“改坏了”。

4.3 Whisper 转写准确率提升技巧

Whisper API 已经有很不错的准确率,但转写质量直接决定后面所有环节的检索效果。如果用户说“VB.NET 的连接池配置”,Whisper 可能会把 VB.NET 转成别的词,后面检索就会偏。这时候可以给 Whisper 传一个prompt参数,里面夹带领域词:

response = openai_client.audio.transcriptions.create( model="whisper-1", file=audio_file, language="zh", prompt="VB.NET,连接池,PostgreSQL,内存泄漏" )

prompt的作用是给模型一个“词汇先验”,相当于告诉它你大概率会遇到这些词。专业术语越多,效果越明显。

另一个实用细节:录音里如果有“嗯”“啊”“那个”这类停顿词,Whisper 经常会原样转写出来。这些词如果不处理,会干扰 embedding。可以在转写后做一次轻量清洗,也可以用 GPT-4 做一遍短文本纠错。我通常只在转写结果明显差时才启用 GPT-4 清洗,因为每多一次模型调用都增加成本和延迟。

4.4 加一层缓存和重试,让服务更稳

真实服务里,同一个用户可能会反复问类似问题,同样的文档切片会被不同问题反复检索。给 embedding 做缓存能省很多 API 调用。最朴素的做法是用字典或functools.lru_cache:

from functools import lru_cache @lru_cache(maxsize=4096) def embed_text_cached(text: str): resp = openai_client.embeddings.create( model="text-embedding-3-small", input=text ) return tuple(resp.data[0].embedding)

注意返回的是 tuple 而不是 list,因为 lru_cache 要求参数和返回值都是可 hash 的。

生产环境还可以对 Whisper 转写结果按文件 hash 做缓存:同一个音频文件不需要一次次重复花钱转写。另外,OpenAI SDK 本身有默认重试机制,但偶尔会遇到 429 限流。比较稳妥的做法是结合tenacity写一个自定义重试:

from tenacity import retry, stop_after_attempt, wait_exponential @retry(stop=stop_after_attempt(3), wait=wait_exponential(min=1, max=10)) def call_gpt(messages): return openai_client.chat.completions.create( model="gpt-4", messages=messages, temperature=0.2, max_tokens=800 )

wait_exponential是指数等待,1 秒开始,最长 10 秒。注意,不是所有请求都适合重试,比如用户已经取消请求,再重试就是浪费资源。重试只应该用在“瞬时故障”,而不是“业务错误”。

5. 常见问题速查表

我在实际跑这套系统时,遇到的高频问题整理成了一张表,给读者排查时参考:

现象可能原因排查与解决方案
Whisper 输出文本是英文未指定language参数,或音频口音较重强制language="zh",用 ffmpeg 重采样为 16kHz 单声道
转写结果出现大量无意义符号音频格式不规范,或采样率不可用先用 ffmpeg 检测并转换格式,再上传本地转成 wav 重试
查询返回Got: 0条Weaviate 里 class 不存在,或向量维度不匹配检查 schema 是否存在,确认所有 embedding 来自同一个模型
检索结果 top1 永远不相关distance阈值太松,或文档切块过小打印_additional.distance,重新设置阈值;调大max_tokens或overlap_tokens
GPT-4 回答“不知道”但文档里有答案向量检索没召回正确片段将 top_k 从 3 提高到 5,或改用 hybrid 搜索
同一个问题每次回答不一样temperature 太高降到 0.2 以下,并提供seed参数
API 调用频繁超时prompt 上下文太大,或网络不稳定按 token 动态裁剪上下文;为调用加指数退避重试
本地 Weaviate 无法启动Docker 服务端口冲突检查docker ps,确认 8080 没被占用,换其他端口

排查时有一个原则:先看数据在哪一步丢的。我的习惯是在transcribe、embed_text、search_docs和call_gpt四个函数入口加日志,记录输入摘要和耗时。定位问题时,比如转写结果正常但查询为空,基本可以直接锁定是 embedding 或 Weaviate 环节,而不是 GPT-4 环节。

结尾

把这套系统从 Demo 推到“能稳定用”,我最大的体会是:跑通链路只花了一个下午,真正让它好用花了将近两周。最折磨人的往往不是模型本身,而是切块方式、检索阈值、prompt 措辞,这些细节堆在一起决定了最终体验。我强烈建议你在落地时给系统加一张日志表,记录每次用户问题的原文、转写结果、召回的 chunk、distance 和最终答案。跑上一两周,你就能用自己的数据说话:哪些问题总是召回得差,哪些 prompt 让模型答得片面,哪些 chunk 切得太碎需要合并。这个迭代过程才是“艺术”的部分,也是让 AI 应用真正符合业务预期的关键。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/27 11:00:15

CHIS5.0表结构文档实战:从建表到数据血缘的完整拆解

简介&#xff1a;这份文档是方正公司医院信息系统&#xff08;CHIS5.0&#xff09;的数据库表结构说明&#xff0c;面向医院信息化开发者、数据库管理员及医疗软件实施人员&#xff0c;用于理解系统底层数据模型、开展二次开发或数据对接。资源包共1个doc文件&#xff0c;约5.0…

作者头像 李华
网站建设 2026/10/22 15:02:40

一台搞定所有版本?一套万能PS5通用配置指南,从SSD到串流全打通

我手头的三台机器&#xff0c;一直是困扰我的一个测试难题。首发光驱版、数字版、以及后来的轻薄版&#xff0c;各自身边的接口布局、散热结构、甚至SSD安装手感都有差别。每次按网上的教程折腾&#xff0c;都要先分辨视频里那台机器到底跟我是同款还是不同款&#xff0c;烦不胜…

作者头像 李华
网站建设 2026/10/22 15:43:41

开源游戏引擎Superpowers:浏览器内用TypeScript做游戏

如果你是因为看到“superpowers”这个词&#xff0c;以为发现了什么能让人瞬间掌握全技能的效率工具&#xff0c;那你不是第一个。最近很多人搜索“想要安装 superpowers”&#xff0c;其实找到的是一个同名开源项目&#xff1a;一套可以直接在浏览器里做 2D/3D 游戏的开发平台…

作者头像 李华
网站建设 2026/10/23 11:20:08

IDEA Debug 高效调试实战:断点、步进与表达式求值技巧

简介&#xff1a;这份PDF资料聚焦IntelliJ IDEA的Debug调试技巧&#xff0c;面向Java后端开发、Web工程开发者以及需要提升调试效率的初中级程序员&#xff0c;帮助解决断点设置、单步跟踪、变量查看等日常排错中的实际问题。资源包内共1个PDF文件&#xff0c;大小约828KB&…

作者头像 李华
网站建设 2026/10/19 0:59:15

手写Whitted-Style光线追踪:从光线求交到递归反射折射的完整实现

Whitted-Style光线追踪这个名字&#xff0c;在我刚学图形学的时候&#xff0c;属于那种"听起来很高端、看公式想摔键盘、但一旦跑通就再也回不去光栅化"的技术。现在回头想&#xff0c;它之所以值得花一整篇博客去拆解&#xff0c;不是因为算法本身多难——真正的难点…

作者头像 李华
网站建设 2026/10/22 13:13:30

ADMM交替方向乘子法详解:原理、LASSO实现与调参实战

简介&#xff1a;交替方向乘子法&#xff08;ADMM&#xff09;是求解大规模分布式优化与统计学习问题的经典算法&#xff0c;通过将全局问题分解为若干局部子问题迭代求解&#xff0c;在压缩感知、矩阵分解、Lasso回归等场景应用广泛。本份资源定位为新手友好的MATLAB入门示例&…

作者头像 李华