女士们先生们,欢迎回到机器学习烹饪秀。我是主持人丹·伯克(Daniel Burke),在今天的节目中,我们将在 NVIDIA DGX Spark 上本地构建一个多模态 RAG(检索增强生成)系统。我想先说明一下,这是一个精简版演示,因此我们会快速过一遍所有概念。如果您想观看完整的逐步操作指南,请查看我频道上的直播回放链接会在描述中提供。目前我的 YouTube 频道上有一个关于“多模态 RAG”的完整播放列表,那里直播构建了所有内容。如果您觉得这个笔记本非常精炼,那是因为在之前的直播中我们花了几小时才理清所有细节。那么废话不多说,让我们开始吧。

我们将重点关注以下内容:首先讨论什么是多模态 RAG。如果您听说过 RAG,那就是检索增强生成(Retrieval Augmented Generation)。我频道上有一期视频讲解文本版的 RAG,但现在我们可以扩展到多模态领域。我们将下载 Neotron 模型系列,NVIDIA 最近发布了两个模型:Neotron Embed VL(VL 代表视觉语言)和 Rerank,这些都在 Hugging Face 上提供。我们还将下载一个数据集,这也是我们在直播中制作的自定义数据集,包含一万份食谱图像样本。这些多模态嵌入模型正是为此设计的,数据集中既包含图像上的文本,也包含 Markdown 格式的纯文本。该数据集同样可在 Hugging Face 上获取。我们将对数据进行嵌入处理并保存到文件,然后创建一个演示供人们交互体验这个多模态 RAG 系统。最后讨论扩展方案以优化项目,所有相关链接都会在下方描述中提供。

RAG 代表检索增强生成(Retrieval Augmented Generation)。如果您想阅读引入该概念的技术规范论文,可以参考此处提供的链接。本质上,RAG 是将自定义数据输入到模型的上下文中。如果您使用过像 Gemini、ChatGPT、Neotron 或 Claude 这样的大语言模型(LLM),您就会知道,当您输入提示词时——无论是什么内容——为了获得最佳结果,通常需要将模型置于最佳的上下文中。例如,简单的提示词可能只得到一般的结果,而详细的提示词则能带来更好的结果。模型生成的输出将基于您提供的上下文进行 grounding(锚定/依据)。

如果要比较 RAG 与模型微调,RAG 通常用于向现有模型注入特定知识。例如,假设您是一家澳大利亚的大型银行(如 ANZ),拥有百万份公司文档——当然这些数字是我虚构的。如果您想为员工创建聊天机器人或搜索系统,虽然可以通过文本直接搜索,但也可以通过类似我们这里演示的流程将其构建为 RAG 系统。当用户输入问题时,您希望该问题的答案能得到公司数据库或特定文档的支持;对于任何应用程序而言,信息都需要有来源依据。这就是 RAG 的用途。相比之下,如果您希望 LLM 或任何类型的模型执行非常特定的任务,那才是进行微调的时候。简而言之:RAG 用于特定知识,微调用于非常特定的任务。

如果您想进一步了解现实的 RAG 工作流,我强烈推荐阅读马丁·福勒(Martin Fowler)的这篇文章。我现在将其打开以便展示。这是一个您可能看到的现实 RAG 管道:请求进入(在我们的案例中可以是文本或图像),然后经过输入护栏、框架、重写器、嵌入模型和向量存储等多个部分。我们将简化这一流程,但如果您想扩展我们即将进行的项目,通常会涉及图中的大部分组件(如重写器、嵌入模型和向量存储等)。由于包含许多不同部分,我将其链接作为潜在的扩展方向。

接下来我们将构建多模态 RAG 管道。“多模态”指的是多种模态。模态可以是文本、图像(视觉)、声音等,有多种类型。在我们的案例中,我们专注于文本和图像,因为我们使用的模型正是基于这两种模态训练的。数据集中包含一万份食谱图像及其关联文本。我想强调,此工作流非常可扩展,适用于任何数据集。正如我之前所说,如果您有一百万份公司文档存放在文件夹或数据库中,无论它们是图像还是文本,都可以应用这种工作流。以前的 RAG 工作流主要处理文本,但这些模型最近才发布,能够在同一嵌入空间中同时处理图像和文本。

我们将使用嵌入模型对所有文档进行嵌入,具体使用的是 Llama Neotron Embed VL 1B V2(您可以试着快速多读几遍这个名字)。这是 NVIDIA 发布的一个非常新的模型,允许商业使用。我们将把这些嵌入存储在数据库中。在我们的案例中,由于只存储一万份嵌入数据,我们可以直接将其保存在单个文件中,文件大小约为 40MB。但如果您有数百万份嵌入数据,您可能需要考虑使用向量数据库(Vector Database)。目前的数据集规模为一万条,虽然不小但也不算巨大。在生产环境中,您可能会有超过百万的嵌入数据。

接下来我们将检索一定数量的样本并将其发送给重排序(Rerank)模型。工作流程如下:我们嵌入数据并保存到文件;当用户输入查询时——在我们的食谱案例中,例如“番茄和帕尔马干酪的食谱”——我们将该查询输入到相同的嵌入模型中。然后将查询嵌入与数据库进行比较,这一步非常快,可能是最快的步骤。接着我们检索最佳样本,并将其传递给重排序模型。重排序模型的职责是进一步优化结果:假设我们有 10,000 份食谱图像和文本,我们可以先提取前 20 或前 100 个最相关的样本(因为重排序计算量稍大),然后再进行精细排序。

这比单纯检索信息要昂贵得多。我们会对前20个结果进行重排序,比如:“这是查询词,这是前20个结果。这个顺序能优化吗?”也许第一个会降到第五位,而第八个上升到第一位,但这留待稍后讨论。因此这是一个可选步骤。在检索到X-T个样本之后,我们会根据需求决定检索数量,比如只想要最好的三个食谱,或者五十个。这是设计上的选择。随后我们将这些内容增强并融入上下文,作为大语言模型的输入。

