跳到主要内容

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:73)声明了一组抽象方法,每家向量库实现一遍:

// 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:49),Pinecone 是 agentset:{namespaceId}:{tenantId}(pinecone/index.ts:33)。每个租户物理上一个独立命名空间。

3. 三种检索模式与混合检索的 RRF 融合

VectorStoreQueryOptions.mode(vector-store.ts:22)是个判别联合,三选一:

模式怎么查直觉
semantic向量近邻(ANN + cosine)「意思相近」,不要求字面命中
keywordBM25 全文检索「字面命中」,经典关键词搜索
hybrid两者都查,再融合兼顾意思和字面,通常最强

3.1 语义 / 关键词:直接交给 Turbopuffer

Turbopuffer 的 query(packages/engine/src/vector-store/turbopuffer/index.ts:86)对前两种模式只是设不同的 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:61):

// 示意,改写自 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:55-60):融合算法有很多种(见 ranx),不同场景要不同融合,所以 Turbopuffer 没把它做进去——作者宁可自己手写,换取可控。

3.3 一个隐藏坑:minScore 只对语义模式生效

余弦距离能归一化成 01 的相似度,但 BM25 和融合后的分数没有统一范围,所以 minScore 过滤只在 semantic 模式下应用(turbopuffer/index.ts:144-153)。距离归一化:(2 - distance) / 2(turbopuffer/index.ts:82,余弦距离在 02,转成 0~1,越大越好)。

3.4 Pinecone 的能力差异

Pinecone 实现里 keyword 模式直接抛错(pinecone/index.ts:43,注释标 TODO),supportsKeyword() 返回 false。所以hybrid + 关键词是 Turbopuffer 独有。上层靠 supportsKeyword() 探测能力——比如 agentic 检索里:mode: vectorStore.supportsKeyword() ? query.type : undefined(agentic/search.ts:64)。这就是「能力查询」抽象的价值:上层不写死厂商,按能力降级。

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-52rerank/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 实现。默认是 zeroentropy:zerank-2(packages/validation/src/re-ranker/constants.ts:25)。Cohere 实现就是包一层 SDK 调用(rerank/cohere.ts:20)。

精华:重排失败要「优雅降级」。 重排是「锦上添花」,挂了不该让整个检索失败——所以 rerank 用 try/catch 包住,失败就返回原始未重排结果(rerank/index.ts:58-61)。

返回里的 unorderedIds: 当重排发生时,queryVectorStore 还会返回原始(未重排)顺序的 id 列表(query.ts:56),供需要「原始召回顺序」的场景用。

6. 嵌入:文档向量 vs 查询向量

嵌入工厂 getNamespaceEmbeddingModel(embedding/index.ts:22)按配置挑 OpenAI / Azure / Voyage / Google,但有个细节值得拎出来:

精华:Voyage 区分输入类型。 有些嵌入模型(如 Voyage)对「被检索的文档」和「检索用的查询」用不同的编码方式效果更好。WrapEmbeddingModel(embedding/wrap-model.ts:4)包一层,把 type("document""query")透传成 Voyage 的 inputType(wrap-model.ts:28-36)。这就是为什么摄取时传 "document"(process-document.ts:172)、检索时传 "query"(chat/route.ts:79)。

7. 巧妙之处汇总

  • 能力查询 supportsKeyword(): 上层按能力降级,不写死厂商(vector-store.ts:89,用例 agentic/search.ts:64)。
  • 手写 RRF 换可控性: 不用内置融合,因为融合算法要可换(turbopuffer/index.ts:61)。
  • 过滤器先校验后翻译 + 模拟缺失操作符: 统一 Mongo 语法,各厂商方言隔离(common/filter.ts + turbopuffer/filter.ts)。
  • 重排失败降级: try/catch 返回原结果(rerank/index.ts:58)。
  • 嵌入区分 document/query: WrapEmbeddingModel 透传 inputType(embedding/wrap-model.ts)。
  • 动态 import 各厂商实现: 省冷启动(vector-store/index.tsrerank/index.ts)。

8. 边界与局限

  • Pinecone 不支持 keyword/hybrid: 直接抛错,标着 TODO(pinecone/index.ts:43)。
  • usage 未追踪: 多处 // TODO: track usage(如 query.ts:28cohere.ts:27)。
  • minScore 只对语义模式可靠: 其他模式分数无统一范围(turbopuffer/index.ts:144)。

9. 横向对比

同 shelf 的检索类项目里,Agentset 的取舍是**「平台化的抽象层」而非「单一最优实现」**:它不押注某一家向量库,而是把多家抹平、按 namespace 配置切换,并把 BYO(自带 key)和托管放在同一套代码里。手写 RRF、过滤器翻译器、重排降级这些,都是为了「既要厂商无关,又要不丢能力」服务。代价是没有任何一条检索路径是为某家深度优化的。

10. 代码地图

主题文件路径符号名
检索单一入口packages/engine/src/vector-store/query.tsqueryVectorStore
向量库抽象基类packages/engine/src/vector-store/common/vector-store.tsVectorStore
按配置挑向量库packages/engine/src/vector-store/index.tsgetNamespaceVectorStore
Turbopuffer 实现 + RRFpackages/engine/src/vector-store/turbopuffer/index.tsTurbopuffer / reciprocalRankFusion
Pinecone 实现packages/engine/src/vector-store/pinecone/index.tsPinecone
过滤器基类 + 校验packages/engine/src/vector-store/common/filter.tsBaseFilterTranslator
Turbopuffer 过滤翻译packages/engine/src/vector-store/turbopuffer/filter.tsTurbopufferFilterTranslator
重排入口 + 降级packages/engine/src/rerank/index.tsgetRerankingModel / rerank
重排抽象类packages/engine/src/rerank/common.tsReranker
嵌入工厂packages/engine/src/embedding/index.tsgetNamespaceEmbeddingModel
嵌入包装(inputType)packages/engine/src/embedding/wrap-model.tsWrapEmbeddingModel