7.3 实现检索与重排


7.3 实现检索与重排

本节摘要:造查询侧两件新零件。retrieval.py 的 Retriever 类:构造时加载 7.2 的三个产物(metas 正文、向量库、BM25——词法索引按 metas 重建,行号天然对齐),search() 把第 5 章的全套参数收进一个签名:recall(每路召回宽度)、bm25_w/vec_w(混合权重)、rerankerrerank_depth(重排器注入 + 深度,0 即关闭——消融的开关就是这么来的);返回带 rrf 分与 rerank_score 的 chunk 字典列表。assemble_context:把命中 chunk 组装成给 LLM 的上下文——同文件的块聚拢(阅读连贯)、块间 分隔、总量封顶并显式截断。ask.py 的 CLI:每个调优参数都暴露成命令行旗标(--recall--bm25-w--rerank--llm),7.4 的扫描脚本就是换个旗标反复调用它;5.3 的标识符归一化作为常开前处理接在查询入口。跑通本节即达成 M2:四类查询可现场演示。

学习目标

阅读完本节,你应当能够:

  1. 说出 Retriever 的构造产物与 search() 各参数对应的调优维度;
  2. 解释重排器用依赖注入而非硬编码的原因(消融与替换);
  3. 设计并运行上下文组装:同文件聚拢、截断声明、总量封顶;
  4. 用 ask.py 的旗标组合复现第 5 章的每个实验。

一、Retriever:把第 5 章收进一个类

# retrieval.py — 混合召回 → RRF 融合 → 重排 → 上下文组装(第 5 章的落地) from bm25 import BM25, code_tokenize from hybrid import rrf_fuse from mini_store import MiniVectorStore from pipeline import load_metas class Retriever: """检索门面:加载索引、两路召回、RRF 融合、可选重排。参数全走 search()。""" def __init__(self, index_dir: str, embed): self.embed = embed self.metas = load_metas(index_dir) # 带 text 的单一事实源(7.2) self.store = MiniVectorStore.load(f"{index_dir}/code_index") self.bm25 = BM25([code_tokenize(m["text"]) for m in self.metas]) # 行号对齐 def _vector_ranking(self, query: str, recall: int) -> list[int]: hits = self.store.search(self.embed([query])[0], top_k=recall) return [self.store.meta.index(h[0]) for h in hits] def search(self, query: str, top_k: int = 5, recall: int = 30, bm25_w: float = 1.0, vec_w: float = 1.0, reranker=None, rerank_depth: int = 0) -> list[dict]: """recall=每路召回宽度;rerank_depth>0 且注入 reranker 才重排(消融开关)。""" rankings = [[i for i, _ in self.bm25.search(query, top_k=recall)], self._vector_ranking(query, recall)] fused = rrf_fuse(rankings, weights=[bm25_w, vec_w]) n = max(rerank_depth, top_k) # 重排候选 = 融合后前 depth 条 hits = [dict(self.metas[i], rrf=round(s, 6)) for i, s in fused[:n]] if reranker and rerank_depth: hits = reranker(query, hits, depth=rerank_depth, top_n=top_k) return hits[:top_k]

三个接口决定讲透:

  1. 重排器走依赖注入reranker 参数)而非 import 死绑:第 9 章评测要对比"有/无重排""交叉编码器/LLM"——注入让被测系统同一个、变量只有重排器;5.2 的 rerankrerank_llm 签名一致,可无缝互换;
  2. rerank_depth=0 即关闭:消融基线不需要注释代码,改个参数就行;
  3. 返回字典带两种分rrf(融合名次分)与重排器写的 rerank_score——下游展示优先后者(5.2 的级联原则),并永远能追溯排序来源。

二、上下文组装:给 LLM 的那一份

# retrieval.py 续 — 上下文组装 def assemble_context(hits: list[dict], max_chars: int = 6000) -> str: """命中 chunk → LLM 上下文:同文件聚拢、块间分隔、总量封顶显式截断。""" blocks: dict[str, list[str]] = {} # path → 块列表(同文件连贯) order: list[str] = [] for h in hits: head = f"### {h['path']}:{h['start']}-{h['end']}({h['kind']} {h['symbol']})" blocks.setdefault(h["path"], []).append(f"{head}\n{h['text']}") if h["path"] not in order: order.append(h["path"]) parts, used = [], 0 for path in order: # 保持命中序:文件内聚、文件间按排名 body = "\n…\n".join(blocks[path]) # 同文件相邻块用省略号衔接 if used + len(body) > max_chars: body = body[: max_chars - used] + "\n…(截断)" parts.append(body) used += len(body) if used >= max_chars: break return "\n\n".join(parts)

