- 作者:老汪软件技巧
- 发表时间:2024-08-22 07:05
- 浏览量:
引言
“变老很糟糕(Getting old sucks)”,这是电影《勇敢者的游戏》里的一句台词。变老会让人不再敏捷、健壮,会让人疲于跟上社会发展的脚步。据统计,截止 2023 年底,60 岁以上人口占全国总人口的 21.1%。若干年后,这个比重可能会更大,而我们自己也将成为构成这一比重的分子之一。如何帮助老年人活得更舒适一些?如何帮助若干年以后的我们自己?我最近在思考这些问题。
有一天下班路上,我刷到了一篇推文,宣布 Google 将举办以 ”科技向善” 为主题的黑客马拉松比赛,鼓励开发者基于 Gemma 模型技术来表达社会关怀。这几乎在一瞬间就激发了我的灵感 —— LLM 擅长处理大段文字,这不正适合用来帮老年人提升阅读体验吗?我有一些长辈,他们视力不好,还可能识字不全。当儿孙辈不在身边时,吃药之前读说明书往往是非常困难的事情。用大语言模型来处理药品说明书的文字内容,并以语音问答的形式来辅助理解,无论是从技术可行性还是用户友好性的角度来考虑,这似乎都非常通顺!
于是,在热爱编程和科技向善这双重精神动力的推动下,我和队友聆絮在 Google Gemma Hackathon 中完成了我们的作品 —— 药童,一个帮助爷爷奶奶们读懂药品说明书的 Web 应用。
如录屏视频所示,爷爷奶奶们只需要用手机拍下药品说明书,然后就可以像语音聊天一样了解药品的信息了。这样的极简交互,尽可能地降低了学习和操作成本,让用户可以即开即用。
接下来,我将会从技术实现层面拆解各个模块,希望其中的点滴也能激发你的灵感。你将会看到 OCR、RAG、ASR、TTS…… 啥啥啥?这都是些啥?别急,且听我娓娓道来。
技术实现
从用户上传照片,到回复用户的提问,其背后的处理流程是这样的:
从图片中提取文字内容;将提取出的文本内容切分、向量化,存储到向量数据库中;用户发送语音问题后,把语音转为文字格式;将问题文本与向量数据库进行匹配,检索出高相关度的知识块;将相关知识上下文与问题文本一起包装成提示词,发送给 LLM;LLM 根据提示词生成文本格式的回答内容;将回答文本转化为语音,播放给用户听。OCR:从图片提取文字
想要提取图片中的文字,有多种方式可以实现,我了解到的方向有两个:一个是经典的光学字符识别(Optical Character Recognition)思路,比如 tesseract、PaddleOCR 等;另一个则是视觉问答(Visual Question Answering)模式,多模态的 Gemini API、PaliGemma 等都有能力做到。
在比赛时间有限的情况下,我选择了个人比较熟悉的 PaddleOCR。处理过程的核心逻辑如下:
# 开启方向检测、设置语言
ocr = PaddleOCR(use_angle_cls=True, lang="ch")
# 传入图片的文件路径
result = ocr.ocr(img, cls=True)
# 逐行输出识别结果
for idx in range(len(result)):
res = result[idx]
for line in res:
print('py_ocr_res', line[1][0])
得到识别结果后,将文本内容传递给向量化服务。
向量化存储
现在我们有了说明书文本内容,那么如何让 LLM 基于这些文本内容去回答用户的问题呢?
我们当然可以把说明书内容整个嵌在提示词里,要求 LLM 根据这些内容作答。但引发的问题就是,在每一轮对话中,LLM 都要处理全量的文本,会降低生成速度和质量,进而影响用户体验。因此,我们需要检索增强式生成(Retrieval-Augmented Generation,RAG),也就是把问题以及和问题相关度高的知识一起发送给 LLM,帮助 LLM 快速而准确地生成回答。
在这一环节,我们要先把通过 OCR 提取出来的文本进行处理,搭建一个知识库,以供后续问答时检索之用。我们借助 LangChain 框架来实现这一处理过程。
首先是切分文本。虽然一张照片内的说明书内容有限,篇幅不会过长,但是为了更快、更精准地匹配问题内容,我们还是要把整段文本切开成一个个小块。
import { RecursiveCharacterTextSplitter } from 'langchain/text_splitter';
const splitter = new RecursiveCharacterTextSplitter({
chunkSize: 1000,
chunkOverlap: 100,
});
const docs = await splitter.splitDocuments(ocrText);
然后,我们把切分好的文本块进行向量化(Embedding)处理,并存到向量数据库中:
import { OllamaEmbeddings } from '@langchain/community/embeddings/ollama';
import { MemoryVectorStore } from 'langchain/vectorstores/memory';
const embedder = new OllamaEmbeddings({
model: 'gemma2’ // 使用本地模型
});
const store = await MemoryVectorStore.fromDocuments(docs, embedder);
const retriever = store.asRetriever();
在上面的 Embedding 逻辑中,我们使用了本地模型。你需要提前安装并启动 Ollama,然后把一个模型下载到本地,如 ollama pull gemma2,就像拉取一个 npm 包一样简单。
至此,我们就把用于检索的知识库准备好了,可以开门迎接用户的提问了。
ASR:语音转文字
当用户用语音提问后,我们需要把音频内容转为文字,你可能在 HuggingFace 上看到过 ASR(Automatic Speech Recognition) 或者 STT(Speech to Text),指的都是这个转换过程。以下是核心逻辑:
import torch
from transformers import AutoModelForSpeechSeq2Seq, AutoProcessor, pipeline
# 加载模型
model_id = "openai/whisper-base"
model = AutoModelForSpeechSeq2Seq.from_pretrained( model_id)
processor = AutoProcessor.from_pretrained(model_id)
pipe = pipeline(
"automatic-speech-recognition",
model=model,
tokenizer=processor.tokenizer,
feature_extractor=processor.feature_extractor,
)
# 传入音频文件路径,生成文本
result = pipe(audio)
print('py_asr_res', result["text"])
得到用户问题的文本内容后,接下来就要进入到一个 LLM 应用的核心流程了。
RAG:检索生成
在这个环节,我们要做的是拿用户的提问内容去之前准备好的向量知识库里做相似性匹配,再把检索到的相关文本块与问题一起传送给 LLM,LLM 会根据提示词生成回答。
首先是组织提示词模板。我们参考 LangChain Hub 中的提示词,编写模板:
import { ChatPromptTemplate } from '@langchain/core/prompts';
const prompt = ChatPromptTemplate.fromMessages([
['system', `你是一个问答任务助手。使用下面 context 中的检索上下文来回答问题。如果你不知道答案,就直接回答不知道。最多用三句话回答。`],
['placeholder', '{context}'],
['user', '{question}']
]);
接着,我们引入本地 LLM:
import { ChatOllama } from '@langchain/community/chat_models/ollama';
const model = new ChatOllama({
model: 'gemma2',
});
由于 LLM 自有其特定的输出格式,我们还需要一个工具来把模型给出的回答解析成字符串:
import { StringOutputParser } from '@langchain/core/output_parsers';
const parser = new StringOutputParser();
最后,我们要用链条把 prompt、model、parser 串起来。什么?你说还没把问题和知识库做检索匹配?放松,LangChain 框架会帮我们处理这些,这正是「链条(chain)」的奥义!
import { RunnablePassthrough, RunnableSequence } from '@langchain/core/runnables';
import { formatDocumentsAsString } from 'langchain/util/document’;
const ragChain = RunnableSequence.from([
{
question: new RunnablePassthrough(),
context: retriever.pipe(formatDocumentsAsString), // retriever 就是此前生成的知识库
},
prompt,
model,
parser,
]);
我们只需要执行这个链条,就可以坐等 LLM 输出答案了:
const output = await ragChain.invoke(userQuestionFromASR);
TTS:文本转语音
现在,我们只需要把文本格式的回答内容给转成语音,然后播放给用户听即可:
import ChatTTS
chat = ChatTTS.Chat()
if chat.load():
pass
else:
sys.exit(1)
wavs = chat.infer(texts, use_decoder=True)
# 保存音频为 .mp3 文件
for index, wav in enumerate(wavs):
save_mp3_file(wav, index)
大功告成!!!
其他
我们使用了 Nuxt 搭建了这个 Web App 的前后端,上述的 OCR、ASR、TTS 都串联其中,非常方便!
GUI 方面,我们使用了 LangUI 这个 TailwindCSS 组件库,找到想用的组件,直接复制粘贴代码就行了,非常方便 +1!
我和队友都是前端工程师,我们本着「应 JS 尽 JS」的信条,在作品中最大限度地用 Web 技术栈来实现功能。前端切图仔没在怕的,非常自豪!
规划和展望
「药童」这个作品,在 Gemma Hackathon 中得到了评委的认可,在故事性、创造性、实用性三个维度的总得分名列第二。这印证了我们的创意和理念确实有其价值。
在人工智能能力突飞猛进的今天,我们很容易就能感受到 AI 技术的强大力量,也都在积极乐观地畅想它将在各个领域掀起变革。然而在这样的狂欢氛围中,我们也可能很容易就忽略了那些身处热潮之外的社会群体,忽略了一点点巧妙的技术应用就能给他们带来帮助。技术可以在高空中绽放出绚烂夺目的光芒,也同样可以在细微处散发温热,暖人心窝。作为驾驭技术的人,我们开发者的目光要投向哪里,这至关重要。
以现实的眼光来审视,「药童」也许不太能够产生商业价值。但我会把它从一个 demo 级别的比赛作品,逐步完善成一个完整可用的实际产品。而距离一个成熟产品,它还有些技术性问题需要解决、优化,比如提升响应速度、支持多轮对话、固定音色、云端部署等等。我会发挥 Web 技术的灵活优势,「药童」可以是微信小程序、网页或任何形态,可以读药品说明书、家电说明书或任何文字内容,我会送它到需要它的地方去。我会延续初心,尽我所能用技术提供帮助,哪怕用户群体再少、应用场景再窄。
科技向善,虽小亦可为之。
写在结尾
我是 Jax,目前在 Keep 写运动体验的前端代码。在做 Web 开发的第 7 年,我仍然是坚定不移的 JavaScript 迷弟,Web 开发带给我太多乐趣。
如果你也喜欢 Web 技术栈,或者也喜欢尝鲜新技术,或者仅仅是对本文内容感兴趣,都欢迎来聊!你可以通过下列方式找到我: