AI 智能体与应用——使用 ChromaDB 掌握 RAG 基础

0 阅读39分钟

本章涵盖以下内容:

  • 使用 RAG 架构实现语义搜索
  • 理解向量存储及其工作机制
  • 使用 ChromaDB 和 OpenAI 实现 RAG

在本章中,我们将探讨两个关键概念:语义搜索(semantic search)检索增强生成(Retrieval Augmented Generation,RAG) 。你将看到,大语言模型(LLM)如何通过聊天机器人来实现语义搜索,使你能够在多份文档中查询信息,并检索出那些在“含义”上最符合你问题的文本片段,而不仅仅是做关键词匹配。这种方式也被称为文档问答(Q&A over documents) ,或者查询知识库(querying a knowledge base)

在前面的章节中,你已经学习了摘要,这是 LLM 的一个典型应用场景。现在,我将带你一步步掌握如何构建一个能够跨多份文档进行搜索的问答聊天机器人的基础能力。你将与 LLM 交互,以找到你想要的答案。

本章聚焦于 RAG——也就是驱动语义搜索系统的设计模式——尤其会重点讲解这些系统中的一个关键组件:向量存储(vector store) 。你将学习与 Q&A 和 RAG 系统相关的技术术语,并理解像 semantic search 和 Q&A 这样的术语为何经常被交替使用。

到本章结束时,你将已经使用 LLM(OpenAI)和向量存储(ChromaDB)的 API,实现一个基础的 RAG 架构。在第 7 章中,你会在这个基础上继续构建基于 RAG 架构的 Q&A 聊天机器人。内容很多,我们现在就开始。

6.1 语义搜索

语义搜索是 LLM 的一个热门应用场景,与摘要和代码生成并列。它也是推动 LLM 和生成式 AI 热潮的关键应用之一。

定义 语义搜索,是指通过关注信息的“含义”来搜索信息。这包括理解查询的含义,从文档存储中检索出那些在语义上与查询最匹配的文档片段,并可选地生成一个自然语言答案。

语义搜索不同于传统的基于关键词的搜索。后者在查询和结果中的具体词汇不完全匹配时,往往就无法找到信息。而语义搜索即使在查询和结果之间没有共享任何一个单词的情况下,也能返回相关结果。

在正式进入代码之前,你需要先清楚理解语义搜索聊天机器人的架构。我会先用一个简单例子带你入门,但到本节结束时,你将理解一个现实世界中的 Q&A 聊天机器人的整体架构。

6.1.1 一个基于单文档的基础 Q&A 聊天机器人

我们先从一个简单场景开始,它有助于你理解 Q&A 聊天机器人是如何工作的,并熟悉其中各个组件。第一个聊天机器人示例会回答有关单份文档的问题,如图 6.1 所示。

image.png

图 6.1 简单 Q&A 聊天机器人的流程包括以下步骤:(1)用户向聊天机器人发送一个包含文档(上下文)和问题的提示;(2)聊天机器人返回初始答案;(3)用户继续追问一个更具体的问题;(4)聊天机器人给出一个更准确的答案。

这个基础设置的主要元素如下:

Document(文档) —— 包含用于语义搜索或信息提取的文本。

Prompt(提示) —— 将用户的问题(语义搜索请求)与作为上下文的文档内容打包在一起,其中包含回答问题所需的信息。

基于 LLM 的聊天机器人 —— 把提示发送给 LLM,由后者理解问题和上下文、选取相关信息,并为用户综合生成答案。

我们来进一步拆解这些概念。

定义 上下文(context)是提示中的文本或信息,与用户问题一起用于形成答案。

定义 综合(synthesize)是指根据给定的问题和上下文生成答案。

实现这个最初的设置不需要你写任何代码。你只要登录 ChatGPT,或者使用 Gemini、Claude 这类其他基于 LLM 的聊天机器人,就可以开始了。我们来试一个简单的 Q&A 交互,所用文本是关于 Paestum 的 Britannica 内容(www.britannica.com)。请把下面这个提示提交给 ChatGPT(你可以在 GitHub 仓库中的一个文本文件里找到这个提示):

Read the following text and let me know how many temples are in Paestum, who built them, and what architectural style they are:

Paestum, Greek Poseidonia, ancient city in southern Italy near the west coast, 22 miles (35 km) southeast of modern Salerno and 5 miles (8 km) south of the Sele (ancient Silarus) River. Paestum is noted for its splendidly preserved Greek temples.
Visit the ruins of the ancient Greek colony of Paestum and discover its history, culture, and society. See all videos for this article
Poseidonia was probably founded about 600 BC by Greek colonists from Sybaris, along the Gulf of Taranto, and it had become a flourishing town by 540, judging from its temples. After many years’ resistance the city came under the domination of the Lucanians (an indigenous Italic people) sometime before 400 BC, after which its name was changed to Paestum. Alexander, the king of Epirus, defeated the Lucanians at Paestum about 332 BC, but the city remained Lucanian until 273, when it came under Roman rule and a Latin colony was founded there. The city supported Rome during the Second Punic War. The locality was still prosperous during the early years of the Roman Empire, but the gradual silting up of the mouth of the Silarus River eventually created a malarial swamp, and Paestum was finally deserted after being sacked by Muslim raiders in AD 871. The abandoned site’s remains were rediscovered in the 18th century.
Temple ruins of columns and statues at Karnak, Egypt (Egyptian architecture; Egyptian archeology; Egyptian history)
The ancient Greek part of Paestum consists of two sacred areas containing three Doric temples in a remarkable state of preservation. During the ensuing Roman period a typical forum and town layout grew up between the two ancient Greek sanctuaries. Of the three temples, the Temple of Athena (the so-called Temple of Ceres) and the Temple of Hera I (the so-called Basilica) date from the 6th century BC, while the Temple of Hera II (the so-called Temple of Neptune) was probably built about 460 BC and is the best preserved of the three. The Temple of Peace in the forum is a Corinthian-Doric building begun perhaps in the 2nd century BC. Traces of a Roman amphitheatre and other buildings, as well as intersecting main streets, have also been found. The circuit of the town walls, which are built of travertine blocks and are 15–20 feet (5–6 m) thick, is about 3 miles (5 km) in circumference. In July 1969 a farmer uncovered an ancient Lucanian tomb that contained Greek frescoes painted in the early classical style. Paestum’s archaeological museum contains these and other treasures from the site.

