Skip to content

03 检索与重排深挖问答(简历第 1 条:混合检索与精排) ​

怎么用这篇:这是简历第一条,也是面试官最可能挖得最深的部分——因为它同时涉及算法(BM25 公式、RRF)和工程(并发、降级、缺陷)。 每个 Q 的结构是:面试官想考(他真正想验证什么)→ 口述回答(这段是要背的)→ 讲解与备注(原理、取舍、哪句加分)→ 代码依据(可引用)→ 追问链 → 别踩的雷。 建议背诵顺序:Q1 → Q5 → Q6 → Q8 → Q9 → Q15。这六个是必问区。 特别提醒:Q8、Q9、Q15 是主动承认缺陷的题,答好它们是这个项目在字节面试里最能拉分的地方——因为绝大多数候选人只会背 RAG 的通用架构,说不出自己代码里哪里是坏的。


一、30 秒讲清检索链路 ​

背诵版总述: "检索是双路并行:向量路走 Qdrant gRPC,用 Embedding 后的查询向量做 ANN 召回;关键词路走我自己实现的 BM25 内存倒排索引,中文按 bigram 切、英文按词切并做大小写归一。两路各取 Top-10,用加权 RRF 融合——score = Σ weight/(k+rank+1),k=60,向量权重 0.7、BM25 权重 0.3;融合后截断,再过一层 cross-encoder 重排,重排走 /v1/rerank 风格的标准 HTTP 接口,失败就降级返回融合原序,不阻塞主链路。"

                      ┌── 向量路:query → Embedding → Qdrant.Query(TopK, filter kb_id)
 检索请求 ──并发(WaitGroup)┤                                                  ─┐
                      └── BM25 路:tokenize(query) → 倒排打分(TopK, kb 过滤)   ─┤
                                                                              ▼
                           加权 RRF 融合 (k=60, 0.7/0.3) → 按分数降序 → 截断 TopK
                                                                              ▼
                           Cross-encoder 重排 (/v1/rerank, top_n)  ──失败──▶ 返回融合原序(降级)
                                                                              ▼
                                                                    rag.buildContext(token 预算截断)

二、深挖问答 ​

Q1. 你已经用了向量检索,为什么还要加 BM25?混合检索到底解决什么问题? ​

面试官想考:你是"照着架构图抄的",还是真的踩过向量检索的坑。

口述回答(背诵这段):

"因为向量检索和关键词检索的失败模式是互补的。向量检索擅长语义泛化——用户问"怎么上传文件",文档里写的是"文档入库流程",它能召回;但它在精确 token 上会栽跟头:像错误码 ERR_4032、产品型号、人名、特定的缩写,Embedding 模型很可能把注意力放在"看起来像代码"这个语义上,召回一堆语义相近但内容无关的段落。而 BM25 是字面匹配,这类查询几乎是必然命中的。 反过来,纯 BM25 不懂同义词和改写,用户换个说法就搜不到。所以两条路都要,各自取 10 条召回,再用 RRF 融合——实测在我自己构造的集合上,那些"关键词能命中但向量排不进前 10"的片段,是靠 BM25 这一路捞回来的,这也是我做混合检索最直接的动机。 注意我说的是'互补'而不是'更准':混合检索不会让每一类 query 都变好,它是把方差降下来——在不同类型的查询上更稳定。"

讲解与备注:

  • 加分句是「把方差降下来」——说明你理解混合检索的本质是 robustness,不是 accuracy。
  • 「各取 10 条再融合」是真实实现:两路用同一个 topK(internal/retriever/retriever.go:99 与 :108)。
  • 不要吹"实测召回提升 X%",你没有真实标注数据。可以说"我用自己构造的评估集做过对比",但不能报数字(详见 08 篇)。
  • 涉及的另一个细节:BM25 那一路上有个门控条件 DocCount() > 0(retriever.go:104),空索引时整路被跳过,见 Q9。

代码依据(两路并发 + 门控):

go
// internal/retriever/retriever.go:95-116
	// 向量检索(始终执行)
	wg.Add(1)
	go func() {
		defer wg.Done()
		vectorResults, vectorErr = r.vectorSearch(ctx, query, topK, filter)
	}()

	// BM25 检索(可选,按 kb_id 集合过滤)
	kbIDs := filterKBIDs(filter)
	if r.config.EnableBM25 && r.bm25Index != nil && r.bm25Index.DocCount() > 0 {
		wg.Add(1)
		go func() {
			defer wg.Done()
			bm25Results = r.bm25Index.SearchFilteredByKBs(query, topK, kbIDs)
		}()
	}

	wg.Wait()

	if vectorErr != nil {
		return nil, "", fmt.Errorf("向量检索失败: %w", vectorErr)
	}

追问链:

  • 追问:那什么时候不该用混合检索? → 答:"纯语义问答场景(比如全是自然语言 FAQ、没有专有名词)混合检索收益有限,反而多花一次 BM25 打分的 CPU;另外 BM25 索引要占内存,文档量特别大的时候成本不低,所以它是个可关的开关,配置项是 enable_bm25。"
  • 追问:两路结果冲突了怎么办?比如向量说 A 最相关,BM25 说 B 最相关? → 答:"RRF 不做裁决,它是把两路的排名贡献加权累加。如果一条同时被两路召回,它的分数会显著高于只被一路召回的——这是 RRF 最重要的性质:两路都认可的结果自动上位。真的两路各说各话时,就是各自按权重贡献,靠 0.7/0.3 的权重表达我更信任向量的先验。"
  • 追问:BM25 那一路为什么不重排? → 答:"单路场景里,融合之后会统一重排一次,重排是对融合结果的整体操作,不是对某一路。多查询场景我特意传了 SkipRerank,因为要等所有路融合完再统一重排一次,避免重复调用重排服务。"

别踩的雷:别说 BM25 是"为了关键词精确匹配"就完了——面试官会接着问"那为什么不用倒排索引 + 向量都放 ES 里",你要能答:"ES 的向量检索我也评估过,但它作为主存储的运维成本和我这套栈不匹配;我选 Qdrant 是因为它 Go 客户端成熟、payload 过滤下推做得好,而 BM25 我自己实现只要 200 行、还能按 kb 维度在打分循环里做过滤。"


Q2. BM25 是你自己实现的?把公式讲一遍。 ​

面试官想考:你是不是真的懂 BM25,还是只会说"用了 BM25 算法"。

口述回答(背诵这段):

"对,是我自己实现的,核心代码 200 行左右。BM25 的分数是按查询词逐项累加的,每一项等于 IDF × TF 归一化项。 IDF 我用的是 log((N - df + 0.5)/(df + 0.5) + 1)——这是 BM25 的非负变体,末尾那个 +1 保证 IDF 不会变成负数,避免"某个词在几乎所有文档里都出现"时反而给文档减分。N 是索引里的文档总数,df 是包含这个词的文档数。 TF 项是 tf × (k1 + 1) / (tf + k1 × (1 - b + b × dl / avgdl))。这里 k1 控制词频饱和——一个词出现 10 次不该比出现 5 次重要一倍,所以分子分母同时含 tf 让它趋于饱和;b 控制文档长度归一化,dl/avgdl 是相对长度,b=0.75 表示 75% 的归一化力度。我取的是经典默认值 k1=1.2、b=0.75,写成了常量。 数据结构上是标准的倒排索引:term → 倒排列表,每条 posting 记 docID + tf;另外维护 docLen、docTerms(删文档时反查用)、totalLen/avgLen(算长度归一化用),整个索引用一把 RWMutex 保护。"

讲解与备注:

  • 「末尾 +1 保证 IDF 非负」是最能证明你真懂的一句,务必说出来。
  • k1/b 是硬编码常量、不可配置(bm25.go:9-12)。如果面试官问"为什么不放到配置里",答:"因为调它需要标注数据来评估,我这边只有自构造的小评估集,开放配置容易让人误调;真要做的话我会先跑 Recall@K 的对比再把这个参数暴露出去。"
  • 一个容易忽略的实现细节:df 用的是全库的 postings 长度,不是按 kb 过滤后的 df(bm25.go:186)。所以多租户场景下 A 库的 IDF 会受 B 库的文档分布影响——分数本身不跨库共享(结果集被过滤了),但打分会有偏移。这个点主动说出来很加分(见 Q10)。

代码依据(IDF + TF 归一化):

go
// internal/retriever/bm25.go:180-199
	for _, term := range tokens {
		postings, ok := idx.inverted[term]
		if !ok {
			continue
		}

		df := float64(len(postings))
		idf := math.Log((n-df+0.5)/(df+0.5) + 1)

		for _, p := range postings {
			// 知识库过滤(在锁内读取 docKB,安全):空集合不过滤
			if len(allowed) > 0 && !allowed[idx.docKB[p.docID]] {
				continue
			}
			dl := float64(idx.docLen[p.docID])
			tfNorm := (float64(p.tf) * (bm25K1 + 1)) /
				(float64(p.tf) + bm25K1*(1-bm25B+bm25B*dl/idx.avgLen))
			scores[p.docID] += idf * tfNorm
		}
	}
go
// internal/retriever/bm25.go:9-12
const (
	bm25K1 = 1.2
	bm25B  = 0.75
)

