文本向量

嵌入与嵌入模型(Embedding and Embedding Models

计算机天生擅长处理数字,但不理解文字、图片的含义。嵌入(Embedding)的核心思想就是将人类世界的符号(如单词、句子、产品、用户、图片)转换为计算机能够理解的数值形式(即向量,本质上是一个数字列表),并且要求这种转换能够保留原始符号的语义和关系

我们可以把它想象成一个翻译过程,把人类语言 “翻译” 成计算机的 “数学语言”。

说明:我们之前一直用的大语言模型是生成式模型。它理解输入并生成新的文本(回答问题、写文章)。它内部实际上也使用嵌入技术来理解输入,但最终目标是 “创造”。

嵌入模型(Embedding Models 是表示型模型。它的目标不是生成文本,而是为输入的文本创建一个最佳的、富含语义的数值表示(向量)。如 OpenAI 的 "text-embedding-3-large" 嵌入模型;Google 的 "gemini-embedding-001" 嵌入模型;阿里的 "Qwen3-Embedding-8B" 嵌入模型等。

什么是向量?

首先我们要知道,嵌入的结果就是一个向量,它本质上是一个数字列表(一维数组)。例如:[0.023, 0.487, -0.129, ..., 0.325]。对于向量来说有两个关键概念需要了解:

向量维度

嵌入结果得到的列表长度是固定的,称为向量的 “维度”。例如,OpenAI 的 text-embedding-ada-002 模型会生成一个 1536 维的向量,text-embedding-3-large 模型会生成一个 3072 维的向量。

维度越高,通常能捕捉更细微的语义信息,但也需要更多的计算和存储资源。

向量空间

想象一个无限延伸的、拥有无数个维度的宇宙,这个宇宙就是一个向量空间。这有点抽象,可以想象一下:

  • 在三维世界里,一个点可以用 (x, y, z) 坐标表示,例如 (2, 5, -1)
  • 在机器学习的高维向量空间中,一个点可能是 (0.1, 0.7, -0.2, 0.4, ..., 0.02),一个有几百或几千个数字的坐标。

在这个空间里,每个点(即每个向量)都能代表一个概念。例如在嵌入模型中,一个点可以代表一个单词、一句话、一张图片、一个用户、一部电影等。

到这里,向量空间的威力就能体现出来:我们可以用数学来度量语义。可以通过计算两个向量之间的 “距离” 或 “相似度” 来实现这一点。

  • 欧氏距离(Euclidean Distance):就是我们高中几何学的两点之间的直线距离。距离越短,相似度越高。
  • 余弦相似度(Cosine Similarity):它忽略向量的绝对长度(大小),只关注两个向量在方向上的差异。在文本和语义的世界里,“方向” 代表 “含义”,而 “长度” 往往只代表 “文本的长度” 或 “词汇的多少”。换句话说,余弦相似度关注的是“你们是否指向同一个方向”/“你们是否代表同一个含义”

因此,在捕捉语义上的相似性上,余弦相似度是更常用的度量方式。

我们又能反推出,由于使用向量来绘制向量空间,而向量是有维度的,维度越高,则更能捕捉极其细微和复杂的语义差别(比如 “高兴” 和 “喜悦” 的区别)。

这能干什么?这能解决一个传统数据库(如 MySQL)不擅长的问题:基于内容的相似性搜索,而不是基于精确匹配的查询。

嵌入模型应用场景

对于嵌入模型,实际上在示例选择器部分,我们已经使用过。当时使用的场景就是可以根据语义相似性完成示例的筛选。

根据嵌入的特性,由此延伸出了许多嵌入模型在 AI 应用的使用场景:

  • 语义搜索(Semantic Search)

传统搜索依赖关键词匹配(搜 “苹果”,只能找到包含 “苹果” 这个词的文档)。

语义搜索则能将查询(如 “一种红色的水果”)和文档库中的所有文档都转换为向量。然后计算查询向量与所有文档向量的相似度,返回最相似的文档。这样即使文档里没有 “红色”“水果” 这些词,但只要它是关于 “苹果” 的,就能被找到

如下图为我们展示了借助嵌入模型进行文档搜索的过程:

  1. 为多文档生成其各自的向量,
  2. 为搜索查询语句生成向量,
  3. 衡量查询向量与每个文档向量之间的相似性,得到相似度最高的文档。

  • 检索增强生成(Retrieval-Augmented Generation, RAG)这是当前大语言模型应用的核心模式。当用户向 LLM 提问时,系统首先使用嵌入模型在知识库(如公司内部文档)中进行语义搜索,找到最相关的内容,然后将这些内容和问题一起交给 LLM 来生成答案。这极大地提高了答案的准确性和时效性。

  • 推荐系统(Recommendation Systems)将用户(根据其历史行为、偏好)和物品(商品、电影、新闻)都转换为向量。喜欢相似物品的用户,其向量会接近;相似的物品,其向量也会接近。通过计算用户和物品向量的相似度,就可以进行精准推荐。

  • 异常检测(Anomaly Detection)正常数据的向量通常会聚集在一起。如果一个新数据的向量远离大多数向量的聚集区,它就可能是一个异常点(如垃圾邮件、欺诈交易)。

Embeddings 嵌入模型类

在 LangChain 中,有很多的嵌入模型提供方,使用不同的模型提供方,需要安装为其各自包,例如:

  • OpenAI: pip install -U langchain-openai
  • Ollama: pip install -U langchain-ollama
  • Google Gemini: pip install -U langchain-google-genai
  • 更多见【这里】

定义嵌入模型

在这里我们选择 OpenAI 来进行后续操作。定义 OpenAI 下的嵌入模型使用:class langchain_openai.embeddings.base.OpenAIEmbeddings,官方接口介绍见【这里】

定义 OpenAIEmbeddings 嵌入模型类与定义聊天模型类似,如下所示:

from langchain_openai import OpenAIEmbeddings

embeddings = OpenAIEmbeddings(
    model="text-embedding-3-large",  # 是 OpenAI 2024年发布的最新嵌入模型,生成3072维的高质量向量
)

在 LangChain 框架中基础 Embeddings 类(OpenAIEmbeddings 继承了它)设计了两个核心方法来处理文本嵌入。

.embed_documents()用于处理文档 Documents。它的输入是多个文本。例如要将一个知识库所有段落都转换成向量后存入数据库,就会使用这个方法。

  • 它返回一个二维列表 List[List[float]]。外层列表的每个元素对应一个输入文档,内层列表则是该文档的向量表示。

.embed_query()用于处理查询 Query。它的输入是单个文本(一个字符串,str)。例如,当用户提出一个问题时,需要将这个问题转换成向量,以便在数据库中搜索相似的文档段落,就会使用这个方法。

  • 它返回一个一维列表,里面是浮点数(List[float]),代表单个查询文本的向量。

其实分别对应下图中文档与查询的向量生成:

之所以设计成两个方法,是因为某些嵌入模型提供商(如 OpenAI、Cohere 等)会针对 “被搜索的文档” 和 “搜索查询本身” 采用不同的优化策略和模型。即使底层是同一个模型,也可能对两者进行不同的预处理(例如添加不同的指令前缀),以获得更好的搜索效果。

  • 文档(.embed_documents()):就像给每本书写一个详细的摘要卡片,要尽可能完整地概括书的内容。这个过程可以慢一点、仔细一点,因为只做一次,然后存起来。

  • 查询(.embed_query()):就像你问图书管理员一个问题,管理员需要快速理解你的意图,然后去卡片盒里找最匹配的书。这个过程要快,而且要能理解日常用语、同义词、甚至不规范的表达。

即使是同一个嵌入模型,对文档和查询也可能做不同的预处理:

# 伪代码示例:某些模型的实际做法

def embed_documents(texts):
    # 给文档添加"检索指令"前缀
    processed = ["search_document: " + t for t in texts]
    return model.encode(processed)

def embed_query(text):
    # 给查询添加"查询指令"前缀
    processed = "search_query: " + text
    return model.encode(processed)

以 OpenAI 的 text-embedding-3-small 为例(虽然它没有强制区分,但很多专用检索模型会这样做):

方法 输入示例 实际发给模型的文本
embed_documents "Python 是一种编程语言" "passage: Python 是一种编程语言"
embed_query "我想学编程,推荐什么语言?" "query: 我想学编程,推荐什么语言?"

这样,文档和查询在向量空间中的表示会更对齐——相似的查询能找到相似的文档。

嵌入文档列表

embed_documents 的语义是 “索引”。它的目的是预处理大量文本,为它们创建向量表示,以便后续被搜索。这一般是一个离线、批量处理的过程。代码如下:

# 导入必要的库
from langchain_openai import OpenAIEmbeddings  # 导入OpenAI的嵌入模型,用于将文本转换为向量
from langchain_community.document_loaders import UnstructuredMarkdownLoader  # 导入Markdown文件加载器,用于读取.md文件
from langchain_text_splitters import CharacterTextSplitter  # 导入文本分割器,用于将长文本分割成小块

# 定义Markdown文件的路径(相对路径,上一级目录的Docs/Markdown文件夹下的文件)
markdown_path = "../Docs/Markdown/脚手架微服务租房平台Q&A.md"

# 创建Markdown加载器实例
# single模式加载后,默认只有一个Document对象(整个文件会被当作一个文档)
loader = UnstructuredMarkdownLoader(markdown_path)

# 执行加载操作,读取文件内容并转换为Document对象
# data是一个列表,默认包含一个Document元素,其中.page_content属性存储了文件的文本内容
data = loader.load()

# 生成文本分割器
# 使用tiktoken编码器(OpenAI使用的分词器)来分割文本
# encoding_name="cl100k_base": 指定使用cl100k_base编码(GPT-4、text-embedding-3系列模型使用的编码)
# chunk_size=200: 每个文本块的最大字符数(或token数,这里实际是按token计数)
# chunk_overlap=50: 相邻两个文本块之间的重叠字符数,用于保持上下文连贯性
text_splitter = CharacterTextSplitter.from_tiktoken_encoder(
    encoding_name="cl100k_base", chunk_size=200, chunk_overlap=50
)

# 执行文档分割操作
# 将原始的大文档按照上述配置切分成多个小文档块
# 返回一个Document对象列表,每个Document包含一部分文本内容
documents = text_splitter.split_documents(data)

# 定义嵌入模型
# OpenAIEmbeddings: 用于将文本转换为向量表示的模型
# model="text-embedding-3-large": 使用OpenAI最新的text-embedding-3-large模型
# 该模型生成3072维的向量,是目前OpenAI最强大的嵌入模型
embeddings = OpenAIEmbeddings(model="text-embedding-3-large")

# 准备文本列表用于向量化
# 从每个分割后的Document对象中提取出.page_content属性(即文本内容)
# 生成一个纯文本字符串的列表,因为嵌入模型需要接收的是字符串列表
texts = [doc.page_content for doc in documents]

# 对文本列表进行批量向量化处理
# embed_documents()方法接收字符串列表,一次调用生成所有文本的向量
# 返回一个二维列表,每个元素是一个向量(浮点数列表)
# 向量的维度由选择的嵌入模型决定(text-embedding-3-large为3072维)
documents_vector = embeddings.embed_documents(texts)

# 输出处理结果统计信息
print(f"文档数量为: {len(documents)}, 生成了{len(documents_vector)}个向量的列表")
# 输出第一个向量的维度,验证向量维度是否正确
print(f"第一个文档向量维度: {len(documents_vector[0])}")
# 输出第二个向量的维度,确认所有向量维度一致
print(f"第二个文档向量维度: {len(documents_vector[1])}")

结果如下:

Created a chunk of size 916, which is longer than the specified 200
Created a chunk of size 916, which is longer than the specified 200
Created a chunk of size 260, which is longer than the specified 200
文档数量为: 96, 生成了96个向量的列表
第一个文档向量维度: 3072
第二个文档向量维度: 3072

可以看到 text-embedding-3-large 嵌入模型,生成的便是 3072 维的高质量向量。共存在 96 个文档,因此生成了 96 个向量的列表。


嵌入单个查询

embed_query 的语义是 “搜索”。它的目的是在用户发起请求时,实时地将一个问题或指令转换为向量,用于在已索引的文档向量中进行检索。这是一个在线、实时、按需处理的过程。

为单个查询生成向量的代码如下:

# 导入OpenAI的嵌入模型类
# OpenAIEmbeddings 提供了将文本转换为向量表示的功能
from langchain_openai import OpenAIEmbeddings

# 定义嵌入模型
# 创建一个OpenAI嵌入模型的实例
# model="text-embedding-3-large": 选择使用OpenAI最新的text-embedding-3-large模型
#   该模型特点:
#   - 生成3072维的高维向量
#   - 是OpenAI目前最强大的嵌入模型
#   - 在相似度计算、检索等任务上表现最佳
#   - 相比旧模型(如text-embedding-ada-002)有更好的语义理解能力
embeddings = OpenAIEmbeddings(model="text-embedding-3-large")

# 嵌入单个查询(文本查询)
# embed_query() 方法专门用于处理单个查询文本(如搜索问题、用户输入等)
# 与 embed_documents() 的区别:
#   - embed_query(): 处理单个字符串,通常用于查询/问题
#   - embed_documents(): 处理字符串列表,通常用于批量处理文档
# 参数:传入要向量化的查询文本字符串
# 返回值:一个浮点数列表,代表该文本的向量表示
# 注意:虽然这里只有一个文本,但不需要写成列表形式
query_vector = embeddings.embed_query("项目中遇到了哪些挑战?如何解决?")

# 打印输出向量的维度信息
# len(query_vector) 获取向量的长度(维度数)
# 对于 text-embedding-3-large 模型,输出应为 3072
print(f"向量维度: {len(query_vector)}")

# 打印向量的前五个数值(切片操作 [:5] 获取前5个元素)
# 向量中的每个数值代表文本在某个维度上的特征
# 这些数值通常是浮点数,范围大约在 -1 到 1 之间
# 实际应用中很少直接查看向量数值,而是用于计算相似度
print(f"向量前五个数值为: {query_vector[:5]}")

结果如下:

向量维度: 3072
向量前五个数值为: [-0.01306734886020422, -0.004718094132840633, -0.005446741823107004, -0.014186487533152103, 0.004673811607062817]

向量存储(Vector Stores)

向量数据库介绍

在 LangChain 中,实际并不需要我们直接手动调用嵌入模型去生成向量,然后手动去比较向量。在我们之前提供的 RAG 知识地图中,存在一个 Vector Stores 向量存储,如下图所示:

实际上,向量是被管理在专门的向量存储介质中,如向量数据库。向量存储的核心任务是解决一个传统数据库(如 MySQL)不擅长的问题:基于内容的相似性搜索(Similarity Search),而不是基于精确匹配的查询。

根据上图,我们想要从向量数据库中进行相似度搜索,首先就需要将向量存储管理起来。想象一下,一篇文档若转换成一个有1536个浮点数的向量。一百万篇文档就是1536MB(约1.5GB)的纯向量数据。这只是一个起点,现实中的数据集可能轻松达到千万甚至亿级。如何高效地存储和管理这些向量?

向量数据库则提供了专门用于高效存储、管理和检索高维向量的能力。其核心就是 “高效地组织和检索这些数据”。

常见的向量数据库核心机制如下:

  • 专门的索引

这是向量数据库的灵魂。它们不会使用暴力搜索,而是会预先为所有向量构建一种特殊的索引结构。

常见的方法有近似最近邻(ANN)搜索:为了追求极致的速度,它愿意牺牲一点点精度。它不会保证找到绝对最相似的向量(即最近邻),但能以极高的概率找到非常相似的向量。通过聚类、分层、压缩等算法技术,将搜索范围从 “整个数据库” 缩小到 “几个最可能的候选集”。

ANN(近似最近邻搜索)
    ├── 图基方法
    │     ├── HNSW(最主流)
    │     ├── NSG
    │     └── KGraph
    ├── 量化方法
    │     ├── PQ(乘积量化)
    │     └── OPQ
    ├── 哈希方法
    │     └── LSH(局部敏感哈希)
    ├── 树方法
    │     ├── KD-Tree
    │     └── Annoy
    └── 倒排方法
          └── IVF(倒排文件索引)

这就像在一个大图书馆里找书,你不是从 A 到 Z 遍历所有书架,而是先根据分类(文学、历史、科学)找到大概区域,再在这个区域内仔细找,这样快得多。

上面为什么 embed_documents 叫“索引”?

在 RAG(检索增强生成)场景中:

  • 文档向量:你调用 embed_documents,把每个文档片段变成一个高维向量(如 1536 维)。

  • 索引过程:你把这些向量存入一个向量数据库(如 Chroma、Milvus、Pinecone)。这个数据库内部会建立一种特殊的结构(如 HNSW 图、倒排索引等),这种结构就是为了快速找到与某个查询向量最相似的文档向量

所以这个建立特殊结构的过程,通常就叫“索引(Indexing)”。

  • 向量相似度计算优化

向量数据库底层使用高度优化的库来进行向量运算。如 FAISS 向量数据库,它是 Facebook AI 研究院开发的一种高效的相似性搜索和聚类的库。它能够快速处理大规模数据,并且支持在高维空间中进行相似性搜索。

这些库充分利用了 CPUSIMD 指令集和 GPU 的并行计算能力,让大规模的向量计算速度极快。

SIMD 指令集指 “单指令多数据流” 技术,是一种采用一个控制器来控制多个处理器,本质上非常类似一个向量处理器,可对控制器上的一组数据(又称 “数据向量”)同时分别执行相同的操作从而实现空间上的并行。简单来说,就是一个指令能够同时处理多个数据。

  • 数据管理功能

现代的向量数据库不仅有专门的索引,还提供了完整的数据管理功能:

CRUD 操作:支持增删改查,可以动态地更新向量数据。

元数据过滤:这是非常关键的功能。除了向量本身,每篇文档还有元数据(Metadata),比如创建时间、作者、类别等。向量数据库允许你先用元数据过滤(“找出 xxxx 年以后的、属于科技类别的文档”),再在这个缩小的范围内进行向量相似度搜索,极大地提升了准确性和效率。

可扩展性与持久化:它们可以轻松地分布式部署,处理海量数据;同时保证数据持久化,不像纯内存方案一样断电丢失。

集成方便:提供友好的 API(如gRPC, RESTful),使得像 LangChain 这样的框架可以轻松地与之集成,开发者无需关心底层细节。

因此,我们可以利用专门的向量数据库(如Chroma, Weaviate, Pinecone, Qdrant, Milvus等),通过构建索引、优化计算流程、并提供丰富的过滤和管理功能,从而实现对海量高维向量数据进行快速、可扩展的 **【相似性检索】** 的一套完整技术方案。

LangChain 框架则通过与这些向量数据库集成,让开发者无需手动处理向量生成、存储和比较的复杂性,只需关注业务逻辑本身,极大地提高了开发效率和应用性能。

下面我们将演示如何使用 LangChain 提供的内存级存储,与集成的向量数据库。查看 LangChain 支持的所有向量数据库点击这里。除了课件上的向量数据库使用,其他存储可查阅官方文档自行接入。

内存存储

我们将使用 LangChain 的 InMemoryVectorStore 来实现向量的内存存储。

基本操作

初始化

LangChain 中的大多数向量在初始化向量存储时接受嵌入模型作为参数。如下所示:

from langchain_openai import OpenAIEmbeddings
from langchain_core.vectorstores import InMemoryVectorStore

# 定义嵌入模型
embeddings = OpenAIEmbeddings(model="text-embedding-3-large")
# 内存存储初始化
vector_store = InMemoryVectorStore(embedding=embeddings)

添加文档

我们可以使用 add_documents 方法,向内存存储中去添加文档。要注意的是,该方法会为添加的文档编排索引,索引列表随着该方法返回。这也就是在前文中,我们一直在提的:当我们想对某文本进行【数据检索】时的两个步骤:

编制索引:用于从源中摄取数据并为其编制索引。

检索和生成:接受用户查询并从索引中检索相关数据,然后将其传递给模型。

对于第一步,我们终于要完成了。如下所示:

from langchain_community.document_loaders import UnstructuredMarkdownLoader
from langchain_text_splitters import CharacterTextSplitter

# 生成分割器
text_splitter = CharacterTextSplitter.from_tiktoken_encoder(
    encoding_name="cl100k_base", chunk_size=200, chunk_overlap=50
)
# 加载文档
data = UnstructuredMarkdownLoader("../Docs/Markdown/脚手架微服务租房平台Q&A.md").load()
# 分割文档
documents = text_splitter.split_documents(data)
# 添加文档
ids = vector_store.add_documents(documents=documents)
print(f"共编排了{len(ids)}个文档索引")
print(f"前3个文档的索引是: {ids[:3]}")

结果输出:

共编排了96个文档索引
前3个文档的索引是: ['3b22073d-8a8f-4f74-b99a-09d6343e7602', '3c2e05ea-1681-4d5e-a31c-f01f2e0a0ac8', 'dcc3da40-b1bf-4ae3-9fd4-1f758b47af0f']

获取文档

使用 get_by_ids 方法,通过索引列表获取对应的文档列表。如下所示:

doc_3 = vector_store.get_by_ids(ids[:3])
print(f"{[doc.page_content for doc in doc_3]}")

结果如下:

[
    '通用问题\n\n为什么做这个项目?\n\n回答1: (出于兴趣爱好开发)\n\n大学期间,我和同学在外合租过一段时间,使用了一些租房平台,于是我有个想法,自己能不能开发一个租房平台,可以让我将理论知识与实践相结合。我希望通过实际项目来加深对 Java 编程语言和相关技术的理解。于是我便查找了一些资料,看了一些开源项目,进行了一些改进。\n\n回答2: (开源项目的解释)',
    '回答2: (开源项目的解释)\n\n这个项目其实是在 github 上找到的一个开源项目,主要是可以支持一些常规的聊天项目,我对于聊天如何实现的比较感兴趣。顺便也想锻炼一下自己工程代码能力,在网上就找到了这个开源项目和项目的一些比较完善的文档和介绍,再加上找了一个业务场景:租房。所以就确定了这个项目。\n\n回答3: (学校课设的解释)',
    '回答3: (学校课设的解释)\n\n这是学校的课设题目,然后在课设项目的基础上,查找了一些资料,进行了一些改进。\n\n这个项目为啥和上个(之前)同学的项目一样?\n\n回答1: (开源项目的回答)'
]

删除文档

使用 delete 方法,删除传入索引列表对应的文档列表;若不传入索引列表,则认为全量删除。如下所示:

vector_store.delete(ids=ids[:3])

测试一下删除是否成功:

doc_3 = vector_store.get_by_ids(ids[:3])
print(f"{[doc.page_content for doc in doc_3]}")

最终输出:[],可以看到确实删除成功。

向量搜索

如果我们传入一个查询,向量存储将嵌入该查询,在所有嵌入的文档中执行相似性搜索,并返回最相似的文档。

这体现了两个重要的概念:首先,需要有一种方法来衡量查询与任何嵌入文档之间的相似性。其次,需要有一种算法能够高效地在所有嵌入文档中执行这种相似性搜索。

对于【相似性】这一点,之前已经讲过,再来回顾下:

  • 欧氏距离(Euclidean Distance):就是我们高中几何学的两点之间的直线距离。距离越短,相似度越高。
  • 余弦相似度(Cosine Similarity):它忽略向量的绝对长度(大小),只关注两个向量在方向上的差异。在文本和语义的世界里,“方向” 代表 “含义”,而 “长度” 往往只代表 “文本的长度” 或 “词汇的多少”。换句话说,余弦相似度关注的是 “你们是否指向同一个方向”/“你们是否代表同一个含义”。

因此,在捕捉语义上的相似性上,余弦相似度是更常用的度量方式。

对于【相似性搜索】则可以通过向量存储提供的搜索方法实现。


相似性搜索

想要获取根据相似性搜索的结果,即嵌入单个查询,并查找相似的文档,并将它们作为文档列表返回。这可以使用 similarity_search 方法来实现。说明:InMemoryVectorStore 是根据【余弦相似度】来捕捉语义的。

代码如下:

search_docs = vector_store.similarity_search(query="数据库表怎么设计的?", k=2)
for doc in search_docs:
    print("*" * 30)
    print(doc.page_content)

方法参数解释如下:

  • query:输入的查询字符串。
  • k:要返回的文档数,默认为 4。

打印结果如下:

******************************
提供两种方案有以下好处:

降低门槛:单机版让用户快速体验核心功能,避免初期陷入复杂部署。

灵活演进:用户可从单机版过渡到集群版,逐步适应生产需求。

业务专属问题

为什么将房源表拆分为水平分表(如按状态分表)?具体如何实现?

原因:

单表数据量过大(如房源状态频繁更新)会导致查询性能下降;

不同状态(上架/已租/下架)的查询频率不同,拆分可减少锁争用;
******************************
数据库: MySQL + MyBatis

MySQL 和 MyBatis 是数据存储和访问的一个常见的做法。MySQL 是一个开源的关系数据库管理系统,它可以免费使用。MyBatis 提供了一种相对简单的方法来执行 SQL 语句,不需要编写复杂的 ORM 映射文件。

缓存: Redis

可以看见,搜索出来的内容,是较为符合我们的预期。

有的同志可能有疑问了:搜索出来的结果,为什么不直接是问题的答案,而只是相似的文档呢?

那么恭喜你已经具备了构建智能应用基础逻辑了。此时我们可以将 “找到的最相关内容” 和我们 “提出的问题” 一起交给 LLM 来生成答案,这便能极大地提高答案的准确性和时效性。以上流程,叫做检索增强生成(Retrieval-Augmented Generation, RAG),这是当前大语言模型应用的核心模式。RAG 我们将会在后续内容进行讲解。


元数据过滤

回顾一下,每个 Document 对象,都包含了以下参数:

  • id:可选的文档标识符。理想情况下,这应该在整个文档集合中是唯一的,并格式化为 UUID,但不会强制执行。
  • page_content:字符串文本。
  • metadata:与内容关联的任意元数据,类型为 dict(可选)。

虽然向量数据库实现了搜索算法,来有效地搜索所有嵌入的文档以找到最相似的文档。但现实场景中,我们还希望通过先根据元数据进行过滤,来帮助缩小搜索范围。例如从特定来源或日期范围检索文档。

代码示例:

from langchain_core.documents import Document

def _filter_function(doc: Document) -> bool:
    return doc.metadata.get("source") == "hahaha"

search_docs = vector_store.similarity_search(
    query="数据库表怎么设计的?",
    k=2,
    filter=_filter_function
)
for doc in search_docs:
    print("*" * 30)
    print(doc.page_content)

这次,我们给搜索方法加入了 filter 参数,它接收一个返回 bool 值的函数,表示我们可以根据条件选择是否过滤某些文档。因此我们定义了一个 _filter_function 过滤函数,可以根据文档元数据先过滤出文档,再去进行搜索。

示例中,我们给出的 source=="hahaha" 是无效的,因此会将所有的文档都过滤掉,导致示例代码运行后结果为空。将 source 换成正确的来源后,再次运行则结果正确。

Redis 向量存储

我们还可以使用 Redis 来存储向量。大多数开发者都熟悉 Redis,因为它速度快、拥有庞大的客户端库生态系统,并且多年来已被众多大型企业采用。从本质上讲,Redis 是一种键值型的 NoSQL 数据库,除了传统用例之外,Redis 还提供了诸如搜索和查询功能等额外能力,允许用户在 Redis 内创建二级索引结构。这使得 Redis 能够以缓存的速度充当向量数据库。


基本概念

理解 RediSearch

RediSearch 是 Redis 官方提供的一款高性能【搜索】与【全文索引】引擎模块。它基于 Redis 构建,使用户能够直接在 Redis 数据库中执行复杂的【搜索】和【分词查询】,无需额外引入外部搜索引擎。RediSearch 特别适用于轻量级、响应速度要求较高的分词搜索场景。

RediSearch 提供了内置的分词功能,既避免 LIKE 的局限性(无法支持分词查询。例如:数据为 “Apple iPhone 17 Pro Max 256GB 星宇橙色”,搜 “苹果 17 橙色” 无法匹配),又无需部署 ES 这种大型组件,集成简单、性能出色,非常适合中等规模的全文检索需求。

理解 Index

Index(索引)是 RediSearch 模块里的概念,用于定义一个查询目录。Index 是一个独立的数据结构,它建立在多个 Redis Keys(Hash 类型)之上,这专门为了极速执行文本搜索、过滤和聚合而设计。它本身不存储数据,而是存储了指向其他 Redis Keys 的指针,和这些 Keys 中特定字段的索引信息。

理解 Index Fields

Index Fields(索引字段)是创建索引时,明确指定的那些需要被索引的字段。它们定义了索引的 “结构” 或 “蓝图”,告诉 RediSearch:“请针对这些字段的内容,以其特定的方式为我构建快速搜索的能力。”

可以把它想象成在一本书后面制作索引页(比如人名索引、主题索引)。我们不会把书中的每一个字都做到索引里,而是只选择那些重要的关键词(字段),并记录下它们出现的页码(文档 ID)。这里的 “关键词” 就是 Index Fields

RediSearch 中,索引字段是有特定的类型:

  • TAG:精确匹配的分类 / 标签,可以用来多值分类、过滤、分组。

  • NUMERIC:整数 / 浮点数,可以进行数值范围查询,排序和统计。

  • TEXT:全文搜索的字符串,支持分词、词干化、模糊匹配。

  • GEO:经纬度坐标,用来查询地理空间,计算距离。

理解 metadata schema

schema 我们讲过,就是描述数据结构的声明格式。metadata schema 则用来描述元数据的结构声明。

这里的元数据是指我们将来要嵌入文档的元数据。因为对于文档元数据来说,它在存入 Redis 后,就被定义成了索引字段

对于文档元数据来说,里面存放的就是一些文档属性值,如 source 表示文档来源。我们还可以手动加入其他元数据,这需要设置每个字段的声明:name 表示字段名,type 表示字段类型。

metadata_schema=[
    {"name": "category", "type": "tag"},
    {"name": "num", "type": "numeric"},
]

一张图总结关系,如下所示:

环境设置

使用 Redis 来存储向量,首先需要将相关环境配置好。

第一步:启动 Redis 服务端

启动后可使用 docker ps 查看是否启动成功。

  • 对于 Redis 版本 >= 8.0
    docker run -d -p 6379:6379 -it redis:latest
    
  • 对于 Redis 版本 < 8.0
    docker run -d -p 6379:6379 redis/redis-stack:latest
    

第二步:安装 Redis 客户端包

由于使用 Python 开发,需安装 redis-py

pip install redis

第三步:安装 LangChain Redis 集成包

pip install -qU langchain-redis

第四步:定义 Redis 连接 URL

连接 URL 的基本结构:

[protocol]://[auth]@[host]:[port]/[database]

示例:redis://localhost:6379

第五步:测试连接(Ping)

import redis
redis_url = "redis://localhost:6379"
# 定义Redis客户端
redis_client = redis.from_url(redis_url)
# Ping
print(redis_client.ping())

若输出 True,则表示连接测试成功。


基本操作

初始化

LangChain 中使用 RedisVectorStore 初始化 Redis 向量存储,需通过 RedisConfig 配置类传入连接信息。

from langchain_openai import OpenAIEmbeddings
from langchain_redis import RedisConfig, RedisVectorStore

# 定义嵌入模型
embeddings = OpenAIEmbeddings(model="text-embedding-3-large")

# 配置 Redis 客户端
redis_url = "redis://192.168.100.238:6379"
config = RedisConfig(
    index_name="qa",
    redis_url=redis_url,
    metadata_schema=[
        {"name": "category", "type": "tag"},
        {"name": "num", "type": "numeric"},
    ],
)
# Redis 存储初始化
vector_store = RedisVectorStore(embeddings, config=config)

关键配置说明:

可通过 redisvl 工具检查索引:

pip install -U redisvl
rvl index info -i qa --host 192.168.100.238 --port 6379

RedisVectorStore 参数:

  • embeddings:用于此存储的 Embeddings 实例。
  • configRedisConfig 配置对象。

RedisConfig 关键参数:

  • index_name:Redis 中索引的名称。
  • redis_url:Redis 实例的 URL。
  • metadata_schema:元数据字段的 schema,支持后续过滤。

可以看到 Index Fields 中就包括了:

  • text:文档文本。
  • embedding:向量,类型为数组。
  • _index_name:索引名。
  • metadata schema:文档元数据。

其中文档元数据的每个属性还都被当作单独的 Index Fields


添加文档

我们可以使用 add_documents 方法,向向量库中去添加文档。这次我们可以给被分割的文档添加相关的元数据。如下所示:

from langchain_community.document_loaders import UnstructuredMarkdownLoader
from langchain_text_splitters import CharacterTextSplitter

# 生成分割器
text_splitter = CharacterTextSplitter.from_tiktoken_encoder(
    encoding_name="cl100k_base", chunk_size=200, chunk_overlap=50
)
# 加载文档
data = UnstructuredMarkdownLoader("../Docs/Markdown/脚手架微服务租房平台Q&A.md", category="QA").load()
# 分割文档
documents = text_splitter.split_documents(data)
# 为文档添加元数据
for i, doc in enumerate(documents, start=1):
    doc.metadata["category"] = "QA"
    doc.metadata["num"] = i

ids = vector_store.add_documents(documents=documents)
print(f"共编排了{len(ids)}个文档索引")
print(f"前3个文档的索引是: {ids[:3]}")

结果输出:

共编排了96个文档索引
前3个文档的索引是: ['qa::01K4Q0A3DSQVZBRFKJD5MS25HG', 'qa::01K4Q0A3DSQVZBRFKJD5MS25HH', 'qa::01K4Q0A3DSQVZBRFKJD5MS25HJ']

在 Redis 中的存储如下所示,下面的 embedding 字段被转换为二进制存储,因此展示了乱码。除此之外,还存放了我们给文档设置的元数据信息。

获取文档

使用 get_by_ids 方法,通过索引列表获取对应的文档列表。如下所示:

ids = [":01K4Q0A3DSQVZBRFKJD5MS25HJ", ":01K4Q0A3DSQVZBRFKJD5MS25HK", ":01K4Q0A3DSQVZBRFKJD5MS25HM"]
doc_3 = vector_store.get_by_ids(ids)
print(f"{[doc.page_content for doc in doc_3]}")

结果如下:

[
    '回答3: (学校课设的解释)\n\n这是学校的课设题目,然后在课设项目的基础上,查找了一些资料,进行了一些改进。\n\n这个项目为啥和上个(之前)同学的项目一样?\n\n回答1: (开源项目的回答)',
    '这个项目为啥和上个(之前)同学的项目一样?\n\n回答1: (开源项目的回答)\n\n因为这个项目本身就是开源项目,网上一搜都是这个项目的博客分析,写得优质的主要就是那几篇,可能我们参考了一样的博客讲解梳理框架吧。抽奖系统虽然听起来简单,但实际上涉及到数据存储、状态转换、异常处理等多个技术点。\n\n回答2: (学校课设的回答)',
    '回答2: (学校课设的回答)\n\n这个问题面试官可能会直接的指出:上个同学和你学校不同,但是跟你的项目完全一样。\n\n这个项目确实是我们学校的开源项目,但是我在实现的时候也在网上找了跟课设要求类似的开源项目,而且发现网上针对这个类型的项目文档还完善的,可能其他的同学也是在网上跟我一样找到了类似的博客或开源项目进行借鉴的吧。'
]

删除文档

使用 delete 方法,删除传入索引列表对应的文档列表。如下所示:

vector_store.delete([":01K4Q0A3DSQVZBRFKJD5MS25HJ"])

或者:

# 删除指定内容
vector_store.index.drop_keys(["qa::01K4Q0A3DSQVZBRFKJD5MS25HJ"])
# 全量删除,删除索引
vector_store.index.delete(drop=True)

向量搜索
相似性搜索

想要获取根据相似性搜索的结果,即嵌入单个查询,并查找相似的文档,并将它们作为文档列表返回。这可以使用 similarity_search 方法来实现。代码如下:

search_docs = vector_store.similarity_search(query="数据库表怎么设计的?", k=2)
for doc in search_docs:
    print("*" * 30)
    print(doc.page_content)

方法参数解释如下:

  • query:输入的查询字符串。
  • k:要返回的文档数,默认为 4。

打印结果如下:

******************************
提供两种方案有以下好处:

降低门槛: 单机版让用户快速体验核心功能,避免初期陷入复杂部署。

灵活演进: 用户可从单机版过渡到集群版,逐步适应生产需求。

业务专属问题

为什么将房源表拆分为水平分表(如按状态分表)?具体如何实现?

原因:

单表数据量过大(如房源状态频繁更新)会导致查询性能下降;

不同状态(上架/已租/下架)的查询频率不同,拆分可减少锁争用;
******************************
数据库: MySQL + MyBatis

MySQL 和 MyBatis 是数据存储和访问的一个常见的做法。MySQL 是一个开源的关系数据库管理系统,它可以免费使用。MyBatis 提供了一种相对简单的方法来执行 SQL 语句,不需要编写复杂的 ORM 映射文件。

缓存: Redis

除了上面的 similarity_search 方法,其实还提供了:

  • 根据向量搜索方法:similarity_search_by_vector
  • 根据查询搜索方法,并返回相似分值:similarity_search_with_score
  • 根据向量搜索方法,并返回相似分值:similarity_search_with_score_by_vector

例如,我们希望【根据查询语句搜索,并返回相似分值】:

scored_results = vector_store.similarity_search_with_score(query="数据库表怎么设计的?", k=4)
for doc, score in scored_results:
    print("*" * 30)
    print(f"Content: {doc.page_content[:100]}...")
    print(f"Metadata: {doc.metadata}")
    print(f"Score: {score}")

结果如下:

******************************
Content: 提供两种方案有以下好处:
降低门槛: 单机版让用户快速体验核心功能,避免初期陷入复杂部署。
Metadata: {'source': '../Docs/Markdown/脚手架微服务租房平台Q&A.md', 'category': 'QA', 'num': 79}
Score: 0.580324351788
******************************
Content: 数据库: MySQL + MyBatis
MySQL 和 MyBatis 是数据存储和访问的一个常见的做法。MySQL 是一个开源的关系数据库管理系
Metadata: {'source': '../Docs/Markdown/脚手架微服务租房平台Q&A.md', 'category': 'QA', 'num': 23}
Score: 0.582783222198

注意:分数越低表示相似度越高。

元数据过滤

对于上述列举的方法,在他们搜索之前,都可以根据元数据先进行过滤。对于 RedisVectorStore 来说,需要使用 Redis 过滤表达式进行筛选。示例如下:

from redisvl.query.filter import Tag
# 过滤表达式
filter_condition = Tag("source") == "hahahaha"
scored_results = vector_store.similarity_search_with_score(
    query="数据库表怎么设计的?",
    k=2,
    filter=filter_condition
)
for doc, score in scored_results:
    print("*" * 30)
    print(f"Content: {doc.page_content[:100]}...")
    print(f"Metadata: {doc.metadata}")
    print(f"Score: {score}")

示例中,我们给出的 source=="hahahaha" 是无效的,因此会将所有的文档都过滤掉,导致示例代码运行后结果为空!将 source 换成正确的来源后,再次运行则结果正确。

对于进行过滤表达式,也可以使用 &| 运算符组合。如我们现根据元数据中的 categorynum 进行过滤,如下所示:

from redisvl.query.filter import Tag, Num
category_is_qa = Tag("category") == "qa"
num_is_under_50 = Num("num") < 50
filter_condition = category_is_qa & num_is_under_50
scored_results = vector_store.similarity_search_with_score(
    query="数据库表怎么设计的?",
    k=2,
    filter=filter_condition
)
for doc, score in scored_results:
    print("*" * 30)
    print(f"Content: {doc.page_content[:100]}...")
    print(f"Metadata: {doc.metadata}")
    print(f"Score: {score}")

结果如下:

******************************
Content: 数据库: MySQL + MyBatis
MySQL 和 MyBatis 是数据存储和访问的一个常见的做法。MySQL 是一个开源的关系数据库管理系
Metadata: {'source': '../Docs/Markdown/脚手架微服务租房平台Q&A.md', 'category': 'QA', 'num': 23}
Score: 0.582783222198
******************************
Content: 咨询消息的持久化优化
分库分表: 按user_id哈希分库,按月份分表(如chat_msg_2023_10);
读写分离:
写操作: 主库(高并发队列缓冲);
读操作: 从库 + Redis缓存...
Metadata: {'source': '../Docs/Markdown/脚手架微服务租房平台Q&A.md', 'category': 'QA', 'num': 47}
Score: 0.657752811909
最大边际相关性搜索

回顾一下什么是最大边际相关性?它是一种重新排序算法,它使用语义相似性作为基础工具,从一个候选集中挑选出一组既能代表查询主题又彼此多样化的结果。

  • 【语义相似性】 就像面试官衡量每个应聘者与职位要求的匹配度。他会给每个应聘者打一个分数。
  • 【最大边际相关性】 就像团队经理(MMR 算法)要组建一个团队。目标是选出一组 “精华” 结果,而不是一个单一结果:

MMR 使用场景

  • 推荐系统:推荐与用户兴趣相关但又不同类型的物品,避免 “信息茧房”。
  • 文档摘要:从长文档中选择能代表主旨又包含不同信息的句子,避免摘要内容重复。
  • RAG(检索增强生成):在从知识库检索完一堆相关文档后,使用 MMR 进行去重和多样化筛选,再交给 LLM 生成答案,能有效提升答案质量和减少幻觉。

使用示例

使用 MMR 搜索,需要用到 max_marginal_relevance_search 方法,代码如下:

from redisvl.query.filter import Tag, Num

# 过滤表达式
category_is_qa = Tag("category") == "qa"
num_is_under_50 = Num("num") < 50
filter_condition = category_is_qa & num_is_under_50

mmr_results = vector_store.max_marginal_relevance_search(
    query="数据库表怎么设计的?",
    k=2,
    fetch_k=10,
    filter=filter_condition
)
for doc in mmr_results:
    print("*" * 30)
    print(f"Content: {doc.page_content[:100]}...")
    print(f"Metadata: {doc.metadata}")

关键参数说明

  • fetch_k:是 MMR 算法第一步中,从向量库中初步获取的候选文档数量。

MMR 两阶段过程

  1. 初步获取:系统首先根据查询的纯向量相似度,从庞大的向量库中找出最相似的 fetch_k 个文档。这一步的目标是 “广撒网”,先找到一个足够大的相关文档池。
  2. 重新排序与筛选:然后,MMR 算法会在这个较小的候选池(大小为 fetch_k)中运行。它不再只考虑与查询的相似度,还会考虑候选文档之间的多样性。它会从这 fetch_k 个文档中,挑选出既与查询相关,彼此之间又不太相似的 k 个文档作为最终结果。

因此,其目的就是在保证相关性的前提下,提升结果的多样性。

******************************
Content: 数据库: MySQL + MyBatis
MySQL 和 MyBatis 是数据存储和访问的一个常见的做法。MySQL 是一个开源的关系数据库管理系
Metadata: {'source': '../Docs/Markdown/脚手架微服务租房平台Q&A.md', 'category': 'QA', 'num': 23}
******************************
Content: 你是如何优化项目性能的?你有哪些性能调优的经验?
房源搜索的筛选+排序用设计模式优化
热点数据存放缓存的优化
没有性能...
Metadata: {'source': '../Docs/Markdown/脚手架微服务租房平台Q&A.md', 'category': 'QA', 'num': 40}

Pinecone 向量存储

Pinecone 介绍

Pinecone 是为机器学习应用量身打造的生产级向量数据库服务,适用于高维向量数据的高效存储、索引与查询。它屏蔽了基础设施管理,提供无缝扩展、实时数据写入和强大安全保障,让开发者和数据科学家能够以极低运维成本,快速构建高效的相似度搜索、推荐系统和 AI 应用。

Pinecone 是一个全托管的向量数据库平台,即负责所有后端维护、扩展、更新和监控,让用户专注于应用开发,无需担心数据库管理。

Pinecone 地址:https://www.pinecone.io/(魔法上网)

环境设置
  • 首次使用需注册新用户,选择个人免费版。
  • 继续创建账户相关信息,或者直接右上角 skip 跳过,这里我们直接跳过。
  • 注册成功会生成一个默认的 API Key。注意保存好你的 key。
  • 也可以创建新的 API key。
  • 设置 PINECONE_API_KEY,将 Key 添加进环境变量。
  • 更新依赖包
pip install -qU pinecone langchain-pinecone

基本操作

初始化 Pinecone 向量库

from langchain_openai import OpenAIEmbeddings
from langchain_pinecone import PineconeVectorStore
from pinecone import Pinecone, ServerlessSpec

# 1. 建立 Pinecone 客户端并创建索引
pc = Pinecone()
index_name = "qa"
if not pc.has_index(index_name):
    pc.create_index(
        name=index_name,                # 索引名称
        dimension=3072,                 # 向量维度,需与嵌入模型维度一致
        metric="cosine",                # 相似度度量方式(余弦相似度)
        spec=ServerlessSpec(
            cloud="aws",                # 云服务商
            region="us-east-1"           # 部署区域
        ),
    )

# 2. 定义嵌入模型
embeddings = OpenAIEmbeddings(model="text-embedding-3-large")

# 3. 获取索引并初始化向量存储
index = pc.Index(index_name)
vector_store = PineconeVectorStore(embedding=embeddings, index=index)

关键参数说明:

  • embedding:用于生成向量的嵌入模型实例
  • index:Pinecone 中已创建的索引对象

添加文档

from langchain_community.document_loaders import UnstructuredMarkdownLoader
from langchain_text_splitters import CharacterTextSplitter

# 1. 生成分割器
text_splitter = CharacterTextSplitter.from_tiktoken_encoder(
    encoding_name="cl100k_base", chunk_size=200, chunk_overlap=50
)

# 2. 加载文档
data = UnstructuredMarkdownLoader("../Docs/Markdown/脚手架微服务租房平台Q&A.md", category="QA").load()

# 3. 分割文档并添加元数据
documents = text_splitter.split_documents(data)
for i, doc in enumerate(documents, start=1):
    doc.metadata["category"] = "QA"
    doc.metadata["num"] = i

# 4. 添加到向量库
ids = vector_store.add_documents(documents=documents)
print(f"共编排了{len(ids)}个文档索引")
print(f"前3个文档的索引是: {ids[:3]}")

输出示例:

共编排了96个文档索引
前3个文档的索引是: ['eee6f5f6-696b-4088-94d0-d0511cc92623', '36b8f351-35e5-4096-81ea-b29701a9b4c8', '8c133041-e2a4-4040-8b11-98b4d408a752']

删除文档

# 全量删除
vector_store.delete(delete_all=True)

# 删除指定 ID 的文档列表
delete_ids = []
vector_store.delete(ids=delete_ids)

向量搜索

search_docs = vector_store.similarity_search(
    query="数据库表怎么设计的?",
    k=2,
    filter={"category": "QA"}
)
for doc in search_docs:
    print("*" * 30)
    print(f"Content: {doc.page_content[:100]}...")
    print(f"Metadata: {doc.metadata}")

输出示例:

******************************
Content: 提供两种方案有以下好处:
降低门槛: 单机版让用户快速体验核心功能,避免初期陷入复杂部署。
Metadata: {'category': 'QA', 'num': 79.0, 'source': '../Docs/Markdown/脚手架微服务租房平台Q&A.md'}
******************************
Content: 数据库: MySQL + MyBatis
MySQL 和 MyBatis 是数据存储和访问的一个常见的做法。MySQL 是一个开源的关系数据库管理系
Metadata: {'category': 'QA', 'num': 23.0, 'source': '../Docs/Markdown/脚手架微服务租房平台Q&A.md'}

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