以Gemini为例进行比较:RTX 4090与RTX 5090在深度学习PC上的计算能力。系统会开始搜索互联网以获取信息,从而确保结果有据可依。理想情况下,系统应去读取NVIDIA文档并据此撰写报告。检索是获取NVIDIA文档的过程,增强是将这些内容放入LLM中,生成则是输出信息。后台的工作流程正是如此:在数据库中搜索、检索结果、增强到输出中,然后基于这些结果生成答案并返回。

我们要构建的是该流程的简化版,但其优势在于可以本地运行。接下来介绍我们的“食材”:嵌入模型和重排序模型,均来自NVIDIA的Neotron系列。这些模型近期发布,相关博客文章介绍了使用方法及应用示例。此外还有一个数据集,我们在直播中创建了包含食谱图片及文本的数据集。当然你也可以使用自己的数据,无论是文本还是图像均可。

我仅为此练习从零创建了这个数据集。这些模型训练得相当广泛,我们将使用Transformers库,并在NVIDIA DGX Spark上运行所有操作。虽然这是本地完成的,但该笔记本也兼容Google Colab。如果需要,你可以在Colab上打开尝试。那么什么是嵌入?简而言之,嵌入是数据的数值化表示模型和计算机喜欢数字而非文本。

在创建嵌入时,输入文本会被分词化为数字,随后这些标记被进一步压缩为嵌入向量。多模态嵌入不仅包含文本,还同时处理图像和文本在同一嵌入空间中。这意味着你可以将图片与文本来匹配,而不仅仅是文本对文本或图像对图像。例如CLIP模型或个人偏爱的SIGLIP 2,后者代表语言-图像预训练。

它比较图片和文本并构建表示。加载图片后,你可以判断哪段文字与图片最相关,这正是我们要解决的核心问题:输入文本查询,返回相关性最高的食谱;反之亦然。这里的关键在于我们实现了多模态处理。对于嵌入而言,通常尺寸越大越好。

向量大小由所使用的嵌入模型决定。在我们的案例中,输入文本和图像处于同一嵌入空间,被转换为大小为2048的向量。这意味着有这么多数字来代表该数据片段。计算机会评估这一表示的质量,并将其与其他一万个样本进行比较以找出最佳匹配。虽然计算机不会真的这样想,但这有助于理解其工作原理。

这些只是前10个数字的示例,实际上数量远多于此。对我们人类来说难以理解,但计算机能懂。然而2048维嵌入的缺点在于占用更多存储空间(需存储每个值)和计算资源。比较48个数字与256个常见嵌入尺寸相比,几乎多10倍的计算和存储开销。

虽然大小是任意决定的,但通常越大越好。若想深入了解,Vicky Boyas关于嵌入原理的论文是很好的资源。简而言之,嵌入是数据样本(如图像、文本或音频)的学习性数值表示。我的设置是在NVIDIA DGX Spark上本地运行,虽然小巧但功能强大,非常适合探索AI世界。

我们将经历三个主要步骤:首先是创建嵌入,这通常是一次性过程。耗时取决于使用的嵌入空间类型。值得注意的是这些模型是视觉语言(VL)模型,即Vision Language。以前的嵌入模型仅存储文本在空间中,而VL将其扩展至图像和文本。

你会发现它们可以单独处理文本或图像,也可以同时处理两者。仅使用文本可得相当不错的结果;仅用图像亦然;结合使用时效果更佳。

将文本对映射到同一个嵌入空间,确实能带来显著的提升。这虽然未必是颠覆性的变革,但整体效果相当不错。我在 DGX Spark 上测试了不同样本量下的食谱嵌入耗时:仅使用文本的10,000个样本大约需要42分钟(此前数据有误,现已更正)。如果批次大小设为8,仅使用图像的10,000个样本耗时约54分钟;而同时嵌入图像和文本则需170分钟左右,即一个半小时以上。这是因为编码了更多信息,本质上是两者的结合。

正如我所述,这是一个一次性过程:你只需创建一次嵌入,然后将其存储到文件中,之后便可随意移动或复用。虽然生成这10,000个样本的嵌入耗时约170分钟,但只需做一次,后续即可无限次使用。检索速度非常快,因为当我们嵌入10,000个样本后,只需对查询进行同样的模型编码,然后将其与数据库中的嵌入进行比较即可返回最佳结果。

仅使用嵌入模型时,准确率约为73%,效果尚可或很好。但当我们加入重排序(re-rank)模型时,它会从结果中选取前X个样本并重新排列以优化顺序,从而再次提升效果。准确率从73%提升至77.6%。第一步是创建嵌入,除非更换了新的嵌入模型(例如 NVIDIA 发布了比 V2 更先进的 Neotron embed VL1B V3),否则无需重新计算整个数据库的嵌入。

接下来是检索结果阶段。假设查询为“带有番茄和帕尔马干酪的意大利面食谱”,想要烹饪一道意式菜肴。以下是演示中实际返回的结果展示,参考的是 learnhuggingface.com 上的多模态 RAG Notebook。该博客文章发布于2026年1月6日,介绍了我们在后台使用的两个新模型。

我们使用的数据集包含各种文档样本,例如“烤玉米”。这些模型是在真实的视觉文档检索基准上训练的,包括来自数字语料库的10,000份包含文本、表格和图表混合内容的文档。因此,对于含有文字、图片和图表的商业文档,这些模型非常擅长处理。我通过 PDF 模拟了这一场景,同时也嵌入了 Markdown 格式的食谱文本。

在实践中,我们将图像和文本映射到同一个嵌入空间。对于这个样本(ID为1),在24,048维的向量空间中,它由一组数字表示。我们在演示中搜索“烤玉米”,选项包括上传图片、生成摘要或重排序,这里我们仅进行文本查询以检索结果。返回的结果包括“烤箱烤玉米”、“烘焙玉米棒”等,匹配度很高。

你可能会问为何不直接进行精确文本搜索?当然可以结合向量搜索与关键词(词汇)搜索。向量搜索的优势在于能处理语义查询,例如“素食早餐”或模糊的“素食早餐食谱”。尝试搜索后,返回了“纯素香肠”、“无麸质荞麦煎饼”等结果。我们可以使用更具体的术语,如“纯素、无麸质早餐食谱”,系统能精准匹配。

查询嵌入与匹配的耗时不到半秒,尽管我们关闭了重排序和生成功能。模型方面使用的是 Embed VL 嵌入模型和相应的重排序模型。回到演示,我们尝试搜索“带有帕尔马干酪、奶酪和番茄的食谱”。虽然聚焦于食物,但这种工作流可扩展至任何文档和图片。返回结果包括“猪排”,并给出了分数,分数越高表示匹配越好,同时以 Markdown 格式显示文本版本。

我们可以选择生成内容或开启重排序功能。在本地运行时,检索速度非常快。对于归一化的嵌入向量,我们使用 L2 范数和余弦相似度;对于未归一化的则点积。当将查询与目标嵌入匹配时,我们输入查询并返回前 K 个结果(例如10,000或100万个样本中只返回前100个)。由于向量已归一化,我们可以直接使用点积计算相似度得分。

排序后的索引,但稍后我们会在实际操作中看到。我们还可以对结果进行重排(rerank)。正如你所见,我们返回前20个结果。需要注意的是,稍后我们会看到重排会增加延迟,因为重排涉及成对比较:将样本一和查询一起输入以获取分数;再将样本二与查询对比获取分数,依此类推。然后根据这些分数对结果进行重新排序。在我们的示例中,我们对前20个结果进行了重排。你会注意到某些结果的排名上升了:有的向上移动五个位置,有的三个或四个;而有些则下降了。这就是重排的好处。当然,这是一个需要做出的设计选择:如果你发现重排确实能提升系统效果,就可以实施它;但如果耗时过长,这种微小的改进可能不值得为此付出延迟增加的代价。

最后一步是生成输出。在检索增强生成(RAG)中,我们可以生成最终回答。例如,我们在提示词中输入:“你是一个乐于助人的烹饪助手”,因为我们正在构建一个食谱聊天机器人。接着要求模型提供以下三个食谱的简要Markdown摘要,包含详细信息。我们将之前根据查询检索到的前三个食谱放入其中,模型将输出一个漂亮的摘要,总结匹配我们查询的前三个食谱。但需要注意的是,生成输出同样会增加延迟。因此,在构建应用时你需要做出决定:是仅基于搜索查询检索最佳结果以构建搜索引擎类应用,还是加入重排和生成步骤。请记住,后两个步骤通常会显著增加延迟。这些是简要提示,稍后会详细展开。

现在让我们进入代码部分,我之前已经准备好了代码并准备重启环境。如前所述,你可以在 learn.huggingface.com 获取这些资源,链接会在下方提供。这里还有书籍版本,阅读起来相当容易,你可以跳转不同章节查看它们的创建过程。如果你想逐步了解其构建细节,还有一系列直播录像可供观看。若要在 Google Colab 中打开代码,只需点击相应按钮即可。假设我在本地资源中有这些链接,我们将从这里开始启动代码。

首先确保 CUDA 可用。如果没有安装 CUDA,运行速度可能会慢很多倍,请务必注意这一点。接下来,我们从 Hugging Face 加载数据集。如前所见,我已将数据集上传至 Hugging Face Hub。该数据集包含一万行数据及一些关于食谱的元信息,其中有一列图像字段是我们需要关注的重点,这些是类似 PDF 页面的图像。在商业应用场景中,你可以上传此类文档图片;此外还有食谱的 Markdown 文本格式。虽然并非总是拥有图像和文本配对数据,但如今借助现代视觉语言模型可以轻松创建这类配对。

让我们查看一个示例样本。这主要是 Markdown 格式,内容包括“培根包裹的朝鲜蓟心”,附带 ID、时间、评分数量、描述(开胃菜)、制作步骤以及用户评论。观察对应的图像,它看起来非常普通且“商务风”,里面包含一些图表或表格内容。这类模型正是专注于处理含有文本、图像、图表和表格的文档数据。由于我们使用的是 Hugging Face 数据集,可以获取所有样本的 Markdown 格式文本。

通过访问 dataset.train,我们可以看到共有10,000个样本。只需指定列名即可访问所有食谱的 Markdown 内容,例如编号42的记录展示了完整的食谱文本。接下来加载模型参数设置。我从文档页面获取了许多配置,特别是关于模态到令牌的映射关系:每种模态有不同的最大令牌长度。图像最多可使用248个令牌;文本输入的最大长度为8,916(原文误读为8,92,根据上下文修正);图像加文本组合的最大长度为10,240。我创建了一个字典来存储这些不同值,随后加载嵌入模型和重排模型。

在此需说明一点:我在代码中添加了特定的 commit hash,因为我在 DGX Spark 上运行且未安装 Flash Attention 2。Oliver Hullworthy 提交了一个 Pull Request,启用了 SDPA(缩放点积注意力)功能。如果你已安装 Flash Attention 2 则无需担心此问题,但我没有,因此我将使用 SDPA 或 eager 模式。该 PR 允许我直接从特定代码文件拉取模型,以便在嵌入和重排两个步骤中均能使用 SDPA 加速。由于这两个模型底层共用相同的代码文件,加载过程大约需要一分钟,我将暂停视频等待加载完成。

(经过约1分7秒后)好的,嵌入模型及其处理器、重排模型及其处理器均已加载完毕。在我的 Nvidia DGX Spark 系统上耗时约1分7秒,目前占用了约6GB的GPU显存。查看仓库可知,嵌入模型约为3.36 GB,重排模型同样为3.36 GB。虽然标注显示为1B参数,但实际上每个模型约为17亿至20亿参数量。我个人倾向于将其称为2B,但在估算时称其为1B也勉强可以接受。无论如何,我们继续下一步:创建或加载嵌入向量。

如前所述,生成嵌入是一次性过程。由于这是演示性质的“烹饪秀”,我已经预先完成了这一步骤。如果你尚未执行,我在代码中提供了生成文本及图像-文本嵌入的脚本(10K safe tensors 格式)。默认情况下,这些是同一模态下的图像和文本嵌入。你可以选择仅生成纯图像嵌入或纯文本嵌入,具体取决于你的需求。

