Files
zenfeed/docs/tech/vector-zh.md
2025-05-19 20:59:40 +08:00

11 KiB
Raw Blame History

适用版本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 通过一个唯一的 uint64 ID 来标识。
    • 节点存储了其对应的原始 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 的语义。
  • Embedding:

    • Embedding 是一个由浮点数组成的向量,由大语言模型 (LLM) 生成。它能够捕捉文本片段的语义信息,使得语义上相似的文本在向量空间中距离更近。
    • vector.Index 存储和比较的就是这些 embeddings。
  • HNSW (Hierarchical Navigable Small World):

    • vector.Index 使用 HNSW 作为其底层的近似最近邻 (ANN) 搜索算法。
    • HNSW 通过构建一个多层的图结构来实现高效搜索。上层图更稀疏,用于快速导航;下层图更密集,用于精确查找。
    • 这种结构使得索引在插入新节点和执行搜索时都能保持较好的性能。
  • 相似度计算 (Similarity Score):

    • Feed 间相似度 (Inter-Feed Similarity):
      • 当评估 HNSW 图中两个 Feed 节点(例如,nodeAnodeB)之间的相似度时,策略是计算 nodeA 的所有 Chunk 向量与 nodeB 的所有 Chunk 向量之间的两两余弦相似度。
      • 最终,这两个 Feed 节点间的相似度取所有这些两两 Chunk 相似度中的最大值 (Maximal Local Similarity)
      • 选择此策略的原因: 对于新闻资讯,只要两篇报道中存在任何一对高度相关的片段(例如,都报道了同一核心事件或引用了同一关键信息),就认为这两篇报道具有强关联性。这有助于最大化召回率,确保用户能发现所有可能相关的资讯,即使它们整体侧重点不同。
      • 潜在影响: 这种策略对局部强相关非常敏感,但也可能因为次要内容的偶然相似而将整体主题差异较大的 Feed 判定为相关,需要在上层应用或通过重排序模型来进一步优化精度。
    • 查询与 Feed 相似度 (Query-Feed Similarity):
      • 当用户使用一个查询向量 q 进行搜索时,计算 q 与目标 Feed 的每一个 Chunk 向量的余弦相似度。
      • 该 Feed 最终与查询 q 的相似度分数,同样取这些计算结果中的最大值
      • 这样做是为了确保只要 Feed 的任何一部分内容与用户查询高度匹配,该 Feed 就会被召回。

3. 主要接口

vector.Index 提供了一组清晰的接口,用于管理和查询基于 Feed 内容语义的向量索引。

  • Add(ctx context.Context, id uint64, vectors [][]float32) error

    • 业务目标: 将一个新的 Feed 文档及其所有内容块Chunks的向量表示添加到索引中使其能够被后续的相似度搜索发现。
    • 核心流程:
      1. 接收 Feed 数据: 接收 Feed 的唯一 id 和代表其所有 Chunks 的 vectors 列表。
      2. 确定插入策略: 根据 HNSW 算法的层级构建原则,为该 Feed 节点随机确定一个在多层图结构中的最高插入层级。
      3. 查找邻近节点: 从选定的最高层级开始逐层向下,在每一层利用该层的图结构(和 EfConstruct 参数指导下的搜索范围)为新 Feed 节点找到一组最相似的已有 Feed 节点(邻居)。此处的“相似”基于我们定义的“最大局部相似性”——即比较两个 Feed 所有 Chunk 向量对,取其中相似度最高的一对作为这两个 Feed 的相似度。
      4. 建立连接: 如果新 Feed 节点被分配到当前层级,则将其与找到的邻居建立双向连接(朋友关系),并更新其在该层级的友邻列表。
      5. 维护图结构: 在添加连接后,可能会触发友邻剪枝逻辑,以确保每个节点的友邻数量符合配置(M2*M),并尝试维护图的良好连接性,避免产生孤立节点或过度密集的区域。
  • Search(ctx context.Context, q []float32, threshold float32, limit int) (map[uint64]float32, error)

    • 业务目标: 根据用户提供的查询向量 q,从索引中高效地检索出语义上最相似的 Feed 列表,并返回它们的 ID 及相似度得分。
    • 核心流程:
      1. 接收查询: 接收查询向量 q、相似度阈值 threshold 和期望返回的最大结果数 limit
      2. 导航至目标区域: 从 HNSW 图的顶层开始,利用稀疏的高层图结构快速定位到与查询向量 q 大致相关的区域,逐层向下,每层都找到与 q 更近的节点作为下一层的入口。
      3. 在底层精确搜索: 到达最底层的图(第 0 层,包含所有 Feed 节点)后,以上一步得到的入口点为起点,进行一次更细致的扩展搜索(受 EfSearch 参数指导的搜索范围)。此搜索旨在找到与查询向量 q 的“最大局部相似性”(即 q 与 Feed 的所有 Chunk 向量相似度中的最大值)满足 threshold 且排名前 limit 的 Feed。
      4. 返回结果: 将符合条件的 Feed ID 及其对应的最高相似度分数打包返回。
  • EncodeTo(ctx context.Context, w io.Writer) error / DecodeFrom(ctx context.Context, r io.Reader) error

    • 业务目标: 提供索引的持久化能力,允许将内存中的索引状态完整地保存到外部存储(如文件),并在需要时恢复。
    • 核心流程 (EncodeTo):
      1. 写入元数据: 保存索引的配置参数(如 M, Ml, EfConstruct, EfSearch)和版本信息。
      2. 写入节点数据: 遍历所有 Feed 节点,依次保存每个节点的 ID、其所有 Chunk 向量(经过量化处理以压缩体积)、以及它在 HNSW 各层级上的友邻关系(友邻 ID 和相似度)。
      3. 写入层级结构: 保存每个层级所包含的节点 ID 列表。
    • 核心流程 (DecodeFrom):
      1. 读取元数据: 恢复索引配置。
      2. 重建节点数据: 读取并重建所有 Feed 节点,包括其 ID、反量化后的 Chunk 向量、以及友邻关系。
      3. 重建层级结构: 恢复 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 进行有损恢复。这是在存储成本和表示精度之间的一种实用权衡。