我当时得到的回答如下(请记住,由于 LLM 具有随机性,你可能会得到略有不同的结果,但它仍然很可能是不完整的):

“Paestum 有三座神庙,它们都属于多立克(Doric)建筑风格。这些神庙分别是:
The Temple of Athena(也被称为 Temple of Ceres),建于公元前 6 世纪;
The Temple of Hera I(也被称为 Basilica),同样建于公元前 6 世纪;
The Temple of Hera II(也被称为 Temple of Neptune),大约建于公元前 460 年,也是三者中保存最完好的一座。
这些神庙因其保存良好的多立克建筑而著称,是重要的历史地标。”

这几乎就是我想要的答案了,但它漏掉了一个问题:是谁建造了这些神庙。如果这不是一个聊天机器人,而是一个 Q&A 引擎,那么客户端系统此时就根本收不到关于“谁建造了神庙”的回答。

注意 Q&A 引擎和摘要引擎类似:它由客户端系统调用,并返回一个答案。而聊天机器人是由人来使用的,因此支持多轮提问,你可以通过交互不断细化答案。

接着,我又发送了这个提示:

  This answer is almost what I expected, but you forgot to answer something: who built the temples?

“抱歉我漏掉了这一点。Paestum 的这些神庙,是由大约公元前 600 年建立 Poseidonia(后来称为 Paestum)这座城市的 来自 Sybaris 的希腊殖民者建造的。因此,这些神庙是由希腊殖民者以多立克建筑风格建成的。”

这体现了聊天机器人相对于引擎的优势:经过几轮追问之后,ChatGPT 把我关于 Paestum 神庙的所有问题都回答出来了。更重要的是,我不需要重新提交整段原文;ChatGPT 记住了最初那段文本。

注意 像 ChatGPT 这样的聊天机器人会创建一个有状态的会话(stateful session) 。它通过将对话历史作为额外上下文传递,从而记住整段交互,这样用户就可以在不重新发送初始文本的情况下不断细化答案。

我们再试一个提示:

  Is the archaeological site in Paestum recognized by UNESCO?

“是的,Paestum 的考古遗址获得了 UNESCO 的认可。1998 年,它被列为 联合国教科文组织世界遗产。这一认定承认了 Paestum 中保存完好的希腊神庙和古代遗迹所具有的历史和建筑价值。”

等等!原始文本中并没有这条信息。ChatGPT 使用了它自己的知识——很可能来自 Wikipedia 或其他来源——来正确回答了这个问题。这种“热衷于利用未提供内容来回答问题”的倾向,有时会像这个例子里一样帮上忙,但正如你很快将看到的,它也可能是危险的。我们再试一个问题:

  How many columns do the three temples have in total?

“Paestum 的三座神庙总共有 94 根柱子:
The Temple of Athena(Temple of Ceres)在短边各有 6 根柱子,长边各有 13 根柱子,总计 32 根。
The Temple of Hera I(Basilica)在短边各有 6 根柱子,长边各有 9 根柱子,总计 30 根。
The Temple of Hera II(Temple of Neptune)在短边各有 6 根柱子,长边各有 14 根柱子,总计 32 根。”

ChatGPT 试图给出答案,但这个答案是错的。The Temple of Hera I 实际上有 50 根柱子,而不是 30 根;你自己就可以在 Wikipedia 上核实这一点。

警告 当聊天机器人在提供的文本中找不到信息时,它可能会转而依赖自己的知识,这就可能导致错误信息或捏造信息,也就是所谓的幻觉(hallucination)

在一个例子里,ChatGPT 给出了关于 UNESCO 的正确信息;而在另一个例子里,它又给出了关于柱子数量的错误答案。这就引出了一个重要问题:你怎么知道 ChatGPT 什么时候提供的是可靠信息,什么时候不是? 一般原则是:对于那些你没有独立验证过、或者不是你自己明确提供给它的事实与细节,你都应该谨慎对待,不要轻易相信 ChatGPT。

提示 为了避免这个问题,应当明确要求 ChatGPT 只使用你提供的信息,并在它不知道答案时明确说明自己不知道。

你可以尝试用下面这个提示来解决刚才的问题:

How many columns do the three temples have in total? Use only the information I gave you in the text. If you don’t know the answer, just say so.

你会得到一个更准确的回应,如下:

“对我之前的回答出错表示抱歉。你提供的文本中没有提到 Paestum 三座神庙总共有多少根柱子。”

提示 为 Q&A 聊天机器人设计安全的提示,可以降低幻觉发生的概率。本书后面的内容会继续深入这一点。

现在我们进入一个更复杂的用例。这一次,聊天机器人必须从更大规模的文本集合中提取特定信息。

6.1.2 一个更复杂的、面向知识库的 Q&A 聊天机器人

前面的基础型 LLM 问答聊天机器人——比如 ChatGPT——处理的是单份文本,其设计可以概括如下:

  • 你向聊天机器人发送一个提示,其中包含你想搜索的文本,以及你要提出的问题。
  • 提示中应明确要求聊天机器人只使用所提供的文本来形成答案。
  • 聊天机器人应维护一个会话,以保留对话历史,从而不断细化答案。

