add tech docs
This commit is contained in:
153
docs/tech/hld-zh.md
Normal file
153
docs/tech/hld-zh.md
Normal file
@@ -0,0 +1,153 @@
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph User_Interactions
|
||||
WebUI["Web UI (zenfeed-web)"]
|
||||
MCPClient["MCP Client"]
|
||||
end
|
||||
|
||||
subgraph Zenfeed_Core_Services
|
||||
HTTPServer["HTTP Server (pkg/api/http)"]
|
||||
MCPServer["MCP Server (pkg/api/mcp)"]
|
||||
API["API Service (pkg/api)"]
|
||||
end
|
||||
|
||||
subgraph Data_Processing_Storage_Main
|
||||
ScraperManager["Scraper Manager (pkg/scrape)"]
|
||||
Rewriter["Rewriter (pkg/rewrite)"]
|
||||
FeedStorage["Feed Storage (pkg/storage/feed)"]
|
||||
LLMFactory["LLM Factory (pkg/llm)"]
|
||||
KVStorage["KV Storage (pkg/storage/kv)"]
|
||||
end
|
||||
|
||||
subgraph FeedStorage_Internals
|
||||
Block["Block (pkg/storage/feed/block)"]
|
||||
ChunkFile["ChunkFile (pkg/storage/feed/block/chunk)"]
|
||||
PrimaryIndex["Primary Index (pkg/storage/feed/block/index/primary)"]
|
||||
InvertedIndex["Inverted Index (pkg/storage/feed/block/index/inverted)"]
|
||||
VectorIndex["Vector Index (pkg/storage/feed/block/index/vector)"]
|
||||
end
|
||||
|
||||
subgraph Scheduling_Notification
|
||||
Scheduler["Scheduler (pkg/schedule)"]
|
||||
Notifier["Notifier (pkg/notify)"]
|
||||
NotifyChan["(Go Channel for Results)"]
|
||||
EmailChannel["Email Channel (pkg/notify/channel)"]
|
||||
end
|
||||
|
||||
ConfigManager["Config Manager (pkg/config)"]
|
||||
|
||||
ExternalDataSources["External Data Sources (RSS Feeds, RSSHub)"]
|
||||
LLMProviders["LLM Providers (OpenAI, Gemini, etc.)"]
|
||||
EmailServiceProvider["Email Service Provider (SMTP)"]
|
||||
|
||||
WebUI --> HTTPServer
|
||||
MCPClient --> MCPServer
|
||||
HTTPServer --> API
|
||||
MCPServer --> API
|
||||
|
||||
API --> ConfigManager
|
||||
API --> FeedStorage
|
||||
API --> LLMFactory
|
||||
|
||||
ScraperManager --> ExternalDataSources
|
||||
ScraperManager --> KVStorage
|
||||
ScraperManager --> FeedStorage
|
||||
|
||||
FeedStorage --> Rewriter
|
||||
FeedStorage --> LLMFactory
|
||||
FeedStorage --> KVStorage
|
||||
FeedStorage --> Block
|
||||
|
||||
Block --> ChunkFile
|
||||
Block --> PrimaryIndex
|
||||
Block --> InvertedIndex
|
||||
Block --> VectorIndex
|
||||
|
||||
Rewriter --> LLMFactory
|
||||
|
||||
Scheduler --> FeedStorage
|
||||
Scheduler --> NotifyChan
|
||||
Notifier --> NotifyChan
|
||||
Notifier --> LLMFactory
|
||||
Notifier --> EmailChannel
|
||||
Notifier --> KVStorage
|
||||
EmailChannel --> EmailServiceProvider
|
||||
|
||||
ConfigManager --> HTTPServer
|
||||
ConfigManager --> MCPServer
|
||||
ConfigManager --> API
|
||||
ConfigManager --> ScraperManager
|
||||
ConfigManager --> Rewriter
|
||||
ConfigManager --> FeedStorage
|
||||
ConfigManager --> LLMFactory
|
||||
ConfigManager --> Scheduler
|
||||
ConfigManager --> Notifier
|
||||
|
||||
LLMFactory --> LLMProviders
|
||||
LLMFactory --> KVStorage
|
||||
```
|
||||
|
||||
## 技术特点
|
||||
|
||||
* 零外部依赖
|
||||
* Golang 资源占用少于采用 Python 的竞品
|
||||
* 采用模块化、面向服务的架构,各组件职责清晰
|
||||
* 系统配置集中管理,并支持热重载,实现动态调整
|
||||
* 提供灵活的内容重写管道,可自定义处理流程
|
||||
* Feed 数据按时间分块存储,支持高效索引与生命周期管理
|
||||
* 支持基于向量嵌入的语义搜索能力
|
||||
* 通过可配置的抓取器和 RSSHub 集成,支持多样化的数据源
|
||||
* 基于规则的调度引擎,实现灵活的事件监控与查询
|
||||
* 可定制的通知路由和多渠道通知发送机制
|
||||
* 实现 MCP (Model Context Protocol) 服务端,便于外部工具集成
|
||||
* 提供统一的 API 接口层,解耦核心业务与通信协议
|
||||
* 内置通用键值存储,用于缓存和持久化辅助状态
|
||||
|
||||
## 组件说明
|
||||
|
||||
1. **配置管理器 (ConfigManager - `pkg/config.Manager`)**
|
||||
* 负责加载、管理和热更新应用的整体配置 (通常存储在 `config.yaml` 中)。其他组件订阅配置变更,以便动态调整其行为。是系统动态性的基础。
|
||||
|
||||
2. **键值存储 (KVStorage - `pkg/storage/kv.Storage`)**
|
||||
* 提供一个通用的键值存储服务。用于存储临时状态、缓存(如 LLM 调用、RSSHub 响应)、小型元数据、以及一些组件的运行状态(如 Scraper 的最后抓取时间、Notifier 的通知发送记录)。
|
||||
|
||||
3. **大语言模型工厂 (LLMFactory - `pkg/llm.Factory`)**
|
||||
* 管理和提供大语言模型 (LLM) 的实例。它根据配置初始化不同的 LLM 客户端 (如 OpenAI, Gemini, SiliconFlow 等),并向上层组件 (如 `Rewriter`, `FeedStorage`, `Notifier`) 提供统一的 LLM 调用接口。这些接口用于文本生成、内容摘要、向量嵌入等 AI 处理任务。,可以动态切换或更新 LLM 配置。
|
||||
|
||||
4. **内容重写器 (Rewriter - `pkg/rewrite.Rewriter`)**
|
||||
* 根据用户在配置文件中定义的重写规则 (Rewrite Rules),对原始 Feed 内容进行管道式处理。每个规则可以针对 Feed 的特定标签 (如标题、正文),通过调用 `LLMFactory` 提供的模型执行操作 (如评分、分类、摘要、过滤、添加新标签等)。处理后的 Feed 用于存储或进一步的逻辑判断。
|
||||
|
||||
5. **Feed 存储 (FeedStorage - `pkg/storage/feed.Storage`)**
|
||||
* 负责持久化存储经过 `Rewriter` 处理后的 Feed 数据,并提供高效的查询接口。它管理着 Feed 数据的生命周期和存储结构。
|
||||
* **关键子组件**:
|
||||
* **Block (`pkg/storage/feed/block.Block`)**: `FeedStorage` 将数据按时间组织成多个 `Block`。每个 `Block` 代表一个时间段内的数据 (例如,过去 25 小时)。这种设计有助于数据的管理,如按时间归档、删除过期数据,并能独立处理冷热数据。
|
||||
* **ChunkFile (`pkg/storage/feed/block/chunk.File`)**: 在每个 `Block` 内部,实际的 Feed 内容(经过序列化,包含所有标签和时间戳)存储在 `ChunkFile` 中。这是一种紧凑的存储方式,支持高效的追加和按偏移读取。
|
||||
* **Primary Index (`pkg/storage/feed/block/index/primary.Index`)**: 为每个 `Block` 内的 Feed 提供主键索引。它将全局唯一的 Feed ID 映射到该 Feed 在对应 `ChunkFile` 中的具体位置(如偏移量),实现通过 ID 快速定位 Feed 数据。
|
||||
* **Inverted Index (`pkg/storage/feed/block/index/inverted.Index`)**: 为每个 `Block` 内的 Feed 标签建立倒排索引。它将标签的键值对映射到包含这些标签的 Feed ID 列表,从而能够根据标签条件快速过滤 Feed。
|
||||
* **Vector Index (`pkg/storage/feed/block/index/vector.Index`)**: 为每个 `Block` 内的 Feed(或其内容切片)存储由 `LLMFactory` 生成的向量嵌入。它支持高效的近似最近邻搜索,从而实现基于语义相似度的 Feed 查询。
|
||||
|
||||
6. **API 服务 (API - `pkg/api.API`)**
|
||||
* 提供核心的业务逻辑接口层,供上层服务 (如 `HTTPServer`, `MCPServer`) 调用,解耦核心业务逻辑与具体的通信协议。接口功能包括:应用配置的查询与动态应用、RSSHub 相关信息的查询、Feed 数据的写入与多维度查询等。此组件会响应配置变更,并将其传递给其依赖的下游组件。
|
||||
|
||||
7. **HTTP 服务 (HTTPServer - `pkg/api/http.Server`)**
|
||||
* 暴露一个 HTTP/JSON API 接口,主要供 Web 前端 (`zenfeed-web`) 或其他HTTP客户端使用。用户通过此接口进行如添加订阅源、配置监控规则、查看 Feed 列表、管理应用配置等操作。它依赖 `API` 组件来执行实际的业务逻辑。
|
||||
|
||||
8. **MCP 服务 (MCPServer - `pkg/api/mcp.Server`)**
|
||||
* 实现 Model Context Protocol (MCP) 服务端。这使得 Zenfeed 的数据可以作为上下文源被外部应用或 LLM 集成。
|
||||
|
||||
9. **抓取管理器 (ScraperManager - `pkg/scrape.Manager`)**
|
||||
* 负责管理和执行从各种外部数据源 (主要是 RSS Feed,支持通过 RSSHub 扩展源) 抓取内容的任务。它根据配置中定义的来源和抓取间隔,定期或按需从指定的 URL 或 RSSHub 路由抓取最新的 Feed 数据。抓取到的原始数据会提交给 `FeedStorage` 进行后续的重写处理和存储。
|
||||
* **关键子组件**:
|
||||
* **Scraper (`pkg/scrape/scraper.Scraper`)**: 每个配置的数据源会对应一个 `Scraper` 实例,负责该特定源的抓取逻辑和调度。
|
||||
* **Reader (`pkg/scrape/scraper/source.go#reader`)**: `Scraper` 内部使用不同类型的 `reader` (如针对标准 RSS URL 的 reader,针对 RSSHub 路径的 reader) 来实际获取数据。
|
||||
|
||||
10. **调度器 (Scheduler - `pkg/schedule.Scheduler`)**
|
||||
* 根据用户配置的调度规则 (Scheduls Rules) 定期执行查询任务。这些规则定义了特定的查询条件,如语义关键词 (基于向量搜索)、标签过滤、以及时间范围等。当 `FeedStorage` 中有符合规则条件的 Feed 数据时,调度器会将这些结果 (封装为 `rule.Result`) 通过一个内部 Go Channel (`notifyChan`) 发送给 `Notifier` 组件进行后续处理。
|
||||
* **关键子组件**:
|
||||
* **Rule (`pkg/schedule/rule.Rule`)**: 每个调度配置对应一个 `Rule` 实例,封装了该规则的查询逻辑和执行计划。
|
||||
|
||||
11. **通知器 (Notifier - `pkg/notify.Notifier`)**
|
||||
* 监听来自 `Scheduler` 的 `notifyChan`。接收到 `rule.Result` 后,它会根据通知路由 (NotifyRoute) 配置对 Feed 进行分组、聚合。为了生成更精炼的通知内容,它可能会再次调用 `LLMFactory` 进行摘要。最终,通过配置的通知渠道 (NotifyChannels) 将处理后的信息发送给指定的接收者 (NotifyReceivers)。其发送状态或去重逻辑可能利用 `KVStorage`。
|
||||
* **关键子组件**:
|
||||
* **Router (`pkg/notify/route.Router`)**: 根据配置的路由规则,将 `rule.Result` 中的 Feed 分配到不同的处理流程或目标接收者。
|
||||
* **Channel (`pkg/notify/channel.Channel`)**: 代表具体的通知发送方式,例如 `EmailChannel` 负责通过 SMTP 发送邮件。
|
||||
159
docs/tech/testing.md
Normal file
159
docs/tech/testing.md
Normal file
@@ -0,0 +1,159 @@
|
||||
# Zenfeed 最新测试策略与风格
|
||||
|
||||
## 1. 引言
|
||||
|
||||
Zenfeed 的测试策略核心目标是:
|
||||
|
||||
* **清晰性 (Clarity)**:测试本身应如文档般易于理解,清晰地表达被测功能的行为和预期。
|
||||
* **可信性 (Reliability)**:测试结果应准确反映代码的健康状况,确保每次提交的信心。
|
||||
* **可维护性 (Maintainability)**:测试代码应易于修改和扩展,以适应项目的持续演进。
|
||||
|
||||
本指南旨在详细介绍 Zenfeed 项目所遵循的测试理念、风格和具体实践。
|
||||
|
||||
## 2. 核心测试理念与风格
|
||||
|
||||
Zenfeed 的测试方法论深受行为驱动开发 (BDD) 的影响,并结合了表驱动测试等高效实践。
|
||||
|
||||
### 2.1 行为驱动开发
|
||||
|
||||
我们选择 BDD 作为核心的测试描述框架,主要基于以下原因(其理念也体现在 `pkg/test/test.go` 的 `Case` 结构设计中):
|
||||
|
||||
* **提升可读性 (Enhanced Readability)**:BDD 强调使用自然语言描述软件的行为。每个测试用例读起来都像一个用户故事或一个功能说明,这使得测试本身就成为了一种精确的"活文档"。
|
||||
* **关注行为 (Focus on Behavior)**:测试不再仅仅是验证代码片段的输入输出,而是从模块、组件或用户交互的层面描述其应有的行为。这有助于确保我们构建的功能符合预期。
|
||||
* **需求驱动 (Requirement-Driven)**:测试直接对应需求描述,而非实现细节。这种自顶向下的方法确保了测试的稳定性,即使内部实现重构,只要行为不变,测试依然有效。
|
||||
|
||||
BDD 通常使用 `Scenario`, `Given`, `When`, `Then` 的结构来组织测试:
|
||||
|
||||
* **`Scenario` (场景)**:描述测试用例所针对的特性或功能点。
|
||||
* 例如:`"Query hot block with label filters"` (查询带标签过滤的热数据块)
|
||||
* **`Given` (给定)**:描述场景开始前的初始上下文或状态(**注意:这不是指方法的输入参数**)。
|
||||
* 例如:`"a hot block with indexed feeds"` (一个已索引了 Feed 的热数据块)
|
||||
* **`When` (当)**:描述触发场景的事件或操作(**这部分通常包含被测方法的输入参数**)。
|
||||
* 例如:`"querying with label filters"` (当使用标签过滤器进行查询时)
|
||||
* **`Then` (那么)**:描述场景结束后预期的结果或状态变化。
|
||||
* 例如:`"should return matching feeds"` (那么应该返回匹配的 Feed)
|
||||
|
||||
为了更好地在代码中实践 BDD,我们定义了 `pkg/test/test.go` 中的 `Case[GivenDetail, WhenDetail, ThenExpected]` 泛型结构。其中:
|
||||
|
||||
* `GivenDetail`: 存储 `Given` 子句描述的初始状态的具体数据。
|
||||
* `WhenDetail`: 存储 `When` 子句描述的事件或方法调用的具体参数。
|
||||
* `ThenExpected`: 存储 `Then` 子句描述的预期结果。
|
||||
|
||||
这种结构化不仅增强了测试数据的类型安全,也使得测试用例的意图更加明确。对于需要模拟依赖项的组件,`GivenDetail` 通常会包含用于配置这些模拟行为的 `component.MockOption`,我们将在后续 Mocking 章节详细讨论。
|
||||
|
||||
### 2.2 表驱动测试
|
||||
|
||||
当一个功能或方法需要针对多种不同的输入组合、边界条件或状态进行测试时,表驱动测试是一种非常高效和整洁的组织方式。
|
||||
|
||||
* **简洁性 (Conciseness)**:将所有测试用例的数据(输入、参数、预期输出)集中定义在一个表格(通常是切片)中,避免了为每个 case编写大量重复的测试逻辑。
|
||||
* **易扩展性 (Extensibility)**:添加新的测试场景变得非常简单,只需在表格中增加一条新记录即可。
|
||||
* **清晰性 (Clarity)**:所有相关的测试用例一目了然,便于快速理解被测功能的覆盖范围。
|
||||
|
||||
**实践约定**:
|
||||
在 Zenfeed 中,**当存在多个测试用例时,必须使用表驱动测试**。
|
||||
|
||||
### 2.3 测试结构约定
|
||||
|
||||
为了保持项目范围内测试代码的一致性和可读性,我们约定在测试文件中遵循以下组织结构:
|
||||
|
||||
1. **定义辅助类型 (Define Helper Types)**:在测试函数的开头部分,通常会为 `GivenDetail`, `WhenDetail`, `ThenExpected` 定义具体的结构体类型,以增强类型安全和表达力。
|
||||
2. **定义测试用例表 (Define Test Case Table)**:将所有测试用例集中定义在一个 `[]test.Case` 类型的切片中。
|
||||
3. **循环执行测试 (Loop Through Test Cases)**:使用 `for` 循环遍历测试用例表,并为每个用例运行 `t.Run(tt.Scenario, func(t *testing.T) { ... })`。
|
||||
4. **清晰的 G/W/T 逻辑块 (Clear G/W/T Blocks)**:在每个 `t.Run` 的匿名函数内部,根据需要组织代码块,以对应 `Given`(准备初始状态,通常基于 `tt.GivenDetail`),`When`(执行被测操作,通常使用 `tt.WhenDetail`),和 `Then`(断言结果,通常对比 `tt.ThenExpected`)。
|
||||
5. **描述性变量名 (Descriptive Variable Names)**:使用与 BDD 术语(如 `given`, `when`, `then`, `expected`, `actual`)相匹配或能清晰表达意图的变量名。
|
||||
|
||||
## 3. 依赖隔离:Mocking (Dependency Isolation: Mocking)
|
||||
|
||||
单元测试的核心原则之一是**隔离性 (Isolation)**,即被测试的代码单元(如一个函数或一个方法)应该与其依赖项隔离开来。Mocking (模拟) 是实现这种隔离的关键技术。
|
||||
|
||||
我们主要使用 `github.com/stretchr/testify/mock` 库来实现 Mocking。特别是对于实现了 `pkg/component/component.go` 中 `Component` 接口的组件,我们提供了一种标准的 Mocking 方式。
|
||||
|
||||
|
||||
```go
|
||||
type givenDetail struct {
|
||||
// Example of another initial state field for the component being tested
|
||||
initialProcessingPrefix string
|
||||
// MockOption to set up the behavior of dependencyA
|
||||
dependencyAMockSetup component.MockOption
|
||||
// ...
|
||||
}
|
||||
|
||||
type whenDetail struct {
|
||||
processDataInput string
|
||||
// ...
|
||||
}
|
||||
|
||||
type thenExpected struct {
|
||||
expectedOutput string
|
||||
expectedError error
|
||||
// ...
|
||||
}
|
||||
|
||||
tests := []test.Case[givenDetail, whenDetail, thenExpected]{
|
||||
{
|
||||
Scenario: "Component processes data successfully with mocked dependency",
|
||||
Given: "YourComponent with an initial prefix and dependencyA mocked to return 'related_data_value' for 'input_key'",
|
||||
When: "ProcessData is called with 'input_key'",
|
||||
Then: "Should return 'prefix:input_key:related_data_value' and no error",
|
||||
GivenDetail: givenDetail{
|
||||
initialProcessingPrefix: "prefix1",
|
||||
dependencyAMockSetup: func(m *mock.Mock) {
|
||||
// We expect DependencyA's FetchRelatedData to be called with "input_key"
|
||||
// and it should return "related_data_value" and no error.
|
||||
m.On("FetchRelatedData", "input_key").
|
||||
Return("related_data_value", nil).
|
||||
Once() // Expect it to be called exactly once.
|
||||
},
|
||||
},
|
||||
WhenDetail: whenDetail{
|
||||
processDataInput: "input_key",
|
||||
},
|
||||
ThenExpected: thenExpected{
|
||||
expectedOutput: "prefix1:input_key:related_data_value",
|
||||
expectedError: nil,
|
||||
},
|
||||
},
|
||||
// ...更多测试用例...
|
||||
}
|
||||
|
||||
|
||||
// 在 for _, tt := range tests { t.Run(tt.Scenario, func(t *testing.T) { ... }) } 循环内部
|
||||
|
||||
// Given 阶段: Setup mocks and the component under test
|
||||
var mockHelperForDepA *mock.Mock
|
||||
defer func() { // 确保在每个子测试结束时断言
|
||||
if mockHelperForDepA != nil {
|
||||
mockHelperForDepA.AssertExpectations(t)
|
||||
}
|
||||
}()
|
||||
|
||||
// 创建并配置 mockDependencyA
|
||||
// dependency_a_pkg.NewFactory 应该是一个返回 DependencyA 接口和 error 的工厂函数
|
||||
// 它接受 component.MockOption 来配置其内部的 mock.Mock 对象
|
||||
mockDependencyA, err := dependency_a_pkg.NewFactory(
|
||||
component.MockOption(func(m *mock.Mock) {
|
||||
mockHelperForDepA = m // 保存 mock.Mock 实例以供 AssertExpectations 使用
|
||||
if tt.GivenDetail.dependencyAMockSetup != nil {
|
||||
// 应用测试用例中定义的 specific mock setup
|
||||
tt.GivenDetail.dependencyAMockSetup(m)
|
||||
}
|
||||
}),
|
||||
).New("mocked_dep_a_instance", nil /* config for dep A */, dependency_a_pkg.Dependencies{})
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(mockDependencyA).NotTo(BeNil())
|
||||
|
||||
// 假设 YourComponent 的构造函数如下:
|
||||
componentUnderTest := NewYourComponent(tt.GivenDetail.initialProcessingPrefix, mockDependencyA)
|
||||
|
||||
// When 阶段: Execute the action being tested
|
||||
actualOutput, actualErr := componentUnderTest.ProcessData(context.Background(), tt.WhenDetail.processDataInput)
|
||||
|
||||
// Then 阶段: Assert the outcomes
|
||||
if tt.ThenExpected.expectedError != nil {
|
||||
Expect(actualErr).To(HaveOccurred())
|
||||
Expect(actualErr.Error()).To(Equal(tt.ThenExpected.expectedError.Error()))
|
||||
} else {
|
||||
Expect(actualErr).NotTo(HaveOccurred())
|
||||
}
|
||||
Expect(actualOutput).To(Equal(tt.ThenExpected.expectedOutput))
|
||||
```
|
||||
100
docs/tech/vector.md
Normal file
100
docs/tech/vector.md
Normal file
@@ -0,0 +1,100 @@
|
||||
## 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)**: 对于包含多个主题或信息点的长文本,单一向量可能难以精确捕捉所有细微的语义,导致关键信息在整体平均化的向量表示中被“稀释”,降低了特定语义片段的表征能力。例如,一篇包含多个不同事件的综合报道,其单一向量可能无法很好地代表其中任何一个特定事件。
|
||||
* 通过 `pkg/llm/embedding_spliter.go` 中的 `embeddingSpliter`,一个 Feed 的文本内容可以被切分成一个或多个语义相对连贯的 **文本块 (Chunks)**。这种切分有助于每个 chunk 聚焦于更具体的主题或信息点。
|
||||
* 每个 Chunk 会被送入 LLM(如 `pkg/llm/embedding.go` 所管理的模型)生成一个 **向量嵌入 (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` 进行有损恢复。这是在存储成本和表示精度之间的一种实用权衡。
|
||||
Reference in New Issue
Block a user