文本虽然只有,但我们已将图像和文本合并到同一个文件中。因此,根据博客文章的说法,它们能给出最佳结果。嗯,我实际上不会加载这个文件,因为我没有把它保存在这里。所以让我展示一下如果我们创建它们会是什么样子,然后我会把它们带回来。这里有一些代码。我们将创建嵌入(embeddings)。接着创建新的嵌入文件。嗯,然后我们遍历数据集 train。

然后选择图像和文本。也就是图像和食谱的 Markdown 格式。接着对文档进行编码,将图像嵌入(Images to embed),文本也嵌入(text to embed)。然后将它们全部堆叠到一个列表中。接着将所有内容连接成一个大的图像文本嵌入集合(image text embeddings all)。然后将它们保存到文件中,也就是上面提到的名为 image_text_embedding_safe_tenses 的文件。我们使用 safe tensions(注:此处应为 safetensors,一种文件格式)来保存它,因为这是一种非常快的方法。

正如你在这里看到的,如果我们要让这个过程运行起来,大约需要一个半小时。但一旦完成,我们就可以反复重用该文件。所以我要在这里暂停一下。我会停止它,让我看看我们做多模态 RAG(检索增强生成)能有多快。正如你所见,我之前已经制作了这个文件。我打算把它复制到笔记本中,这样我们就不必坐在那里等待了。

所以让我们假装我们已经创建了它们。好了。文件找到,从 image_text_embeddings_10k 加载了嵌入数据。我们的嵌入形状是 (10,96),因为我们有那么多样本。而且每个嵌入都是一个 48 维向量(注:原文为 a48,应为 48)。所以如果我们看单个样本,好了。我们从嵌入文件中获取第一个样本,这些是前 10 个值。

所以再说一遍,它们对我们来说没什么意义,但在数值特征空间中,对计算机来说意义重大。所以当我们要样本时,比如输入 1(如果我们在这里键入一),它会有一些不同的值。然后如果我们在里面输入二,又是不同的值。但是当我们使用余弦相似度比较它们时,它会给出一个分数来显示它们的关联程度有多高。

现在我们可以基于输入查询检索样本。嗯,一旦我们存储了嵌入数据,有一个非常重要的注意点:我要把这个记下来。注意是如果你使用特定的模型存储了你的嵌入数据,你必须使用相同的模型来嵌入查询以获得相似的结果。所以如果你不这样做,比如说,你嵌入了你的数据集,然后加载了它,然后用不同的模型嵌入查询并比较它们,你可能会得到很差的结果。

所以经验法则是:使用相同的模型嵌入文档和查询。所以我们在这里输入一个可以是文本的查询。我们甚至可以把它改成“睡前茶食谱”,对吧?如果你想喝睡前的茶或者早餐时间的茶,对吧?那可以是任何你想要的东西。哦,还有一个点,既然我们在这里提到了输入护栏(input guard rails),因为我们的系统设计为仅与食谱一起工作,由于这种通用性,你的查询实际上可能是“黄色拖拉机”,然后模型仍然会返回相关内容。

所以也许你可以有一个步骤在开始时检查查询是否与食物相关。这是我们不在本视频中涵盖的扩展功能,但只是让你意识到这一点。所以我们做 L2 归一化(L2 normalization)。嗯,我让你们去研究那是什么意思,但这基本上意味着我们的查询——抱歉,是我们的嵌入数据将被归一化到相同的特征空间中。

然后我们就可以仅仅使用这里的点积算子轻松地比较它们,然后用 torch arg sort 对它们进行排序。然后我们得到排序后的最高分,接着返回这些排序后的分数和索引。所以我们的查询因为我们要使用视觉语言空间(vision language space),可以是输入字符串或输入图像。这些视觉语言模型真的很酷。

我们还放入了目标嵌入(target embeddings)以进行匹配,在我们的案例中就是那些图像文本嵌入。我们之前见过它们。所以这是样本零(sample number zero)。然后我们要回前 K 个结果,这里是 100。你可以把它改成任何你想要的值。比如我只想要前 20 个。我将其保留为 100,因为这是一个有趣的数字。

现在,如果我们这样做,注意这相当快。所以如果我们要执行它需要 70 毫秒(注:原文此处有矛盾,后文说是8毫秒)。所以在幕后它正在做的是嵌入我们的查询——即查询嵌入(query embeddings),然后计算所有目标嵌入的余弦相似度。所以我们在 8 毫秒内将我们的查询与 10,000 个嵌入进行比较。这就是该操作的速度。所以这真的很酷。检索步骤非常快。

我们的文档嵌入创建大约需要一个小时,但现在的检索时间低于 10 毫秒。这太棒了。那么我们会得到什么结果呢?所以这是我们的排序分数(result sorted scores)和排序索引(sorted indices)。这些分数将接近 1。它们在零和一之间。所以越接近一,我们的查询与匹配的文档就越相似。

现在你可能得不到完美的 1.0,但我的意思是如果你得到了,那太棒了。可能你是在用相同的输入匹配文档中的相同样本。例如,如果你搜索样本零,理想情况下你会得到样本零作为最佳相关结果返回。然后正如你所见,分数缓慢开始下降到 0.4 左右。所以这些分数仍然都很高,对吧?就像它们再次取决于你使用的数据集一样。

确定好分数的最佳方法是反复尝试许多样本,然后这个张量——抱歉是第二个张量 result_sorted_indices 将返回一个匹配此分数的索引列表。所以如果我们看索引号,也就是数据集 train,以及我们的查询是什么?好了,“炸猪排配帕尔马干酪和番茄酱”。是的,这就是我们找到的。

然后如果我们看图像,好了,这是我们得到的结果。“帕尔马干酪炸猪排配番茄酱”。所以这就是我们的查询:带有番茄和帕尔马干酪的食谱。如果我们输入“黄色拖拉机”会怎样?让我们看看那个结果。我们会得到更低的分数吗?是的,我们会。我们得到的分数要低得多,对吧?但我们仍然会得到一个结果返回,因为模型总是会检索某些内容,除非我们放入护栏。