到目前为止所描述的方法,对于在单份文本中进行搜索是完全可行的。但如果你的聊天机器人需要回答的是分散在多个来源中的公司信息呢?例如:企业内网页面、共享文件夹,以及各种格式的文档(PDF、DOCX、TXT、PPT)。这正是我们接下来几章要解决的问题。

在设计一个企业级 Q&A 聊天机器人时,最大的障碍之一是:你不可能把公司的全部内容连同用户问题一起放进提示中。尤其是在处理大文档时,这会迅速超出模型的上下文窗口。实际上,把上下文收窄到“最相关的那部分”不仅能提升速度和成本效率,也能提升准确性。很多时候,给模型更少但更聚焦的上下文,反而比给它一大堆关系松散的信息效果更好。

要做到这一点,聊天机器人就必须能够访问公司的知识库,并且只检索出回答当前问题所需的那部分内容。理想情况下,它应该能够“理解”这个知识库,以至于你可以自然地提问——而不必每次都手动提供背景上下文——如图 6.2 所示。

image.png

图 6.2 企业级 Q&A 聊天机器人的假想设计。聊天机器人的知识范围通过多种数据源扩展:企业内网、知识库,以及文档。

现在你可能会想:怎样才能像图 6.2 那样,把 ChatGPT(或者 Gemini chat、Claude chat 等)连接到公司的内网、知识库和文档上,从而让你只发送问题本身,而不必再附带文本?很遗憾,你不能直接把标准版 ChatGPT 连接到你的本地数据上。前面那种方案在标准 ChatGPT 上行不通。最接近的替代方式,是使用 ChatGPT Plus,并通过 OpenAI 的 My GPTs 功能配置一个自定义版本,这样你就可以上传文档供后续查询使用。对于简单用例,这种方式很方便。但如果你希望对聊天机器人如何与文本源和 LLM 交互拥有更精细的控制,就需要换一种方法。这时,RAG 设计模式就登场了。

6.1.3 RAG 设计模式

检索增强生成(Retrieval Augmented Generation,RAG) 设计模式,是构建 Q&A 聊天机器人的经典方案。我们先拆开看看 RAG 这几个词分别代表什么:

Retrieval(检索) —— 这一步是从预先准备好的数据源中检索上下文,通常这个数据源是一个为语义搜索优化过的向量存储。检索是 RAG 架构中的关键部分。

Augmented(增强) —— 这意味着答案会因为检索阶段提供的上下文而被改善或增强。

Generation(生成) —— 这指的是生成对你问题的回答。由于本书聚焦于 LLM 和生成式 AI,因此这里的答案生成是由 LLM 执行的,也就要求聊天机器人能够与 LLM 交互。

你可能会想:这些信息到底是从哪里检索出来的?又是怎么检索出来的?关键在于,你必须先把这些信息预处理好,以便你的自定义聊天机器人能够轻松访问它们,并用它们来增强 LLM 生成的答案。RAG 设计模式分为以下两个阶段,接下来几个小节我会进一步拆解:

内容摄取阶段(content ingestion stage,索引阶段 indexing) —— 所有用户将要查询的内容,都会被存入一个专门的数据库中,并以一种特殊格式进行索引,以便后续高效检索(这里的“特殊”具体指什么,我稍后会说明)。

问答阶段(question-answering stage,Q&A;检索与生成阶段 retrieval and generation) —— 聊天机器人接收用户问题,从这个专门的数据库中检索相关信息,再把这些信息连同用户原始问题一起交给 LLM。LLM 生成增强后的答案,并将其返回。

内容摄取阶段(索引)

在用户能够查询 Q&A 聊天机器人之前,你需要先把相关内容——例如来自各种来源和格式的企业文档——存入一个**向量存储(vector store)**中。向量存储是一种经过优化的特殊数据库,专门用于快速搜索和检索,如图 6.3 所示。

image.png

图 6.3 RAG 的内容摄取阶段。文档从各个来源提取出来,被切分成块(chunks),并转换为嵌入(embeddings),随后一同存入向量数据库。该数据库会同时保存原始文本块及其嵌入(向量形式)。

在内容摄取阶段,文本会先从数据源中提取出来,再被切分成较小的块。每个文本块都会被转换成一个嵌入(embedding) ,也就是该文本的数值向量表示。把内容切成块是很关键的,因为嵌入模型只能处理有限长度的内容,而且你也希望搜索命中的是小而精确的相关片段,而不是大段混杂着不同相关性的内容。你可以使用向量存储自带的专有模型(如果它有的话)、LLM 提供方的模型(例如 OpenAI),或者某个专门的 embedding 库来生成这些嵌入。最终,嵌入和对应的文本块会被一同存入向量数据库中。

这些嵌入的作用,是在 Q&A 阶段为内容建立可高效查找的索引。这意味着,用户问题中的文本不需要与结果中的文本字面完全匹配,也能得到相关答案。比如,你在向量存储中查询 “feline animals”,即使文档块中根本没有出现 “feline” 这个词,它仍然可能返回提到 cat、lion、tiger 的文本块。

问答阶段(检索与生成)

一旦信息已经被切分成小块、转换为嵌入并存入向量存储,用户就可以开始向你的 Q&A 聊天机器人提问了。下面我们按照图 6.4 所示的流程,走一遍 Q&A 阶段:

image.png

图 6.4 RAG 的 Q&A 阶段:检索与生成

  • 聊天机器人使用一个 retriever(检索器) 将用户问题转换成嵌入。
  • 检索器使用这个嵌入,在向量存储中执行相似度搜索。
  • 向量存储返回若干相关的文本块。
  • 这些检索到的内容会和原始用户问题一起,被放入提示中,作为“上下文(context)”。
  • 聊天机器人将提示发送给 LLM,后者综合生成一个回答并返回给用户。

