DLAI-向量数据库应用构建笔记-全-

DLAI 向量数据库应用构建笔记(全)

001:课程介绍 🚀

在本课程中,我们将学习如何使用向量数据库构建多种类型的应用程序。向量数据库已成为结合大型语言模型(特别是RAG,即检索增强生成)构建应用的关键基础设施。不仅如此,其应用范围实际上比普遍认知的更为广泛。

课程概述

向量数据库允许你输入一组向量(例如,在实现RAG时文档的嵌入向量),然后向其发送查询(例如,一个新文本查询的嵌入向量),以检索与你的查询相关的文档。正是这种能力,使得向量数据库成为RAG的关键组件,用于为LLM获取额外的上下文以生成响应。

然而,这种获取相似向量的能力,也使其对许多其他应用非常有用。例如,在图像相似性搜索中,你可以计算图像的嵌入向量,然后使用向量数据库快速找到相似图像。或者,你可以通过检查一个新项目是否有任何相似项来进行异常检测,如果没有,则它可能是一个异常点。

你将学习构建的应用

以下是本课程将涵盖的六个核心应用示例:

  1. 基础文本语义搜索:你将从为文本文档构建一个基础的语义搜索应用开始。
  2. RAG应用:接下来,你将构建一个检索增强生成应用。
  3. 推荐系统:然后,你将构建一个推荐系统。
  4. 混合搜索产品推荐:你将实现一个用于产品推荐的混合搜索应用。
  5. 子-父图像相似性应用:你将构建一个基于图像相似性的子-父关系应用。
  6. 异常检测应用:最后,你将基于服务器日志数据集构建一个异常检测应用。

通过这些示例,你将学习如何使用向量数据库来存储和帮助你操作多种不同的数据。我们将从维基百科文本、人脸图片、问答文本、网络日志形式的结构化数据以及包含配对图像和文本的时尚数据中提取示例。

核心技术:混合搜索

你将学习到的一个很酷的技术是混合搜索。在这种技术中,你可以使用向量数据库来操作同时包含稀疏和密集分量的向量。

例如,在产品推荐场景中,你将看到如何同时使用服装图像的密集嵌入向量和文本描述的稀疏嵌入向量,以及如何调节一个控制向量稀疏部分与密集部分相对权重的“旋钮”。

其核心思想可以概括为:
混合向量 = α * 稀疏向量 + β * 密集向量
其中,α 和 β 是权重参数,用于平衡两种表示形式的影响。

课程资源与致谢

本课程是与Pinecone合作构建的。许多人为此课程的创建付出了努力,感谢Pinecone团队的James Bgg、Ra Shihasha和Bear Douglas,以及DeepLearning.AI的Dila Eadin和Eshma Gagari所做的贡献。

总结

在本介绍课中,我们一起了解了向量数据库的广泛应用前景以及本课程将涵盖的六个实践项目。从下一课开始,你将首先探索语义搜索的基础知识,这将是贯穿本课程其他课程的核心技能。掌握了向量数据库的基础知识并完成了这些应用构建后,相信你会发现更多值得探索的可能性。让我们进入下一个视频,开始学习吧!

002:语义搜索 🔍

概述

在本节课中,我们将学习语义搜索的基本概念,并动手构建一个简单的语义搜索应用程序。我们将使用Pinecone向量数据库和Sentence Transformers模型,将文本数据转化为向量嵌入,并基于语义相似性进行检索。


什么是语义搜索?🤔

语义搜索是一种专注于搜索内容含义的搜索方式,这与词法搜索形成对比。

词法搜索寻找的是字符串的字面或模式匹配。

语义搜索是一个极其强大的构建模块,它是我们所见的大多数从文本到生成式AI应用的基础。

在接下来的课程中,我们将介绍向量数据库和语义搜索的基础知识。


构建语义搜索应用 🛠️

上一节我们介绍了语义搜索的概念,本节中我们来看看如何构建一个具体的应用。

我们将构建一个非常简单的语义搜索应用程序,使用Pinecone。

我们的流程是:从左侧的用户开始,下载一个核心数据集,将其转化为一系列向量嵌入,并存储在Pinecone中。

然后,我们将构建一个简单的问答响应系统。例如,我们可以提问“哪个国家人口最多?”。这将是一个足够通用的应用程序,你也可以向它提出多个问题。


准备数据与环境 📥

以下是构建应用的第一步:导入必要的库并准备数据。

import warnings
warnings.filterwarnings('ignore')
from sentence_transformers import SentenceTransformer
from deeplearningai_utils import DLIUtils
from tqdm import tqdm
import pandas as pd

我们下载Quora数据集的一个子集,以便管理。

data = pd.read_csv('quora_questions.csv')
data_subset = data.iloc[240000:290000]

查看数据的前几行,确认数据结构。

print(data_subset.head())

数据中包含问题和问题标识符,看起来可用。接下来,我们提取所有问题。

questions = []
for q in data_subset['question']:
    questions.append(q)
all_questions = '\n'.join(questions)
print('-' * 50)
print(all_questions[:500]) # 打印前500个字符作为示例

我们准备了大约100,000个问题。现在,开始将它们转化为嵌入向量。


生成向量嵌入 🔢

根据你的设备,你可能可以使用CUDA加速。如果没有,也没关系,因为我们的数据集不大。

我们使用Sentence Transformer模型将数据转化为嵌入向量。

device = 'cuda' if torch.cuda.is_available() else 'cpu'
model = SentenceTransformer('all-MiniLM-L6-v2', device=device)

创建一个示例问题并查看其嵌入向量的维度。

sample_question = "What is the capital of France?"
sample_embedding = model.encode(sample_question)
print(f"嵌入向量维度: {sample_embedding.shape}") # 应为 (384,)

现在,我们有了嵌入向量和数据,可以开始使用Pinecone了。


连接与配置Pinecone 🌲

我们使用一个辅助工具DLIUtils来管理API密钥。

utils = DLIUtils()
api_key = utils.get_pinecone_api_key()

连接到Pinecone。Pinecone V3采用了单例对象模式。

import pinecone
pinecone.init(api_key=api_key)

进行一些清理工作,删除可能存在的旧索引,然后创建新索引。

index_name = utils.create_index_name()
if index_name in pinecone.list_indexes():
    pinecone.delete_index(index_name)

pinecone.create_index(
    name=index_name,
    dimension=384,
    metric='cosine',
    spec=pinecone.ServerlessSpec(cloud='aws', region='us-west-2')
)

