数据截至 (上游 commit 8c51b8dc5408)
检索与作答:一次提问的完整链路
本章讲什么:
retrieve_chunks里那四种配置分别怎么走、权限是怎么在向量检索之前就收窄范围的、padding 是什么、图片最后怎么进到大模型的请求里。
1. 主线一图
所有检索接口最终都汇进 DocumentService.retrieve_chunks(core/services/document_service.py:178)。
query / query_image
|
v
[setup] 决定这次走哪种配置(见下表)
|
+-------------------+-------------------+
| |
[并行 A] 编码查询 [并行 B] 查权限
embed_for_query find_authorized_and_filtered_documents
| |
+-------------------+-------------------+
|
doc_ids 为空? --是--> 直接返回 []
|否
v
[vector search] 在 doc_ids 范围内检索 top-k
|
v
[rerank] 仅非 ColPali 且开了 reranker 时执行
|
v
[padding] 仅 ColPali 且 padding>0 时执行
|
v
[result] 下载内容、拼文档元数据 --> List[ChunkResult]
关键点是并行 A/B 那一层:编码查询要跑模型(几十到几百毫秒),查权限要走数据库(几毫秒到几十毫秒),两者无依赖,所以 asyncio.gather 同时发(core/services/document_service.py:349-352)。
2. 四种配置
代码开头就把话说明白了(core/services/document_service.py:213-217):
| 配置 | reranking | colpali | 实际行为 |
|---|---|---|---|
| 1 | 关 | 关 | 纯 pgvector 相似度,直接返回 |
| 2 | 关 | 开 | 只用多向量结果,chunks = chunks_multivector |
| 3 | 开 | 关 | pgvector 召回后用 FlagReranker 重排 |
| 4 | 开 | 开 | 不跑独立 reranker,直接信多 向量库自己的打分 |
配置 4 那行注释很重要:"When ColPali is enabled we rely on the ColPali vector store scoring directly"(第 382 行)。因为快路存储内部已经做过一次精确 MaxSim 重排了,再叠一个交叉编码器重排属于重复劳动。
判定逻辑:
use_standard_reranker = should_rerank and (not using_multivector) and self.reranker is not None
一个反直觉的事实:两条路不混合
第 474-475 行:
if using_multivector:
chunks = chunks_multivector
开了 ColPali 就只有多向量结果,普通向量检索连发都不发(第 388 行的 if not using_multivector 才添加搜索任务)。这和摄取端一致——一个文档只会进一个库。
所以 "混合检索"(ColPali + 文本 BM25/向量融合)在这份代码里不存在。这是设计取舍,不是遗漏。
3. 权限:在向量检索之前就收窄
先查白名单,再做向量搜索
doc_ids = await self.db.find_authorized_and_filtered_documents(
auth, filters, system_filters, status_filter=["completed"],
)
core/services/document_service.py:336-341。这一步返回的是一个文档 ID 列表,后面所有向量检索都带 doc_ids=doc_ids 参数,在数据库层面就限定了范围。
这个顺序不能反:先向量检索再过滤权限,会出现"top-k 全被过滤掉、结果为空"的情况。
注意 status_filter=["completed"] 写死在这里——正在摄取的文档天然不可见。
访问控制本身很简单
_build_access_filter_optimized(core/database/postgres_database.py:1199)只有两行有效逻辑:
if auth.app_id:
return "app_id = :app_id"
return "owner_id = :user_id"
| 部署形态 | 用哪个字段 |
|---|---|
| 云上(token 带 app_id) | app_id |
| 自托管 / 开发模式 | owner_id |
只有一层。没有文档级 ACL、没有角色。租户边界就是 app_id 这一列。
文件夹范围:名字骗人
_build_folder_scope_filters(core/services/document_service.py:80)的 docstring 特意警告:
尽管参数叫
folder_name,它接受的是完整文件夹路径(如/Company/Department/Reports)。命名是历史遗留,过滤实际打在folder_path列上。
配合 folder_depth 三种语义:
folder_depth | 含义 | 生成的过滤键 |
|---|---|---|
None 或 0 | 仅精确匹配这一层 | folder_path |
-1 | 包含全部子孙 | folder_path_prefix |
n > 0 | 最多往下 n 层 | folder_path_prefix_depth |
4. 重排:先多捞一点
用独立 reranker 时会先超采样(core/services/document_service.py:389-391):
oversample_k = k
if use_standard_reranker:
oversample_k = max(k, min(3 * k, 20))
捞 3 倍(封顶 20)交给交叉编码器打分,再截回 k 个。理由是向量召回的排序不够准,给重排器更多素材才有意义。
重排器是 FlagReranker(core/reranker/flag_reranker.py:9),包了 FlagEmbedding 的 FlagAutoReranker,默认模型 BAAI/bge-reranker-large(见 morphik.toml 的 [reranker]),默认关闭。
小提醒:第 464 行那条日志写的是
f"Reranked {k*10} chunks",和实际的3*k(封顶 20)对不上。看日志排查时别被误导。
5. padding:把前后页一起捞回来
要解决什么
"第 37 页提到了扭矩表,但表格跨到了第 38 页"。只返回命中页,答案就是残的。
padding=n 表示:每个命中页,额外把前 n 页和后 n 页一起取回来。
怎么做的
_apply_padding_to_chunks(core/services/document_service.py:554):
命中 chunks
|
[1] 只保留图片块 padding 只对页面图片有意义,非图片块被过滤掉
| (没有图片块就直接返回空列表!)
[2] 按 document_id 分组
|
[3] 算出要补的 (doc_id, chunk_number±i) 集合,排掉已命中的
|
[4] get_chunks_by_id 一次批量取回
|
[5] 合并去重,补充块的 score 置 0,打上 __morphik_padding 标记
|
[6] 排序:先按分数降序(命中的在前),再按 doc_id、页号
第 1 步那个行为要特别注意(第 593-596 行):
if not image_chunks:
# No image chunks to pad, return empty list since padding is only for images
logger.info("No image chunks found for padding, returning empty list")
return []
开了 padding 但结果里全是文本块 → 返回空。 不是"不做 padding",是直接空。
判定是不是图片块的函数 _is_image_chunk(第 578 行)有三级判断:metadata 里的 is_image 布尔值 → 内容以 data 开头 → 内容是带图片扩展名的存储 key。因为 output_format="url" 时内容字段里放的是存储 key 而不是 data URI。
分组返回
/retrieve/chunks/grouped 端点会把结 果重新组织成"主命中 + 它的邻居"结构,方便 UI 渲染(_create_grouped_chunk_response_from_results:692)。每个 ChunkResult 上的 is_padding 标记就是靠这里区分的。
6. 从检索结果到答案
DocumentService.query(core/services/document_service.py:1028)串起后半程。默认 k=20,注释标了出处:"from contextual embedding paper"。
retrieve_chunks(return_preloaded_docs=True)
| (顺手把文档元数据也带回来,省一轮查询)
v
_create_document_results --> 每个 chunk 对应的 Document
|
v
chunk.augmented_content(doc) --> 视频块会拼上帧描述和字幕,其他原样
|
v
CompletionRequest(context_chunks=[...], chunk_metadata=[...])
|
v
LiteLLMCompletionModel.complete
图片怎么进 LLM 请求
这是全链路最后一个关键机制。process_context_chunks(core/completion/litellm_completion.py:78):
for chunk in context_chunks:
if chunk.startswith("data:image/"):
if is_ollama:
base64_data = chunk.split(",", 1)[1] # Ollama 要裸 base64
ollama_image_data.append(base64_data)
else:
image_urls.append(chunk) # 其他厂商吃完整 data URI
else:
context_text.append(chunk)
判据就是字符串前缀 data:image/。 页面图片在存储层就是以 data URI 形式存的,所以到这里天然可识别,不需要额外的类型标注。
然后拼成 OpenAI 兼容的多模态消息(litellm_completion.py:485-487):
for img_url in image_urls[:NUM_IMAGES]:
content_list.append({"type": "image_url", "image_url": {"url": img_url}})
这就是"把原页直接给模型看"落地的那一行。 检索出来的第 37 页,原封不动进了模型的视觉输入。
行内引用
开了 inline_citations 时,系统提示词换成一份强制引用的版本(get_system_message:22),并且每个上下文块后面追加一行来源(format_user_content:154):
formatted_chunks.append(f"{chunk}\nSource: {citation}")
页码从哪来?core/services/document_service.py:1141-1147:
if is_colpali:
metadata["page_number"] = chunk.chunk_number + 1 # 0-based 转 1-based
else:
metadata["page_number"] = chunk.metadata.get("page_number")
"页即块"这个约定在这里兑现了价值:ColPali 路径下页码不用额外记录,chunk_number + 1 就是。
7. 关键细节与坑
min_score是个摆设。 它出现在RetrieveRequest(core/models/request.py:138)、retrieve_chunks、retrieve_docs、query的所有签名里,一路往下传,但在document_service.py里从未被用来过滤任何结果。想按分数卡阈值得自己在调用方做。output_format="url"会跳过图片下载。 它设置skip_image_content=True,让存储层直接返回 key 而不是内容(document_service.py:230-231)。适合前端自己去取图的场景。- 图片查询不能用于问答。
/query端点在core/api.py:647-652直接拒绝query_image,只有/retrieve/chunks支持以图搜图。 - 聊天历史存在 Redis,Postgres 是兜底。
core/api.py:675-690:先读chat:{chat_id},没有才回落数据库。 - 性能埋点贯穿全链路。
perf_tracker从 API 层一路传到retrieve_chunks,每个阶段都打点。排查慢查询时先看这份日志摘要(document_service.py:540-548)。
8. 代码地图
| 主题 | 文件路径 | 符号名 |
|---|---|---|
| 检索主逻辑 | core/services/document_service.py | retrieve_chunks |
| 分组检索 | core/services/document_service.py | retrieve_chunks_grouped、_create_grouped_chunk_response_from_results |
| padding | core/services/document_service.py | _apply_padding_to_chunks |
| 文件夹范围过滤 | core/services/document_service.py | _build_folder_scope_filters |
| 问答主逻辑 | core/services/document_service.py | query |
| 结果组装(含图片下载) | core/services/document_service.py | _create_chunk_results、_create_document_results |
| 权限白名单查询 | core/database/postgres_database.py | find_authorized_and_filtered_documents |
| 访问过滤条件 | core/database/postgres_database.py | _build_access_filter_optimized |
| 元数据过滤 DSL | core/database/metadata_filters.py | build |
| 重排器 | core/reranker/flag_reranker.py | FlagReranker.rerank |
| 上下文块分流(文本/图片) | core/completion/litellm_completion.py | process_context_chunks |
| 用户消息拼装与引用 | core/completion/litellm_completion.py | format_user_content、get_system_message |
| 多模态请求构造 | core/completion/litellm_completion.py | _handle_standard_litellm、_handle_streaming_litellm |
| 视频块内容增强 | core/models/documents.py | ChunkResult.augmented_content |
| HTTP 检索端点 | core/api.py | retrieve_chunks、retrieve_chunks_grouped、query_completion |