当聊天机器人接收到用户问题后,检索器会使用与内容摄取阶段相同的嵌入模型,把这个自然语言查询转换为向量表示。然后,它通过对“问题向量”和“存储在库中的文本块向量”执行相似度搜索,向向量存储发起查询。向量存储会返回最相关的文档块——也就是那些嵌入与查询向量距离最近的文本块,通常会按向量距离进行排序。

当这些相关文本块被检索出来之后,聊天机器人会给 LLM 发送一个提示,其中包含原始问题,以及一个由这些检索片段组成的上下文。LLM 随后生成(在 RAG 术语中也称为综合 synthesize)答案,并把它返回给聊天机器人,再由聊天机器人交付给用户。

最后这一步,其实和“基于单文档的简单问答”那个场景很相似:你仍然是把原始问题和上下文一起提供给 LLM。不同之处在于,之前那个简单场景中的上下文,是整篇输入文本;而现在,上下文则变成了从向量存储中检索出来的若干文本块。也就是说,在“单文档问答”和“基于向量数据库的多文档问答”之间,最大的差异,就是多了向量存储这个组件:它为答案提供了所需信息。之前在简单聊天机器人场景中由 ChatGPT 一个人扮演的角色,现在被拆成了两个部分:一个是负责接收查询并检索信息的编排型聊天机器人,另一个是负责综合生成答案的 LLM

现在你已经理解了 RAG 设计模式的高层架构,我们接下来就来看其中一个最关键的组件:向量存储。之后,你就可以开始第一次真正动手实现 RAG 了。

6.2 向量存储

前面我已经多次提到过向量存储,但都只是简要带过。在这一节中,我会解释:它是什么、它的用途是什么、它提供了哪些能力,以及一些典型例子。

6.2.1 什么是向量存储?

向量存储是一种专门设计用来高效存储和查询高维向量(high-dimensional vectors) 的存储系统。向量在 AI 中非常关键,因为嵌入——也就是文本、图像、声音或视频的数值表示——正是由向量构成的。简而言之,嵌入就是一种向量,它通过自身的各个维度来表达词语的含义。

在 LLM 和机器学习应用中,向量存储最主要的用途,是存储那些作为文本块(或者视频块、图像块、音频块)索引的嵌入。向量存储中的搜索,属于相似度搜索(similarity search) ,它衡量的是“查询的嵌入”与“已存储文本块的嵌入”之间的距离。搜索结果可能是距离最近的那个向量,也可能是一组距离最近的向量列表。这种语义相似性,反映的就是不同文本块在“意义层面”上的接近程度。

6.2.2 向量存储是如何工作的?

向量距离的计算通常会使用一些常见函数,例如 欧氏距离(Euclidean distance)余弦距离(cosine distance)汉明距离(Hamming distance) 。这些距离度量会被用于诸如 k-Nearest Neighbors(KNN) 以及更可扩展的 Approximate Nearest Neighbor(ANN) 搜索算法中,而 ANN 也是当前相似度搜索的标准算法。