追问链:

  • 追问:dl/avgLen 会不会除零? → 答:"看起来会,实际不会,这是我自己实现时必须想清楚的不变式:DocCount()==0 的时候函数提前返回 nil 了;而且 tokenize 后 tokens 为空的文档根本不会进索引(直接 return),所以只要索引里有文档,totalLen 必然大于 0、avgLen 也大于 0。用库的时候不用关心这个,自己写就必须自己保证。"(这段话极加分)
  • 追问:加锁粒度怎么样?会不会阻塞检索? → 答:"RWMutex,读走 RLock、写走 Lock。而且我把分词放在加锁之前,锁的临界区只有打分循环,尽量减少持锁时间。不过要承认一点:读锁覆盖整个打分循环,大索引下写操作(入库 Add)会被拖慢,这个我还没有更好的方案,Qdrant+ES 那种把索引放外部服务的做法就没有这个问题。"
  • 追问:删除文档怎么做? → 答:"删一个 chunk 要按它包含的每个 term 去倒排列表里线性找到并移除,然后回收空的 term key,再更新 totalLen 和 avgLen。复杂度是 O(词数 × 倒排长度),文档多的时候是能感知的。而且我这里有个小瑕疵:removeLocked 没有清理 docChunks 反查表,会残留一点字符串。"

别踩的雷:不要把它说成"BM25 就是 TF-IDF 的改进版"就带过。面试官会立刻问"改进了什么"——正确说法是:TF 做了饱和(不是线性)、加了文档长度归一化、IDF 用的是概率模型导出的形式。


Q3. 中文分词你怎么做的?为什么不上 jieba? ​

面试官想考:中文检索是这个项目里最容易露馅的地方。他要知道你是思考过还是随手写的。

口述回答(背诵这段):

"我的分词器是自己写的,规则很简单但对我这个场景够用:英文和数字按连续字符聚合成一个 token 并做 ASCII 小写化;中文按 bigram 切。也就是"向量数据库"会切成 向量 / 量数 / 数据 / 据库 这四个。 选 bigram 而不是词典分词,主要是三个考虑:一是零词典依赖,不用维护词表、不用处理新词和专有名词,代码只有 180 行;二是中文里大部分两字词的边界在 bigram 层面能被覆盖,检索场景对分词的精度要求没有分词任务本身那么高——BM25 是统计的,多切出来的噪声 bigram 天然被 IDF 压下去;三是性能,O(n) 一次遍历,没有 HMM/CRF 的开销。 但 bigram 有个明显的漏洞:单字查询命中不了。用户搜"库",bigram 索引里没有这个 token。所以生产装配的时候我额外打开了 unigram——中文除了 bigram 之外再输出一份单字,"向量数据库"会变成 数/向/量/数/据/库 + 4 个 bigram。代价是索引体积变大,这是一个明确的取舍,我在代码注释里也写了。 如果要做企业级的中文检索,我会把分词器抽象成一个接口——它本来就是接口——然后接 Elasticsearch 或者 jieba 的实现,或者干脆上带中文优化的向量模型。"

讲解与备注:

  • 这是最容易露怯的一题:很多候选人会说"用了 BM25"但说不出中文怎么切。你要抢先把 bigram 的代价说出来(单字查询漏召回),再给出你已经做的补偿(unigram)。
  • 「噪声 bigram 被 IDF 压下去」是加分句,说明你理解 IDF 的统计作用。
  • 生产装配位置:internal/app/app.go:101,开了 WithCJKUnigram();英文停用词表虽然实现了,但生产装配没启用。如果被问"停用词呢",诚实答:"停用词过滤我实现了(内置 44 个英文停用词),但生产装配没打开——因为我的分析里英文内容占比不高,而停用词过滤会轻微影响 docLen 的统计口径;这是个可以按语料开合的开关。"

代码依据(bigram + 可选 unigram):

go
// internal/retriever/tokenizer.go:23-28
// WithCJKUnigram 让中文在 bigram 之外额外输出 unigram。
// 默认只输出 bigram("向量数据库" -> 向量/量数/数据/据库),
// 单字查询(如"库")将无法命中;开启后可提升召回,但索引体积略增。
func WithCJKUnigram() TokenizerOption {
	return func(c *tokenizerConfig) { c.cjkUnigram = true }
}
go
// internal/app/app.go:101
	bm25 := retriever.NewBM25Index(retriever.NewSimpleTokenizer(retriever.WithCJKUnigram()))

追问链:

  • 追问:bigram 会不会把"南京市长江大桥"这种歧义处理错? → 答:"会,bigram 不解决分词歧义,它刻意绕过这个问题。对 BM25 来说歧义的影响是双向的:多切出来的无效 bigram 会被 IDF 压低,真正的词边界对应的 bigram 仍然是命中的。真正需要精确分词的是实体识别类任务,不是检索召回。"
  • 追问:为什么英文要小写化,中文不用? → 答:"英文大小写是同一个词的不同写法(Qdrant 和 qdrant 应该命中同一批文档),所以归一化;中文没有大小写概念。另外我是用 ASCII 位运算做的(r+32),比 strings.ToLower 少一次分配。"
  • 追问:数字和字母混在一起呢?比如 BM25算法v2? → 答:"我的规则里 ASCII alnum 连续就聚成一个 token,所以会切成 bm25、算法、v2 三个——bm25 和 v2 保持完整,这是刻意的,因为版本号和型号不能被切开。"

别踩的雷:绝对不要说"中文分词我用的 jieba"(Go 项目里没有,代码里也没有)。也不要说"bigram 和分词效果差不多"——要说清它牺牲了什么、你在哪补的。


Q4. RRF 是什么?为什么用 RRF 而不是把两路分数加权相加? ​

面试官想考:这是混合检索的核心问题,答不好整条 bullet 就废了。

口述回答(背诵这段):

"RRF 是 Reciprocal Rank Fusion,倒数排名融合。它的公式是:对每个文档,把它在每一路里的排名倒数按权重加起来——score(d) = Σ weight_i / (k + rank_i(d) + 1),rank 从 0 开始算,k 我取 60。 它的关键设计是只用排名、完全不用原始分数。这一点正是我选它的理由:向量检索给的是余弦相似度,范围 0~1;BM25 给的是无上界的实数,实测同一次查询里不同文档的 BM25 分数能差几十倍。要把它们加权相加,就必须先归一化,而 min-max 归一化会把当次结果里的最高分强行拉成 1.0,等于假设"每次查询都有且只有一个完美答案";z-score 又假设分布形态。这些假设对检索分数都不成立。 RRF 绕开了这个问题:排名是天然可比的、有界的、无参的。而且它有个很好的性质——同时被两路召回的文档会叠加两次贡献,自动上位,这正好符合"两个独立信号都认可就更可信"的直觉。 k=60 的作用是平滑:分母是 k + rank + 1,k 越大,第 1 名和第 10 名的分数差距越小,融合越不容易被单路的头部结果绑架。60 是这个算法的经典默认值,我也做了兜底——配置里给 0 或者负数时会在函数内回落到 60。"

讲解与备注:

  • 这段有三个加分点:量纲不可比(方法论级理由)、双路命中自动上位(性质级理解)、k 的平滑作用(参数级理解)。这三层能盖住 90% 的候选人。
  • 权重 0.7/0.3 的来源:配置项 retriever.vector_weight / bm25_weight,配置校验里强制两者之和为 1(容差 0.001),这是"算法约束写进校验"的例子,可以主动提。
  • 但要诚实:0.7/0.3 是先验经验值,不是调出来的。被问"这个比例怎么定的"要答"基于'语义为主、关键词为辅'的先验,没有标注数据支撑调参;评估框架已经在了,这是我下一步要做的实验"。

代码依据(加权 RRF):