“炸黄西葫芦”。好吧,不太远。标题里有黄色这个词,但不是很理想。我们可能会在那里加一个检查以确保它与食物相关。再来一次,“早餐时间茶的食谱”。我们会得到什么结果?好的,我们得到一些高得多的分数。这是排名第一的结果。我们会得到什么返回呢?“传统的英式下午茶司康饼,配果酱和……”

奶油味不错,对吧?所以让我们退出这里,回到我们最初的查询,因为这个笔记本就是为此设置的。但为了让大家能随意尝试,我鼓励你们去试试不同的查询。你也可以用图片进行测试。比如我们有这张图……抱歉,我应该准备得更充分一点像个烹饪节目似的。我会用这张名为 musaka.jpeg 的图片。

这就是我们的图片。如果我们想查询它,因为我们的模型是 VL(视觉语言)空间,所以我们可以打开这张图片并获取到它。我们会想:“嘿,我想找一个烹饪这道菜的食谱。”那我们现在能做什么呢?我们可以输入测试图片查询。在后台,我们的匹配查询到嵌入函数将会把该查询作为图像进行嵌入。这里有个小细节需要注意,这可能需要在嵌入模型页面上提交拉取请求(Pull Request)。对于图像,要将它们作为文档进行嵌入。

在底层,它将使用相同的方法,但不知为何,模型页面中的这种方法只处理文本。所以我们往下走,我只是想展示一下这看起来是什么样子的。我们得到了什么?地中海茄子奥拉吉娜蛋糕(Mediterranean eggplant orene cakes)。如果我们看看那张图片,它看起来像什么?

看这里。地中海茄子奥拉吉娜蛋糕(oregine cakes)。如果我们看看它看起来的样子,嗯……茄子奥拉吉娜。这是一回事吧?蛋糕(Cakes)。我拼写错得离谱了,呃……没关系。我们得到的是地中海茄子奥拉蛋糕(or cakes)。那作为第二名的结果是什么?

我们要找的是穆沙卡(Mushuka)。所以这会是一个“米高慢炖锅千层面配西葫芦和菠菜”(mile high crock-pot lasagna with zucchini and spinach)。它看起来像什么?再次强调,这不是我们的数据库。这只是返回的食谱。所以这相当接近了,对吧?我们搜索的是那张图片,它们非常相似。我喜欢这个结果。所以试试你自己的图片看看效果如何。但重点是现在我们可以进行文本查询和图片查询了。

让我们继续到下一个阶段。我们有了检索,有了嵌入创建,也有了检索(RAG 中的 R)。我们还可以做一个基于文本的查询。我们可以以漂亮的 HTML 卡片形式显示前几个结果。我们得到了什么?这是我们的查询测试图片。所以第一个文本匹配结果是地中海茄子奥拉吉娜蛋糕,配西葫芦的米高慢炖锅千层面,以及没有……(原文此处似有口误或识别错误“vegetable lasagna without lasagna”)的蔬菜千层面。

然后是图片匹配结果。记住我们的食谱图像匹配不是自然图像,它们是文档图像(如扫描件或截图)。我们现在还可以对输出进行重排序。所以让我们回到概述,我们做了什么?我们对数据集进行了嵌入处理。我们将这些保存到了数据库中,它们在一个名为 embeddings.safetensors 的单文件中。(注:原文 safetenses 应为 safetensors)。我们现在检索了前 X 个样本。所以我们到了重排序阶段。

正如我所说的,重排序模型与嵌入模型的骨干网络非常相似。如果我们去社区页面,我是从这个拉取请求中加载它的。所以我从这里的提交记录中加载了它,以便启用 SDPA 使用(Scaled Dot-Product Attention)。如果你有 Flash Attention 2,可能开启它会很有益。那么让我们试试这里的查询:“香蕉酸奶油面包”(Banana sour cream bread)。对输出进行重排序。让我们看看重排序会发生什么。

所以我们得到了……哦,那起作用了吗?实际上,我想我们可能需要一秒钟的等待时间。噢,是的,来了。这就是我们要到的那些结果。“文本结果排序索引”(text result sorted indices)。嗯,然后是我们的文本查询……也就是香蕉酸奶油面包。太棒了。

这工作正常了。接下来要做的是进行一些重排序。我们可以选择样本数量,因为我们会被返回结果。我们将 top K 设置为 100。我们有 10,000 个样本。但是当你再次进行重排序时,它会将查询与每个样本进行比较:查询对样本、查询对样本……这将导致速度变慢。这需要计算时间。通常,你可能希望将限制在前 10 到 20 个样本上,而不是前 100 个。但这当然取决于你的系统设置。

所以我们重排序顶部样本。在我们的案例中,我们将重排序前 20 个。然后提取要重排序的文本和图像。接着在这里格式化一个数据集。这一切都取自于重排序页面,我只是稍作修改以适配我们的系统。所以我们有图像、文档,然后将它们放入字典中。在哪里呢?哦,它被转换成一个示例字典,然后我们进行批量处理(batch dictionary),再传递给模型,最后再次对它们排序。

如果我们在这里查看,这只是完全相同的过程再次演示:要重排序的样本。我们放入问题(即我们的测试查询文本),放入文档文本(即要重排序的文本),然后放入文档图像,也就是我们要重排序的图像。我们确保将样本移动到目标设备(target device)。如果它们是张量,我们应该肯定使用 GPU。然后我们从重排序模型获取重排序输出。

我将现场演示一下。所以我们要对前 20 个样本进行重排序。这需要一些推理时间(inference time)。正如我们之前所见,检索非常快,但重排序……如我所发现的那样(如果你知道为什么这可能需要更长时间,请留言评论)。可能有一些方法可以随着时间的推移加快速度,但这将是我需要调查的事情。是的,这花了 16.3 秒,这不是理想的状态,对吧?

