跳到主要内容

数据截至 (上游 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):

配置rerankingcolpali实际行为
1纯 pgvector 相似度,直接返回
2只用多向量结果,chunks = chunks_multivector
3pgvector 召回后用 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含义生成的过滤键
None0仅精确匹配这一层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_chunksretrieve_docsquery 的所有签名里,一路往下传,但在 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.pyretrieve_chunks
分组检索core/services/document_service.pyretrieve_chunks_grouped_create_grouped_chunk_response_from_results
paddingcore/services/document_service.py_apply_padding_to_chunks
文件夹范围过滤core/services/document_service.py_build_folder_scope_filters
问答主逻辑core/services/document_service.pyquery
结果组装(含图片下载)core/services/document_service.py_create_chunk_results_create_document_results
权限白名单查询core/database/postgres_database.pyfind_authorized_and_filtered_documents
访问过滤条件core/database/postgres_database.py_build_access_filter_optimized
元数据过滤 DSLcore/database/metadata_filters.pybuild
重排器core/reranker/flag_reranker.pyFlagReranker.rerank
上下文块分流(文本/图片)core/completion/litellm_completion.pyprocess_context_chunks
用户消息拼装与引用core/completion/litellm_completion.pyformat_user_contentget_system_message
多模态请求构造core/completion/litellm_completion.py_handle_standard_litellm_handle_streaming_litellm
视频块内容增强core/models/documents.pyChunkResult.augmented_content
HTTP 检索端点core/api.pyretrieve_chunksretrieve_chunks_groupedquery_completion