> 适用版本: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 节点(例如,`nodeA` 和 `nodeB`)之间的相似度时,策略是计算 `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. **维护图结构**: 在添加连接后,可能会触发友邻剪枝逻辑,以确保每个节点的友邻数量符合配置(`M` 或 `2*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` 进行有损恢复。这是在存储成本和表示精度之间的一种实用权衡。