混合检索与上下文
从页面感知切片到两路召回、RRF、重排与父文档扩展,逐步组织能用于回答的证据。
切片保留来源
导入后的片段保留文档标识、来源、页码与原文,章节信息和父片段关系用于后续扩展。索引按工作区组织,检索不会混用其他工作区的资料。这样即使模型最终只使用短片段,也能回到原文核对。
文档解析与索引是后台任务,前端区分处理中、就绪、失败、替换和删除。仅就绪文档进入问答证据集;上传成功不等于索引已经完成。
为什么片段不能只保存文本
纯文本块足以做相似搜索,却不足以解释答案。片段 ID 用于去重和任务关联,文档 ID 用于限定来源,页码帮助回到原文,章节与父片段关系帮助补齐上下文。两处完全相同的文字也可能来自不同页或不同文档,不能简单按文本相等合并来源身份。
切得太短,定义和前提可能分离;切得太长,候选包含大量无关内容、模型输入和重排成本上升。页面感知切片与父文档扩展分别处理定位和上下文完整性,不需要为了提供完整解释就把整篇论文作为一个候选。
检索链路中的每一步
flowchart TD
Q[问题与工作区过滤] --> E[原查询、可选改写与扩展]
E --> D[FAISS 向量召回]
E --> B[FTS5 或 BM25 词法召回]
D --> F[RRF 排名融合]
B --> F
F --> R[可选 CrossEncoder 重排]
R --> G{证据是否足够}
G -->|不足且允许纠正| W[扩大本地候选并重检索]
W --> P[父片段扩展与上下文处理]
G -->|足够| P
P --> A[回答与引用校验]
候选规模、最终 topK 和上下文预算分别控制不同阶段。候选集决定重排能看到多少材料,topK 决定最终取多少片段,字符预算限制实际送给生成器的文本。只把 topK 调大,未必能解决首轮没有召回正确片段的问题。
为什么用两路召回
向量召回覆盖语义相近但措辞不同的问题,BM25 补充专有名词、错误码和精确术语。真实实现使用 FAISS 执行向量查询,词法通道可以使用 SQLite FTS5,也保留 BM25Okapi 回退路径。
两路原始分数的尺度不同,不能简单直接相加。_rrf_ranked 按排名累计 1 / (k + rank),支持多查询融合,并叠加可配置的章节权重。原始问题保留在融合查询集中,避免改写丢掉有效信息。
def _rrf_ranked(
question: str | list[str],
n: int,
index_dir: str | Path | None,
workspace_id: str = "default",
rrf_k: int | None = None, # None 时读取 retrieval.rrf_k(默认 20)
trace: dict[str, Any] | None = None,
metadata: list[dict[str, Any]] | None = None,
filters: dict[str, Any] | None = None,
) -> list[tuple[int, float]]:
"""优化版双通道 RRF 融合:支持 multi-query,融合 RRF 排名并叠加章节权重。"""
if trace is None:
trace = {}
retrieval_cfg = load_settings().get("retrieval", {})
if rrf_k is None:
rrf_k = int(retrieval_cfg.get("rrf_k", 20))
section_weights = retrieval_cfg.get("section_weights") or _DEFAULT_SECTION_WEIGHTS
queries = (
[question]
if isinstance(question, str)
else [str(item) for item in question if str(item).strip()]
)
if not queries:
return []
# 调用方已加载元数据时直接复用,避免重复读取索引文件
if metadata is None:
_, metadata = _get_cached_index(index_dir, workspace_id)
fused: dict[int, float] = {}
with track_latency("rrf_fusion", trace):
for query in queries:
with track_latency("dense_search", trace):
dense = _dense_search(
query, n, index_dir, workspace_id, trace, filters=filters
)
with track_latency("bm25_search", trace):
bm25 = _bm25_search(
query, n, index_dir, workspace_id, metadata=metadata, filters=filters
)
# 1. Dense 稠密向量通道打分
for rank, (chunk_idx, _) in enumerate(dense, 1):
base_rrf = 1.0 / (rrf_k + rank)
boost = _get_section_boost(
metadata[chunk_idx].get("section", ""), section_weights
)
fused[chunk_idx] = fused.get(chunk_idx, 0.0) + (base_rrf * boost)
# 2. BM25 词法匹配通道打分
for rank, (chunk_idx, _) in enumerate(bm25, 1):
base_rrf = 1.0 / (rrf_k + rank)
boost = _get_section_boost(
metadata[chunk_idx].get("section", ""), section_weights
)
fused[chunk_idx] = fused.get(chunk_idx, 0.0) + (base_rrf * boost)
return sorted(fused.items(), key=lambda x: x[1], reverse=True)
用排名融合的理由
例如某片段在向量通道排第一、词法通道排第四,它对融合结果贡献 1 / (k + 1) + 1 / (k + 4);另一片段只在一路出现,则只有该路贡献。这里的 k 是 RRF 平滑参数,不是最终返回数量 topK。
这种方法减少了原始分数量纲不同造成的问题,也会损失原始分数差距的信息:第一与第二究竟差得很大还是很小,排名本身不表达。因此融合分用于候选排序,不能当成片段支持结论的概率。
当前实现还能叠加章节权重,因而是带配置因素的排名融合。比较检索实验时,除了 dense 或 hybrid 标签,也要固定 RRF 参数、章节权重和多查询策略。
重排与父文档
混合召回先取较大的候选集,再用 CrossEncoder 对问题和候选成对打分。经过重排选出的短片段可以扩展到同一父片段下的上下文;命中的子片段放在前面,后续长度截断仍保留关键原文。
父文档扩展有字符预算,不会把整篇文档无条件塞进窗口。上下文还可以做去重、压缩与顺序重排,减少重复资料占用预算,缓解有用证据位于长上下文中间的问题。
CrossEncoder 在候选上联合查看问题和片段,适合纠正初召回排序,但要为每个候选增加推理开销。若正确内容根本没进入候选,重排器也无法凭空补出;因此应先定位是召回问题还是排序问题,再调整候选数量。
父片段扩展采用先短后长的思路:用较精确的子片段命中,再附上同一父片段内的上下文。命中内容优先保留,避免预算截断先留下大量背景、最后反而删掉关键证据。扩展文本与原始片段来源仍需保持关联,才能解释引用。
工作区与过滤不能在后续丢失
查询限定某个文档或元数据时,这些条件要沿向量、词法、多查询和纠正分支传递。只在第一轮过滤、扩大候选时忘记条件,会让用户明确排除的资料重新进入答案。
索引缓存也必须感知更新。当前缓存签名考虑基础向量文件、元数据及增量片段 manifest 的变化,加载时有锁避免并行研究重复读取同一索引。缓存提高加载效率,但不应让文档替换后继续用旧的元数据和片段位置。
证据不足时纠正
项目的 CRAG-lite 不搜索互联网。它先以证据数量和尺度对应的阈值作确定性检查,再扩大本地候选池并启用多查询重检索。
def grade_evidence(
chunks: list[dict[str, Any]],
min_items: int = 2,
min_score: float = 0.35,
rerank_min_score: float = 0.60,
) -> bool:
"""Return ``True`` when the retrieved evidence is sufficient.
Sufficient means at least ``min_items`` chunks and a scale-appropriate score.
RRF scores are not comparable to cosine or reranker probabilities.
"""
if len(chunks) < max(1, min_items):
return False
scales = {str(chunk.get("score_scale", "cosine")) for chunk in chunks}
if "rerank_probability" in scales:
top_score = max(
(float(chunk.get("score", 0.0)) for chunk in chunks
if chunk.get("score_scale") == "rerank_probability"),
default=0.0,
)
return top_score >= rerank_min_score
if scales == {"rrf"}:
return True
top_score = max((float(chunk.get("score", 0.0)) for chunk in chunks), default=0.0)
return top_score >= min_score
当前纠正分支保留工作区和显式元数据过滤条件,追加纠正检索结果后再做上下文处理。扩大候选并不保证能找到答案;最终仍由引用与支持校验约束输出。
“证据不足”至少有两类原因:资料存在但初轮没取到,以及当前工作区根本没有所需资料。扩大候选主要帮助前者;反复检索不能解决后者。因此纠正有轮次限制,仍不足时应进入拒答或明确的缺口说明。
参数变化怎样验证
| 参数或策略 | 可能改善什么 | 需要观察的代价 |
|---|---|---|
| 增大候选规模 | 提高正确资料进入重排的机会 | 重排耗时与模型计算 |
| 增大最终 topK | 覆盖更多相关方面 | 上下文噪声与重复 |
| 开启父片段扩展 | 补齐前提、定义与段落关系 | 输入长度与来源定位 |
| 增加多查询 | 覆盖不同措辞与子问题 | 检索调用及重复候选 |
| 调整切片与重叠 | 改善可检索粒度 | 索引体积与重新索引成本 |
切片参数修改只影响之后的解析和重建,已有索引需要重新处理才能按新粒度生效。比较参数时应使用同一语料和标注,保存逐题结果,而不是挑一条恰好改善的回答判断整体收益。
演示与真实检索的区别
演示只对预置或上传文本做本地词项匹配,按命中词和文档频率计算排序分数,然后摘录原文。它能展示导入、检索、引用和拒答的交互,但界面中选择 hybrid 或 rerank 不会启动相应模型。真实召回算法应通过后端固定语料评测验证。
![CitedRAG 对话页:回答的每个结论后面带 [1][2] 引用编号,下方展开证据](/covers/citedrag-art.webp)