11 KiB
适用版本:v0.2.2
1. 引言
vector.Index 组件是 Zenfeed 系统中负责实现内容语义相似度检索的核心模块,与 block.Block 一一对应。它的主要目标是根据用户提供的查询向量,快速找到与之在语义上最相关的 Feed(通常是新闻资讯、文章等文本内容)。
该索引的核心设计理念是服务于文档级别的召回 (Document-level Recall)。与许多传统向量索引将每个文本块(chunk)视为独立节点不同,vector.Index 将整个 Feed 文档作为图中的一个节点。而 Feed 内容经过 embedding_spliter 切分后产生的多个文本块(chunks),它们各自的向量嵌入(embeddings)则作为该 Feed 节点的属性。
这种设计的独特性在于:
- 搜索结果直接是 Feed ID:用户搜索后直接获得相关 Feed 的标识符,而不是零散的文本片段。
- 相似度聚焦于“任何部分相关即相关”:如果一个 Feed 的任何一个 chunk 与查询向量高度相似,整个 Feed 就被认为是相关的。其最终得分为该 Feed 所有 chunks 与查询向量相似度中的最大值。
- 为新闻资讯场景优化:这种设计特别适合新闻资讯类应用,优先保证相关内容的召回率,确保用户不会错过重要信息,即使该信息仅是文章的一部分。
vector.Index 底层采用 HNSW (Hierarchical Navigable Small World) 算法来组织和搜索这些 Feed 节点,以实现高效的近似最近邻查找。
2. 核心概念
理解 vector.Index 的运作方式,需要熟悉以下核心概念:
-
Feed (Node):
- 在
vector.Index的 HNSW 图中,每个节点 (node) 代表一个独立的 Feed 文档 (例如一篇新闻报道)。 - 每个 Feed 通过一个唯一的
uint64ID 来标识。 - 节点存储了其对应的原始 Feed ID 以及与该 Feed 相关的多个向量。
- 在
-
Chunk (Vector Represented by
[][]float32):- 一个 Feed 的内容(尤其是其文本标签,如标题、正文)可能较长。如果直接将整个长文本生成单一的 embedding,可能会遇到以下问题:
- LLM 输入长度限制: 许多 embedding 模型对输入文本的长度有限制。
- 语义稀释 (Semantic Dilution): 对于包含多个主题或信息点的长文本,单一向量可能难以精确捕捉所有细微的语义,导致关键信息在整体平均化的向量表示中被“稀释”,降低了特定语义片段的表征能力。例如,一篇包含多个不同事件的综合报道,其单一向量可能无法很好地代表其中任何一个特定事件。
- 通过
embeddingSpliter,一个 Feed 的文本内容可以被切分成一个或多个语义相对连贯的 文本块 (Chunks)。这种切分有助于每个 chunk 聚焦于更具体的主题或信息点。 - 每个 Chunk 会被送入 LLM 生成一个 向量嵌入 (vector embedding)。
- 因此,一个 Feed 节点在索引中会关联一组向量 (vectors
[][]float32),每个子向量代表其一个 Chunk 的语义。
- 一个 Feed 的内容(尤其是其文本标签,如标题、正文)可能较长。如果直接将整个长文本生成单一的 embedding,可能会遇到以下问题:
-
Embedding:
- Embedding 是一个由浮点数组成的向量,由大语言模型 (LLM) 生成。它能够捕捉文本片段的语义信息,使得语义上相似的文本在向量空间中距离更近。
vector.Index存储和比较的就是这些 embeddings。
-
HNSW (Hierarchical Navigable Small World):
vector.Index使用 HNSW 作为其底层的近似最近邻 (ANN) 搜索算法。- HNSW 通过构建一个多层的图结构来实现高效搜索。上层图更稀疏,用于快速导航;下层图更密集,用于精确查找。
- 这种结构使得索引在插入新节点和执行搜索时都能保持较好的性能。
-
相似度计算 (Similarity Score):
- Feed 间相似度 (Inter-Feed Similarity):
- 当评估 HNSW 图中两个 Feed 节点(例如,
nodeA和nodeB)之间的相似度时,策略是计算nodeA的所有 Chunk 向量与nodeB的所有 Chunk 向量之间的两两余弦相似度。 - 最终,这两个 Feed 节点间的相似度取所有这些两两 Chunk 相似度中的最大值 (Maximal Local Similarity)。
- 选择此策略的原因: 对于新闻资讯,只要两篇报道中存在任何一对高度相关的片段(例如,都报道了同一核心事件或引用了同一关键信息),就认为这两篇报道具有强关联性。这有助于最大化召回率,确保用户能发现所有可能相关的资讯,即使它们整体侧重点不同。
- 潜在影响: 这种策略对局部强相关非常敏感,但也可能因为次要内容的偶然相似而将整体主题差异较大的 Feed 判定为相关,需要在上层应用或通过重排序模型来进一步优化精度。
- 当评估 HNSW 图中两个 Feed 节点(例如,
- 查询与 Feed 相似度 (Query-Feed Similarity):
- 当用户使用一个查询向量
q进行搜索时,计算q与目标 Feed 的每一个 Chunk 向量的余弦相似度。 - 该 Feed 最终与查询
q的相似度分数,同样取这些计算结果中的最大值。 - 这样做是为了确保只要 Feed 的任何一部分内容与用户查询高度匹配,该 Feed 就会被召回。
- 当用户使用一个查询向量
- Feed 间相似度 (Inter-Feed Similarity):
3. 主要接口
vector.Index 提供了一组清晰的接口,用于管理和查询基于 Feed 内容语义的向量索引。
-
Add(ctx context.Context, id uint64, vectors [][]float32) error- 业务目标: 将一个新的 Feed 文档及其所有内容块(Chunks)的向量表示添加到索引中,使其能够被后续的相似度搜索发现。
- 核心流程:
- 接收 Feed 数据: 接收 Feed 的唯一
id和代表其所有 Chunks 的vectors列表。 - 确定插入策略: 根据 HNSW 算法的层级构建原则,为该 Feed 节点随机确定一个在多层图结构中的最高插入层级。
- 查找邻近节点: 从选定的最高层级开始逐层向下,在每一层利用该层的图结构(和
EfConstruct参数指导下的搜索范围)为新 Feed 节点找到一组最相似的已有 Feed 节点(邻居)。此处的“相似”基于我们定义的“最大局部相似性”——即比较两个 Feed 所有 Chunk 向量对,取其中相似度最高的一对作为这两个 Feed 的相似度。 - 建立连接: 如果新 Feed 节点被分配到当前层级,则将其与找到的邻居建立双向连接(朋友关系),并更新其在该层级的友邻列表。
- 维护图结构: 在添加连接后,可能会触发友邻剪枝逻辑,以确保每个节点的友邻数量符合配置(
M或2*M),并尝试维护图的良好连接性,避免产生孤立节点或过度密集的区域。
- 接收 Feed 数据: 接收 Feed 的唯一
-
Search(ctx context.Context, q []float32, threshold float32, limit int) (map[uint64]float32, error)- 业务目标: 根据用户提供的查询向量
q,从索引中高效地检索出语义上最相似的 Feed 列表,并返回它们的 ID 及相似度得分。 - 核心流程:
- 接收查询: 接收查询向量
q、相似度阈值threshold和期望返回的最大结果数limit。 - 导航至目标区域: 从 HNSW 图的顶层开始,利用稀疏的高层图结构快速定位到与查询向量
q大致相关的区域,逐层向下,每层都找到与q更近的节点作为下一层的入口。 - 在底层精确搜索: 到达最底层的图(第 0 层,包含所有 Feed 节点)后,以上一步得到的入口点为起点,进行一次更细致的扩展搜索(受
EfSearch参数指导的搜索范围)。此搜索旨在找到与查询向量q的“最大局部相似性”(即q与 Feed 的所有 Chunk 向量相似度中的最大值)满足threshold且排名前limit的 Feed。 - 返回结果: 将符合条件的 Feed ID 及其对应的最高相似度分数打包返回。
- 接收查询: 接收查询向量
- 业务目标: 根据用户提供的查询向量
-
EncodeTo(ctx context.Context, w io.Writer) error/DecodeFrom(ctx context.Context, r io.Reader) error- 业务目标: 提供索引的持久化能力,允许将内存中的索引状态完整地保存到外部存储(如文件),并在需要时恢复。
- 核心流程 (
EncodeTo):- 写入元数据: 保存索引的配置参数(如
M,Ml,EfConstruct,EfSearch)和版本信息。 - 写入节点数据: 遍历所有 Feed 节点,依次保存每个节点的 ID、其所有 Chunk 向量(经过量化处理以压缩体积)、以及它在 HNSW 各层级上的友邻关系(友邻 ID 和相似度)。
- 写入层级结构: 保存每个层级所包含的节点 ID 列表。
- 写入元数据: 保存索引的配置参数(如
- 核心流程 (
DecodeFrom):- 读取元数据: 恢复索引配置。
- 重建节点数据: 读取并重建所有 Feed 节点,包括其 ID、反量化后的 Chunk 向量、以及友邻关系。
- 重建层级结构: 恢复 HNSW 的多层图。
4. 内部实现细节补充
4.1 核心数据表示
- Feed 节点 (
node): 每个 Feed 在内存中表示为一个node对象,它不仅存储了 Feed 的 ID 和其所有 Chunk 的向量 (vectors [][]float32),还关键地维护了它在 HNSW 图各个层级上的“友邻列表” (friendsOnLayers)。这个友邻列表是图连接性的基础。 - 分层图 (
layers): 索引内部维护一个layers列表,代表 HNSW 的多层结构。高层图节点更少、连接更稀疏,用于快速跳转;底层图(尤其是第0层)节点最多、连接最密集,用于精确搜索。 - 全局节点池 (
m): 一个从 Feed ID 到node对象的映射,方便快速访问任何已索引的 Feed。
4.2 索引构建的关键机制
- 概率性分层 (
randomInsertLevel): 新加入的 Feed 节点会被随机分配到一个最高层级。这种概率机制(受Ml参数影响)形成了 HNSW 的金字塔式层级结构。 - 动态邻居选择 (
insertAndLinkAtLevel中的搜索逻辑): 当一个新 Feed 节点加入某一层时,它会基于“最大局部相似性”在该层搜索一定数量(受EfConstruct影响)的最近邻居。 - 连接维护与剪枝 (
makeFriend,tryRemoveFriend): 与邻居建立双向连接后,为保证图的性能和结构(避免节点拥有过多邻居),会有一套剪枝逻辑。这套逻辑不仅考虑移除相似度最低的连接,有时还会考虑被移除连接的另一端节点的连接状况,试图避免制造“孤岛”节点,甚至在必要时(通过tryRemakeFriend)为连接数过少的节点尝试从“邻居的邻居”中寻找新的连接机会。
4.3 存储效率:向量量化
- 为了显著减少索引在持久化存储时占用的空间,
float32类型的向量在写入磁盘前会通过vectorutil.Quantize被转换为int8类型,并记录下转换所需的最小值和缩放比例。读取时再通过vectorutil.Dequantize进行有损恢复。这是在存储成本和表示精度之间的一种实用权衡。