数据截至 (上游 commit eb1f187f8722)
Agentset 检索引擎 — 向量库抽象、混合检索、过滤翻译、重 排
本章讲什么:
@agentset/engine这一层,全库工程含量最高的部分。一次检索从「一句话查询」到「排好序的 chunk」中间发生的所有事:嵌入、向量库抽象、三种检索模式、混合检索的 RRF 融合、过滤器跨厂商翻译、重排。
1. 一次检索的主线
所有检索都从一个函数进:queryVectorStore(packages/engine/src/vector-store/query.ts:17)。它做三步:
一句话 query
│
▼ ① 算查询向量
embed(model, query) ──────────────▶ embedding: number[]
│
▼ ② 查向量库(模式三选一)
vectorStore.query({ mode, topK, filter, minScore, ... })
│
▼ ③ 可选:重排
if (rerank) → getRerankingModel → rerank(results)
│
▼
{ query, results, unorderedIds }
关键点:嵌入和向量库都是「按 namespace 配置动态挑的」。调用方先用工厂函数拿到实例,再传进来:
// 示意,见 chat/route.ts:76
const [vectorStore, embeddingModel] = await Promise.all([
getNamespaceVectorStore(namespace, tenantId), // 挑 Turbopuffer 还是 Pinecone
getNamespaceEmbeddingModel(namespace, "query"), // 挑 OpenAI / Voyage / Google ...
]);
2. 厂商无关的抽象:VectorStore 基类
它要解决的小问题: 不同向量库(Turbopuffer、Pinecone)API 完全不同,但上层业务不该关心用的是哪家。
做法是经典的「抽象基类 + 多实现」。VectorStore(packages/engine/src/vector-store/common/vector-store.ts:83)声明了一组抽象方法,每家向量库实现一遍:
// vector-store.ts:73
abstract class VectorStore<Filter = VectorFilter> {
abstract query(options): Promise<VectorStoreQueryResponse>;
abstract upsert(options): Promise<void>;
abstract deleteByIds(ids): Promise<{ deleted?: number }>;
abstract deleteByFilter(filter): Promise<{ deleted?: number }>;
abstract supportsKeyword(): boolean; // ← 关键:能力查询
// ... getDimensions / warmCache / deleteNamespace
}
怎么挑实现: getNamespaceVectorStore(packages/engine/src/vector-store/index.ts:6)读 namespace 的 vectorStoreConfig.provider,switch 到对应实现,并用动态 import 按需加载(省冷启动):
// 示意,改写自 vector-store/index.ts:24 起
switch (config.provider) {
case "MANAGED_PINECONE": case "PINECONE": {
const { Pinecone } = await import("./pinecone/index");
return new Pinecone({ apiKey, indexHost, namespaceId, tenantId });
}
case "MANAGED_TURBOPUFFER": case "TURBOPUFFER": {
const { Turbopuffer } = await import("./turbopuffer/index");
return new Turbopuffer({ apiKey, region, namespaceId, tenantId });
}
}
精华:MANAGED_* vs 裸 provider。 MANAGED_TURBOPUFFER 用平台自己的 key(env.DEFAULT_TURBOPUFFER_API_KEY),裸 TURBOPUFFER 用客户自带的 key——同一套代码既支持「平台托管」又支持「自带向量库(BYO)」(vector-store/index.ts:51-66)。_exhaustiveCheck: never(:71)保证将来加 provider 忘了处理时 TS 直接报错。
租户隔离落到命名空间名字上: Turbopuffer 构造时把 namespaceId 和 tenantId 拼成命名空间名 as_{namespaceId}_{tenantId}(turbopuffer/index.ts:50),Pinecone 是 agentset:{namespaceId}:{tenantId}(pinecone/index.ts:34)。每个租户物理上一个独立命名空间。
3. 三种检索模式与混合检索的 RRF 融合
VectorStoreQueryOptions.mode(vector-store.ts:22)是个判别联合,三选一:
| 模式 | 怎么查 | 直觉 |
|---|---|---|
semantic | 向量近邻(ANN + cosine) | 「意思相近」,不要求字面命中 |
keyword | BM25 全文检索 | 「字面命中」,经典关键词搜索 |
hybrid | 两者都查,再融合 | 兼顾意思和字面,通常最强 |
3.1 语义 / 关键词:直接交给 Turbopuffer
Turbopuffer 的 query(packages/engine/src/vector-store/turbopuffer/index.ts:87)对前两种模式只是设不同的 rank_by:
// 示意,改写自 turbopuffer/index.ts:104 起
params.mode.type === "semantic"
? { rank_by: ["vector", "ANN", params.mode.vector], distance_metric: "cosine_distance" }
: { rank_by: ["text", "BM25", params.mode.text] } // keyword
3.2 混合:手写 Reciprocal Rank Fusion
它要解决的小问题: hybrid 模式下,向量检索给一个排序列表、BM25 给另一个,怎么合成一个最终排序?不能简单按分数加——两套分数量纲不同。
思路:RRF(倒数排名融合)。 不看分数,只看排名:一个文档在某个列表里排第 r 位,就给它加 1/(k+r) 分;两个列表的分加起来,总分高的排前面。排名越靠前贡献越大,且天然抹平了量纲差异。
Turbopuffer 先用 multiQuery 一次发两个查询,再手写融合(turbopuffer/index.ts:62):
// 示意,改写自 turbopuffer/index.ts:61 起。k 这里取 topK
for (const results of resultLists) { // 遍历 [向量列表, BM25 列表]
for (let rank = 1; rank <= results.length; rank++) {
const item = results[rank - 1];
scores[item.id] = (scores[item.id] || 0) + 1.0 / (k + rank); // 倒数排名累加
}
}
return Object.entries(scores).sort(([, a], [, b]) => b - a).map(...); // 按总分降序
精华:为什么不用向量库内置融合? 注释直说了(turbopuffer/index.ts:56-61):融合算法有很多种(见 ranx),不同场景要不同融合,所以 Turbopuffer 没把它做进去——作者宁可自己手写,换取可控。
3.3 一个隐藏坑:minScore 只对语义模式生效
余弦距离能归一化成 01 的相似度,但 BM25 和融合后的分数没有统一范围,所以 2,转成 0~1,越大越好)。minScore 过滤只在 semantic 模式下应用(turbopuffer/index.ts:145-154)。距离归一化:(2 - distance) / 2(turbopuffer/index.ts:83,余弦距离在 0
3.4 Pinecone 的能力差异
Pinecone 实现里 keyword 模式直接抛错(pinecone/index.ts:44,注释标 TODO),supportsKeyword() 返回 false。所以hybrid + 关键词是 Turbopuffer 独有。上层靠 supportsKeyword() 探测能力——比如 agentic 检索里:mode: vectorStore.supportsKeyword() ? query.type : undefined(agentic/search.ts:65)。这就是「能力查询」抽象的价值:上层不写死厂商,按能力降级。
4. Mongo 风格过滤器 → 各厂商方言的翻译
它要解决的小问题: 用户想按元数据过滤(如 { category: "docs", year: { $gte: 2023 } })。这套 Mongo 风格语法对用户友好,但 Turbopuffer 的过滤格式是 ["And", [["field", "Gte", 2023]]] 这种,Pinecone 又是另一套。需要一个翻译层。
思路: 一个抽象基类 BaseFilterTranslator(packages/engine/src/vector-store/common/filter.ts:172)定义通用操作符体系($eq/$ne/$gt/$in/$and/$or/$not...)和校验逻辑;每家向量库一个子类,把通用 AST 翻译成自家方言,并声明自己支持哪些操作符。
Turbopuffer 的翻译器(turbopuffer/filter.ts:43)声明自己不支持 $nor/$not/$regex/$elemMatch(getSupportedOperators,filter.ts:47),再把操作符映射成 Turbopuffer 词:
// turbopuffer/filter.ts:61
private operatorMap = {
$eq: "Eq", $ne: "NotEq", $gt: "Gt", $gte: "Gte",
$lt: "Lt", $lte: "Lte", $in: "In", $nin: "NotIn",
};
// 翻译:{ year: { $gte: 2023 } } → ["year", "Gte", 2023]
精华细节:
- 不支持的操作符靠模拟。 Turbopuffer 没有
$all(数组全包含),就用「多个$in串成And」模拟(turbopuffer/filter.ts:252-263;基类也有通用simulateAllOperator,filter.ts:290)。$exists: true模拟成NotEq null(filter.ts:247-250)。 - 翻译前先校验。
validateFilter(common/filter.ts:346)在翻译前递归检查过滤器是否合法(比如逻辑操作符不能出现在字段层),非法直接抛带定位的错误信息(ErrorMessages,filter.ts:326)。 - 单条件自动包 AND。 Turbopuffer 要求顶层是连接词,所以单个字段条件会被自动包成
["And", [cond]](turbopuffer/filter.ts:86-96)。
5. 重排(rerank)
它要解决的小问题: 向量检索快但不够准。把 topK(比如 50)个候选交给一个更慢但更准的「重排模型」重新打分排序,取前 N。
queryVectorStore 在检索后,若开了 rerank 就调 rerank(vector-store/query.ts:44-52 → rerank/index.ts:40):
// 示意,改写自 rerank/index.ts:40 起
const rerankedResults = await model.doRerank(results, options);
return rerankedResults.map((result) => ({
...results[result.index], // 按重排模型给的新顺序重组
rerankScore: result.rerankScore,
}));
和向量库同构的抽象: Reranker 是抽象类(rerank/common.ts:8),getRerankingModel(rerank/index.ts:11)按 provider:model 字符串挑 Cohere 或 ZeroEntropy 实现。默认是 cohere:rerank-v4.0-pro(packages/validation/src/re-ranker/constants.ts:24-25)。Cohere 实现就是包一层 SDK 调用(rerank/cohere.ts:20)。
精华:重排失败要「优雅降级」。 重排是「锦上添花」,挂了不该让整个检索失败——所以 rerank 用 try/catch 包住,失败就返回原始未重排结果(rerank/index.ts:58-61)。
返回里的 unorderedIds: 当重排发生时,queryVectorStore 还会返回原始(未重排)顺序的 id 列表(query.ts:56),供需要「原始召回顺序」的场景用。