提示 这里我不会展开讲解距离度量或相似度搜索算法。如果你感兴趣,可以参考 Yikun Han 等人的这篇学术论文(https://arxiv.org/pdf/2310.11703.pdf),或者 Erika Shorten 写的那篇通俗文章 “Distance Metrics in Vector Search”https://mng.bz/BzQ2)。

最早的一批向量存储(例如 Milvus)出现在 2019 年,最初是为了支持稠密向量搜索(dense vector search) ,主要应用于图像识别。这类存储系统能够高效地保存和比较图像嵌入。之所以称为“稠密向量搜索”,是因为这类向量(也就是 embeddings)的绝大多数维度都具有非零值。

在更早期,像 Term Frequency-Inverse Document Frequency(TF-IDF) 这样的搜索技术使用的是稀疏向量(sparse vectors) ,其中绝大多数值都为零。这类技术用于词汇搜索(lexical search) ,强调的是精确词语匹配,并曾被实现于诸如 Lucene 或 BM25 这样的系统中。

Milvus 最初是为图像嵌入构建的,也就是用向量来表达一张图片的语义。后来,向量存储逐渐扩展到了商品推荐等任务,而随着 LLM 的兴起,新一代向量存储也开始涌现,并专门聚焦于基于文本的语义相似度搜索

6.2.3 向量库与向量数据库

最早的一批向量存储(严格说应叫向量库 vector libraries),例如 Facebook AI Similarity Search(FAISS,由 Meta 开发) ,为了保持简洁,功能都很有限。它们会使用不可变数据结构把嵌入保存在内存中,并提供高效的相似度搜索能力。然而,随着 LLM 的普及,这类库暴露出了一些明显局限:

处理底层文本的问题 —— 向量库只存储 embeddings,因此你必须把原始数据(例如文本或图像)存储到别的地方。这就要求你为每一份数据创建一个唯一标识符,以同步原始文本和它对应的嵌入,从而增加了实现和维护复杂度。

无法更新 —— 向量库使用不可变数据结构,因此不支持更新。这使它们不适合那些数据经常变化、尤其是日内频繁变动的场景。

数据摄取时无法查询 —— 向量库通常不支持在导入数据的同时进行查询,因为同时读写会影响性能与扩展性。

为了弥补这些问题,像 Pinecone 这样的厂商开始推出功能更完善的向量数据库(vector databases)

同时处理文本与嵌入 —— 向量数据库会同时存储文本和相应的 embeddings,从而简化客户端应用的工作流。很多产品甚至还可以帮你生成 embeddings。它们也支持为文本附加元数据,例如来源(provenance)和 lineage 信息。

完整的 CRUD(create, read, update, delete)能力 —— 向量数据库支持更新数据,因此更适合数据频繁变化的场景。

导入期间可查询 —— 向量数据库允许在导入新数据的同时执行相似度搜索,从而提升扩展性与性能。

很快,向量数据库又引入了缓存、分片(sharding)、分区(partitioning)等特性,这些特性让它们在可扩展性、性能、鲁棒性与持久性上越来越接近传统的关系型数据库和 NoSQL 数据库。与此同时,像 PostgreSQL 这样的关系型数据库,以及像 MongoDB 这样的 NoSQL 数据库——它们本来就具备这些传统优势——也通过增加对向量类型的支持作出了适配。这样一来,你就可以把 embeddings 和文本存储在同一条记录或文档中,从而非常方便地把文本和对应嵌入关联起来。在本书后面的内容里,我会把 vector store、vector database 和 vector library 这几个术语交替使用,因为它们在意义上已经越来越趋同。

6.2.4 最流行的向量存储

由于这些系统演进非常快,而且彼此之间不断趋同,因此要制作一张能精确概括主流向量存储特征的表其实并不容易。不过,表 6.1 至少能让你对本书出版时市场上的主流选项有一个大致印象,也能为你后续深入探索提供一个起点。

表 6.1 最流行的向量存储及其相关特征

向量存储类型网站
FAISS向量库https://github.com/facebookresearch/faiss/wiki/
Milvus向量数据库https://milvus.io
Qdrant向量数据库https://qdrant.tech
Chroma向量数据库www.trychroma.com
Weaviate向量数据库https://weaviate.io
Pinecone向量数据库www.pinecone.io
Vald向量数据库https://vald.vdaas.org
ScaNN向量库https://mng.bz/dWaw
KDB时序数据库https://kdb.ai
Elasticsearch搜索引擎www.elastic.co
OpenSearchElasticsearch 的分支https://opensearch.org
PgVectorPostgreSQL 扩展https://github.com/pgvector/pgvector
MongoDB AtlasMongoDB 扩展www.mongodb.com/

6.2.5 使用 Chroma 存储文本并执行语义搜索

在带你构建一个企业级 Q&A 聊天机器人之前,你首先需要学会如何把文本存入向量存储,并对其执行语义搜索。这会帮助你理解基础原理。我们将使用 Chroma,这是一个非常容易上手的向量数据库。你只需要通过 pip 安装相应的 Python 包即可。现在开始吧。

搭建 ChromaDB

首先,你需要为 Chroma 搭建一个 Jupyter Notebook 环境。按照下面的步骤来做:

  1. 为第 6 章代码创建虚拟环境。打开终端,创建一个 ch06 文件夹,进入其中,然后执行:
C:\Github\building-llm-applications\ch06>python -m venv env_ch06

2. 激活虚拟环境:

C:\Github\building-llm-applications\ch06>.\env_ch06\Scripts\activate

你应该看到:

(env_ch06) C:\Github\building-llm-applications\ch06>

3. 安装所需依赖包(notebookchromadbopenai):

pip install -r requirements.txt

4. 如果你已经克隆了 GitHub 仓库,就可以直接用 jupyter notebook 06-chromadb-ingestion-and-querying.ipynb 启动对应 notebook;否则,就从零开始创建:

jupyter notebook

5. 通过 File > New > Notebook 创建一个 notebook,并将其保存为 06-chromadb-ingestion-and-querying.ipynb

在 notebook 中,先导入 chromadb 模块,并创建一个**内存模式(in-memory)**的向量数据库客户端。请注意:这种内存客户端在 notebook 会话结束后会丢失全部数据:

import chromadb
chroma_client = chromadb.Client()

接着,创建一个集合(collection),用于存储来自 Britannica 网站关于 Paestum 的内容(collection 可以理解成一个桶,用来存放文档及其对应的 embeddings):

tourism_collection = chroma_client.create_collection(
    name="tourism_collection")

插入内容

把关于 Paestum 的内容添加进这个 collection 中,并手动将文本切分为一组较小的块(或文档)。Chroma 会使用它的默认 embedding 模型,自动根据你提供的文本生成 embeddings,除非你显式指定别的模型。为了方便展示,这里我缩短了原文;完整版本你可以在我 GitHub 仓库中的 Paestum-Britannica.txt 文件里找到,或者直接查看我放在 GitHub 上的 notebook。存储文本时,附带上元数据(例如每份文档的来源 URL 和关联 ID)会很有帮助,如下面这个代码清单所示。

代码清单 6.1 创建并填充一个 ChromaDB collection

tourism_collection.add(
    documents=[
        "Paestum, Greek Poseidonia, …[shortened] … Greek temples.", 
        "Poseidonia was probably …[shortened] … in the 18th century.",
        "The ancient Greek part of …[shortened] … from the site."
    ],
    metadatas=[
        {"source": "https://www.britannica.com/place/Paestum"}, 
        {"source": "https://www.britannica.com/place/Paestum"},
        {"source": "https://www.britannica.com/place/Paestum"}
    ],
    ids=["paestum-br-01", "paestum-br-02", "paestum-br-03"]
)

运行完这段代码之后,你就可以开始对向量存储执行搜索了。接下来我们就这么做。

执行语义搜索

现在我们来执行一个与之前在 ChatGPT 中做过的查询相似的问题。你可以询问 Paestum 有多少座多立克神庙(Doric temples) ,并明确指定你只想要最接近的一个结果:

results = tourism_collection.query(
    query_texts=["How many Doric temples are in Paestum"],
    n_results=1
)
print(results)

下面是一个简化后的结果示例:

{'ids': [['paestum-br-03']], 'distances': [[0.7664762139320374]], 'metadatas': [[{'source': 'https://www.britannica.com/place/Paestum'}]], 'embeddings': None, 'documents': [['The ancient Greek part of Paestum consists of two sacred areas containing three Doric temples in a remarkable state of preservation. …[SHORTENED] … Paestum’s archaeological museum contains these and other treasures from the site.']]}

Chroma 理解了这个查询的含义,并返回了包含答案的正确文本块,同时也附带返回了来源元数据以及该文本块与查询之间 embedding 距离。

注意 与查询 ChatGPT 不同——在那里你必须同时发送问题和整段原文——查询 Chroma 时,你只需要发送问题即可,因为内容已经预先存储在 ChromaDB 里了。

检查语义接近度

为了看看返回的文本块(paestum-br-03)相对于另两个文本块(paestum-br-01paestum-br-02)而言,究竟和问题有多接近,你可以把返回结果数设为 3:

results = tourism_collection.query(
    query_texts=["How many Doric temples are in Paestum"],
    n_results=3
)
print(results)

你应该会看到类似如下结果:

{'ids': [['paestum-br-03', 'paestum-br-01', 'paestum-br-02']], 'distances': [[0.7664762139320374, 0.8946815729141235, 1.336229681968689]], 'metadatas': [[{'source': 'https://www.britannica.com/place/Paestum'}, {'source': 'https://www.britannica.com/place/Paestum'}, {'source': 'https://www.britannica.com/place/Paestum'}]], 'embeddings': None, 'documents': [['The ancient Greek part of … [SHORTENED]  … the 18th century.']]}

paestum-br-03 的 embedding 与问题的 embedding 距离最近,为 0.76。而 paestum-br-02 最远,为 1.33。这证明 Chroma 正确识别出了最相关的文本块。

注意 向量数据库本身并不会像 ChatGPT 那样直接生成答案。它只会返回与你查询在语义上最接近的文本块。要得到一个表达完整、自然的答案,你仍然需要让一个 LLM 对原始问题和这些检索出的文本块进行处理。这种方式还能节省成本,因为 OpenAI 这类 LLM 厂商是按处理 token 数收费的。

这一节只是让你初步感受一下 Chroma 能做什么。如果你想深入了解,请查看官方文档 https://docs.trychroma.com/,尤其建议关注 Client-Server Mode 那部分,了解如果你希望 Chroma 与 LLM 系统分开部署在不同主机上时,该如何以客户端/服务器模式运行它。既然你现在已经知道如何查询向量存储了,接下来就可以尝试实现完整的 RAG 模式,包括生成完整答案。

以不同方式实例化 ChromaDB

到目前为止,你使用的是 ChromaDB 的本地内存实例。你也可以像下面这样创建一个本地磁盘持久化实例的客户端:

client = chromadb.PersistentClient(path="./chroma_db")
collection = client.create_collection("my_persistent_collection")

或者,你也可以在同一台电脑上、甚至另一台电脑上,通过 HTTP 客户端的方式访问 Chroma。要做到这一点,请打开命令行并执行以下命令(假设你已经通过 pip 安装了 ChromaDB):

chroma run --port 8010

然后,在你的 notebook 或应用中像下面这样实例化 HTTP 客户端:

client = chromadb.HttpClient(host="http://localhost", port=8010)

客户端一旦创建完成,你对它的操作方式就和内存客户端没有区别了。

6.3 从零实现 RAG

现在,我们来真正实现 RAG:构建一个使用 GPT-5-nano 模型和向量数据库的聊天机器人。然后,我们会问它和 6.1.1 节中问过 ChatGPT 的那道关于 Paestum 神庙的同样问题。在使用 ChatGPT 时,你必须同时发送问题和 Britannica 上关于 Paestum 的整段原文。而当你拥有了自己的聊天机器人之后,你就只需要问问题即可,如图 6.5 所示。正如图 6.5 的架构图中展示的那样,这个聊天机器人会去查询 ChromaDB、检索相关内容,并把它们连同原始问题一起发送给 GPT-5-nano,从而生成完整答案。

image.png

图 6.5 包含 ChromaDB 与 GPT-5-nano 模型的 RAG 架构

我们会通过实现几个函数,一步一步把这个聊天机器人搭建起来。首先,导入 OpenAI 库,并设置 OpenAI API key(这里默认你仍然在使用上一节中的同一个 notebook):

from openai import OpenAI
import getpass

OPENAI_API_KEY = getpass.getpass('Enter your OPENAI_API_KEY')

现在实例化 OpenAI 客户端:

openai_client = OpenAI(api_key=OPENAI_API_KEY)

6.3.1 从向量数据库中检索内容

你已经知道如何对向量存储执行语义搜索了。这里我们把这段逻辑封装成一个可复用函数:

def query_vector_database(question):
    results = tourism_collection.query(
        query_texts=[question],
        n_results=1
    )
    results_text = results['documents'][0][0]
    return results_text

我们来用同样的问题试一下这个函数:

results_text = query_vector_database("How many Doric 
↪temples are in Paestum")
print(results_text)

你会看到类似这样的输出:

The ancient Greek part of Paestum consists of two sacred areas containing three Doric temples in a remarkable state of preservation. During the ensuing Roman period a typical forum [SHORTENED]

这正是我们预期的结果:注意,这个检索出来的文本块中正确包含了 “three Doric temples” 这句话。

6.3.2 调用 LLM

接下来,我们需要设计一个提示,把用户问题与从向量数据库中检索出的上下文组合起来,然后提交给 LLM。为简单起见,我们先使用一个基础提示,并把调用 LLM 的代码封装进一个新函数,如下一个代码清单所示。

代码清单 6.2 定义并执行提示的函数

def prompt_template(question, context):
    return f'Read the following text and answer this question: 
    ↪{question}. \nContext: {context}'

def execute_llm_prompt(prompt_input):
    prompt_response = openai_client.chat.completions.create(
        model='gpt-5-nano',
        messages=[
            {"role": "system", "content": "You are an assistant 
            ↪for question-answering tasks."},
            {"role": "user", "content": prompt_input}
        ])
    return prompt_response

使用一个简单的 Q&A 提示

我们先用一个简单的 Q&A 提示来测试这些函数。这里,我们用的就是之前让 ChatGPT 出现幻觉的那个问题:

trick_question = "How many columns have the three temples got in total?"
tq_result_text = query_vector_database(trick_question)
tq_prompt = prompt_template(trick_question, tq_result_text)
tq_prompt_response = execute_llm_prompt(tq_prompt)
print(tq_prompt_response)

我们得到如下输出:

ChatCompletion(id='chatcmpl-CGB90lzMBRKYQgWuHCbVLxFPhbMxU', choices=[Choice( finish_reason='stop', index=0, logprobs=None, message=ChatCompletionMessage( content='The text does not provide the number of columns for the three temples.', refusal=None, role='assistant', annotations=[], audio=None, function_call=None, tool_calls=None))], created=1757972122, model='gpt-5-nano-2025-08-07', object='chat.completion', service_tier='default', system_fingerprint=None, usage=CompletionUsage(completion_tokens=983, prompt_tokens=290, total_tokens=1273, completion_tokens_details= CompletionTokensDetails(accepted_prediction_tokens=0, audio_tokens=0, reasoning_tokens=960, rejected_prediction_tokens=0), prompt_tokens_details= PromptTokensDetails(audio_tokens=0, cached_tokens=0)))

GPT-5-nano 没有发生幻觉。相反,它正确地意识到自己并没有足够的信息来回答这个问题。几个月前,当我用同样的代码去调用 GPT-3.5-turbo 时,它给出了一个错误答案,说总共有 24 根柱子,并且还对每座神庙的柱子数量做出了错误假设。接下来我会向你展示,我当时是怎么修复这个问题的。

使用一个更安全的 Q&A 提示

一般来说,幻觉可以通过使用一种广为人知的 Q&A 提示来缓解。这种提示可以在 LangChain Hub 页面(LangSmith 的一部分)上找到。

提示 LangChain Hubhttps://smith.langchain.com/hub)是一个很受欢迎的 LLM 资源库,会持续更新开源模型、提示模板,以及各种用例建议。我非常推荐你去看看。

这里是来自 https://smith.langchain.com/hub/rlm/rag-prompt 的、用于降低幻觉风险的推荐 RAG 提示:

Use the following pieces of retrieved context to answer the question. If you don’t know the answer, just say that you don’t know. Use three sentences maximum and keep the answer concise.
QUESTION {question}
CONTEXT {context}
ANSWER 

我们现在把 prompt_template 函数更新成这种写法:

def prompt_template(question, text):
    return f'Use the following pieces of retrieved context to 
      ↪answer the question. Only use the retrieved context to 
      ↪answer the question. If you don't know the answer, or 
      ↪the answer is not contained in the retrieved context, 
      ↪just say that you don't know. Use three sentences 
      ↪maximum and keep the answer concise. \nQuestion: 
      ↪{question}\nContext: {text}. Remember: if you do not 
      ↪know, just say: I do not know. Do not make up an 
      ↪answer. For example do not say the three temples have 
      ↪got a total of three columns. \nAnswer:'

现在重新提交那个“陷阱问题”:

trick_question = "How many columns have the three temples got in total?"
tq_result_text = query_vector_database(trick_question)
tq_prompt = prompt_template(trick_question, tq_result_text)
tq_prompt_response = execute_llm_prompt(tq_prompt)
print(tq_prompt_response)

这次得到的输出如下:

ChatCompletion(id='chatcmpl-9nCco9P3xSdArsptotrmJEjtd2N5D', choices=[Choice( finish_reason='stop', index=0, logprobs=None, message=ChatCompletionMessage( content='I do not know.', role='assistant', function_call=None, tool_calls= None))], created=1721513630, model='gpt-4o-mini-2024-07-18', object='chat .completion', service_tier=None, system_fingerprint='fp_8b761cb050', usage= CompletionUsage(completion_tokens=5, prompt_tokens=383, total_tokens=388))

做得很好!你已经成功阻止了 LLM 产生幻觉。现在,你的聊天机器人将只会使用存储在向量数据库中的知识来回答问题;如果数据库中没有答案,它也会明确承认自己不知道。

6.3.3 构建聊天机器人

现在,我们已经可以用一个单独函数来实现这个聊天机器人了。用到的代码,就是本节中已经讲过的这些:

def my_chatbot(question):
    results_text = query_vector_database(question)      #1
    prompt_input = prompt_template(question, 
                                   results_text)  #2
    prompt_output = execute_llm_prompt(
        prompt_input) #3

    return prompt_output
#1 Retrieves content from the vector store
#2 Creates the LLM prompt
#3 Executes the LLM prompt

我们用最初那个问题来测试它:

question = """Let me know how many temples there
are in Paestum, who constructed them, and what 
architectural style they are"""
result = my_chatbot(question)
print(result)

我们得到如下输出:

ChatCompletion(id='chatcmpl-CGBGz5h3kD6006MccRdhPYB7HuwSR', choices=[Choice( finish_reason='stop', index=0, logprobs=None, message=ChatCompletionMessage( content='There are three Doric temples in the ancient Greek part of Paestum. They are the Temple of Athena (Ceres), the Temple of Hera I (Basilica), and the Temple of Hera II (Neptune); Athena and Hera I date from the 6th century BC, while Hera II was probably built about 460 BC. I do not know who constructed them.', refusal=None, role='assistant', annotations=[], audio=None, function_call=None, tool_calls=None))], created=1757972617, model='gpt-5-nano-2025-08-07', object='chat.completion', service_tier= 'default', system_fingerprint=None, usage=CompletionUsage(completion_tokens= 1495, prompt_tokens=398, total_tokens=1893, completion_tokens_details= CompletionTokensDetails(accepted_prediction_tokens=0, audio_tokens=0, reasoning_tokens=1408, rejected_prediction_tokens=0), prompt_tokens_details= PromptTokensDetails(audio_tokens=0, cached_tokens=0)))

这个综合生成的回答相当完整,因为它回答了我们提的所有问题。

你现在应该为自己已经做到的事情感到骄傲!你已经实现了一个基础聊天机器人,它能够基于导入到向量数据库中的文本来回答问题,并在必要时补充相关信息。它不会返回数据库中不存在的信息,因此也就不会产生幻觉或编造答案。

这里最重要的收获是:你现在已经理解了一个基于 Q&A 的 LLM 系统的内部工作原理,以及 RAG 设计模式的组件与工作流。这些知识会在你使用 LangChain、LlamaIndex、Semantic Kernel 这类框架时非常有帮助,因为这些框架可能会把底层实现细节隐藏起来。理解原理之后,你会更容易排查问题,也更容易看懂系统幕后究竟发生了什么。在下一章重新用 LangChain 实现 RAG 之前,我们先回顾一下你到目前为止学到的 RAG 术语。

6.3.4 RAG 术语回顾

在本章中,你一直在学习并不断完善对 RAG 术语的理解。其中一些术语与你之前见过的概念意思相近。表 6.2 会帮助你把这些知识整合起来,尤其是那些可以用不同术语表达的概念。

表 6.2 RAG 术语表

术语定义替代术语
Retrieval Augmented Generation(RAG)一种用例:生成文本(通常是答案)时,会用从某个为语义搜索优化的内容存储中检索出的信息来增强生成效果,这个内容存储通常是向量存储。Q&A
Text chunk来自文档的一段文本片段。文档会被切分为 chunks,以便更有效地搜索,尤其是在向量存储这类面向非结构化文本的专用存储中。Text fragment,chunk,text node,node
Embeddings文本片段的数值(向量)表示,用于给文本块建立语义搜索索引。Vector
RAG content ingestion stageRAG 设计中的一个阶段:把文本导入并索引到一个上下文存储中,以便针对自然语言问题进行高效检索。在向量存储中,文本会被切成块,并通过对应的 embeddings 建立索引。Text indexing,text vectorization,indexing stage
Vector store一种内存存储或专用数据库,用来保存文本块及其对应的 embeddings,而 embeddings 就是这些文本块的索引。Vector database
Semantic similarity基于“含义”比较文本片段,通常通过计算不同文本 embeddings 之间的距离来完成,可使用余弦距离或欧氏距离。Vector similarity,cosine similarity
Semantic search基于“含义”来搜索信息。它通过比较搜索问题的 embeddings 与向量存储中文本块 embeddings 之间的语义相似性来完成。Q&A,vector search
Context在提示中与用户问题一同提供的文本(或信息),用来形成答案。它可以是一整篇文档,也可以是一组从向量存储中通过语义搜索检索到的文本块。
Synthesize生成答案,通常是根据用户问题以及提供所需信息的上下文来完成。Generate
RAG question-answering stageRAG 设计中的一个阶段:用户提出一个搜索问题,应用对某个内容存储(通常是向量存储)执行语义搜索,并把原始问题连同检索到的上下文一起交给 LLM。LLM 综合生成答案并返回给应用,再由应用传递给用户。RAG Q&A stage;retrieval and generation stage

现在,你已经准备好用 LangChain 来重新实现 RAG 了。我们将在下一章处理这件事。

小结

  • 基础的 Q&A 聊天机器人会在一个提示中,直接把问题和支持性文档一并传给 LLM。这种方式适合简单场景,但无法扩展到大型知识库。
  • 检索增强生成(RAG) 系统能够跨大型知识库回答问题。它通过向量搜索来找到相关文档,再由 LLM 进行综合生成,从而输出连贯答案。
  • RAG 运行分为两个顺序阶段:摄取阶段(ingestion) 会把文档转换为 embeddings 并存入向量数据库;检索阶段(retrieval) 则根据查询 embedding 找到相似文档,并将其传给 LLM。
  • 向量存储是一类为 embedding 相似度搜索而优化的数据库。它会同时存储文本块及其向量表示,并在给定查询时返回语义上最接近的结果。
  • 像 ChromaDB 和 Pinecone 这样的平台提供持久化存储和高级索引策略,因此能够在数百万文档上进行高效检索。你需要根据规模、延迟要求和部署约束来选择它们。
  • RAG 系统通常需要集成三类 API:embedding 模型 API(如 OpenAI、Cohere、Google Vertex AI)、向量存储连接,以及用于答案生成的 LLM API。你需要分别为这些服务配置 API key 和 endpoint。
  • 摄取阶段和检索阶段使用的 embedding 维度必须一致。例如 OpenAI 的 text-embedding-3-small 使用 1,536 维;如果你切换模型,就必须为整套语料重新生成 embeddings。
  • RAG 流水线的链路如下:
    问题 → 生成查询 embedding → 搜索向量存储 → 检索文档 → 插入提示 → 用 LLM 生成答案
  • RAG 的准确性取决于文本块质量和检索相关性。分块策略不好(比如句子被切断、上下文残缺)时,即使检索本身做得很准,结果也会变差。
  • 你应该测试不同的 k 值(通常在 2 到 8 之间),具体取值取决于你的内容密度和上下文窗口大小。可以先从 k=4 作为基线开始,再根据答案质量做优化。