index = pinecone.Index(index_name)
print(index)

索引创建完成后,我们就可以上传数据了。


上传数据到Pinecone ⬆️

我们将分批上传数据,每批200条,总共上传10,000条。

以下是上传数据的步骤:

  1. 获取数据子集。
  2. 遍历问题。
  3. 为每个向量生成唯一ID。
  4. 在元数据中存储原始问题文本。
  5. 使用模型生成向量嵌入。
  6. 将ID、向量和元数据打包成元组。
  7. 使用zip函数整理数据并上传到Pinecone。
batch_size = 200
max_vectors = 10000
questions_to_upload = questions[:max_vectors]

for i in tqdm(range(0, len(questions_to_upload), batch_size)):
    i_end = min(i + batch_size, len(questions_to_upload))
    batch_ids = [str(x) for x in range(i, i_end)]
    batch_metadata = [{'text': q} for q in questions_to_upload[i:i_end]]
    batch_embeddings = model.encode(questions_to_upload[i:i_end]).tolist()
    vectors_to_upsert = zip(batch_ids, batch_embeddings, batch_metadata)
    index.upsert(vectors=list(vectors_to_upsert))

上传完成后,检查索引状态。

print(index.describe_index_stats())

应该看到我们有10,000个向量。


执行语义查询 ❓

现在进入最激动人心的部分:提问。我们定义一个简单的查询函数。

该函数执行以下操作:

  1. 接收文本问题。
  2. 将其转化为嵌入向量。
  3. 在Pinecone索引中查询最相似的K个结果。
  4. 返回并显示相关的文本问题。
def run_query(query_text, top_k=10):
    query_embedding = model.encode(query_text).tolist()
    results = index.query(
        vector=query_embedding,
        top_k=top_k,
        include_metadata=True,
        include_values=False
    )
    for match in results['matches']:
        print(f"相似度: {match['score']:.4f} - 问题: {match['metadata']['text']}")

让我们运行几个查询来测试系统。

print("查询: 哪个城市人口最多?")
run_query("Which city has the highest population in the world?")
print("\n" + "-"*50 + "\n")

print("查询: 如何制作巧克力蛋糕?")
run_query("How do I make chocolate cake?")

系统会返回语义上相似的问题,例如“世界上最美的城市是哪个?”或“如何制作美味的蛋糕?”,这证明了我们基于含义的搜索是有效的。


总结 🎯

本节课中我们一起学习了语义搜索的原理,并从头到尾构建了一个语义搜索系统。

我们使用Pinecone向量数据库存储了从Quora问题标题生成的嵌入向量,并创建了一个可扩展、可重复使用的查询系统。

在下一节课中,我们将使用Pinecone和OpenAI构建一个检索增强生成系统。

003:检索增强生成 (RAG) 🧠

在本节课中,我们将使用 Pinecone 和 OpenAI 构建一个 RAG(检索增强生成)系统。我们将处理一个维基百科文章样本数据集,为文章创建向量嵌入,然后从 Pinecone 进行简单的文档检索以查看搜索结果。最后,我们将利用 OpenAI 基于这些检索结果生成一篇结构清晰、内容精炼的文章。

概述

我们将构建一个经典的 RAG 系统。系统流程如下:用户提出一个问题(例如“柏林墙是什么?”),系统会从我们预先准备并存储在 Pinecone 中的数据集中检索相关文档。与之前课程不同,这次我们不仅会得到长文档,还会通过提示工程,将这些检索结果发送给 OpenAI,从而获得一个经过总结、书写优美的回答。这就是 RAG 的核心思想。

准备工作

首先,我们导入必要的包并设置环境。

import warnings
warnings.filterwarnings('ignore')

我们导入所需的包,包括一个用于管理 OpenAI 和 Pinecone 密钥的实用工具包 dlai_utils

import dlai_utils

接下来,设置 Pinecone。我们获取 API 密钥并连接到 Pinecone 服务。

# 获取 Pinecone API 密钥
pc_api_key = dlai_utils.get_pinecone_api_key()

# 连接到 Pinecone
import pinecone
pinecone.init(api_key=pc_api_key, environment='us-west1-gcp')

然后,我们获取索引名称,如果索引已存在则删除它,再创建一个新索引,并获取指向该索引的指针。

# 获取索引名称
index_name = dlai_utils.get_index_name()

# 删除已存在的索引(如果存在)
if index_name in pinecone.list_indexes():
    pinecone.delete_index(index_name)

# 创建新索引
pinecone.create_index(name=index_name, dimension=1536, metric='cosine')

# 获取索引对象
index = pinecone.Index(index_name)

加载与查看数据

数据已预先准备好。我们下载一个包含维基百科文章的压缩 CSV 文件,解压后使用 pandas 将其加载为 DataFrame。

# 下载数据文件
!wget -q -O lesson2_wiki.csv.zip https://example.com/path/to/file # 示例URL

# 解压文件
import zipfile
with zipfile.ZipFile("lesson2_wiki.csv.zip", 'r') as zip_ref:
    zip_ref.extractall(".")

# 使用 pandas 读取 CSV 文件
import pandas as pd
df = pd.read_csv("lesson2_wiki.csv")

# 查看数据前几行
print(df.head())

数据包含以下几列:

  • id: 数据的唯一标识符。
  • metadata: 包含文章来源和内容的元数据。
  • values: 向量嵌入本身,即一列浮点数。

准备并上传向量嵌入

现在,我们将准备数据格式并将其上传到 Pinecone 索引中。

以下是准备每条向量记录的过程:

from ast import literal_eval
import tqdm

prepped = []
for i, row in tqdm.tqdm(df.iterrows(), total=len(df)):
    # 从字符串解析元数据字典
    metadata = literal_eval(row['metadata'])
    # 构建符合 Pinecone 格式的向量记录
    vector_record = (
        row['id'],           # 唯一ID
        row['values'],       # 向量嵌入值
        metadata             # 元数据
    )
    prepped.append(vector_record)

一个 Pinecone 向量记录本质上是一个包含三部分的元组:

  1. id: 唯一标识符。
  2. values: 向量嵌入(浮点数列表)。
  3. metadata: 与向量关联的元数据(例如文章信息)。

为了高效上传,我们将数据分批处理,每批 200 个向量。

batch_size = 200