设计动机:LLM 读代码上下文与检索器看 chunk 不同——同文件的块放在一起(类骨架与方法本来就该连读,3.2 的切分代价在这里被补偿);### 路径:行号 的标题让回答能引用出处;max_chars 按 4 字符 ≈ 1 token 折算(经验值),6000 字符约 1.5k token,给问答场景留足余量。

三、ask.py:把调优参数全部暴露成旗标

# ask.py — CLI 入口:python ask.py "查询" [--index index] [--rerank 30] [--llm] import argparse from query_norm import normalize_query # 5.3:标识符归一化,常开 from retrieval import Retriever, assemble_context from rerank_ce import rerank # 5.2:交叉编码器重排 ANSWER_PROMPT = """根据以下代码上下文回答问题。回答里引用文件路径与行号; 上下文里没有答案就直说"上下文中没有找到",不要编造。 问题:{query} 上下文: {context}""" def answer_with_llm(query: str, context: str) -> str: """写法示意:client 为所选厂商 SDK,参数以官方文档为准。""" resp = client.chat.completions.create( model="你选定的模型", temperature=0.0, messages=[{"role": "user", "content": ANSWER_PROMPT.format(query=query, context=context)}]) return resp.choices[0].message.content def main(): ap = argparse.ArgumentParser(description="codeqa:CLI 代码问答") ap.add_argument("query") ap.add_argument("--index", default="index") ap.add_argument("--top-k", type=int, default=5) ap.add_argument("--recall", type=int, default=30, help="每路召回宽度") ap.add_argument("--bm25-w", type=float, default=1.0, help="词法路权重") ap.add_argument("--vec-w", type=float, default=1.0, help="语义路权重") ap.add_argument("--rerank", type=int, default=0, help="重排深度,0=不重排") ap.add_argument("--llm", action="store_true", help="组装上下文交 LLM 生成回答") args = ap.parse_args() query = normalize_query(args.query) # 混排查询先归一(5.3 同构契约) retriever = Retriever(args.index, embed) # embed:0.2 统一入口 hits = retriever.search(query, top_k=args.top_k, recall=args.recall, bm25_w=args.bm25_w, vec_w=args.vec_w, reranker=(rerank if args.rerank else None), rerank_depth=args.rerank) for h in hits: score = h.get("rerank_score", h.get("rrf", 0.0)) # 级联:重排分优先 print(f"[{score:.4f}] {h['path']}:{h['start']}-{h['end']} " f"{h['symbol']} ({h['kind']})") if args.llm: print("\n" + answer_with_llm(args.query, assemble_context(hits))) if __name__ == "__main__": main()

旗标与 7.4 扫描维度一一对应:--recall 扫召回宽度、--bm25-w/--vec-w 扫权重配比、--rerank 扫重排深度、--llm 开关端到端回答。回答提示的三句纪律(引用出处、找不到直说、不许编造)是代码问答可信度的下限保障——上下文组装给了出处,提示词要求用出处。

四、接通与 M2 验证

# m2_check.sh — M2 验证:四类查询各一例(示意,路径换成你的仓库) python ask.py "kill_session" --index index # 标识符型:应第一位 python ask.py "处理用户注销时删了哪些东西" --index index --rerank 30 # 意图型 python ask.py "revokeToken 在哪被调用" --index index # 混排型 python ask.py "登出逻辑" --index index # 换说法型

验收口径:标识符型第一位命中(BM25 一路保底);其余三类 top-5 内出现正确 chunk。若不达标,先别动参数——按 7.4 的排查顺序表走:金标对不对 → 切分好不好 → 召回有没有 → 排序对不对,逐层归因再动手。

⚠️ 一个常见集成坑:retrieval.pypipeline.py import,而 pipeline.py__main__ 入口有 argparse——被 import 时不执行(if __name__ == "__main__" 守卫),这是 Python 模块复用的基本盘;但 embedclient 这两个 0.2 式的"环境注入"在两个脚本里都要先定义再跑,建议各建一个 config.py 统一初始化后 from config import embed

本节要点回顾

  1. Retriever 构造加载三产物,search() 收拢第 5 章全部参数——重排器注入、depth=0 即关,消融只改参数不改代码;
  2. 上下文组装三原则:同文件聚拢(补偿切分代价)、出处入标题、总量封顶显式截断;
  3. ask.py 把调优维度全部旗标化,标识符归一化常开在查询入口;
  4. M2 验证四类查询;不达标先按排查顺序归因,再谈调参。

机器能跑了。下一节试车:演示仓库跑通四类查询,再把 chunk 大小、召回宽度、混合权重、重排深度四个旋钮依次拧一遍——顺序有主张,不是乱拧。


作者与出处
原作者: 灏天文库
整理: 灏天文库整理
本站整理收录,版权归原作者/开源协议所有;欢迎通过原文链接访问源仓库。
发布者: 作者: 灏天文库 转发
评论区 (0)
U