如果你想要快速得到食谱,你会愿意坐在那里等待 20 秒吗?理想的情况是,如果你必须优先考虑最佳结果,而延迟并不那么重要。但在搜索问题中,延迟通常总是很重要的。所以这里存在权衡。现在我们得到对数(logits)。这不会是精确的……所以对数是重排序模型给出的某种分数,但它们不像检索模型给出的那样清晰明确。所以让我们更多地看看重新排序后的索引(reranked indices)。

如果我们获取重排序后的索引,我们得到了什么?这是我们的张量。所以这里是 0 和 1。如果我们的重排序张量格式完全相同,它应该是 0, 1, 2, 3, 4, 5, 6。因为这是一个 ARG SORT(参数排序)张量,这意味着索引 0 和 1 实际上保持不变。所以最初的前两个结果(即索引号为 0 和 1)仍然保持在位置 0 和 1,而编号为 7 的结果从下面这里或那里上升到了这里。所以编号 7 的样本向上移动了五个位置,但我有一个辅助函数来帮助我们查看这一点。所以这是一个用于重排序样本的辅助函数。

在我们的数据集中,包含查询文本、排序后的索引、重排样本数量、模型名称、重排过程设备、文本列、图像列、数据集划分,然后返回需要重新排序的数据样本和重排后的索引函数。最后,我们可以用一些漂亮的颜色来展示重排的变化效果。这里就是原始样本:样本零原本排在第一位,所以没有变化;样本一原本是第二位(顺便提一下,这是从零开始的索引),而加一是从一加一的索引。正如之前所说,前两名没有变化,但请看这里:重排模型将原本排在第八位的样本七提升到了第三位,也就是上升了五位。

接着看,样本六原本排在第七位,现在变成了第四位,也就是上升了三位。其余的排名变化也类似。因此,根据这些变化的剧烈程度,你可以决定是否使用重排模型。就我个人的感觉而言(我们尚未对此进行正式评估,因为这是一个玩具数据集),检索步骤对我来说已经足够了。就我个人的看法而言,在我们这个包含一万份食谱的具体问题中,重排并没有增加太多价值。但这需要你在自己的系统中做出决定。

让我们看看重排前后的对比。我让 Claude 帮我生成了一些用于查看的 HTML 代码,但这只是以不同的方式展示相同的信息。查询内容是“香蕉酸奶油面包”,也就是我们输入的内容,这些是原始检索到的样本:“香蕉酸奶油面包”非常完美,“最好的香蕉”。接下来看重排后的样本:前两个仍然相同,但现在第三个从第七位移动到了第三位:“酸奶油巧克力片香蕉面包”。这比“有史以来最好的香蕉面包”更好吗?

如果你的查询中包含“酸奶油”,我会说这个结果更好。在这种情况下,重排起作用了。但它是否足够好到值得花费 16 秒的重排时间呢?可能不值得。如果能把重排时间缩短到一秒以内,那可能就值得了。现在我们已经完成了检索步骤,接下来可以进行增强(Augmentation)。这一步可能是最简单的,因为我们只需要将前三份食谱或前 X 份食谱放入生成模型的提示词中即可。

这就是增强过程,对吧?如果你是一家企业,想要检索一些内容并将其作为输入进行增强,请记住 RAG 的整个目标是给我们的 LLM(大型语言模型)提供特定的上下文。现在增强已经准备好了,所以增强的生成步骤结合在一起了。我们现在可以根据结果生成答案。至于在生成步骤中使用哪个模型,在这个演示中我将使用 Qwen2B 指令版(Qwen),因为它是一个小模型。

不过它也具备视觉语言功能。如果你想要一个高质量的模型,你可能会选择类似 Neotron V3 这样的产品。这可以在 DGX Spark 上本地运行,但目前它有点太大了。也许可以尝试的一个扩展是将 Neotron V3 集成到这个管道中运行。所以我暂时先坚持使用较小的模型,但如果你更倾向于生产系统,可能会选择 Neotron 3 Nano。我就把它放在那里了。

由于我们在生成端没有做任何太复杂的事情,所以我选择了一个较小的模型。我们只是生成一个简短的摘要。如果你想进行复杂的生成任务,那么确实可能需要像 Neotron 3 Nano 这样的大模型。现在让我们加载生成模型。需要注意的是,Neotron 3 Nano 将占用约 40 GB 的内存。相比之下,Qwen 这样的小模型只使用 4.6 GB 或 4.26 GB。

如果我们在 DGX Spark 上操作,我们可能实际上有空间运行 Neotron V3。但你看,我们正在推动通用消费级 GPU 的极限。我们已经使用了 45 GB 的内存,这对于拥有 128 GB 内存的 Spark来说是可以接受的。但如果你处于 GPU 受限的环境中,这一点值得考虑。现在 Qwen3VL2B Instruct 已加载完毕。

我们将在此创建一个函数来生成食谱摘要。基本上,这个提示词的意思是:给定我们的食谱作为输入,为我提供一个食谱摘要。我们可以将文本以列表形式传入。当我们回到 Gemini 时,Gemini 会根据 RTX vs. 4090 或 5090 发起搜索,找到一些文档链接(如你所见),将这些信息放入上下文窗口中,然后基于这些链接返回结果。

这也是我们系统中可以做的事情:我们可以生成摘要,并提供指向该摘要来源的实际食谱的链接。这样它就扎根于上下文中了。现在让我们运行它。如果我们只想传入三个示例食谱,我们可以将第 42、43 和 44 个 Markdown 格式的内容传入我们的生成食谱摘要模型函数(抱歉,口误)。然后模型将输出如下内容:

“食谱摘要:培根包裹的朝鲜蓟心,一款快速简单的开胃菜;关键食材:鳄梨;促销时囤货并冷冻小贴士:简单且具成本效益的小贴士,很简单,对吧?”我们可以自定义此输出。我只是简单地将其做成摘要形式。但正如再次强调的,LLM 的世界任你驰骋(the world is your oyster)。你可以将其制作成任何你想要的样子。