for i in range(0, len(prepped), batch_size):
    # 获取当前批次
    i_end = min(i + batch_size, len(prepped))
    batch = prepped[i:i_end]
    # 上传批次到索引
    index.upsert(vectors=batch)

print("数据上传完成。")

上传完成后,我们可以验证索引中的向量数量。

# 描述索引统计信息
stats = index.describe_index_stats()
print(f"索引中共有 {stats['total_vector_count']} 个向量。")

设置 OpenAI 并执行查询

现在,我们设置 OpenAI 来生成嵌入和完成文本。

# 获取 OpenAI API 密钥
openai_api_key = dlai_utils.get_openai_api_key()

# 设置 OpenAI 客户端
import openai
openai.api_key = openai_api_key

# 定义一个辅助函数来获取文本的嵌入向量
def get_embedding(texts, model="text-embedding-ada-002"):
    response = openai.Embedding.create(input=texts, model=model)
    return [data['embedding'] for data in response['data']]

一切就绪,我们可以开始执行查询了。让我们以“柏林墙是什么?”为例。

首先,为查询问题生成向量嵌入。

query = "What is the Berlin Wall?"
query_embedding = get_embedding([query])[0]

接着,在 Pinecone 索引中查询最相似的文档。

# 查询 Pinecone
results = index.query(
    vector=query_embedding,
    top_k=3,                # 返回最相似的3个结果
    include_metadata=True   # 在结果中包含元数据
)

# 从结果中提取文本内容
context_texts = [match['metadata']['text'] for match in results['matches']]
# 将文本用换行符连接以便查看
print("\n--- 检索到的文档片段 ---\n")
print("\n---\n".join(context_texts))

构建提示并进行总结

上一节我们介绍了如何从 Pinecone 检索相关文档。本节中,我们来看看如何利用这些文档,通过提示工程构建一个查询,并让 OpenAI 生成一篇总结性文章。

我们从 Pinecone 返回的结果中提取文本,作为生成回答的上下文。

以下是构建提示的步骤:

# 构建提示工程
prompt_start = "Answer the question based on the context below.\n\nContext:\n"
prompt_end = f"\n\nQuestion: {query}\nAnswer:"

# 将上下文文本连接起来
context = "\n\n---\n\n".join(context_texts)

# 组合成完整的提示
prompt = prompt_start + context + prompt_end

