基于AI的会议记忆助理:从语音转写到智能问答的完整实现
1. 项目概述:当会议有了“记忆”
你有没有过这样的经历?开完一个长达一小时的会议,大家讨论得热火朝天,定下了七八个待办事项,结果散会后,每个人对“谁、在什么时候、做什么”的理解都出现了微妙的偏差。或者,一周后你想回顾上次会议关于某个技术方案的决策细节,却发现会议纪要写得语焉不详,关键信息早已模糊不清。更常见的是,作为会议记录者,你一边要参与讨论,一边要奋笔疾书,手忙脚乱,最后发现记录下来的内容支离破碎,重点全无。
“AI Meeting Memory Assistant”这个项目,就是为了解决这些痛点而生的。它不是一个简单的录音转文字工具,而是一个真正意义上的“会议记忆体”。其核心是利用人工智能技术,特别是大语言模型(LLM)和自动语音识别(ASR),为线上或线下会议构建一个可查询、可分析、可执行的“第二大脑”。想象一下,会议结束后,你不仅能获得一份逐字稿,还能立刻得到一份结构清晰的纪要,自动提炼出的待办事项,甚至能随时向这个“助理”提问:“我们刚才关于预算部分是怎么讨论的?”或者“张三承诺的交付物是什么时候?”,它都能从会议录音中精准定位并给出答案。
这个项目非常适合有一定Python基础的开发者、产品经理、团队负责人,或者任何对AI应用落地感兴趣的人。它涉及的技术栈清晰且前沿,包括语音处理、自然语言理解、向量数据库等,是一个绝佳的练手项目,能让你亲手打造一个解决真实工作痛点的智能工具。接下来,我将拆解整个项目的实现思路、技术细节和避坑指南,手把手带你从零构建属于你自己的“会议记忆助理”。
2. 核心架构与工具选型
构建一个AI会议记忆助理,我们需要一个清晰、可扩展的架构。整个系统可以看作一个数据处理管道,从原始的音频输入开始,经过多道工序,最终产出结构化的知识和便捷的查询接口。
2.1 整体技术栈设计
一个稳健的架构是项目成功的基础。我设计的核心流程如下:
- 音频采集与预处理 :获取会议录音。对于线上会议(如腾讯会议、Zoom),可以通过系统音频录制或官方API;对于线下会议,则使用录音设备。得到的音频文件(通常是
.wav或.mp3)需要进行降噪、归一化等预处理,提升后续识别准确率。 - 语音转文本(ASR) :这是将声音转化为可处理文本的关键一步。我们需要选择一个准确率高、支持中文、并且最好能区分说话人(声纹识别或说话人分离)的ASR服务或模型。
- 文本后处理与结构化 :原始的转写文本是杂乱无章的,包含口语化词汇、重复、语气词等。这一步需要利用大语言模型(LLM)的强大能力,对文本进行清洗、分段,并提取核心信息,如生成会议纪要、提炼行动项(Action Items)、识别关键决策点等。
- 知识存储与索引 :结构化的信息需要被有效存储,以便快速检索。这里不能只用传统数据库,因为我们的查询可能是自然语言,比如“上次说的那个营销方案有什么风险?”。因此,需要引入 向量数据库 ,将文本转化为向量(Embedding),实现语义级别的相似度搜索。
- 交互查询接口 :为用户提供一个自然的方式与“会议记忆”交互。这可以是一个简单的命令行工具,一个Web界面,或者集成到Slack、钉钉等办公软件中的机器人。用户通过提问,系统从向量数据库中检索相关片段,并让LLM生成一个精准、连贯的答案。
基于这个流程,我的技术选型如下:
- 编程语言 : Python 。这是AI和数据处理领域的事实标准,拥有最丰富的库和社区支持。
- 语音转文本(ASR) :
- 首选方案 : OpenAI Whisper 。这是一个开源模型,准确率极高,支持多语言,且能进行说话人分离(需要额外标注数据或结合其他工具)。它可以在本地运行,无需网络,保障数据隐私。对于大多数场景,
whisper-1(基础模型)或whisper-2(中等模型)在精度和速度上取得了很好的平衡。 - 备选方案 :各大云服务商的ASR API,如阿里云、腾讯云、百度云。它们通常更稳定,集成说话人分离功能更成熟,但会产生持续费用,且数据需上传至云端。
- 首选方案 : OpenAI Whisper 。这是一个开源模型,准确率极高,支持多语言,且能进行说话人分离(需要额外标注数据或结合其他工具)。它可以在本地运行,无需网络,保障数据隐私。对于大多数场景,
- 大语言模型(LLM) :
- 云端方案 : OpenAI GPT-4/3.5-Turbo API 或 Claude API 。它们能力强大,使用简单,是快速验证想法和构建原型的首选。需要注意API调用成本和网络稳定性。
- 本地方案 : Ollama + 开源模型 (如
Qwen2.5、Llama 3.2、DeepSeek-Coder)。如果你对数据隐私有极高要求,或希望零成本运行,这是一个绝佳选择。Ollama简化了本地大模型的部署和管理。虽然效果可能略逊于顶级云端模型,但对于会议纪要总结、信息提取等任务已完全足够。
- 向量数据库 : ChromaDB 。它轻量、易用,纯Python实现,非常适合嵌入到应用程序中。它提供了简单的API来存储文档、生成嵌入向量并进行相似性搜索。对于入门和中等规模的项目,ChromaDB是完美选择。
- 嵌入模型 :为了将文本存入向量数据库,我们需要一个模型将文本转换为向量。可以使用 OpenAI的
text-embedding-3-smallAPI ,也可以使用 本地模型 ,如BAAI/bge-small-zh-v1.5,这是一个在中文上表现优异的开源嵌入模型,可以通过Hugging Face的sentence-transformers库调用。 - 应用框架 : Gradio 或 Streamlit 。这两个库能让你用极少的代码快速构建出交互式的Web界面,非常适合演示和内部使用。Gradio更轻快,Streamlit在数据展示上更强大一些。
选型心路 :为什么选择这套组合?核心是 平衡效率、成本与隐私 。Whisper解决ASR的“有和无”问题,且免费。LLM方面,初期用云端API快速迭代功能,验证价值;待核心流程跑通后,可以平滑迁移到本地Ollama方案以控制长期成本并保障隐私。ChromaDB和Gradio极大地降低了开发门槛,让我们能聚焦在业务逻辑而非基础设施上。
2.2 开发环境准备
工欲善其事,必先利其器。一个干净的开发环境能避免很多依赖冲突。
# 1. 创建并激活虚拟环境 (推荐使用 conda 或 venv)
conda create -n meeting-ai python=3.10
conda activate meeting-ai
# 2. 安装核心依赖
pip install openai-whisper # 语音转文字
pip install openai # 如需使用GPT API
pip install chromadb # 向量数据库
pip install sentence-transformers # 使用开源嵌入模型
pip install gradio # 构建Web界面
pip install pydub # 音频处理
pip install langchain # (可选)用于编排LLM应用链,初期可不用
# 3. 安装Ollama (如果选择本地LLM方案)
# 访问 https://ollama.com/ 下载并安装对应操作系统的版本
# 安装后,在终端拉取一个模型,例如:
# ollama pull qwen2.5:7b
注意事项 :
- Whisper模型第一次运行时会自动下载,模型文件较大(如
medium模型约1.5GB),请确保网络通畅和磁盘空间。 - 如果使用本地嵌入模型(如
BAAI/bge-small-zh-v1.5),第一次运行SentenceTransformer时也会下载模型文件。 - 对于音频处理,
pydub依赖ffmpeg。在Mac上可以用brew install ffmpeg安装;在Ubuntu上用sudo apt install ffmpeg;在Windows上需要从官网下载二进制文件并配置环境变量。
3. 核心模块实现详解
有了清晰的架构和准备好的环境,我们就可以开始动手实现各个核心模块了。我会按照数据处理流程,逐一拆解。
3.1 音频处理与高精度转写
会议录音的质量直接决定了后续所有环节的上限。我们的目标是获得一份带时间戳、且尽可能区分说话人的文本。
import whisper
from pydub import AudioSegment
import numpy as np
class AudioTranscriber:
def __init__(self, model_size="medium"):
"""
初始化Whisper模型。
:param model_size: tiny, base, small, medium, large。 精度和速度的权衡。
"""
print(f"正在加载Whisper-{model_size}模型...")
self.model = whisper.load_model(model_size)
print("模型加载完毕。")
def transcribe(self, audio_path, language="zh"):
"""
转录音频文件。
:param audio_path: 音频文件路径。
:param language: 音频语言,'zh'代表中文。
:return: Whisper的完整结果字典,包含'segments'(带时间戳的句子列表)等信息。
"""
# 使用Whisper进行转录
result = self.model.transcribe(audio_path, language=language, fp16=False) # fp16=False在某些CPU上更稳定
return result
def format_transcript(self, result):
"""
将Whisper结果格式化为带时间戳的文本。
:param result: Whisper.transcribe()返回的结果。
:return: 格式化后的字符串。
"""
transcript_text = ""
for segment in result["segments"]:
start = segment["start"]
end = segment["end"]
text = segment["text"].strip()
# 格式:[00:01:23 -> 00:01:45] 这里是说话内容。
start_str = f"{int(start//3600):02d}:{int((start%3600)//60):02d}:{int(start%60):02d}"
end_str = f"{int(end//3600):02d}:{int((end%3600)//60):02d}:{int(end%60):02d}"
transcript_text += f"[{start_str} -> {end_str}] {text}\n"
return transcript_text
# 使用示例
if __name__ == "__main__":
transcriber = AudioTranscriber(model_size="medium")
# 假设我们有一个 meeting_20240520.wav 文件
result = transcriber.transcribe("meeting_20240520.wav")
formatted_text = transcriber.format_transcript(result)
print(formatted_text[:500]) # 打印前500字符预览
# 保存完整转录文本
with open("meeting_transcript.txt", "w", encoding="utf-8") as f:
f.write(formatted_text)
实操要点与避坑指南 :
- 模型大小选择 :
tiny和base模型速度快,但中文准确率一般,适合演示或英文会议。small和medium是中文场景的 甜点区 ,准确率和速度平衡得很好。large模型最准,但速度慢、显存占用高,除非对精度有极致要求,否则不推荐。 - 音频预处理至关重要 :Whisper对音频质量有一定要求。如果原始录音环境嘈杂,建议先用
pydub进行预处理。from pydub import AudioSegment from pydub.effects import normalize audio = AudioSegment.from_file("noisy_meeting.mp3") # 简单降噪:这里以压缩动态范围为例,实际复杂降噪需专用库如noisereduce audio = audio.low_pass_filter(3000) # 低通滤波,切掉部分高频噪音 audio = normalize(audio) # 归一化音量 audio.export("cleaned_meeting.wav", format="wav") - 说话人分离的挑战 :Whisper本身不直接输出说话人标签。实现说话人分离(又称“语音分割聚类”)是一个独立课题,可以使用
pyannote.audio这样的专业库,但其使用(特别是训练好的模型)有一定门槛。 一个实用的折中方案是:在会议开始时,让每位参与者按顺序报一下自己的名字,后期人工或通过简单规则结合时间戳来区分。对于小型固定团队会议,这通常是可接受的。 - 处理长音频 :超过30分钟的会议音频,直接喂给Whisper可能会内存不足。需要将其分割成15-20分钟的小段,分别转录后再合并。注意处理分段处语句不完整的问题。
3.2 基于LLM的智能信息萃取
拿到逐字稿只是第一步,一堆文字不是“记忆”,而是“噪音”。我们需要LLM来充当“理解者”和“整理者”,从中提炼出有价值的结构化信息。
我们将设计几个核心的提炼任务:
- 会议纪要生成 :用简洁、正式的语言总结会议的核心内容、讨论过程和结论。
- 行动项提取 :自动识别出所有带有承诺性质的待办事项,包括负责人、内容和时间(如果提及)。
- 关键问答对生成 :预判用户可能会问的问题,并从会议内容中生成对应的答案。这能为后续的向量检索提供高质量的“种子”数据。
import openai # 或者使用 ollama 的客户端
import json
import os
class MeetingAnalyzer:
def __init__(self, llm_type="openai", model_name="gpt-3.5-turbo"):
"""
初始化LLM分析器。
:param llm_type: 'openai' 或 'ollama'
:param model_name: 模型名称,如 'gpt-4', 'qwen2.5:7b'
"""
self.llm_type = llm_type
self.model_name = model_name
if llm_type == "openai":
# 假设OPENAI_API_KEY已设置在环境变量中
self.client = openai.OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
elif llm_type == "ollama":
# Ollama 通常通过本地HTTP API调用
self.client = openai.OpenAI(
base_url='http://localhost:11434/v1',
api_key='ollama', # ollama不需要真实的key,但openai库要求有
)
model_name = model_name # Ollama的模型名如 'qwen2.5:7b'
else:
raise ValueError("llm_type must be 'openai' or 'ollama'")
def _call_llm(self, prompt, system_message="你是一个专业的会议助理,擅长从会议记录中提取和总结信息。"):
"""统一的LLM调用函数"""
messages = [
{"role": "system", "content": system_message},
{"role": "user", "content": prompt}
]
if self.llm_type == "openai":
response = self.client.chat.completions.create(
model=self.model_name,
messages=messages,
temperature=0.2, # 低温度,输出更确定、更专注
max_tokens=1500
)
return response.choices[0].message.content
else: # ollama
response = self.client.chat.completions.create(
model=self.model_name,
messages=messages,
temperature=0.2
)
return response.choices[0].message.content
def generate_summary(self, transcript_text):
"""生成会议纪要"""
prompt = f"""
请根据以下的会议转录文本,生成一份简洁、专业的会议纪要。
纪要需包含:会议主题、主要参会人(如果文本中有提及)、核心讨论点、做出的决策、以及下一步行动计划概要。
请使用中文输出。
会议转录文本:
{transcript_text[:6000]} # 限制长度,避免超出token限制
"""
summary = self._call_llm(prompt, system_message="你是一个专业的秘书,擅长撰写会议纪要。")
return summary
def extract_action_items(self, transcript_text):
"""提取行动项"""
prompt = f"""
请仔细阅读以下会议记录,并提取出所有明确的行动项(Action Items)。
每个行动项请按照以下JSON格式输出一个对象:
{{
"task": "具体的任务描述",
"assignee": "负责人(如果提及)",
"deadline": "截止时间(如果提及,否则写‘未明确’)",
"source_context": "提取出该行动项的原始文本片段"
}}
请将所有的行动项放在一个JSON数组里输出。
会议记录:
{transcript_text[:6000]}
"""
response_text = self._call_llm(prompt, system_message="你擅长从对话中识别承诺和待办事项。")
# LLM可能不会返回纯净的JSON,我们需要尝试解析
try:
# 尝试找到JSON数组部分
import re
json_match = re.search(r'\[\s*\{.*\}\s*\]', response_text, re.DOTALL)
if json_match:
action_items = json.loads(json_match.group())
else:
# 如果找不到,尝试直接解析整个响应(风险较高)
action_items = json.loads(response_text)
return action_items
except json.JSONDecodeError as e:
print(f"解析行动项JSON失败: {e}")
print(f"LLM返回的原始文本:\n{response_text}")
# 返回一个空列表或包含错误信息的结构
return [{"error": "解析失败", "raw_response": response_text[:200]}]
def generate_qa_pairs(self, transcript_text, num_pairs=10):
"""生成潜在的问答对,用于丰富知识库"""
prompt = f"""
假设你是参会者,在会后需要回顾这场会议。请根据以下会议记录,生成{num_pairs}个最可能被问到的、有明确答案的问题。
并为每个问题提供基于会议记录的准确答案。
请以JSON格式输出,格式如下:
{{
"qa_pairs": [
{{"question": "问题1", "answer": "答案1"}},
{{"question": "问题2", "answer": "答案2"}}
]
}}
会议记录:
{transcript_text[:8000]} # 可以给更多上下文来生成更好的问题
"""
response_text = self._call_llm(prompt)
try:
qa_data = json.loads(response_text)
return qa_data.get("qa_pairs", [])
except json.JSONDecodeError:
# 简易回退:按行分割,假设奇数行是问题,偶数行是答案(不推荐用于生产)
return []
# 使用示例
if __name__ == "__main__":
# 读取之前保存的转录文本
with open("meeting_transcript.txt", "r", encoding="utf-8") as f:
transcript = f.read()
analyzer = MeetingAnalyzer(llm_type="openai", model_name="gpt-3.5-turbo") # 或使用 ollama
print("正在生成会议纪要...")
summary = analyzer.generate_summary(transcript)
print("=== 会议纪要 ===\n", summary)
print("\n正在提取行动项...")
actions = analyzer.extract_action_items(transcript)
print("=== 行动项 ===")
for i, action in enumerate(actions, 1):
print(f"{i}. 任务:{action.get('task')}")
print(f" 负责人:{action.get('assignee')}")
print(f" 截止时间:{action.get('deadline')}")
print(f" 来源:{action.get('source_context')[:100]}...\n")
# 保存结构化结果
with open("meeting_structured_output.json", "w", encoding="utf-8") as f:
output = {
"summary": summary,
"action_items": actions
}
json.dump(output, f, ensure_ascii=False, indent=2)
核心技巧与经验之谈 :
- Prompt工程是关键 :LLM的输出质量极大程度上依赖于你的提示词(Prompt)。上面的示例Prompt已经过优化,但你可以根据自己会议的风格(是脑暴会、决策会还是周例会)进一步调整。例如,对于技术评审会,可以要求LLM额外提取“提出的风险”和“建议的解决方案”。
- 处理长文本 :LLM有上下文长度限制(如GPT-3.5-Turbo是16K token)。如果转录文本很长,你需要将其分割成有重叠的块,分别分析后再合并结果,或者使用“Map-Reduce”策略:先让LLM总结每一段,再总结这些总结。
- 输出格式控制 :明确要求LLM以JSON等结构化格式输出,可以极大简化后续的代码处理。但LLM有时会“说废话”,在JSON外加解释。因此,代码中需要包含健壮的解析逻辑,比如用正则表达式提取JSON部分。
- 本地LLM的调优 :使用Ollama运行本地模型时,如果发现提取效果不佳,可以尝试:
- 换模型 :
qwen2.5:7b在中文理解和指令跟随上表现不错。llama3.2、deepseek-coder(如果会议涉及代码)也是好选择。 - 调参数 :适当提高
temperature(如到0.7)可能让模型更有创造性,但也会更不稳定。对于总结任务,低温度(0.1-0.3)通常更好。 - 写更详细的Prompt :本地小模型需要更具体、步骤更清晰的指令。
- 换模型 :
3.3 构建可查询的向量记忆库
现在,我们有了原始的转录文本、结构化的纪要、行动项。如何让用户能像对话一样自然地问询呢?这就需要向量数据库。它的原理是:将文本转化为高维空间中的点(向量),语义相似的文本,其向量在空间中的距离也近。
import chromadb
from chromadb.config import Settings
from sentence_transformers import SentenceTransformer
import uuid
class MeetingMemory:
def __init__(self, persist_directory="./meeting_memory_db"):
"""
初始化记忆库。
:param persist_directory: 向量数据库持久化存储路径。
"""
# 初始化嵌入模型(使用开源本地模型)
print("正在加载嵌入模型...")
self.embed_model = SentenceTransformer('BAAI/bge-small-zh-v1.5')
print("嵌入模型加载完毕。")
# 初始化Chroma客户端,并持久化
self.client = chromadb.PersistentClient(path=persist_directory)
# 获取或创建一个集合(collection),类似于数据库的表
self.collection = self.client.get_or_create_collection(
name="meeting_transcripts",
metadata={"hnsw:space": "cosine"} # 使用余弦相似度进行搜索
)
def _chunk_text(self, text, chunk_size=500, chunk_overlap=50):
"""
将长文本分割成重叠的小块,以适应嵌入模型的上下文窗口并提高检索精度。
:param text: 原始文本。
:param chunk_size: 每个文本块的大致字符数。
:param chunk_overlap: 块之间的重叠字符数,避免切断完整句子。
:return: 文本块列表。
"""
# 简单的按句子分割实现(生产环境建议用更专业的分句工具,如 spaCy)
sentences = text.replace('\n', ' ').split('。')
chunks = []
current_chunk = ""
for sentence in sentences:
if len(current_chunk) + len(sentence) < chunk_size:
current_chunk += sentence + "。"
else:
if current_chunk:
chunks.append(current_chunk.strip())
current_chunk = sentence + "。" # 开始新块
# 处理重叠:将当前块的开头部分作为下一个块的开头(简化逻辑)
# 更优方案是保留一个滑动窗口
if current_chunk:
chunks.append(current_chunk.strip())
# 简单的重叠处理:复制部分内容到下一个块的开头(这里简化,实际可更精细)
if chunk_overlap > 0 and len(chunks) > 1:
adjusted_chunks = []
for i in range(len(chunks)):
if i == 0:
adjusted_chunks.append(chunks[i])
else:
# 取前一个块的最后一部分作为当前块的前缀
overlap_part = chunks[i-1][-chunk_overlap:] if len(chunks[i-1]) > chunk_overlap else chunks[i-1]
adjusted_chunks.append(overlap_part + " " + chunks[i])
chunks = adjusted_chunks
return chunks
def add_meeting(self, meeting_id, transcript_text, summary="", action_items=[]):
"""
将一次会议的记忆存入向量数据库。
:param meeting_id: 会议唯一标识符(如日期+主题)。
:param transcript_text: 会议转录文本。
:param summary: 会议摘要。
:param action_items: 行动项列表。
"""
# 1. 准备要存储的文档
documents_to_store = []
metadatas_to_store = []
ids_to_store = []
# 将转录文本分块存储
transcript_chunks = self._chunk_text(transcript_text)
for i, chunk in enumerate(transcript_chunks):
chunk_id = f"{meeting_id}_transcript_{i}"
documents_to_store.append(chunk)
metadatas_to_store.append({"type": "transcript_chunk", "meeting_id": meeting_id, "chunk_index": i})
ids_to_store.append(chunk_id)
# 存储摘要(作为一个独立文档)
if summary:
summary_id = f"{meeting_id}_summary"
documents_to_store.append(summary)
metadatas_to_store.append({"type": "summary", "meeting_id": meeting_id})
ids_to_store.append(summary_id)
# 存储每个行动项
for j, item in enumerate(action_items):
# 将行动项对象转换为描述性文本
action_text = f"行动项:{item.get('task', '')}。负责人:{item.get('assignee', '未指定')}。截止时间:{item.get('deadline', '未明确')}。"
action_id = f"{meeting_id}_action_{j}"
documents_to_store.append(action_text)
metadatas_to_store.append({"type": "action_item", "meeting_id": meeting_id, "task": item.get('task', '')[:50]})
ids_to_store.append(action_id)
# 2. 生成嵌入向量并存入集合
if documents_to_store:
# 使用本地嵌入模型生成向量
embeddings = self.embed_model.encode(documents_to_store).tolist()
self.collection.add(
embeddings=embeddings,
documents=documents_to_store,
metadatas=metadatas_to_store,
ids=ids_to_store
)
print(f"会议 '{meeting_id}' 的记忆已存入,共 {len(documents_to_store)} 个文档块。")
def query(self, question, n_results=5, filter_meeting_id=None):
"""
向记忆库提问。
:param question: 自然语言问题。
:param n_results: 返回最相关的文档数量。
:param filter_meeting_id: 可选,仅搜索特定会议的记录。
:return: 相关文档列表和它们的元数据。
"""
# 将问题也转化为向量
query_embedding = self.embed_model.encode([question]).tolist()[0]
# 构建查询过滤器
where_filter = None
if filter_meeting_id:
where_filter = {"meeting_id": filter_meeting_id}
# 执行相似度搜索
results = self.collection.query(
query_embeddings=[query_embedding],
n_results=n_results,
where=where_filter,
include=["documents", "metadatas", "distances"]
)
return results
# 使用示例:构建记忆库
if __name__ == "__main__":
memory = MeetingMemory()
# 假设我们已经有了分析结果
meeting_id = "20240520_产品需求评审"
with open("meeting_transcript.txt", "r", encoding="utf-8") as f:
transcript = f.read()
with open("meeting_structured_output.json", "r", encoding="utf-8") as f:
import json
structured_data = json.load(f)
# 将会议记忆存入数据库
memory.add_meeting(
meeting_id=meeting_id,
transcript_text=transcript,
summary=structured_data["summary"],
action_items=structured_data["action_items"]
)
# 进行查询
print("\n=== 记忆库查询测试 ===")
query_results = memory.query("我们讨论了关于用户登录功能的哪些问题?", n_results=3)
print(f"查询到 {len(query_results['documents'][0])} 个相关片段:")
for i, (doc, meta, dist) in enumerate(zip(query_results['documents'][0], query_results['metadatas'][0], query_results['distances'][0])):
print(f"\n--- 结果 {i+1} (距离:{dist:.4f}) ---")
print(f"类型:{meta['type']}")
print(f"内容:{doc[:200]}...") # 预览前200字符
深度解析与优化策略 :
- 分块的艺术 :直接存入整篇转录文本,检索时可能会返回不相关的长文。分块(Chunking)是向量检索系统的核心技巧。
chunk_size不宜过大(通常200-1000字符),以保证检索精度;chunk_overlap(如50-200字符)能防止完整的句子被切断,保留上下文。更高级的分块策略可以按语义(如段落)或句子边界进行。 - 元数据的力量 :注意我们在存储时为每个文档块附加了
metadata,如type(是转录片段、摘要还是行动项)和meeting_id。这允许我们在查询时进行过滤,例如:“只搜索行动项”或“只搜索某次特定会议的内容”。这极大地提升了查询的精准度。 - 嵌入模型的选择 :
BAAI/bge-small-zh-v1.5是针对中文优化的开源模型,在中文语义相似度任务上表现优异,且完全本地运行,无隐私顾虑。如果你的会议主要是英文,可以考虑all-MiniLM-L6-v2等模型。 关键点:用于生成存储向量的模型(嵌入模型)和用于生成查询向量的模型必须是同一个! - 相似度度量 :ChromaDB默认使用余弦相似度(
cosine),这对于文本语义相似度是合适的。距离值越小,表示越相似。
3.4 打造自然语言问答接口
最后一步,我们将向量检索和LLM的能力结合起来,创造一个能理解问题、查找资料并组织答案的智能问答接口。这被称为“检索增强生成”(RAG)。
class MeetingQABot:
def __init__(self, memory: MeetingMemory, analyzer: MeetingAnalyzer):
"""
初始化问答机器人。
:param memory: 已初始化的MeetingMemory实例。
:param analyzer: 已初始化的MeetingAnalyzer实例(用于最终答案生成)。
"""
self.memory = memory
self.analyzer = analyzer
def ask(self, question, meeting_id=None, use_llm_for_answer=True):
"""
向会议记忆提问。
:param question: 自然语言问题。
:param meeting_id: 可选,限定在特定会议中搜索。
:param use_llm_for_answer: 是否使用LLM基于检索结果生成答案。若为False,则直接返回检索到的文本片段。
:return: 答案字符串和相关来源片段。
"""
# 1. 从记忆库中检索相关上下文
search_results = self.memory.query(question, n_results=5, filter_meeting_id=meeting_id)
retrieved_docs = search_results['documents'][0]
retrieved_metas = search_results['metadatas'][0]
if not retrieved_docs:
return "抱歉,在会议记录中没有找到相关信息。", []
# 2. 如果不需要LLM加工,直接返回最相关的片段
if not use_llm_for_answer:
most_relevant = retrieved_docs[0]
return f"根据会议记录,相关信息如下:\n\n{most_relevant}", list(zip(retrieved_docs, retrieved_metas))
# 3. 使用LLM基于检索到的上下文生成一个连贯、准确的答案
context_str = ""
for i, (doc, meta) in enumerate(zip(retrieved_docs, retrieved_metas)):
context_str += f"[来源{i+1}: 类型-{meta['type']}] {doc}\n\n"
prompt = f"""
你是一个专业的会议助理。请根据以下提供的会议记录片段,回答用户的问题。
你的回答必须严格基于提供的上下文。如果上下文信息不足以回答问题,请如实说明“根据现有会议记录,无法确定”。
请用清晰、简洁的中文给出答案。
用户问题:{question}
相关会议记录上下文:
{context_str}
请开始你的回答:
"""
answer = self.analyzer._call_llm(
prompt,
system_message="你是一个严谨的会议信息查询助手,你的回答必须基于给定的事实,不 extrapolate。"
)
return answer, list(zip(retrieved_docs, retrieved_metas))
# 使用示例:集成与问答
if __name__ == "__main__":
# 初始化各个组件
memory = MeetingMemory()
analyzer = MeetingAnalyzer(llm_type="openai", model_name="gpt-3.5-turbo") # 问答可以用更强的模型,如gpt-4
bot = MeetingQABot(memory, analyzer)
# 模拟用户提问
questions = [
"上次会议决定下一步要做什么?",
"关于预算超支的问题,大家是怎么讨论的?",
"张三负责的任务是什么?"
]
for q in questions:
print(f"\n用户提问:{q}")
answer, sources = bot.ask(q, meeting_id="20240520_产品需求评审")
print(f"助理回答:{answer}")
print("--- 参考来源(前2个)---")
for doc, meta in sources[:2]:
print(f" [{meta['type']}] {doc[:150]}...")
print("-" * 50)
RAG流程的精髓 :
- 检索(Retrieve) :将用户问题向量化,在向量数据库中查找语义最相关的文本片段。这步解决了LLM“不知道”内部知识的问题。
- 增强(Augment) :将检索到的相关片段作为额外的上下文,和用户问题一起构建成一个新的Prompt。
- 生成(Generate) :LLM基于这个“增强后”的Prompt来生成答案。由于答案的依据来自检索到的真实会议记录,其准确性和可信度远高于LLM凭空编造。
注意事项 :
- 幻觉问题 :即使提供了上下文,LLM有时仍会“捏造”事实。在Prompt中强调“严格基于上下文”和“无法确定时请说明”至关重要。对于关键信息,可以设计让系统直接引用原文。
- 上下文长度 :检索到的多个片段加起来可能很长,需注意不要超过LLM的上下文窗口。需要设计策略,比如只取前3个最相关的片段,或者对检索到的内容再进行一次摘要。
- 多轮对话 :当前的实现是单轮的。要实现多轮对话(记住之前的问答历史),需要将历史对话也纳入到检索和Prompt构建中,复杂度会显著增加。
4. 集成与部署:打造可用产品
至此,核心功能模块都已就绪。但要让其成为一个真正的“助理”,我们需要一个友好的用户界面,并将其部署成可长期运行的服务。
4.1 使用Gradio快速构建Web界面
Gradio能让我们在半小时内搭出一个功能完整的演示界面。
import gradio as gr
from datetime import datetime
# 假设我们已经有了上面定义的 MeetingMemory, MeetingAnalyzer, MeetingQABot 类
# 这里我们创建一个集成的系统类
class MeetingAISystem:
def __init__(self):
self.memory = MeetingMemory()
self.analyzer = MeetingAnalyzer(llm_type="ollama", model_name="qwen2.5:7b") # 示例用本地模型
self.qa_bot = MeetingQABot(self.memory, self.analyzer)
self.transcriber = AudioTranscriber(model_size="small") # 使用较小的模型以加快Web界面响应
def process_meeting(self, audio_file, meeting_topic):
"""处理上传的会议音频,并存入记忆库"""
if audio_file is None:
return "请先上传会议录音文件。", None, None, None
meeting_id = f"{datetime.now().strftime('%Y%m%d_%H%M')}_{meeting_topic}"
try:
# 1. 转写
yield f"正在转写音频 '{meeting_topic}'...", None, None, None
result = self.transcriber.transcribe(audio_file)
transcript = self.transcriber.format_transcript(result)
# 2. 分析
yield f"音频转写完成,正在分析内容...", transcript, None, None
summary = self.analyzer.generate_summary(transcript)
actions = self.analyzer.extract_action_items(transcript)
# 3. 存储
yield f"分析完成,正在存入记忆库...", transcript, summary, actions
self.memory.add_meeting(meeting_id, transcript, summary, actions)
return f"会议 '{meeting_topic}' 处理完成!已存入记忆库。", transcript, summary, actions
except Exception as e:
return f"处理过程中出现错误:{str(e)}", None, None, None
def ask_question(self, question, history=None):
"""回答用户关于会议的问题"""
if not question.strip():
return "请输入问题。", ""
answer, sources = self.qa_bot.ask(question)
# 将来源格式化为易读的字符串
source_text = "\n\n--- 参考来源 ---\n"
for i, (doc, meta) in enumerate(sources[:3]): # 显示前3个来源
source_text += f"{i+1}. [{meta.get('type', 'N/A')}] {doc[:120]}...\n"
return answer, source_text
# 创建系统实例
system = MeetingAISystem()
# 构建Gradio界面
with gr.Blocks(title="AI会议记忆助理", theme=gr.themes.Soft()) as demo:
gr.Markdown("# 🎙️ AI会议记忆助理")
gr.Markdown("上传会议录音,获得智能纪要、行动项,并随时向它提问。")
with gr.Tab("📥 处理新会议"):
with gr.Row():
with gr.Column():
audio_input = gr.Audio(label="上传会议录音", type="filepath")
topic_input = gr.Textbox(label="会议主题(可选)", placeholder="例如:5月20日产品需求评审会")
process_btn = gr.Button("开始处理会议", variant="primary")
with gr.Column():
status_output = gr.Textbox(label="处理状态", interactive=False)
transcript_output = gr.Textbox(label="会议转录文本", lines=10, interactive=False)
with gr.Row():
summary_output = gr.Textbox(label="智能会议纪要", lines=6, interactive=False)
actions_output = gr.JSON(label="提取的行动项", interactive=False)
process_btn.click(
fn=system.process_meeting,
inputs=[audio_input, topic_input],
outputs=[status_output, transcript_output, summary_output, actions_output]
)
with gr.Tab("❓ 问答记忆库"):
with gr.Row():
with gr.Column():
question_input = gr.Textbox(label="输入你的问题", placeholder="例如:上次会议谁负责API接口开发?", lines=3)
ask_btn = gr.Button("提问", variant="primary")
with gr.Column():
answer_output = gr.Textbox(label="助理回答", lines=6, interactive=False)
sources_output = gr.Textbox(label="回答依据", lines=4, interactive=False)
ask_btn.click(
fn=system.ask_question,
inputs=[question_input],
outputs=[answer_output, sources_output]
)
gr.Markdown("---\n*提示:首次使用需要加载模型,处理时间可能较长。后续提问会很快。*")
# 启动界面
if __name__ == "__main__":
demo.launch(server_name="0.0.0.0", server_port=7860, share=False) # share=True 可生成临时公网链接
这个界面提供了两个核心功能页签:“处理新会议”用于上传和分析录音;“问答记忆库”用于与历史会议对话。Gradio会自动处理文件上传、进度更新和结果展示。
4.2 部署与优化建议
一个在本地运行的脚本和一个可分享的Web服务是两回事。以下是让项目更“产品化”的建议:
- 持久化与数据库 :我们虽然用
ChromaDB持久化了向量数据,但会议元数据(如会议主题、时间、参会人列表、原始音频文件路径等)最好再用一个关系型数据库(如SQLite或PostgreSQL)来管理,方便做列表展示和高级筛选。 - 异步处理 :处理长音频和调用LLM API可能是耗时的。在Web服务中,应该使用异步任务队列(如Celery + Redis),让文件上传后立即返回,处理在后台进行,完成后通过通知或刷新页面告知用户。
- 安全与权限 :如果部署到内网供团队使用,需要添加简单的用户认证和权限管理,确保会议记录只能被授权人员访问。
- 性能优化 :
- 缓存 :对常见的查询结果进行缓存。
- 嵌入模型量化 :使用量化的嵌入模型(如
BAAI/bge-small-zh-v1.5本身就有量化版)可以大幅减少内存占用和提高推理速度。 - LLM API超时与重试 :网络调用需设置合理的超时和重试机制。
- 扩展功能 :
- 多模态输入 :除了音频,支持直接上传会议录像,从中提取音频轨进行处理。
- 集成日历 :与Google Calendar或Outlook日历集成,自动抓取会议链接并录制。
- 实时字幕 :将转写模块稍加修改,可以做成实时字幕工具,用于线上会议辅助。
- 情感分析 :在分析阶段加入对发言情绪(积极、消极、中性)的判断,帮助回顾会议氛围。
5. 常见问题与排查实录
在实际开发和运行中,你肯定会遇到各种各样的问题。这里记录了一些典型问题及其解决方法。
5.1 音频处理相关问题
问题1:Whisper转写中文准确率不高,特别是专业术语和人名。
- 排查 :首先检查音频质量。背景噪音、多人同时说话、说话人距离麦克风远都会严重影响准确率。
- 解决 :
- 预处理 :务必进行降噪和音量归一化。可以尝试更专业的降噪工具如
noisereduce库。 - 选择更大模型 :从
base升级到small或medium模型,精度提升显著。 - 提供提示词 :Whisper的
transcribe方法支持initial_prompt参数。你可以传入一段包含本次会议可能出现的专业术语、参会人姓名的文本,能有效引导模型。
result = model.transcribe(audio_path, language="zh", initial_prompt="本次会议涉及机器学习、神经网络、Transformer等术语。参会人有张三、李四、王五。") - 预处理 :务必进行降噪和音量归一化。可以尝试更专业的降噪工具如
问题2:处理长音频时内存溢出(OOM)。
- 解决 :必须进行音频分割。
import whisper from pydub import AudioSegment import tempfile import os def transcribe_long_audio(model, audio_path, chunk_length_ms=20*60*1000): # 20分钟 audio = AudioSegment.from_file(audio_path) chunks = [audio[i:i+chunk_length_ms] for i in range(0, len(audio), chunk_length_ms)] full_text = "" for i, chunk in enumerate(chunks): with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp: chunk.export(tmp.name, format="wav") result = model.transcribe(tmp.name, language="zh") full_text += result["text"] + " " os.unlink(tmp.name) # 删除临时文件 return full_text
5.2 LLM调用与提示工程问题
问题3:LLM提取的行动项格式混乱,无法解析成JSON。
- 排查 :小模型或指令不清晰时容易发生。
- 解决 :
- 强化Prompt :在Prompt中更明确地指定JSON格式,甚至给出更具体的例子(Few-shot Learning)。
- 后处理 :使用更健壮的解析方法,比如先尝试用
json.loads(),如果失败,再用正则表达式提取可能的结构,或者使用ast.literal_eval()作为备选。 - 降级方案 :如果实在无法解析,可以要求LLM以纯文本的“1. 2. 3.”列表形式输出,然后在代码中按行解析。
问题4:使用本地Ollama模型时,响应速度非常慢。
- 排查 :检查电脑资源(CPU/内存/GPU)占用。7B参数量的模型在无GPU的CPU上推理确实会慢。
- 解决 :
- 量化 :使用Ollama拉取量化版本的模型,如
qwen2.5:7b-q4_K_M,能在几乎不损失精度的情况下大幅提升速度、降低内存。 - GPU加速 :确保Ollama使用了GPU(如果可用)。在拉取模型时,Ollama通常会自动选择兼容的版本。可以通过
ollama run观察日志确认。 - 调整参数 :减少生成答案的
max_tokens,对于摘要和提取任务,通常不需要很长的回复。
- 量化 :使用Ollama拉取量化版本的模型,如
5.3 向量检索与问答问题
问题5:问答时,返回的答案与问题不相关,或者“幻觉”严重。
- 排查 :
- 检索质量差 :问题“我们预算多少?”可能检索到的是“我们预算很紧张”的片段,而不是具体的数字。这可能是嵌入模型对数字不敏感,或者分块不当导致上下文丢失。
- LLM不遵从上下文 :即使检索到了正确片段,LLM也可能忽略它,自己编造。
- 解决 :
- 优化检索 :
- 尝试不同的
chunk_size和chunk_overlap。 - 对存储的文档块进行“丰富”,比如在存储时,不仅存文本,还在前面加上“会议主题:XXX,时间:XXX”等元信息。
- 使用 混合搜索 :结合向量相似度搜索和关键词(BM25)搜索,ChromaDB支持此功能。
- 尝试不同的
- 优化Prompt :在给LLM的指令中,用更强烈的语气强调“必须”、“只能”基于上下文,并设计让LLM在答案中引用来源片段编号的机制。
- 后处理验证 :让LLM在生成答案后,再判断一下答案中的关键事实是否能在提供的上下文中找到依据。
- 优化检索 :
问题6:随着会议增多,向量数据库查询变慢。
- 解决 :
- 索引优化 :ChromaDB默认使用HNSW索引,对于百万级以下的数据量性能很好。确保在创建集合时使用了合适的距离函数。
- 过滤 :积极使用
where参数过滤meeting_id或type,能极大缩小搜索范围。 - 分集合存储 :可以为不同项目或不同时间段的会议创建不同的集合(Collection)。
- 硬件 :向量搜索是计算密集型操作,更快的CPU/GPU会有帮助。
5.4 部署与运行问题
问题7:Gradio界面在公网无法访问,或者运行一段时间后崩溃。
- 解决 :
- 端口与防火墙 :确保服务器防火墙开放了
7860端口(Gradio默认)。启动时使用demo.launch(server_name="0.0.0.0")允许所有IP访问。 - 无头服务器 :在服务器上运行时,可以添加
share=False并配置Nginx反向代理,或者使用Gradio的share=True生成临时公网链接(有效期72小时)。 - 内存泄漏 :长时间运行,尤其是处理大量音频时,可能会内存累积。确保音频处理完成后,及时释放大对象(如音频数据、模型临时变量)。考虑定期重启服务,或使用像
gunicorn这样的WSGI服务器配合多进程。
- 端口与防火墙 :确保服务器防火墙开放了
问题8:如何将这个系统自动化,比如自动录制每周例会?
- 思路 :这需要结合操作系统级的自动化工具。
- 录制 :在会议电脑上使用自动化脚本(如Python的
pyautogui、pynput)控制录音软件开始/结束,或者直接捕获系统音频输出(更复杂)。 - 触发 :使用定时任务(如cron)或监听日历事件(通过API)来触发录制和分析流程。
- 管道 :将本项目的核心代码封装成一个接收音频文件路径的函数,由自动化脚本在录制完成后调用。
- 通知 :分析完成后,将纪要和行动项通过邮件或办公软件(如钉钉/飞书/Slack机器人)自动发送给参会人。
- 录制 :在会议电脑上使用自动化脚本(如Python的
这个项目从概念到实现,涉及了现代AI应用的多个关键环节:语音处理、大语言模型提示工程、向量数据库检索、应用集成。每一步都有值得深挖的细节和优化的空间。我希望这份超详细的指南,不仅能让你成功复现一个AI会议记忆助理,更能为你打开一扇门,去探索如何将AI能力巧妙地融入日常工作流,解决那些真实而具体的效率痛点。
更多推荐


所有评论(0)