现在,这个演示对我们开发者来说很好,但其他人如何使用这种类型的系统呢?当然,我们要把它做成一个 Gradio 演示。首先让我们回顾一下查询内容:我们将使用这里的这个查询,然后使用我们的“匹配查询到嵌入”函数将嵌入与查询进行匹配。好的,这些只是检索到的结果。现在我们可以创建一个用于显示食谱文本的辅助函数(这将是 HTML 格式)。

然后我们将所有内容整合在一起,因为 Gradio 是如何工作的呢?Gradio 从输入(可以是文本或图像)开始,经过函数处理输出结果。在我们的案例中,流程将是:输入(文本或图像)、检索、重排、生成和显示结果。但在我们的案例中,我们将设置“重排”为可选,“生成”也为可选。所以当我们进入 Gradio 演示时,这是我们的输入,可以是文本查询或图像查询。

我们可以将“生成摘要”设置为真,“重排”也设置为真,或者反之亦然。所以让我们……

让它在后台运行。所以,如果我们想用图片进行搜索,比如你拍了一张厨房台面的照片(这张图是我用 Gemini 生成的),我们来看看能得到什么结果。我们会让它运行起来,但这就是我们的检索函数在 Gradio 界面背后所做的操作:我们输入查询文本或查询图片。如果你同时输入了查询文本和图片,我们会专注于输入的文本部分。但对此的一个扩展是结合文本和图片进行联合查询。所以我的挑战在于,你会如何更新代码以实现基于图片和文本的组合查询?接着我们执行检索步骤:将匹配到的查询与嵌入向量进行比对,收集这些分数和样本,然后在此处创建图片画廊和输出文本画廊。这就是演示中显示的内容。

结果显示为“罗勒酱烤番茄”。看起来怎么样?不知为何,它不允许我全屏显示。没关系。“罗勒酱烤番茄”、“烤番茄沙拉”、“波浪形切片番茄”,还有“赤脚女伯爵(Barefoot Contessa)”。所以,图片中包含的食材基本有:番茄、橄榄油、干牛至、粗盐以及新鲜研磨的黑胡椒。这就是这张图片中呈现的所有食材。这也是这些新模型的一个酷之处,因为它们使用了 SigLIP 技术。SigLip-2 已经在大量自然图像上进行了训练,尽管它们是针对文档进行微调的。我们发现,在这个多模态 RAG 管道中,这些模型实际上也能很好地处理自然图像。

接着我们得到食谱摘要:“罗勒酱烤番茄”、“烤番茄沙拉”,或者是“赤脚女伯爵的波浪形切片番茄”。你看,生成时间只用了几秒钟。虽然背后有很多优化措施我们可以实施,但作为一个演示来说这已经相当酷了,对吧?我们可以从文本查询或图片查询开始。回到这里,我们有检索步骤。然后可选地有重排序(re-ranking)步骤,这与我们之前讨论的完全一样。唯一的区别在于:如果我们进行重排序,输出结果会有所不同;如果不进行重排序,我们只需将重排序设置为关闭。

最后,生成步骤也是可选的。这就是我们要执行生成的地方。如果“摘要生成”选项为真(在这里选择 true 或 false),我们就运行一些生成步骤。当然,我们将其设为可选是因为如果只想进行从查询到食谱的搜索,也可以这样做。然后返回一个计时字典,以便我们知道每个步骤花费了多长时间。如果我们想做一个演示测试查询:“一杯茶”。干杯,我这里有一杯茶。我们来看看能得到什么结果。

我们在演示测试查询中输入“一杯茶”,没有图片,不进行重排序,但生成摘要。看看会发生什么:查询文本“一杯茶”,输入查询。重排选项设置为 false,跳过重排序;摘要生成设置为 true,正在生成摘要。正如你所见,查询嵌入匹配时间非常快,但生成时间大约为 7 秒左右。因此,几个优化步骤可能是我们对该项目的第一个扩展方向。让我们看看我们的摘要结果:丁香肉桂茶、香料查伊浓缩液(Spiced Chai Concentrate)、阿富汗茶。

如果我们在本地运行并将此传递给演示,让我们在这里输入“一杯茶”,并且确实想要生成摘要。我们来看看能得到什么结果:丁香和肉桂茶、香料查伊浓缩液、阿富汗茶,听起来很美味。然后我们可以在画廊中显示这些图片:这是我们得到的食谱卡片。接着我们可以将其转换为 Gradio 演示,就像所有优秀的烹饪节目一样,我们实际上已经做到了这一点。如果你想查看 Gradio 演示代码,可以在这里找到。它将为我们提供与上方完全相同的设置:一杯茶、丁香和肉桂茶、香料查伊浓缩液、阿富汗茶以及正宗的家庭食谱。

如果你想了解如何运行它,只需转到“文件”部分。那里有所有的应用程序文件,这是主要的一个。它在 Hugging Face 上运行。如果你想本地运行它,你可能需要使用 app_local.py。那里也有我们的嵌入文件。让我们回到这里,我们快要完成了。如果搜索“一杯茶”,这当然是在我们的笔记本中运行的。我们要生成摘要,以确保输出相同。在后台,检索结果非常快,正如我们所见。看这里:丁香和肉桂茶。我们得到了一些食谱图片、分数以及作为精美 Markdown HTML 的食谱文本,最后是我们的摘要。

我们当然可以自定义摘要提示词。我鼓励你尝试一下,也许你可以将其显示得比我这里更好看一些。然后我们有计时数据。让我们中断它。如果我们想将此上传到 Hugging Face,该怎么做呢?我们需要将 Gradio 演示转换为文件。我这里的 demos 文件夹已经存在了,所以我可能会创建一个目录叫 multi-modal-rag。好了,我想向你展示这是实时的演示。我不一定需要更改所有这些内容。

我们将创建 app.py 文件,我会把它写在那里并保存到那里。如果你想查看这个演示,它将在 Hugging Face 上展示。本质上,它会加载模型和数据集(包括数据集和模型的加载),创建辅助函数——就像我们在笔记本中看到的那样:重排序函数、生成食谱摘要,然后提供一些可视化功能。这将把 Markdown 格式的食谱转换为 HTML 卡片。接着我们设置 spaces.gpu,以便使用 Hugging Face Spaces 提供的 GPU(在 Zero GPU 上)。