go
// internal/retriever/rrf.go:13-28
func FuseRRF(vectorResults []RetrieveResult, bm25Results []BM25Result, allDocs map[string]RetrieveResult, cfg RRFConfig) []RetrieveResult {
	if cfg.K <= 0 {
		cfg.K = 60
	}

	scores := make(map[string]float64)

	// 向量检索结果按 rank 贡献分数
	for rank, r := range vectorResults {
		scores[r.ID] += float64(cfg.VectorWeight) / float64(cfg.K+rank+1)
	}

	// BM25 检索结果按 rank 贡献分数
	for rank, r := range bm25Results {
		scores[r.ID] += float64(cfg.BM25Weight) / float64(cfg.K+rank+1)
	}

追问链:

  • 追问:为什么 rank 里有个 +1? → 答:"因为 rank 从 0 开始,而 RRF 的标准形式是 1-based 的 1/(k+rank)。不 +1 的话第 1 名的分母会变成 k+0,和标准定义差一位,虽然对排序影响不大,但会让 k 的语义漂移——k=60 时第 1 名系数应该是 1/61。"
  • 追问:如果我想让某一路完全失效呢? → 答:"把权重设成 0 就行,配置校验允许。但更干净的做法是关掉 enable_bm25,直接不跑那一路,省一次打分。顺便说一个我发现的问题:配置里还有个 strategy.fusion 字段(rrf / none),但它实际没有接线——融合与否是由有没有 BM25 结果决定的,不是这个字段决定的,这是我 review 时发现的一处'声明了但没生效'的配置。"(主动暴露,极加分)
  • 追问:同分怎么办? → 答:"这是我这套实现的一个真实短板:融合结果是通过 map 收集的,Go 的 map 遍历顺序随机,而排序我用的是 sort.Slice(非稳定排序),所以完全同分的文档名次在多次请求间可能不一样。多查询融合场景特别容易触发——三路各自的第 1 名分数严格相等。修法是加 tie-break,比如按 ID 字典序做二次排序。这个我没修,因为它不影响相关性,但确实影响结果的可复现性,对评估对比是个隐患。"

别踩的雷:

  • 不要说"RRF 就是把分数归一化后相加"(把 RRF 和加权求和搞混,直接暴露没读过算法)。
  • 不要声称 k=60 是你调出来的最优值。

Q5. 两路检索是并行的吗?错误怎么处理?如果向量库挂了会怎样? ​

面试官想考:并发实现细节 + 失败语义。这是"能不能写生产代码"的分水岭题。

口述回答(背诵这段):

"是并行的,用 sync.WaitGroup 起两个 goroutine,两路都跑完再融合——因为融合需要两路的完整排名,没法流式合并。 错误处理上我要诚实说一个设计上不够好的地方:目前的语义是向量路失败则整个检索失败,不会降级成"只用 BM25 的结果"。当时的考虑是向量检索是主路、BM25 是补充,主路挂了返回只有关键词的结果可能给用户错误的信心;但现在回头看,BM25 的结果一定不比"什么都没有"更差,更合理的策略是降级 + 在响应里标记降级状态。这是我会改的。 BM25 那一路不会返回 error——它在内存里,接口本身就没有 error 返回值;它只可能因为索引为空被整个跳过。所以实际只有向量路会失败,失败原因通常是 Qdrant 连不上或者 Embedding 服务不可用。 另外要说明的是,这个检索链路目前没有任何超时预算:Qdrant 调用直接透传上游的 context,我没在检索层加 WithTimeout;重排有自己的 30 秒 HTTP 超时。也就是说如果 Qdrant 卡住、而上游是不带超时的请求,这个请求就会一直挂住。这是我在 review 后列进待办的事。"

讲解与备注:

  • 这一段的价值在于你主动承认了两个设计缺陷(不降级、无超时预算)并给出了理由和改法。比"我的错误处理很完善"可信十倍。
  • 同时"BM25 接口没有 error 返回值"这个细节能证明你真读过自己的接口定义。
  • 注意区分:多路检索(多查询)里的单路失败是忽略的,只有全部失败才报错——这跟单路场景的策略不同,面试官可能会追问这个不对称。

代码依据(并发 + 错误策略):

go
// internal/retriever/retriever.go:112-116
	wg.Wait()

	if vectorErr != nil {
		return nil, "", fmt.Errorf("向量检索失败: %w", vectorErr)
	}
go
// internal/retriever/retriever.go:314-323(多路场景:单路失败只告警不中断)
			out, method, serr := r.searchFused(ctx, q, req.TopK, req.Filter)
			if serr != nil {
				mu.Lock()
				if firstErr == nil {
					firstErr = serr
				}
				mu.Unlock()
				slog.Warn("多路检索单路失败,忽略该路", "query", q, "err", serr)
				return
			}

追问链:

  • 追问:为什么多路场景单路失败可以忽略,单路场景向量失败就整体失败? → 答:"确实不对称,这是我该统一的地方。多路的语义是'多角度召回',丢一路还有其它路兜底;单路场景里向量是主路,我当时的判断是宁可明确报错也不要给不完整的答案。但更一致、更好的做法应该都是:降级 + 标记,把决定权交给上层。"
  • 追问:并行的收益有多大? → 答:"两路都是 IO 型等待(一路是 Embedding HTTP 调用 + Qdrant RPC,一路是纯内存 CPU 打分),并行后总耗时约等于较慢的那一路,而不是两者相加。BM25 那一路通常在毫秒级,所以瓶颈基本都在向量路上,实际收益就是省掉了 BM25 的串行时间。"
  • 追问:有测试证明并行吗? → 答:这里必须诚实:"没有。我在测试里给 mock 留了一个 delay 字段,但最终没有用它写并行性断言,所以'两路并行'目前只有代码依据、没有测试保护。而这在 checklist 里本来是列了验收项的——这是我测试覆盖率上的一个真实缺口,我可以补一个'两路各延迟 100ms,断言总耗时小于 180ms'的用例。"

别踩的雷:不要把 errgroupCtx 说成"一路失败就取消其它路"——代码里它只在函数退出时 cancel,实际没有 fail-fast 语义(retriever.go:293-311)。被问到就说:"我用了 errgroupCtx 这个变量名,但它实际只在函数返回时取消,没有实现'一失败即取消';真实语义是收集所有路的结果,全部失败才报错。"


Q6. RRF 融合之后,重排(Cross-encoder)是什么?和向量检索有什么区别? ​

面试官想考:你有没有真正理解 bi-encoder / cross-encoder 的本质差异,还是只知道"重排能提升效果"。

口述回答(背诵这段):

"向量检索用的是双塔(bi-encoder):query 和文档分别编码成向量,然后算余弦相似度。因为两者是独立编码、编码时互相看不见,所以文档向量可以离线预计算、存进向量库;代价是模型无法建模 query 和文档之间的细粒度交互——它只能比较两个"语义摘要"像不像。 Cross-encoder 是把 query 和文档拼成一个序列一起送进模型,让注意力在两者之间自由交互,直接输出一个相关性分数。它的精度明显更高,因为能看到词级别的对应关系;代价是每个 query-文档对都要跑一次模型,没法预计算,所以只能对少量候选在线算。 这个成本和精度的特性决定了它们在链路里的位置:双塔负责从百万级文档里召回几十条(可以预计算、便宜),cross-encoder 负责把这几十条精排成 Top-5(贵但准)。所以我把它放在 RRF 融合之后。 我的实现是接标准 HTTP 接口:POST /v1/rerank,body 是 {model, query, documents, top_n},响应回来 {results: [{index, relevance_score}]},我按 relevance_score 降序、再用 index 回填原始候选。这个格式是 Jina 和 Cohere 的通用约定,所以既能接 Jina、Cohere,也能接本地部署的 bge-reranker,换个 base_url 就行。" (如果被追问"你还做了另一种模式") "另外我还实现了一个 llm 模式:不依赖专用 rerank 端点,用通用大模型的 chat/completions 逐条给文档打 0–10 分。这是为没有 rerank 服务、只有 Ollama 之类本地模型的场景准备的降级路径。它的代价很直白——N 个候选就是 N 次 LLM 调用,延迟随候选数线性增长,所以我已经在思考把它改成一次请求批量打分。"

讲解与备注:

  • 「双塔独立编码所以能预计算 / cross-encoder 必须拼在一起所以不能预计算」——这是这道题的唯一核心,一句话说透就能过。
  • llm 模式是通过配置 reranker.mode 切换的(api / llm / ollama),internal/reranker/reranker.go:57-70 是工厂。
  • llm 模式的解析做了三级正则兜底(分数:8 / 8分 / 文本末尾数字)+ 收敛到 [0,10],这是个细节加分点:因为本地小模型不听话,经常输出"我给它 8 分(满分 10)"这种带解释的文本。

代码依据(请求/响应协议 + 降级):

go
// internal/reranker/reranker.go:90-102
type rerankRequest struct {
	Model     string   `json:"model"`
	Query     string   `json:"query"`
	Documents []string `json:"documents"`
	TopN      int      `json:"top_n"`
}

type rerankResponse struct {
	Results []struct {
		Index          int     `json:"index"`
		RelevanceScore float64 `json:"relevance_score"`
	} `json:"results"`
}
go
// internal/retriever/retriever.go:179-183(失败降级,不阻塞主链路)
	reranked, err := r.reranker.Rerank(ctx, query, candidates, topN)
	if err != nil {
		slog.Warn("重排失败,降级返回原结果", "query", query, "err", err)
		return results
	}

追问链:

  • 追问:重排的候选池多大? → 答:这是必须主动交代的缺陷。"目前候选池就是融合后截断到的 topK,也就是 10 条左右——没有做 oversample。这在工程上是有问题的:主流做法是召回 50~100 条再重排取 10 条,让重排有发挥空间;我的实现是'召回 10 条、重排 10 条',重排只能在很小的池子里调整顺序,没法把'向量排第 30 名但语义更相关'的文档捞回来。这是我从架构上最想改的一处,代价只是把两路的 topK 乘 3~5 倍,多花一点向量检索和重排的算力。"
  • 追问:重排失败会怎样? → 答:"降级返回融合原序,不阻塞回答,只打一条 warn 日志。这在测试里是覆盖了的。但降级带来一个副作用:返回给上层的 score 字段语义会变——重排成功时它是 relevance_score(0~1 的相似度),降级时它是 RRF 分数(量级 0.01 左右)。前端如果拿这个分数做展示或者排序,会看到量级跳变。修法是把两个分数拆成不同字段,这个我也列在待办里了。"
  • 追问:重排超时怎么控制? → 答:"HTTP 客户端超时 30 秒,可重试错误(网络错误、429、5xx)按 1s/2s/4s 退避重试最多 3 次,4xx 立即失败。有个细节我要承认:一次重排失败就整批丢失,没有部分成功或者用上一批排序兜底;而且 api 模式下 top_n 只是转发给远端,我本地没有再按 topN 截断,所以远端如果忽略 top_n 就会返回全部候选——这跟 llm 模式的行为不一致。"

别踩的雷:

  • 不要说"cross-encoder 比向量检索快"(正好反了)。
  • 不要说"重排能提升召回率"——重排不改召回集合,它改的是排序质量(precision / 排序指标),召回率是召回阶段决定的。这个区分能体现你懂评估指标(跟 07 篇的评估章节呼应)。

Q7. 为什么选 Qdrant?集合(Collection)怎么设计的?多知识库怎么隔离? ​

面试官想考:选型能力和多租户设计。字节面试官对多租户特别敏感。

口述回答(背诵这段):

"选 Qdrant 有三个理由:一,它的官方 Go 客户端成熟,用 gRPC(默认 6334 端口),我整套后端是 Go,不想为了向量库引入 Python 服务;二,它的 payload 过滤能力够强且能下推到检索引擎,这对我的多租户设计是硬需求;三,它单机部署简单,docker 一个容器起来,适合我这个规模的系统。备选的 Milvus 更重、要拆多个组件;Weaviate 也一样;pgvector 我考虑过,但它在数据量大之后 ANN 的性能和索引维护能力不如专用向量库。 集合设计上我做了一个很关键的决定:全局只有一个集合,多知识库靠 payload 里的 kb_id 过滤,而不是一个知识库一个集合。理由是集合数量会随租户数线性增长——一千个知识库就是一千个集合,每个集合有独立的索引结构和内存开销,运维和元数据管理都会爆炸;单集合 + 过滤的代价是每次检索多一次过滤判断,但 Qdrant 的过滤是下推到 HNSW 检索过程里的、不是检索完再筛,所以性能可以接受。 payload 里我存了 13 个字段:kb_id、document_id、chunk_id、filename、heading_context、chunk_index、content 正文、source_type、start_ms/end_ms(音视频时间戳)、page_number、heading、anchor。正文也存在 payload 里,这样检索一次就能拿到内容去组装上下文,不用二次回表;代价是向量库的存储体积变大,而且正文更新时 payload 也要跟着更新。 隔离的实现是:单知识库时过滤条件 {"kb_id": "<id>"},多个知识库时 {"kb_id": [id1, id2]},前者生成 Qdrant 的 MatchKeyword,后者生成 MatchKeywords(语义是命中任一,也就是 OR),多个不同字段之间是 AND,全部挂在 filter 的 Must 下。不指定知识库时不下发 filter——这是系统级 API Key 的"不限范围"场景,需要靠上层权限保证调用者本来就有全量权限。"

讲解与备注:

  • "单集合 + payload 过滤 vs 一库一集合"是标准的多租户面试题,一定要给理由(集合数随租户爆炸)。如果面试官追问"那数据量到亿级怎么办",答:"Qdrant 支持分片(sharding),单集合可以按 kb_id 做分片键水平扩展;真要拆物理隔离,也可以按大客户单独分集合——但那是商业化需求,不是技术必然。"
  • 要主动暴露两个不足:没有为 kb_id 建 payload 索引(全仓没有 CreateFieldIndex 调用),意味着过滤目前是无索引的线性判断,数据量大之后会退化;以及过滤条件类型支持不全(只认 string / []string / int / int64,其他类型静默忽略)——如果一个 bool 或者 float 过滤条件传进来,会被默默丢掉、导致过滤范围变大,这是潜在越权风险。主动说出来 + 给出"应该 fail-fast 报错"的修法,是强加分项。

代码依据(过滤下推 + 多值 OR):

go
// internal/vectorstore/qdrant.go:210-231
func buildFilter(filter map[string]any) *pb.Filter {
	var conditions []*pb.Condition
	for key, val := range filter {
		switch v := val.(type) {
		case string:
			conditions = append(conditions, pb.NewMatchKeyword(key, v))
		case []string:
			// 多值匹配(如多知识库范围):MatchKeywords = MatchAny(keywords...)
			if len(v) > 0 {
				conditions = append(conditions, pb.NewMatchKeywords(key, v...))
			}
		case int:
			conditions = append(conditions, pb.NewMatchInt(key, int64(v)))
		case int64:
			conditions = append(conditions, pb.NewMatchInt(key, v))
		}
	}
	if len(conditions) == 0 {
		return nil
	}
	return &pb.Filter{Must: conditions}
}
go
// internal/rag/engine.go:1058-1065(上层怎么构造过滤条件)
	switch len(ids) {
	case 0:
		return nil
	case 1:
		return map[string]any{"kb_id": ids[0]}
	default:
		return map[string]any{"kb_id": ids}
	}

追问链:

  • 追问:payload 里存正文,是不是违反了"向量库存向量、关系库存元数据"的分层? → 答:"这是一个刻意的取舍。严格分层确实更干净,但检索路径上每多一次回表就多一次网络往返和 N+1 风险——10 条结果就是 10 次查询。我把正文放在向量库、把可变的状态放在 PostgreSQL:文档的入库状态、任务状态、知识库归属这些会变的东西在 PG,检索时要用的不可变内容在 Qdrant。代价是删除文档要清理两处,我用一条按 document_id 的 filter 删除解决。"
  • 追问:维度不一致怎么办? → 答:诚实答:"我在代码里没有做维度校验——pipeline 只校验了'向量条数等于 chunk 条数',没有校验每个向量的维度是否等于集合维度。理论上如果换了 Embedding 模型但没换集合,写入会在 Qdrant 侧报错,但错误信息不会指向'你换了模型'这个根因。这是我该补的校验:启动时比对配置维度和集合实际维度,不一致就拒绝启动。"
  • 追问:Embedding 要不要做归一化? → 答:"我在客户端没有做归一化,语义上依赖集合的 distance 设置。这里有个知识点:用余弦距离时 Qdrant 内部会做归一化,但如果配成 dot(点积)就必须自己保证向量是单位向量,否则点积会偏向长向量。我默认用 cosine,所以目前是对的,但这是一个隐含耦合——如果我改成 dot,就必须在 Embedding 客户端或入库路径补归一化。"

别踩的雷:不要声称"Qdrant 一定比 Milvus 好"——要说"在我这个规模和技术栈下更合适",并给出被排除的理由。


Q8. 【重点题】如果一条文档只被 BM25 召回、没进向量 Top-K,会发生什么? ​

面试官想考:这是代码级的深挖——他在测你到底读没读过自己写的融合代码。这道题答好了,前面的"AI 写的吧"质疑基本消解。

口述回答(背诵这段):

"这是我这套实现里一个真实的 bug,我明确知道它。RRF 融合的时候,我需要把每条结果的正文补齐——代码里的做法是:先去向量结果列表里找,找不到就去一个 allDocs 的 map 里找,再找不到就只留一个 ID。问题是那个 allDocs map 只用向量结果构建,所以一条"BM25 命中、但向量没召回"的 chunk,走到融合结果里就只剩 {ID: xxx}——Content 是空字符串,Metadata 是 nil。 后果是这条结果会被计入 Top-10、可能挤掉一条有正文的候选,然后在组装上下文的时候变成一条空内容的上下文条目:既白占 token 预算,又因为 metadata 为空导致引用来源里没有文件名,前端点开只能看到一个空的引用卡片。 根因是我把 BM25 的返回结构设计得太轻了——BM25Result 只有 ID 和 Score 两个字段,正文根本没带出来;而融合层又没有回查向量库补正文(vectorstore.Get 在检索链路上没被调用过,只在"用户点开引用"那个接口里用)。 修法有两个:一是在融合后对 Content == "" 的 ID 批量调一次 Qdrant 的 Get 补齐正文,缺点是多个 RTT;二是让 BM25 索引里也存一份正文(内存换一次回表),我倾向第二种——索引本来就全在内存里,多存一份正文的代价可控,而且是大 token 场景下更省事。" (如果被追问"你怎么发现的") "是我在自审检索链路时顺着 allDocs 的来源找出来的。当时我先看的是融合函数的入参,发现 allDocs 只在 searchFused 里从 vectorResults 构建,就意识到 BM25 独有命中没有正文来源。而且我还回头看了测试——测试里确实构造了一条"BM25 独有"的文档,但断言只写了'结果非空',没有断言内容完整性,所以这个 bug 逃过了单测。这让我意识到'结果条数对'和'结果内容对'是两回事。"

讲解与备注:

  • 这道题的回答结构是:承认 → 复现路径 → 根因 → 修法 → 为什么测试没抓到。这五段是面试官心里"高级工程师"的标准答案形状。
  • 「测试只断言了结果非空、没断言内容完整性」这一句是王炸——它证明你不仅读代码,还审视了测试的有效性。
  • 如果面试官接着问"那你为什么没修",答:"因为它不影响回答能不能出来(只是引用卡片缺文件名、浪费一点预算),而当时的迭代优先级在 MCP 和评估服务上。如果现在让我排,我会把它放在 BM25 启动回灌之后、oversample 之前。"

代码依据(三处构成这个 bug):

go
// internal/retriever/retriever.go:123-133(allDocs 只由向量结果构建)
		allDocs := make(map[string]RetrieveResult, len(vectorResults))
		for _, vr := range vectorResults {
			allDocs[vr.ID] = vr
		}

		rrfCfg := RRFConfig{
			K:            r.config.RRFK,
			VectorWeight: r.config.VectorWeight,
			BM25Weight:   r.config.BM25Weight,
		}
		fusedResults = FuseRRF(vectorResults, bm25Results, allDocs, rrfCfg)
go
// internal/retriever/rrf.go:33-42(找不到就只剩 ID)
		var result RetrieveResult
		// 优先从向量结果中获取完整信息
		if doc, ok := findInVector(vectorResults, id); ok {
			result = doc
		} else if doc, ok := allDocs[id]; ok {
			result = doc
		} else {
			result = RetrieveResult{ID: id}
		}
		result.Score = float32(score)
go
// internal/retriever/types.go:44-49(BM25Result 结构太轻,没有正文)
// BM25Result BM25 检索结果
type BM25Result struct {
	ID    string
	Score float32
}

追问链:

  • 追问:那这些空内容会不会让模型幻觉? → 答:"会加剧。上下文里出现一个只有编号没有内容的条目,模型有可能把它当成'这条资料是空的',也可能直接忽略;更糟的是如果 [编号] 和 sources 的下标错位,引用会张冠李戴。不过我这边编号是按 items 顺序生成的、sources 也是同序产出的,所以映射本身没错,错的是内容缺失。"
  • 追问:那你怎么保证引用编号和来源一一对应? → 答:"buildContext 里用一个循环同时产出 ContextItem(带 Index = i+1)和 Source,两者一一对应;渲染模板里用 [{{.Index}}] 生成编号,所以编号是上下文顺序的自然下标,不会错位。"

别踩的雷:千万不要说"我的融合是完整的"。这是你代码里实打实的缺陷,被面试官指出来而你之前说"没问题",信任会立刻崩。


Q9. 【重点题】BM25 索引存在哪里?服务重启了会怎样? ​

面试官想考:持久化与状态恢复——这是"玩具项目 vs 生产系统"的分水岭。这个项目在这里有一个严重缺陷,你必须主动交代。

口述回答(背诵这段):

"这是我认为这个项目最严重的一个功能缺陷,我不回避:BM25 索引是纯内存的、进程重启就没了,而且启动时没有任何回灌逻辑。 具体来说:服务启动时只是 NewBM25Index(...) 建了一个空索引(internal/app/app.go:101);加索引只有两个入口——入库 pipeline 写完之后逐条 Add,以及一个 Rebuild 全量重建方法,但那个方法只在单测里被调用过,生产代码里没有调用点。所以服务重启之后索引是空的。 更糟的是它是静默退化:检索那一路有 DocCount() > 0 的门控,索引为空就直接跳过 BM25,整个检索变成纯向量。用户和监控看到的是"还能用",只有我自己埋的 trace 里 method 字段从 hybrid 变成 vector,而那个字段只在开启思考链路时才会回传。也就是说,线上表现就是"混合检索悄悄变成单路检索",没有任何告警。 修法我列了三步:第一优先是启动时从 PostgreSQL 的 chunk 记录(或者 Qdrant 的 payload scroll)回灌索引——正文本来就在 payload 里,启动时拉一遍就能重建,代价是启动时间随数据量增长;第二步是给回灌加就绪状态,索引没就绪时要么拒绝服务、要么在响应里标记降级,同时加一条"BM25 索引为空但配置要求启用"的启动告警;第三步才是考虑落盘快照(gob 序列化倒排表)来跳过回灌,但快照的失效判断和版本兼容又会引入新复杂度,所以要谨慎。 另外提一句,回灌方案还依赖我先修另一个坑:Rebuild 内部调的是 Add,而 Add 会把 document_id 传成空串,导致 docChunks 这个"文档→chunk"的反查表不会被填充,于是我入库重试时用的 RemoveByDoc(documentID) 就删不掉东西了。所以真要启用 Rebuild,必须先把它改成调 AddWithDocID。"

讲解与备注:

  • 这是全篇最值钱的答案。它一次性展示了:知道缺陷、知道影响面、知道为什么没人发现、知道怎么修、还知道修的时候会踩到第二层的坑。
  • 面试官可能的反应是"那你为什么不早修"——准备好的答法:"因为它不影响 demo 和功能验收,我当时的优先级排给了 MCP 和评估服务;而且修它需要先解决 Rebuild 的映射缺口,属于一个两三天的独立任务,我不想在评估体系落地前就动检索核心。"
  • 相关延伸:评估 CLI(cmd/eval)也受这个缺陷影响——它装配时也新建空 BM25、不灌数据,所以 DocCount()>0 门控会跳过 BM25,CLI 报告实际只测了纯向量检索,跟线上 hybrid 行为不可比。这一点如果你能主动说出来,说明你对自己系统的边界极其清醒(见 07 篇)。

代码依据(空索引 + 无生产调用点 + 静默门控):

go
// internal/app/app.go:101(服务启动:建空索引)
	bm25 := retriever.NewBM25Index(retriever.NewSimpleTokenizer(retriever.WithCJKUnigram()))
go
// internal/retriever/retriever.go:102-110(门控:索引为空就整路跳过,无告警)
	// BM25 检索(可选,按 kb_id 集合过滤)
	kbIDs := filterKBIDs(filter)
	if r.config.EnableBM25 && r.bm25Index != nil && r.bm25Index.DocCount() > 0 {
		wg.Add(1)
		go func() {
			defer wg.Done()
			bm25Results = r.bm25Index.SearchFilteredByKBs(query, topK, kbIDs)
		}()
	}
go
// internal/retriever/bm25.go:217-231(Rebuild 走 Add,丢失 docChunks 映射)
func (idx *defaultBM25Index) Rebuild(docs []BM25Doc) {
	idx.mu.Lock()
	...
	idx.mu.Unlock()

	for _, doc := range docs {
		idx.Add(doc.ID, doc.Content, doc.KBID)
	}
}

追问链:

  • 追问:为什么不干脆把 BM25 也放到外部引擎(ES / OpenSearch)? → 答:"这是一个合理的选择,很多团队就这么做。我自研的收益是:零额外组件、能按 kb_id 在打分循环里直接过滤、代码只有两百行完全可控;代价就是现在这些持久化和内存问题。真要上企业级、文档到千万级,我会换 ES 的 BM25,把自研这套降级成可选实现——它本来就是接口 BM25Index,换实现不动上层。"
  • 追问:内存能撑多少文档? → 答:绝对不要给数字。"我没有做过容量测试,所以我说不出可信的数字。定性的判断是:内存大约是'词数 × 文档数'量级,开了 unigram 之后中文的 term 数量会显著增加;我的配置里没有做任何内存上限或淘汰,这也是一个待补的点。"
  • 追问:多个实例部署会怎样? → 答:"每个实例各自持有一份内存索引,而且只有收到入库请求的那个实例会更新自己的索引——所以水平扩容会立刻出现索引不一致。真要水平扩容,必须把 BM25 挪到共享的外部引擎,或者用一个广播机制把 chunk 变更同步给所有实例。这也是我判断'自研 BM25 只适合单实例'的原因。"

别踩的雷:不要试图掩盖。面试官只要问一句"你重启过服务吗、重启后关键词检索还准吗"就能戳穿;主动说反而显示你对自己的系统做过 failure mode 分析。


Q10. 多知识库的隔离在检索层怎么保证?会不会越权拿到别人的文档? ​

面试官想考:安全与多租户。字节的面试官会往这上面挖。

口述回答(背诵这段):

"隔离分三层。第一层在 HTTP 层:请求进来先解析知识库范围,登录用户指定 kb_id 时会校验归属,不指定就展开成他名下的全部知识库,这一步不通过直接拒绝。 第二层是检索条件:上层生成 filter,单库是 {"kb_id": id},多库是 {"kb_id": [ids]},不指定时是 nil。注意 nil 的语义是"不过滤",也就是全库检索——这个是给系统级 API Key 用的,它本来就有全量权限,不是漏洞,但它意味着这一层的安全性完全依赖调用方传对了参数,所以它是一个"必须有上层配合"的防御。 第三层是执行:Qdrant 侧用 Must 条件把 kb_id 下推到检索里;BM25 侧在打分循环里逐条判断 docKB[docID] 是否在允许集合内,不在就 continue,也就是在算分之前就过滤掉,不会出现"先算分再筛"导致的分差信息泄露。 我要主动说三个不够好的地方。第一,Qdrant 侧没有为 kb_id 建 payload 索引,所以过滤目前是无索引的,数据量上来会慢,应该建 keyword 索引、更好的做法是用 Qdrant 的 tenant 索引。第二,BM25 的 kb 过滤只认 kb_id 这一个 key——如果上层传的是别的过滤条件(比如 document_id 或 source_type),向量路会过滤、BM25 路不会,两路的过滤范围就不一致了。目前上层只传 kb_id 所以没暴露,但这是个隐藏的不对称。第三,BM25 算 IDF 用的 df 是全库的文档频率,不是 kb 内的,所以严格说跨租户的文档分布会影响彼此的打分——分数不会跨库返回,但打分会受影响。" (如果被追问"你怎么保证不越权") "我不敢说'保证'。我能说的是:越权路径我在接口层和 MCP 层都做了校验,知识库归属检查在 handler 里;但我在自审时发现了一个跨层的不一致——API Key 类型的凭据在 REST 层被当作系统级处理,没有按它的 owner 收敛知识库范围。这个属于权限模型的缺陷,我已经定位到了,修法是让检索范围从凭据的 owner 派生,而不是从凭据类型派生。详细的那部分在认证权限那篇里。"(详见 06 篇)

讲解与备注:

  • "nil 过滤 = 全库"这个语义一定要主动讲,因为它是典型的越权入口形状。讲清楚"这是给系统级 Key 的、依赖上层传参"比装作没有更可信。
  • 三个不足按"性能 → 一致性 → 统计泄漏"排序,说明你的分析是分层的。
  • 关于 REST 层的 owner 缺陷,如果面试官的兴趣在检索侧,就简短带过并指向"权限那块的细节我可以展开"——把话题引导到你准备最充分的地方。

代码依据(BM25 侧在算分前过滤):

go
// internal/retriever/bm25.go:189-198
		for _, p := range postings {
			// 知识库过滤(在锁内读取 docKB,安全):空集合不过滤
			if len(allowed) > 0 && !allowed[idx.docKB[p.docID]] {
				continue
			}
			dl := float64(idx.docLen[p.docID])
			tfNorm := (float64(p.tf) * (bm25K1 + 1)) /
				(float64(p.tf) + bm25K1*(1-bm25B+bm25B*dl/idx.avgLen))
			scores[p.docID] += idf * tfNorm
		}

追问链:

  • 追问:为什么 BM25 用"过滤后再算分"而不是"算完再筛"? → 答:"两个原因。一是效率,不在允许集合里的 posting 直接跳过,省掉乘法和 map 写入;二是安全,算完再筛的话虽然结果集是一样的,但排序位置可能通过别的渠道泄漏(比如耗时差异),过滤前置更干净。"
  • 追问:知识库范围为空(nil)时你担心吗? → 答:"担心,所以我在两处做了防御:一是 handler 层对登录用户强制展开成他自己的知识库集合,不会留空;二是数据源路由的白名单兜底,如果数据源或者范围超出允许集合会降级到向量库并打告警。真正的系统性修法还是让检索范围永远从凭据派生,不给'不过滤'这个选项。"

别踩的雷:不要说"隔离靠 Qdrant 的 collection 分开",你的实现是单集合 + payload 过滤;说错了等于承认没读过自己代码。


Q11. 召回条数和最终返回条数是两个数吗?为什么不召回更多再重排? ​

面试官想考:这是唯一一道"你主动指出自己架构缺陷"就能拿满分的题,因为大多数候选人会背"漏斗式召回",而你的实现恰恰不是。

口述回答(背诵这段):

"在我现在的实现里,它们其实是一个数,而这正是我要改进的地方。 具体是:向量路和 BM25 路都用同一个 topK(请求级 TopK,或者配置里的默认 10)各自召回,RRF 融合后先截断到 topK,然后才把这一批送到重排——也就是说重排的候选池就是最终返回的那 10 条,重排只能在池子内部换顺序,没法把外面的文档捞进来。 标准做法应该是漏斗:召回阶段取一个更大的 N(比如 3~5 倍 K,50 条左右),重排在这个大池子上打分,再取前 K 条返回。我这个实现等于把漏斗的下半截砍掉了,重排的价值被压缩了——它能修正排序,但不能修正召回。举例来说,一条语义非常相关、但向量模型把它排在 30 名、关键词也没命中的文档,在我这里就永远进不了最终结果。 改造成本其实很低:给两路检索单独传一个 recallTopK = topK × 4,融合后不截断直接交重排,由重排返回 topK。风险是向量检索条数变多会略微增加延迟、重排的文档数变多会明显增加重排成本(重排是按候选数计费/计时的),所以倍率需要拿评估跑一下。" (如果被追问"那你当初为什么这么做") "坦白说这是实现顺序造成的:我最开始只做单路向量检索,那时候 topK 就是唯一的条数概念;后加 BM25 和重排时沿用了同一个 topK,没有重新审视'召回预算'这个独立的超参。这也是我 review 自己代码时的一个收获——当链路里新增一个阶段时,要重新检查已有的所有参数语义,不能沿用。"

讲解与备注:

  • 这句"新增阶段时要重新审视参数语义"是很成熟的工程反思,非常适合字节面试。
  • 注意:分解(decomposition)和 Step-Back 路径用的是 MaxChunks(默认 5)作为重排的 topN,比 TopK 还小——所以那些路径的候选池更小。这个细节被问到时可以说。
  • 相关的另一半:reranker.top_n 这个配置项在主链路实际不生效——因为调用方总是传一个大于 0 的 topN,配置只在 topN<=0 时兜底。我在 review 报告里把它列成了 P1-1「配置声明了但没接线」,第一轮修的时候在实现里补了读取,但更深一层的问题是调用方永远显式传值,所以配置仍然不生效。这个双层缺陷讲出来非常加分。

代码依据(先截断、后重排):

go
// internal/retriever/retriever.go:73-80
	if len(fusedResults) > topK {
		fusedResults = fusedResults[:topK]
	}

	// Reranker(可选):单路场景路内重排;SkipRerank 时由调用方在融合/汇总后统一重排
	if !req.SkipRerank {
		fusedResults = r.rerankIfEnabled(ctx, req.Query, fusedResults, topK, req.Trace)
	}

追问链:

  • 追问:扩大召回倍数会有什么代价? → 答:"两个可量化的代价:向量检索的 topK 变大,Qdrant 的返回和网络传输变大,但 ANN 检索本身开销增长不大(因为 HNSW 是近似搜索,候选数主要影响结果收集);重排是主要成本——cross-encoder 的耗时和候选数近似线性,所以我不会把倍率开到 10 倍以上,一般 3~5 倍。"
  • 追问:怎么判断这个改动有没有效果? → 答:"用我已有的评估 CLI:expected_ids 就是"期望命中的 chunk",Recall@K 直接能测出'召回池扩大'带来的召回率变化;重排的效果则要看 LLM-as-Judge 的准确性和忠实度。改这个之前我应该先跑一遍基线,这也是我评估体系最该先补的一步。"

别踩的雷:不要嘴硬说"我的设计就是先重排再截断"(和代码相反),也不要说"oversample 没必要"(这是 RAG 的标准工程实践,硬顶会显得没做过调研)。


Q12. 你的检索结果里 score 是什么分数?可靠吗? ​

面试官想考:细节敏感度。这个问题直指一个"字段语义不稳定"的实现问题。

口述回答(背诵这段):

"这个字段的语义不稳定,我直说。它有两个来源:如果重排成功了,score 是重排服务返回的 relevance_score,是个 0~1 的相关性分数;如果重排被跳过或者失败了,score 是 RRF 融合分,量级大概是 0.7/(60+rank+1),也就是0.01 左右。 所以同一个字段,可能返回 0.95,也可能返回 0.011,取决于重排服务的可用性。这对前端是危险的——如果拿它做展示或者阈值判断,会在重排降级的瞬间看到量级跳变。 另外这也不是一个"绝对可比"的分数:余弦相似度和 RRF 分之间没有换算关系,不同 query 之间的 RRF 分也不可直接比较大小(因为 RRF 是排名倒数,两条结果分数相近不代表相关性相近)。 我的修法是把结果结构拆开:fusion_score 和 rerank_score 两个字段各自表达,并且明确标注本次是否经过重排(比如一个 reranked: true/false)。这样前端和评测才有一致的解释。" (如果被问"那现在前端怎么用的") "目前的用法是引用卡片上显示分数,属于信息展示、不参与逻辑判断,所以没有踩到这个坑;但只要有下游拿它排序就会出问题。"

讲解与备注:

  • 「同一字段、两套量纲」是一个很高级的观察,能说明你对接口契约稳定性敏感。
  • 相关事实:检索侧没有任何 score 阈值过滤(Qdrant 的 ScoreThreshold 没设,RRF 之后也没有相对阈值),所以低分结果同样会进上下文。这也是一个可挑的点。
  • 顺带一个可以主动抛出的改进:可以给 RRF 结果做一个"相对阈值"(比如低于最高分 30% 的丢弃),比绝对阈值对不同 query 更鲁棒。

代码依据(RRF 分覆盖进 Score;重排分也是同一个字段):

go
// internal/retriever/rrf.go:42(融合时把 RRF 分写进 Score)
		result.Score = float32(score)
go
// internal/reranker/reranker.go:194-199(重排成功时写 relevance_score)
			results = append(results, RerankResult{
				ID:       c.ID,
				Content:  c.Content,
				Score:    float32(rr.RelevanceScore),
				Metadata: c.Metadata,
			})

追问链:

  • 追问:有没有做分数阈值过滤? → 答:"没有,这是我该补的。目前所有召回结果都会进入 token 预算裁剪,裁剪只按条数和 token 数,不看分数——所以哪怕相似度 0.1 的结果也会占用预算,这对回答质量是负面的(噪声加大幻觉概率)。Qdrant 支持 ScoreThreshold,RRF 之后我也可以做相对阈值。"
  • 追问:为什么不用归一化把 RRF 分映射到 0~1? → 答:"可以做展示层的归一化(min-max 到当次结果内),但不能当成相关性用——它只是当次结果内的相对位置。我这个阶段选择不引入这个假精度。"

别踩的雷:不要声称"score 是相关的置信度"——它既不是概率也不是校准过的相关性分数。


Q13. Embedding 这一层你怎么调的?批量、重试、限流、失败怎么办? ​

面试官想考:外部依赖治理。这一层看起来简单,但里面藏着真实的脏数据风险。

口述回答(背诵这段):

"Embedding 我封装成一个 OpenAI 兼容客户端,只调 /v1/embeddings,所以既能接 GPT 的 text-embedding,也能接本地 vLLM、Ollama,换个 base_url 就行;provider 不认识时直接报错,不静默回退——这一点是我第一轮 review 时修的,之前 provider 字段根本没被读。 批量上我按 batch_size 切片,默认 100,部署时我调到 10 左右,因为单次请求太大容易被服务端限流或者超时;切片是串行的,我没有做批内并发,理由是这一层已经在打外部 API,并发更容易触发对方限流,而入库本身是异步的、不阻塞用户,所以用时间换稳定。 限流用 golang.org/x/time/rate 的令牌桶,QPS 从配置来,默认 10,每次请求前 Wait 一次。 失败重试上,网络错误、429、5xx 是可重试错误,按 1s/2s/4s 的指数退避重试,默认 3 次;4xx(比如 key 错了、模型名不对)不可重试,立刻失败并往上抛,最终让入库任务进入重试/失败流程。 我要承认两个真实的问题。第一,响应回填是按 index 的,但没校验返回条数——如果上游少返回几条,对应位置会是 nil 向量,而 pipeline 只校验了'向量条数等于 chunk 条数',条数是一样的,所以 nil 向量会被真的写进 Qdrant。这是脏数据入口,修法是断言返回条数、断言每个 embedding 非空、断言 index 不越界且不重复,任何一条不满足就整批失败。第二,我没有做向量归一化和维度校验,归一化的问题在 Qdrant dot 距离下会暴露,维度不匹配只能等 Qdrant 报错。"

讲解与备注:

  • 「串行批、不并发」这个选择要主动解释成设计取舍(外部 API 限流 + 异步链路不急),否则会被当成"不会写并发"。
  • 「nil 向量能进库」是这个项目里第二重要的真实缺陷(仅次于 BM25 重启失效),主动说 + 给断言方案 = 强加分。
  • 顺带可以提的健壮性问题:batch_size <= 0 时循环步长为 0 会死循环(i += BatchSize),正常路径有配置默认值兜底,但热更新路径不跑默认值填充——这是一个"配置校验不完整"的例子。这个点很深,谨慎使用:只在面试官明显对配置治理感兴趣时说。

代码依据(批切分 + 限流 + 重试):

go
// internal/embedding/embedder.go:49-66
	for i := 0; i < len(texts); i += e.config.BatchSize {
		end := i + e.config.BatchSize
		if end > len(texts) {
			end = len(texts)
		}
		batch := texts[i:end]

		if err := e.limiter.Wait(ctx); err != nil {
			return nil, fmt.Errorf("限流等待失败: %w", err)
		}

		vectors, err := e.embedBatch(ctx, batch)
		if err != nil {
			return nil, fmt.Errorf("第 %d-%d 条 Embedding 失败: %w", i, end, err)
		}

		allVectors = append(allVectors, vectors...)
	}
go
// internal/embedding/embedder.go:158-165(按 index 回填、不校验条数 → nil 风险)
	vectors := make([][]float32, len(texts))
	for _, d := range embResp.Data {
		if d.Index < len(vectors) {
			vectors[d.Index] = d.Embedding
		}
	}

	return vectors, nil

追问链:

  • 追问:为什么不用并发批? → 答:"两个原因:一是外部服务大多按 QPS 限流,并发批会让重试和限流互相放大;二是入库是后台异步任务,用户感知不到这点延迟,稳定性优先。如果确认目标服务支持高并发(比如自建的 vLLM),我会把批并发放到配置里,默认 1。"
  • 追问:批量大小怎么影响质量? → 答:"批量大小不影响单条向量的质量,只影响吞吐和失败重试的粒度——批越大,一次失败损失的工作越多,重试成本越高。所以小批 + 多次是更稳的组合。"
  • 追问:有没有做 Embedding 缓存? → 答:诚实:"没有。重复的问题、多轮会话里反复问相似问题,每次都会重新调一次 Embedding。加一层基于文本哈希的 LRU 缓存是明显的优化点,尤其是查询侧的 Embedding(同一 query 重复检索的场景很多)。"

别踩的雷:不要说你做了"向量归一化"或"维度校验"——代码里都没有,被要求现场找会很难看。


Q14. 多查询(Multi-Query)和 HyDE 是怎么和检索结合的?融合逻辑一样吗? ​

面试官想考:你是否理解"多路召回"和"两路融合"是两个不同层次的融合,以及它们各自的实现差异。

口述回答(背诵这段):

"是两层融合,用的都是 RRF,但实现是两份不同的代码。 第一层是'两路检索'的融合,也就是语义路 + 关键词路,用的是加权 RRF,权重 0.7/0.3 从配置来,因为我对两路的信任度是不一样的。 第二层是'多查询'的融合:Multi-Query 会先用一次 LLM 调用把原问题扩成几个不同表达角度的变体,每个变体各自跑一遍完整的检索(内部已经做了两路融合),然后跨路用不带权重的 RRF 融合——因为每一路都是同一种检索方式,没有先验理由给不同变体不同权重。多路的并发上限是配置里的 multi_query_concurrency,默认 3,用信号量控制。 HyDE 是第三种:先让 LLM 凭空写一段"假设的真实文档",用这段假设文档去向量检索(因为假设文档和真实文档的语体更接近,向量距离更小),再和原查询的检索结果做 RRF 融合。HyDE 只走向量路,不参与 BM25,因为它假设的是语义相似。 有几个实现细节我要说清楚:一,多路场景下每一路的检索都传了 SkipRerank,不在路内重排,等所有路融合完之后统一重排一次,避免 N 次重排调用;二,多路融合的 k 是硬编码 60,走的不是我配置里的 rrf_k——这是一处配置不一致,我在 review 时记下来了;三,HyDE 每一步都有降级:模板渲染失败、LLM 生成失败、Embedding 失败、假设文档检索失败,任何一步失败都回落到原查询,不会让整个请求失败。"

讲解与备注:

  • 「两层融合、一个是加权一个是不加权、而且是两份代码」是这题的核心答案,能证明你看过实现而不是只看了 README。
  • 「多路里传 SkipRerank、融合后统一重排一次」是性能与一致性兼顾的设计,且单测里有断言(TestSearch_SkipRerank 断言 reranker 被调用 0 次、默认恰好 1 次),可以拿来当"我怎么验证的"素材。
  • 这些增强能力默认都是关闭的(multi_query_enabled、decomposition_enabled、hyde_enabled、step_back_enabled 默认 false),因为每一次都多一次 LLM 调用、直接增加首 token 延迟。这个取舍要主动说。

代码依据(多路 RRF 无权重 + k 硬编码):

go
// internal/retriever/rrf.go:73-79
	scores := make(map[string]float64)
	// 每路结果按 rank 贡献分数
	for _, results := range listOfResults {
		for rank, r := range results {
			scores[r.ID] += 1.0 / float64(k+rank+1)
		}
	}
go
// internal/retriever/retriever.go:347-365(融合后再整体重排一次)
	topK := req.TopK
	if topK <= 0 {
		topK = r.config.TopK
	}
	fused := FuseMultiQuery(results, 60, topK)
	slog.Info("多路检索完成", "路数", len(queries), "有效路", valid, "融合数", len(fused))

	// 思考链路:多路融合结果(F4/N7)
	if req.Trace != nil {
		req.Trace(RetrieveTrace{ ... })
	}

	// 融合后整体重排一次(F1/AC2)
	fused = r.rerankIfEnabled(ctx, req.Query, fused, topK, req.Trace)

追问链:

  • 追问:这两个融合函数为什么不合并成一个? → 答:"可以合并,实际上第二层是'每路权重都是 1'的特例。我当初分开写是因为它们的输入类型不同(一个是 RetrieveResult + BM25Result 两种结构,一个是同构的二维切片),合并需要先做类型统一。要重构的话我会让 FuseRRF 接收统一的'排名列表 + 权重'结构,FuseMultiQuery 变成它的一个调用。这是明确的重复代码,我认。"
  • 追问:多查询的效果怎么衡量? → 答:"理论上能提升召回覆盖,代价是 N 次 LLM 生成 + N 次检索的延迟。我没有量化数据,但评估框架可以测:同一份数据集跑 strategy.query=single 和 multi,比 Recall@K 和 LLM-as-Judge 分数。这也是我说评估体系是基础设施的原因——没有它这些开关都只能凭感觉。"

别踩的雷:不要把 HyDE 说成"生成答案再拿去检索"——正确的是"生成假设文档(不是答案),用它的向量去检索真实文档"。


Q15. 【重点题】你这套检索链路,如果让你现在重做,你会先改哪三处? ​

面试官想考:复盘能力 + 优先级判断。这是面试收尾阶段的常见问题,也是你把"我懂我的系统"落到实处的最后机会。

口述回答(背诵这段):

"按'影响面 × 修复成本'排序,我会改三处。 第一,BM25 的启动回灌。因为它是功能级缺陷、而且静默——重启之后混合检索退化成纯向量,没有任何告警。修法是启动时从 Qdrant 的 payload 或 PostgreSQL 拉一遍 chunk 正文重建索引,同时加'索引未就绪'的状态和告警。这一条还有个前置依赖:得先把 Rebuild 从调 Add 改成调 AddWithDocID,否则文档到 chunk 的反查表会是空的,重试补偿就删不掉东西。 第二,融合后补齐正文。BM25 独有的命中现在会带着空正文进上下文,既浪费 token 预算又没有引用信息。修法是让 BM25 索引同时存正文,融合时直接用,省掉回表。 第三,召回与重排解耦(oversample)。把召回数从 topK 放大到 3~5 倍,重排再取 topK——这一步是真正能提升回答质量的改动,因为它让重排有了发挥空间;前面两条更多是修 bug。 这三条之外,我还会补两类'非功能'的事:一个是检索链路的超时预算(现在 Qdrant 调用没有超时,靠上游 ctx 兜着);另一个是 payload 索引(kb_id 建索引)和分数阈值过滤。但如果只让我做三件,就是上面那三条——因为它们直接影响'召回质量'和'数据正确性',这两个是 RAG 系统的命脉。"

讲解与备注:

  • 这个排序体现优先级思维:先修静默错误 → 再修脏数据 → 再做效果优化。面试官听到"静默"两个字会明显加分,因为生产事故基本都是静默的。
  • 「前置依赖」那句(Rebuild → docChunks)展示了你的改动是考虑二阶效应的,不是拍脑袋列 TODO。
  • 结尾把"功能修复"和"非功能补强"分开,说明你有完整的技术债观。

代码依据:见 Q8、Q9、Q11 的三段代码,此处不重复。

追问链:

  • 追问:这三条你估计要多久? → 答:"回灌 + 就绪状态大概两三天(含测试);补齐正文是一天,主要是改索引结构和融合逻辑;oversample 半天改代码,但要留时间跑评估对比。不估太细,因为回灌那里我预判会遇到内存和启动时间的权衡。"
  • 追问:改完之后你怎么证明变好了? → 答:"三步:先跑基线(当前状态的 Recall@1/3/5 和忠实度);改完再跑同一份数据集;对比差异。没有基线的话任何优化都是猜——这也是我这次最想补上的一课:我先把评估框架建起来了,但基线数据还没跑完,这是我的欠账。"(这句要背熟,它同时回答了"效果如何"这个必问题)

别踩的雷:不要列一堆"我会加缓存、加监控、上 K8s"这种泛泛而谈——面试官要的是针对这个系统的具体判断。


Q16. 检索这块的测试覆盖怎么样?你怎么保证改检索不会把别的地方弄坏? ​

面试官想考:测试意识 + 对自身测试有效性的判断。这也是"AI 写的代码你怎么验收"的延伸。

口述回答(背诵这段):

"internal/retriever 有 26 个测试函数,覆盖的边界比较实:分词器(英文小写化、中文 bigram、中英混排、扩展汉字、纯标点、数字混排)、BM25(增删改、Rebuild、按 kb 过滤的四种情况、tf 更高者排第一)、RRF(去重、两路都命中排第一、改权重必须改变第一名——这条是权重真正生效的证明)、降级(BM25 空索引退化为纯向量、reranker 失败不报错)、以及 Trace 埋点和 SkipRerank 的调用次数断言。 但我要诚实说三个覆盖盲区。第一,internal/vectorstore 一个测试文件都没有——buildFilter、payload 双向转换、集合创建这些全都没测,所以我对 Qdrant 过滤语义的信心只来自代码阅读,不是测试。第二,没有覆盖跨 chunk 的流式解析/粘包这类底层风险(那是前端的),检索侧对应的是并行性没有被验证——我在测试里给 mock 留了 delay 字段但没用它断言过总耗时。第三,没有真实 Qdrant 的集成测试,全部是 mock 或者 httptest,所以'filter 真的下推了吗''payload 往返后类型对吗'这类问题只有上真实环境才能确认。 至于防回归:CI 里跑 go build、go vet、go test ./...,还有一层选择性的 race 检测——只对 store / task / api / eval 四个包开了 -race,retriever 和 rag 没有跑 race,而这两块恰恰是并发最密集的地方,这是 CI 配置上的一个疏漏,我应该补上。"

讲解与备注:

  • 「改权重必须改变第一名」这个测试值得专门提——它是"测试断言行为而不是断言实现"的好例子。
  • 三个盲区 + CI 的 race 疏漏主动说出来,比"测试覆盖很好"可信得多。
  • 如果面试官说"那你补一下"→ 这正是你想听的,答:"我会按优先级补三个:buildFilter 的表驱动单测、retriever 的并发 race 测试、以及一个可选的 docker-compose 集成测试(起真 Qdrant 跑一遍 upsert→search→delete 的往返)。"

代码依据(测试里可引用的断言):

go
// internal/retriever/retriever_test.go:206-229(TestFuseRRFWeightChange 断言权重真实生效)
func TestFuseRRFWeightChange(t *testing.T) {
	// 权重 0.9/0.1 → 0.1/0.9 必须改变第一名
go
// internal/retriever/retriever_test.go:574-602(TestSearch_SkipRerank 用调用计数断言)
	// SkipRerank=true → reranker 调用 0 次;默认 → 恰好 1 次(mockReranker.calls 计数)

追问链:

  • 追问:如果只能加一个测试,你加哪个? → 答:"给 buildFilter 加表驱动测试。因为它是安全边界——知识库隔离靠它,而它现在完全没测,且有个已知的类型静默忽略问题。测试用例我会覆盖:string→MatchKeyword、[]string→MatchKeywords、空切片、int/int64、以及不受支持的类型应该报错而不是静默忽略(这条现在会失败,正好说明我该改实现)。"
  • 追问:mock 会不会让测试变成"测试自己写的假设"? → 答:"会,这是 mock 测试的固有风险——我 mock 的 Qdrant 返回什么,测试就只能验证我对 Qdrant 的假设。所以我承认'filter 真的下推了'这件事只能用集成测试证明。我的取舍是:单元测试保证我自己的逻辑(融合、打分、降级)正确,Qdrant 的语义靠文档和真实环境验证。"

别踩的雷:不要说"我的测试是全绿的所以没问题"——测试全绿和测试有效是两件事,能区分这两者的人不多。


三、诚实承认的不足与改进(检索与重排专版) ​

#不足影响面试怎么讲得体改进方案
1BM25 纯内存、启动无回灌,Rebuild 无生产调用点重启后 hybrid 静默退化为纯向量,无告警"这是最严重的功能缺陷,我是自己 review 时发现的。它的可怕之处不是坏,而是静默地坏——只有 trace 里 method 从 hybrid 变 vector 能看出来。"启动从 payload/PG 回灌 + 就绪状态 + 告警;先修 Rebuild 的 docChunks 映射(改用 AddWithDocID)
2BM25 独有命中正文为空(allDocs 只由向量结果构建)空上下文条目占用 token 预算、引用卡片缺文件名"这是个真实 bug,根因是我把 BM25Result 设计得太轻(只有 ID+Score),融合层又没有回表补正文。"BM25 索引存正文;或融合后按 ID 批量 Get 补齐
3召回无 oversample,重排候选池 = topK重排只能改排序、不能救召回"等于把漏斗的下半截砍了。主流是召回 50 条重排取 10 条,我是 10 条进 10 条出。"recallTopK = topK × 4,融合后不截断交重排;用评估验证
4score 字段语义不稳定(重排成功=相关性分,降级=RRF 分)下游按分数排序会出现量级跳变"同一字段两套量纲,这是我接口契约设计上的疏忽。"拆成 fusion_score / rerank_score + reranked 标志
5向量路失败则整体失败,不降级为 BM25-only可用性降低(Qdrant 抖动即检索不可用)"当时想的是主路挂了不该给不完整的答案,现在看应该降级 + 标记,把判断交上层。"降级返回 BM25 结果 + 响应里标记 degraded
6检索链路无超时预算Qdrant 卡住会拖住请求"只靠上游 ctx 兜,检索层自己没有 WithTimeout。"在 retriever 入口加可配置的检索总超时(含向量+BM25+重排)
7Qdrant 无 payload 索引、无 score 阈值、无归一化、无维度校验数据量大后过滤变慢;低质结果进上下文;dot 距离下语义错误;维度不匹配无前置报错"四个'没做',其中维度校验我会优先补——启动时比对配置维度和集合实际维度,不一致直接拒绝启动。"建 kb_id keyword 索引;引入 ScoreThreshold 和相对阈值;dot 距离下补归一化;启动维度校验
8Embedding 响应不校验条数/index → nil 向量可入库脏数据进入向量库且难定位"这是脏数据入口,pipeline 只比了条数,条数一样所以拦不住。"断言条数、非空、index 不重复不越界,任一不满足整批失败
9同分排序不确定(map 遍历 + 非稳定排序)多查询融合结果不可复现,影响评估对比"三路各自的第 1 名分数严格相等时,名次可能每次不一样。"加 tie-break(按 ID 字典序二次排序),或改 sort.SliceStable
10配置不一致 / 失效:strategy.fusion 未接线;多路融合 k 硬编码 60;reranker.top_n 在主链路不生效配置文件与真实行为不符,误导使用者"这是我在 review 报告里列为 P1 的一类问题:声明了但没接线。修了读取,但更深的接线问题(调用方永远显式传值)我第二次复盘才发现。"配置项收敛:要么接线要么删掉;在配置文档里标注"仅当未指定时生效"
11internal/vectorstore 零测试;retriever 不跑 race过滤语义、payload 往返无测试保护;并发改动无保护"Qdrant 过滤是安全边界,它没测试是我最该补的。"buildFilter 表驱动单测 + retriever race 测试 + 可选的真实 Qdrant 集成测试
12无查询/Embedding/重排缓存重复 query 重复付费与延迟"多轮会话里重复问题的场景很多,加一层文本哈希 LRU 是明显收益。"查询向量缓存 + rerank 结果缓存(同 query+chunk 集合)

四、背诵清单(10 条,考前只读这个) ​

  1. 两路并行:向量(Qdrant gRPC)+ 自研 BM25(内存倒排),各取 Top10,sync.WaitGroup 并发,向量失败整体失败(设计缺陷,会改成降级+标记)。
  2. BM25 公式:IDF = log((N−df+0.5)/(df+0.5)+1)(非负变体),TF 项 tf(k1+1)/(tf+k1(1−b+b·dl/avgdl)),k1=1.2、b=0.75 硬编码。
  3. 中文分词:bigram(生产额外开 unigram 补单字召回),英文按词小写化,无词典、无 jieba、无停用词(实现了但我们没开)。
  4. RRF:Σ weight/(k+rank+1),k=60,权重 0.7/0.3(校验强制和为 1);只用排名不用分数,因为余弦 0~1 和 BM25 无上界量纲不可比。
  5. 重排:cross-encoder,/v1/rerank(兼容 Jina/Cohere/bge),30s 超时,1s/2s/4s 重试 3 次,失败降级返回融合原序;另有 llm 模式(逐条 LLM 打 0-10 分)。
  6. 候选池缺陷:重排池 = topK,没有 oversample,所以重排只能改排序不能救召回。
  7. BM25 独有命中无正文:allDocs 只由向量结果构建 → 空内容进上下文,真实 bug。
  8. BM25 无持久化、启动无回灌:重启后静默退化为纯向量(DocCount()>0 门控跳过),只有 trace 的 method 字段能看出来。
  9. 多租户:单集合 + payload kb_id 过滤(Must + MatchKeyword/MatchKeywords),过滤前置于算分;无 payload 索引;不支持的类型静默忽略(潜在越权)。
  10. 三个"没做"要主动认:无 payload 索引 / 无 score 阈值 / 无缓存无超时;以及 Embedding 不校验返回条数 → nil 向量可入库。

持续学习,持续构建。