![](https://github.com/OpenDocCN/dsai-notes-pt1-zh/raw/master/docs/dlai-vecdb-appbd/img/5c4d6b14ac8318338d2d3339b85c5e23_6.png)

print("\n--- 发送给 OpenAI 的完整提示 ---\n")
print(prompt)

现在,我们将这个精心构建的提示发送给 OpenAI 的完成 API,以生成最终的回答。

# 调用 OpenAI API 生成回答
response = openai.Completion.create(
    model="gpt-3.5-turbo-instruct",  # 使用的模型
    prompt=prompt,
    max_tokens=1500,                  # 生成回答的最大长度
    temperature=0.7                   # 控制创造性的参数
)

# 提取并打印生成的回答
generated_answer = response.choices[0].text.strip()
print("\n" + "="*50)
print("生成的总结文章:")
print("="*50 + "\n")
print(generated_answer)

总结

在本节课中,我们一起学习了如何构建一个完整的 RAG 系统。我们首先将文档数据转换为向量嵌入并存储到 Pinecone 中。当用户提出问题时,系统会从 Pinecone 检索出最相关的文档片段。然后,我们通过提示工程,将这些片段作为上下文与问题一起组合成完整的提示,发送给 OpenAI。最终,我们获得了一篇基于检索内容、书写流畅的总结性文章。这个过程展示了如何将检索能力与大型语言模型的生成能力相结合,以产生更准确、信息更丰富的回答。在下一节课中,我们将探索如何构建一个基础的推荐系统。

004:推荐系统 📚

在本节课中,我们将基于之前课程所学,构建一个简单的新闻文章推荐系统。我们将使用一个新闻文章样本数据集,首先基于文章标题生成向量嵌入并构建推荐系统,然后进一步扩展到基于文章内容本身来构建推荐系统。


数据准备与导入

首先,我们需要导入必要的库并设置环境。与之前的课程类似,我们将使用DeepLearning.AI和Pinecone的相关工具包。

# 导入必要的包
import warnings
warnings.filterwarnings('ignore')
from deeplearning_ai_utils import *
import pandas as pd
from tqdm.auto import tqdm
import openai

接下来,设置Pinecone和OpenAI的API密钥,并连接到Pinecone服务。

# 设置Pinecone和OpenAI
dlai_tools = DLAIUtils()
PINECONE_API_KEY = dlai_tools.get_pinecone_api_key()
OPENAI_API_KEY = dlai_tools.get_openai_api_key()

openai.api_key = OPENAI_API_KEY

我们将使用一个名为“all-the-news-3.zip”的数据集,其中包含新闻文章。下载并解压后,我们查看其结构。

# 加载数据
df = pd.read_csv('all-the-news-3.csv', nrows=99)
print(df.head())

数据包含日期、作者、标题和文章内容等列。我们主要关注titlearticle两列。


基于标题的推荐系统 🏷️

上一节我们准备好了数据,本节中我们来看看如何基于文章标题构建推荐系统。核心思路是将文章标题转换为向量嵌入,存储到Pinecone中,然后通过查询找到最相关的标题。

创建Pinecone索引

以下是创建和准备Pinecone索引的步骤。

# 获取索引名称并连接到Pinecone
index_name = dlai_tools.get_pinecone_index('news')
pinecone.init(api_key=PINECONE_API_KEY, environment='us-west1-gcp')

# 如果索引已存在则删除,然后重新创建
if index_name in pinecone.list_indexes():
    pinecone.delete_index(index_name)
pinecone.create_index(name=index_name, dimension=1536, metric='cosine')
index = pinecone.Index(index_name)

生成嵌入并上传数据

我们需要一个函数来获取文本的嵌入向量。然后,我们将分批读取数据,为每个标题生成嵌入,并上传到Pinecone。

以下是生成嵌入的辅助函数:

def get_embeddings(articles):
    response = openai.Embedding.create(
        model="text-embedding-ada-002",
        input=articles
    )
    return [data['embedding'] for data in response['data']]

以下是准备和插入数据的主要循环逻辑:

# 分批读取数据并上传嵌入
chunk_size = 400
max_rows = 20000
prep = []
chunk_num = 0

![](https://github.com/OpenDocCN/dsai-notes-pt1-zh/raw/master/docs/dlai-vecdb-appbd/img/b5af4cc71a6feac4286ee55a49056fa9_8.png)

with tqdm(total=max_rows) as pbar:
    for chunk in pd.read_csv('all-the-news-3.csv', chunksize=chunk_size, nrows=max_rows):
        titles = chunk['title'].tolist()
        embeds = get_embeddings(titles)
        for i in range(len(titles)):
            prep.append((str(chunk_num), embeds[i], {'title': titles[i]}))
            chunk_num += 1
        if len(prep) >= 300:
            index.upsert(vectors=prep)
            prep = []
        pbar.update(len(chunk))
    if prep:  # 上传剩余向量
        index.upsert(vectors=prep)

这个过程将20,000个标题的向量嵌入上传到了Pinecone。

执行查询与获取推荐

现在,我们可以定义一个函数来根据查询词获取推荐。

def get_recommendations(index, search_term, top_k=10):
    # 获取查询词的嵌入
    query_embedding = get_embeddings([search_term])[0]
    # 查询Pinecone索引
    results = index.query(vector=query_embedding, top_k=top_k, include_metadata=True)
    return results

让我们搜索与“Obama”相关的文章标题。

# 获取关于“Obama”的推荐
recommendations = get_recommendations(index, "Obama")
for match in recommendations['matches']:
    print(f"Score: {match['score']:.4f}")
    print(f"Title: {match['metadata']['title']}")
    print()

系统会返回与“Obama”最相关的文章标题及其相似度分数。


基于文章内容的推荐系统 📄

基于标题的搜索效果很好,但有时标题不能完全反映文章内容。本节中,我们将构建一个更强大的系统,基于整篇文章的内容进行推荐。

重新准备索引与数据

首先,我们需要清除旧的索引,并创建一个新的来存储基于内容的嵌入。

# 删除旧索引并创建新索引
if index_name in pinecone.list_indexes():
    pinecone.delete_index(index_name)
pinecone.create_index(name=index_name, dimension=1536, metric='cosine')
index = pinecone.Index(index_name)

处理文章内容并分块

由于文章可能很长,我们需要将其分割成更小的块(chunks),然后为每个块生成嵌入。这里使用LangChain的文本分割器。

from langchain.text_splitter import RecursiveCharacterTextSplitter

text_splitter = RecursiveCharacterTextSplitter(
    chunk_size=400,
    chunk_overlap=20,
    length_function=len,
)

接下来,我们定义一个函数来处理文章块并管理向量的上传。

def embed_chunks(text_chunks, titles, prep_list, embed_counter):
    # 为文本块生成嵌入
    embeds = get_embeddings(text_chunks)
    for i in range(len(text_chunks)):
        # 准备向量数据,包含标题和原始文本块
        prep_list.append((
            str(embed_counter),
            embeds[i],
            {'title': titles[i], 'text': text_chunks[i]}
        ))
        embed_counter += 1
        # 达到批次大小时上传
        if len(prep_list) >= 300:
            index.upsert(vectors=prep_list)
            prep_list.clear()
    return embed_counter, prep_list

现在,遍历数据集,处理每篇文章。

prep = []
embed_counter = 0
seen_titles = set()  # 用于去重

for idx, row in tqdm(df.iterrows(), total=min(1000, len(df))):  # 处理前1000行作为示例
    article = row['article']
    title = row['title']
    if pd.isna(article):
        continue
    # 分割文章内容
    chunks = text_splitter.split_text(article)
    # 为每个块生成嵌入并准备上传
    embed_counter, prep = embed_chunks(chunks, [title]*len(chunks), prep, embed_counter)

# 上传最后一批向量
if prep:
    index.upsert(vectors=prep)

这个过程将文章内容分块并上传了约10,500个向量嵌入。

执行基于内容的查询

查询函数与之前类似,但返回结果后我们需要根据标题进行去重,以避免同一篇文章的不同块被重复显示。

def get_content_recommendations(index, search_term, top_k=10):
    query_embedding = get_embeddings([search_term])[0]
    results = index.query(vector=query_embedding, top_k=top_k*2, include_metadata=True)  # 多查一些用于去重
    return results

# 获取基于内容的推荐
recommendations = get_content_recommendations(index, "Obama")
seen = {}
for match in recommendations['matches']:
    title = match['metadata']['title']
    if title not in seen:
        print(f"Score: {match['score']:.4f}")
        print(f"Title: {title}")
        print(f"Snippet: {match['metadata']['text'][:200]}...")  # 打印内容片段
        print()
        seen[title] = True
    else:
        print(f"Already seen: {title}")

这次返回的结果是基于文章内容与“Obama”的相关性,而不仅仅是标题,因此结果集会有所不同。


总结 🎯

本节课中我们一起学习了如何利用向量数据库构建两种类型的推荐系统:

  1. 基于标题的推荐:将文章标题转换为向量,实现快速的相关标题检索。
  2. 基于内容的推荐:将长篇文章分割成块,为每个内容块创建向量嵌入,从而实现更深入、更准确的语义搜索,并能有效去重。

我们掌握了使用Pinecone存储嵌入、使用OpenAI生成嵌入以及使用LangChain处理文本的核心流程。在下一节课中,我们将探索混合搜索,结合关键词匹配和向量搜索的优势,以构建更强大的检索系统。

005:混合搜索 🔍

在本节课中,我们将学习混合搜索。你将利用Pinecone的一项功能,该功能允许索引条目同时拥有稠密向量和稀疏向量。这意味着我们可以同时对稀疏向量和稠密向量进行搜索。为此,我们将利用BM25和CLIP从时尚产品数据中生成向量,以便同时搜索文本和图像产品描述。

概述

上一节我们介绍了基础的向量搜索。本节中,我们将看看如何结合两种不同类型的向量表示——稀疏向量和稠密向量——来进行更强大的混合搜索。我们将使用一个时尚产品数据集,通过BM25算法生成稀疏向量,通过CLIP模型生成稠密向量,并将它们一同存储在Pinecone中。

环境设置与数据准备

首先,我们需要导入必要的库并设置环境。

import warnings
warnings.filterwarnings(‘ignore’)
# 导入所需包,包括用于生成稀疏向量的BM25编码器
from pinecone_text.sparse import BM25Encoder

接着,我们获取Pinecone API密钥并连接到索引。这次,我们将相似度计算指标从余弦相似度改为点积。

# 创建Pinecone索引,使用点积作为相似度度量
index_name = “hybrid-search-demo”
if index_name in pinecone.list_indexes():
    pinecone.delete_index(index_name)
pinecone.create_index(name=index_name, metric=“dotproduct”, dimension=512)
index = pinecone.Index(index_name)

现在,让我们载入数据集。我们使用Hugging Face上的小型时尚产品图像数据集。

from datasets import load_dataset
fashion = load_dataset(“ashraq/fashion-product-images-small”, split=“train”)
# 查看数据集结构
print(fashion)

查看一下数据样本和元数据。

# 查看一条数据样本
sample = fashion[900]
print(sample)
# 将元数据转换为Pandas DataFrame以便查看
import pandas as pd
metadata = fashion.remove_columns([‘image’]).to_pandas()
print(metadata.head())

我们的数据集包含产品ID、性别、主类别、子类别和文章类型等信息。

生成稀疏向量与稠密向量

接下来,我们将为数据生成两种向量。

稀疏向量:BM25编码

BM25是一种基于词频的信息检索技术,用于生成文本的稀疏向量表示。

以下是创建和训练BM25编码器的步骤:

# 1. 初始化BM25编码器
bm25 = BM25Encoder()
# 2. 使用产品显示名称数据来拟合编码器
bm25.fit(metadata[‘productDisplayName’])
# 3. 编码一个示例查询
sparse_query_vector = bm25.encode_queries(“dark blue French connection jeans for men”)
print(“稀疏查询向量示例:”, sparse_query_vector)
# 4. 编码文档(产品名称)以用于索引
sparse_doc_vectors = bm25.encode_documents(metadata[‘productDisplayName’].tolist())

稠密向量:CLIP编码

CLIP是一个由OpenAI开发的神经网络,能够理解图像和文本的关联。我们将使用sentence-transformers库中的CLIP模型来生成图像的稠密向量。

以下是生成稠密向量的步骤:

from sentence_transformers import SentenceTransformer
# 1. 加载CLIP模型
model = SentenceTransformer(‘clip-ViT-B-32’)
# 2. 为图像生成稠密向量
# 注意:模型.encode()方法可以直接处理图像
dense_vector = model.encode([fashion[0][‘image’]])
print(“稠密向量维度:”, dense_vector.shape)

上传混合向量到Pinecone

现在,我们将把生成的稀疏向量和稠密向量一同上传到Pinecone索引中。这是混合搜索的核心:每个数据条目同时拥有两种向量表示。

以下是批量上传向量的代码流程:

from tqdm.auto import tqdm

![](https://github.com/OpenDocCN/dsai-notes-pt1-zh/raw/master/docs/dlai-vecdb-appbd/img/6a1ce69c6956fd8e6c2fe9856b38033e_10.png)

![](https://github.com/OpenDocCN/dsai-notes-pt1-zh/raw/master/docs/dlai-vecdb-appbd/img/6a1ce69c6956fd8e6c2fe9856b38033e_11.png)

batch_size = 200

for i in tqdm(range(0, len(fashion), batch_size)):
    # 获取批次数据
    i_end = min(i+batch_size, len(fashion))
    batch = fashion[i:i_end]
    # 提取元数据
    meta_batch = batch.remove_columns([‘image’])
    meta_dict = meta_batch.to_pandas().to_dict(orient=‘records’)
    # 提取图像
    image_batch = [item[‘image’] for item in batch]
    # 为批次生成稀疏向量
    sparse_embeds = bm25.encode_documents([item[‘productDisplayName’] for item in meta_dict])
    # 为批次生成稠密向量
    dense_embeds = model.encode(image_batch).tolist()
    # 创建唯一ID
    ids = [item[‘id’] for item in meta_dict]
    # 准备上传数据,同时包含稀疏和稠密向量
    upserts = []
    for _id, sparse_vec, dense_vec, metadata in zip(ids, sparse_embeds, dense_embeds, meta_dict):
        upserts.append({
            ‘id’: str(_id),
            ‘sparse_values’: sparse_vec,
            ‘values’: dense_vec,
            ‘metadata’: metadata
        })
    # 上传到Pinecone
    index.upsert(vectors=upserts)

执行混合搜索查询

数据上传完毕后,我们就可以执行混合搜索了。关键在于查询时同时提供稀疏和稠密向量表示,并通过alpha参数控制两者的权重。

让我们搜索“男士深蓝色French Connection牛仔裤”。

以下是执行查询的步骤:

# 1. 为查询文本生成稀疏向量
query_text = “dark blue French connection jeans for men”
sparse_query_vec = bm25.encode_queries(query_text)
# 2. 为查询文本生成稠密向量(CLIP也可以编码文本)
dense_query_vec = model.encode([query_text]).tolist()[0]

# 3. 执行混合搜索查询,初始alpha设为0.5(均衡权重)
alpha = 0.5
from pinecone import HybridQueryVector
query_vector = HybridQueryVector(
    sparse=sparse_query_vec,
    dense=dense_query_vec,
    alpha=alpha
)
results = index.query(
    vector=query_vector,
    top_k=10,
    include_metadata=True
)

为了直观展示结果,我们创建一个辅助函数来显示返回的产品图片。

from IPython.display import Image, display
import io

def display_results(query_results):
    for match in query_results[‘matches’]:
        # 从元数据中获取图像ID并找到对应图像
        img_id = match[‘metadata’][‘id’]
        # 这里需要根据你的数据结构调整获取图像的逻辑
        # 假设我们有一个根据ID获取图像的函数 get_image_by_id
        # img = get_image_by_id(img_id)
        # display(Image(img))
        print(f”ID: {img_id}, Score: {match[‘score’]:.2f}, 产品名: {match[‘metadata’].get(‘productDisplayName’, ‘N/A’)}“)
# 显示结果
display_results(results)

调整Alpha参数以平衡搜索

alpha参数控制着稀疏向量(关键词匹配)和稠密向量(语义匹配)在最终搜索分数中的权重比例。

  • alpha = 1:仅使用稠密向量(纯语义搜索)。
  • alpha = 0:仅使用稀疏向量(纯关键词搜索)。
  • 0 < alpha < 1:混合两者,值越接近1,语义匹配权重越高。

以下是如何使用alpha参数调整搜索的示例:

def hybrid_scale(dense_vec, sparse_vec, alpha: float):
    # 缩放稠密向量
    scaled_dense = [v * alpha for v in dense_vec]
    # 缩放稀疏向量
    scaled_sparse = {
        ‘indices’: sparse_vec[‘indices’],
        ‘values’: [v * (1 - alpha) for v in sparse_vec[‘values’]]
    }
    return scaled_dense, scaled_sparse

# 尝试不同的alpha值
for alpha in [0, 0.5, 1]:
    print(f”\n=== Alpha = {alpha} ===")
    scaled_dense, scaled_sparse = hybrid_scale(dense_query_vec, sparse_query_vec, alpha)
    query_obj = HybridQueryVector(dense=scaled_dense, sparse=scaled_sparse)
    results = index.query(vector=query_obj, top_k=5, include_metadata=True)
    display_results(results)

通过调整alpha,你可以观察到返回结果的变化:

  • alpha较低时,结果更偏向于精确匹配“French Connection”、“jeans”等关键词的产品。
  • alpha较高时,结果更偏向于在语义上与“深蓝色”、“男士牛仔裤”概念相似的产品,即使标题中没有完全相同的品牌词。

总结

本节课中,我们一起学习了混合搜索。我们了解了如何利用Pinecone存储同一实体的稀疏向量和稠密向量,并通过BM25和CLIP模型分别生成这两种向量。我们实践了将混合向量上传至索引,并执行查询。最关键的是,我们学会了使用alpha参数来灵活调整关键词搜索与语义搜索在混合查询中的权重,从而根据不同的需求优化搜索结果。混合搜索结合了两种方法的优点,能在单一查询中提供更精确、更相关的检索结果。

006:面部相似度搜索 👨‍👩‍👧

在本节课中,我们将探索一个有趣的问题:孩子看起来更像父亲还是母亲?我们将使用向量嵌入技术,通过一个公开的英国王室家庭图像数据集,科学地判断威廉王子是更像查尔斯国王还是戴安娜王妃。这个方法简单且易于扩展,你也可以尝试用于自己的家庭照片。

我们将构建一个面部相似度搜索系统,通过计算孩子与父母面部特征的平均相似度得分,来判断孩子更像谁。得分更高的一方,意味着与孩子的面部特征更相似。

我们将使用Deepface开源库,其内置的Facenet模型能生成128维的向量。我们将使用“Families in the Wild”数据集中的图像,并将生成的向量存储在Pinecone向量数据库中。相似度的计算方式是:分别计算孩子与父亲、孩子与母亲的平均相似度得分,并进行比较。

环境准备与数据导入

首先,我们进行标准的环境设置,导入必要的库并抑制警告信息。

import warnings
warnings.filterwarnings('ignore')
# 其他必要的导入语句...

接下来,获取Pinecone的API密钥。我们已经提前为你准备好了数据集,并下载到了本地。

# 假设数据已下载并解压到 `family_photos` 目录

数据集的结构如下:顶层目录是family,其下包含dadmomchild三个子目录,分别存放对应人物的照片。

让我们先查看一些图片。这里有一个辅助函数show_image用于显示图片。

def show_image(image_path):
    # 加载、调整大小并显示图片的代码
    pass

以下是随机查看的图片示例:

  • 查尔斯国王(父亲)的图片
  • 戴安娜王妃(母亲)的图片
  • 威廉王子(孩子)的图片

生成并存储面部向量嵌入

现在,我们将使用Deepface模型为每个人的每张图片生成向量嵌入,并将结果存储到一个文件中,以便后续使用。

def generate_vectors():
    # 遍历每个目录(dad, mom, child)中的所有图片
    # 对每张图片使用Deepface模型生成128维的向量嵌入
    # 将结果(人物标签、图片文件名、向量)写入 `vectors.vec` 文件
    pass

运行此函数后,vectors.vec文件将包含所有图片的向量数据。文件格式为:人物标签图片文件名向量值

数据可视化:PCA与t-SNE降维

在将数据上传到Pinecone之前,我们先通过降维技术将高维向量可视化,以便直观地观察数据分布。

主成分分析(PCA) 是一种用于减少数据维度的技术,它通过将大量变量转换为一个较小的变量集,同时保留原始数据中的大部分信息。公式可以表示为将原始数据X投影到主成分方向上得到降维后的数据Z

t-分布随机邻域嵌入(t-SNE) 是一种专门用于将高维数据可视化为二维或三维图形的技术,它能揭示数据点之间潜在的聚类和关系。

通常,我们会先使用PCA进行初步降维以减少计算量,然后再应用t-SNE。t-SNE中有一个重要的超参数困惑度(perplexity),它控制了算法考虑每个点周围邻居的数量。

以下是生成和绘制t-SNE图的代码流程:

def generate_tsne_dataframe(person):
    # 1. 从 `vectors.vec` 文件中提取指定人物的所有向量。
    # 2. 使用PCA将向量降至较低维度(例如50维)。
    # 3. 使用t-SNE将PCA结果进一步降至2维。
    # 4. 返回包含2维坐标的DataFrame。

def plot_tsne(perplexity=30):
    # 1. 为 `dad`, `mom`, `child` 分别调用 `generate_tsne_dataframe`。
    # 2. 使用matplotlib将三组数据的2维坐标绘制在同一张散点图上,用不同颜色区分。
    # 3. 显示图表。

通过调整perplexity参数(例如27或44),可以观察散点图形态的变化。理想情况下,同一个人物的不同照片对应的点应该紧密地聚集在一起,这表示模型能稳定地提取该人物的特征。图中父亲、母亲、孩子各自形成独立的簇,这表明我们的向量嵌入质量良好。

构建Pinecone向量索引并上传数据

数据可视化后,我们开始在Pinecone中建立索引并上传向量数据。

# 删除已存在的索引(确保环境干净)
# 创建新的索引,指定维度为128(与Facenet模型输出一致)
# 获取索引连接

接下来,读取vectors.vec文件,将每一行解析为元数据(人物、文件名)和向量,然后上传至Pinecone索引。

def upload_vectors_to_pinecone():
    with open('vectors.vec', 'r') as f:
        for line in f:
            person, filename, vec_str = line.strip().split(' ', 2)
            vector = eval(vec_str) # 注意:实际应用中应使用更安全的解析方法
            # 将 (vector, metadata) 上传到Pinecone

上传完成后,我们可以检查索引状态,确认向量数量(例如241个)是否正确。

计算面部相似度得分

现在进入核心环节:计算孩子与父母之间的平均相似度得分。

我们定义一个test函数,它接受一个父辈人物(“dad”或“mom”)作为输入,然后执行以下操作:

  1. 从该父辈人物的所有图片中随机选择一张,获取其向量。
  2. 以此向量作为查询向量,在Pinecone索引中搜索与孩子(child) 最相似的Top K张图片。
  3. 计算这些Top K结果的相似度得分的平均值。这个平均值代表了该父辈人物与孩子的平均面部相似度。
def test(parent_label, top_k=5):
    # 1. 随机获取一个父辈人物的向量作为查询向量。
    # 2. 使用Pinecone查询,寻找与孩子最相似的top_k个向量。
    # 3. 计算并返回这top_k个结果的相似度得分的平均值。
    pass

然后,我们分别计算父亲-孩子和母亲-孩子的平均得分。

dad_child_avg_score = test('dad')
mom_child_avg_score = test('mom')

运行结果显示,父亲(查尔斯国王)与孩子(威廉王子)的平均相似度得分为0.43,而母亲(戴安娜王妃)与孩子的平均相似度得分为0.35。因此,根据我们的系统计算,威廉王子在面部特征上更像他的父亲查尔斯国王

寻找最相似的特定图片

除了平均得分,我们还可以找出与孩子某张特定照片最相似的父辈照片。

我们选择一张具有代表性的威廉王子成人照片,生成其向量嵌入,然后在Pinecone中查询与父亲(dad) 最相似的前几张图片。

# 1. 加载选定的孩子图片,生成向量 `target_vector`。
# 2. 以 `target_vector` 查询Pinecone索引,限定在`dad`的范围内,返回最相似的几个结果。
# 3. 从返回结果中获取相似度最高的图片ID和元数据。

查询结果是一个包含ID、元数据和得分的列表。通过元数据中的文件名,我们可以加载并显示那张被系统判定为与威廉王子最相似的查尔斯国王的照片。直观来看,这两张照片确实显示出很高的相似度。

课程总结 🎯

本节课中,我们一起构建了一个面部相似度搜索系统。我们首先使用Deepface模型为王室家庭成员的照片生成了向量嵌入,并通过PCA和t-SNE技术对数据进行了可视化分析。接着,我们将向量存储在Pinecone数据库中,并通过查询计算了孩子与父母之间的平均相似度得分。最终,我们的系统得出结论:威廉王子在面部特征上更像查尔斯国王。我们还演示了如何找出与孩子特定照片最相似的父辈照片。

这个方法不仅适用于这个有趣的案例,你也可以轻松地使用自己的家庭照片进行尝试。在下一节课中,我们将利用Pinecone构建一个异常检测系统,用于分析思科ASA防火墙的日志文件。

007:异常检测 🔍

在本节课中,我们将构建一个异常检测系统。具体来说,我们将构建一个小型机器学习模型,用于检测思科ASA日志文件中的异常日志条目。为了让课程更易于理解并缩短训练时间,我们将使用我们准备好的一个小型数据集进行监督学习来训练模型。之后,我们将使用该模型,并输入一个样本数据集来查找异常日志条目。让我们开始编码和创新。

概述 📋

上一节我们介绍了向量数据库的基本应用。本节中,我们将利用向量数据库Pinecone和句子转换器(Sentence Transformers)来构建一个针对日志文件的异常检测系统。我们将通过一个已标注的小型训练集训练模型,然后使用该模型分析样本日志数据,找出其中的异常条目。

环境设置与数据准备 ⚙️

首先,我们需要导入必要的库并设置环境。

import warnings
warnings.filterwarnings('ignore')
# 导入其他依赖包,例如 pinecone, sentence_transformers, torch 等

接下来,获取Pinecone的API密钥并初始化索引。以下是标准操作流程:

import os
from deeplearningai_utils import get_pinecone_api_key
import pinecone

![](https://github.com/OpenDocCN/dsai-notes-pt1-zh/raw/master/docs/dlai-vecdb-appbd/img/d8cb4c41592ad1f3c220eb9489c56c79_4.png)

# 获取API密钥
api_key = get_pinecone_api_key()
# 初始化Pinecone
pinecone.init(api_key=api_key, environment='your-environment')
# 删除旧索引(如果存在)并创建新索引
index_name = "anomaly-detection-logs"
if index_name in pinecone.list_indexes():
    pinecone.delete_index(index_name)
pinecone.create_index(name=index_name, dimension=256, metric="cosine")
# 获取索引指针
index = pinecone.Index(index_name)

课程已提前下载了数据文件 training.tar.zip。如果离线运行,需要取消注释相应的下载代码行并解压。数据包含一个样本日志文件 sample.log 和一个已标注的训练集 training.txt

让我们查看一下样本日志文件的前几行,了解其格式:

2023-10-01 08:00:00 %ASA-6-302013: Built inbound TCP connection...
2023-10-01 08:00:01 %ASA-6-302014: Teardown TCP connection...

这是标准的思科ASA日志格式,包含日期、时间、日志标识符和实际日志消息。

已标注的训练集 training.txt 格式略有不同。每一行包含两个日志条目和一个相似度标签,由插入符号 ^ 分隔。格式为:日志A ^ 日志B ^ 相似度得分。得分是一个0到1之间的连续值,1表示完全相同,0表示完全不同。

构建与训练模型 🤖

我们将基于句子转换器构建一个非常简单的模型。模型结构包括一个基础的词嵌入层、一个池化层和一个将维度降至256的全连接层。

以下是模型定义的代码:

from sentence_transformers import SentenceTransformer, models
import torch

# 1. 定义词嵌入层
word_embedding_model = models.Transformer('bert-base-uncased')
# 2. 定义池化层
pooling_model = models.Pooling(word_embedding_model.get_word_embedding_dimension())
# 3. 定义全连接层,将维度降至256
dense_model = models.Dense(in_features=pooling_model.get_sentence_embedding_dimension(),
                           out_features=256,
                           activation_function=torch.nn.Tanh())
# 组合成完整模型
model = SentenceTransformer(modules=[word_embedding_model, pooling_model, dense_model])
# 指定设备(CPU或GPU)
device = 'cuda' if torch.cuda.is_available() else 'cpu'
model.to(device)
print(f"Using device: {device}")

现在,我们准备训练数据并开始训练模型。训练数据来自 training.txt 文件。

以下是数据加载和训练准备的代码:

from sentence_transformers import InputExample
from torch.utils.data import DataLoader

train_examples = []
# 打开训练文件
with open('training.txt', 'r') as f:
    lines = f.readlines()
for line in lines:
    line = line.strip() # 去除空白字符
    if line:
        # 按插入符‘^’分割
        a, b, label = line.split('^')
        # 将相似度得分转换为浮点数
        label = float(label.strip())
        # 创建InputExample对象
        train_examples.append(InputExample(texts=[a.strip(), b.strip()], label=label))

# 创建数据加载器
train_dataloader = DataLoader(train_examples, shuffle=True, batch_size=16)
# 定义损失函数(这里使用余弦相似度损失)
from sentence_transformers import losses
train_loss = losses.CosineSimilarityLoss(model=model)
# 设置训练参数
num_epochs = 2
warmup_steps = int(len(train_dataloader) * num_epochs * 0.1) # 10% 的数据用于预热

接下来,我们开始训练模型。同时,我们也加载稍后用于测试的样本日志数据。

# 训练模型
model.fit(train_objectives=[(train_dataloader, train_loss)],
          epochs=num_epochs,
          warmup_steps=warmup_steps,
          show_progress_bar=True)

# 准备样本数据(用于后续生成嵌入向量并插入Pinecone)
samples = []
with open('sample.log', 'r') as f:
    samples = [line.strip() for line in f.readlines() if line.strip()]

模型训练完成后,我们用它为所有样本日志生成嵌入向量。

# 生成嵌入向量
embeddings = model.encode(samples, convert_to_tensor=True, show_progress_bar=True)
print(f"Generated {len(embeddings)} embeddings of dimension {embeddings.shape[1]}")

将数据存入向量数据库并查询 🗃️

现在,我们将生成的嵌入向量存入Pinecone向量数据库。

以下是插入数据的代码:

# 准备数据以便插入Pinecone
vectors_to_upsert = []
for i, (log_line, emb) in enumerate(zip(samples, embeddings)):
    # 每条数据需要一个唯一ID和对应的向量
    vectors_to_upsert.append((f"log_{i}", emb.tolist(), {"text": log_line}))

# 分批插入数据
batch_size = 100
for i in range(0, len(vectors_to_upsert), batch_size):
    batch = vectors_to_upsert[i:i+batch_size]
    index.upsert(vectors=batch)
print("Data inserted into Pinecone.")

数据插入后,我们可以进行查询。我们选取一条已知的正常日志条目作为查询向量。

# 选择一条已知的正常日志(例如第一条)
good_log_line = samples[0]
print(f"Querying with good log line: {good_log_line}")

![](https://github.com/OpenDocCN/dsai-notes-pt1-zh/raw/master/docs/dlai-vecdb-appbd/img/d8cb4c41592ad1f3c220eb9489c56c79_19.png)

# 生成这条日志的嵌入向量
query_embedding = model.encode([good_log_line], convert_to_tensor=True).tolist()[0]

![](https://github.com/OpenDocCN/dsai-notes-pt1-zh/raw/master/docs/dlai-vecdb-appbd/img/d8cb4c41592ad1f3c220eb9489c56c79_21.png)

# 在Pinecone中查询最相似的100条记录
query_results = index.query(vector=query_embedding, top_k=100, include_metadata=True)

分析与识别异常 🔎

查询结果返回了与正常日志最相似的条目列表。相似度得分最高为1(即完全相同的日志),得分越低表示相似度越低。

以下是查看和解析结果的代码:

# 打印相似度最高的前10条结果
print("Top 10 most similar log entries:")
for match in query_results.matches[:10]:
    print(f"Score: {match.score:.4f} | Text: {match.metadata['text']}")

# 我们的假设是:得分最低的条目最有可能是异常
# 获取结果中得分最低的一条(即列表中的最后一条,因为结果默认按得分降序排列)
if query_results.matches:
    worst_match = query_results.matches[-1]
    print("\nPotential anomaly found (lowest similarity score):")
    print(f"Score: {worst_match.score:.4f}")
    print(f"Log Text: {worst_match.metadata['text']}")

在我们的示例中,得分最低的日志条目(例如得分0.32)其消息内容为 sig fault detected in the matrix,这与典型的思科ASA日志模式明显不同,因此被识别为异常。

总结 🎯

本节课我们一起学习了如何构建一个基于向量数据库的异常检测系统。我们首先利用已标注的小型数据集训练了一个句子嵌入模型。然后,我们使用该模型将样本日志数据转换为向量,并存储到Pinecone中。最后,通过查询一条已知的正常日志,我们根据返回结果的相似度得分成功识别出了潜在的异常日志条目。

这种方法的核心思想是:正常数据点在向量空间中彼此靠近,而异常点则远离主要集群。通过计算查询点与数据库中所有点的相似度,并寻找得分最低的(即最不相似的)点,我们就可以有效地发现异常。

你可以尝试将这种方法应用于其他类型的数据,利用句子转换器的强大能力来探索和发现数据中的异常模式。

008:课程总结

在本课程中,我们学习了使用向量数据库构建应用程序的核心知识。

课程概述

我们首先了解了向量数据库的基本概念,然后逐步构建了多种应用。我们构建了简单的搜索和推荐系统,探索了检索增强生成技术,进行了混合搜索实践,在王室成员中寻找了面部相似性,并最终在思科ASA日志文件中发现了异常条目。

核心内容回顾

以下是我们在本课程中涵盖的主要主题:

  • 向量搜索基础:我们学习了如何将数据转换为向量,并使用相似性搜索来查找相关信息。
  • 检索增强生成:我们探讨了如何结合向量数据库与大语言模型,以生成更准确、基于事实的响应。
  • 混合搜索:我们实践了结合关键词搜索和向量搜索的方法,以提升搜索结果的全面性和相关性。
  • 相似性应用:我们通过面部识别案例,展示了向量数据库在寻找相似项目方面的能力。
  • 异常检测:我们利用向量数据库分析了日志数据,识别出偏离正常模式的异常条目。

展望未来

我们仅仅触及了向量数据库可能性的表面。我鼓励你深入探索我们今天课程内容之外的广阔领域。

我期待看到你独立构建出的精彩应用。

在本节课中,我们一起学习了向量数据库的核心应用场景,从基础搜索到高级的RAG和异常检测。希望这些知识能为你构建自己的智能应用打下坚实的基础。

posted @ 2026-03-26 08:18  布客飞龙II  阅读(0)  评论(0)    收藏  举报