然后我们执行检索,这里进行重排序(当然是可选的),以及生成步骤(也是可选的)。接着我们创建 Gradio 界面,这就是给我们提供这个演示的地方。让我们回去看看:我们已经将其写入文件了,即 app.py,然后我们可以更改为 requirements.txt。这将是我们希望应用演示安装的依赖项要求。我们还可以修改 README 文件,写在这里。为了让你知道这是由我编写的,我会稍作更改。

因为如果我们不修改就直接上传到 Hugging Face,它不会重建空间,因为 HuggingFace 后台很智能。就像 GitHub 一样,它能知道你的文件何时发生了变化。所以,如果你想了解这个系统是如何一步步构建的完整系列教程,请查看 YouTube 播放列表。现在我要打开 Chrome 浏览器中的 YouTube,复制这个播放列表的链接并写下来。

这里有一个 README 文件。嗯,我要把嵌入(embeddings)文件放进去。其实我不需要这么做,因为它已经在这个空间里了,但我只是想向你展示这个完整的工作流程,让你知道我们从头到尾都走了一遍。我会把它复制进去。现在我们的演示文件夹里包含了 app.py、图片文本嵌入以及 10k safe tenses(注:此处为语音识别误差,指代某种数据文件)。

记得我们在开始时创建嵌入时生成的那个东西吗?它将被存储在我们的空间中。我不想在这里播放演示,但我想展示一下这个完整的流程。然后我们还想把这个也存进去。这是一个演示图片,就是之前用过的厨房台面图。好了,我们可以把它上传到 Hugging Face 了。

我要传入我想上传的文件夹路径。我已经定义了想调用的空间名称。它不会完全重新创建这个空间,只是上传一些新文件到这里。“Multimodal rag with Neotron”。好了。这是一个仓库类型的空间,使用的是 Gradio 因为我们在用 Gradio 演示。我们将创建一个仓库,获取完整的仓库名称,然后使用 Hugging Face Hub 辅助函数在这里上传文件夹。

我们要把文件上传到演示中,用于这个 YouTube 视频,也就是你现在正在观看的内容。这应该很快。好了,它开始上传一些文件了。“Demo successfully uploaded”(演示成功上传)。现在你会注意到只有发生变化的文件才会被上传。就在不到一分钟前,README 是唯一真正改变的文件,但图片文本嵌入保持不变,因为这些文件没有变化。

这就是 Hugging Face 仓库的美妙之处,它们在后台很智能(利用 Zet 技术)。它们只在你文件实际发生变化时才会更新,这非常好。最后几点说明:我们面临着重排序的权衡。当然你可能会获得更好的检索结果,但延迟也会增加。生成的权衡在于:它会产生很好的摘要和概述,但也增加了延迟。

所以有一些问题需要考虑:你的应用想要做什么?你想要它速度快吗?还是追求最佳结果?或者是生成式内容?这是根据你的构建目标所做的设计选择。图片和文本嵌入在理论上确实能给出最好的结果,但它们并不总是可用。在这里我们默认使用了图片文本嵌入、食谱图片和 Markdown 文本在一个空间里。

但如果你只有图片,仍然可以使用这些嵌入模型来实现;或者如果只有文本,也可以使用这些嵌入模型。但如果你想增强你的嵌入效果,比如你只有图片,可以使用视觉语言模型为其添加文本或元数据;反之亦然。例如如果你只有文本,你可能想生成一张与之关联的图片。

那么我会如何扩展这个系统呢?首先我要添加一些实际的评估(evals)。目前我们只是基于“感觉”来判断,基本上是在看我们的管道并说:“嘿,它返回了这些结果。看起来怎么样?”如果看起来很完美,那就太好了。但如果你想要一些指标数据,你可能需要创建一个数据集,给定这个样本,它应该检索出另外五个相关样本,然后你可以得到一些实际的指标。

如果我们想改进我们的 RAG 管道,肯定可以在某个环节加入词汇搜索(lexical search)。将向量搜索与基于文本的匹配结合起来。例如,如果我们输入查询“tomato and palmes(可能是pancakes)and cheese”,它也会直接匹配这些文本,而不仅仅是向量。我们还可以进行输入检查器查询重写(query rewriting)。

但我将把这份文档留给你,作为你可能想要阅读的内容,以了解如何扩展我们的系统。这里有一些很棒的指南:直接提示(direct prompting)、嵌入、评估、微调、护栏机制、混合检索器、查询重写和重排序。如果我们想将其构建为更生产就绪的系统,下一步就会走向这些方向。

最后,如果想改进我们的数据集,因为我们是从头开始创建这个数据集的。我选择了 10,000 个样本,既不算小也不算极端大。我们可以升级这个工作流来处理超过一百万的样本。事实上,许多企业确实有那么多需要搜索的样本。目前我们使用的是单个嵌入文件(safe tenses)。

但在百万级样本的情况下,我们可能需要考虑专用的向量数据库。这是我建议从那里开始研究的事情。当然你也可以尝试仅使用本地文件,但这取决于你需要在哪里运行它。还有布局创建:我们现在的食谱 PDF 相当单调?能否升级它们使其更加多样化,以反映现实中真实的食谱卡片的样子?

还能改进吗?比如我们想专门搜索不同的图表。能否让我们的搜索管道在搜索不同配料、营养成分、烹饪时间等方面表现得更好?但这更具体于食谱搜索。现在,我刚才提到的这些点是因为你必须思考针对你特定的数据集如何改进搜索效果。但这就是从头开始的 multimodal RAG 管道。

如果你想看它是如何构建的,当然你可以去我频道上的 YouTube 播放列表查看。但如果想要所有代码,我会链接在下面。这里有一个很棒的笔记本(notebook),你可以直接阅读通过。如果有任何问题或发现任何错误,请留下评论或在 GitHub 上提交 issue。我们下次视频见。嘿