跳到主要内容

摄入流水线:一份文件怎么变成向量落库

本章讲摄入:从「上传一个 PDF」到「它的向量躺在库里可被检索」中间发生了什么。

1. 为什么摄入必须异步

摄入很慢。解析一个大 PDF 要跑 OCR、渲染每页成图、算多向量 embedding——可能几十秒到几分钟。如果同步做,HTTP 请求会超时。

Morphik 的解法:上传即入队,立刻返回

客户端 POST /ingest/file


存原文件到 storage(本地/S3)


redis.enqueue_job("process_ingestion_job", ...) ← 丢进队列就返回


返回一个 status="processing" 的 Document(客户端可轮询状态)

--- 另一个进程 ---
arq worker 从 Redis 拿任务 ──▶ 慢慢跑完整流水线

路由把文件存好后 enqueue:见 core/routes/ingest.py:107ingest_file,以及批量入队处 redis.enqueue_job("process_ingestion_job", **job_payload)(core/routes/ingest.py:353)。真正干活的是 worker 里的 process_ingestion_job(core/workers/ingestion_worker.py:373),它跑在 arq(RedisSettings,core/workers/ingestion_worker.py:16)调度的后台进程里,由 WorkerSettings(core/workers/ingestion_worker.py:1858)配置并发上限。

2. 解析:把千奇百怪的文件变成文本

MorphikParser(core/parser/morphik_parser.py:196)是统一入口。它按文件类型分流,不同类型走不同的解析器——这里藏着不少性能取舍。

2.1 分流表

文件类型走哪条路为什么
.txt/.md/.json/.csv/.yaml…直接 decode,不解析纯文本没必要上重型解析器
.xlsx/.xlsmopenpyxl 直接转 markdown 表比 Docling 快「秒 vs 分钟」,且标签和值留在同一行
视频VideoParser 抽帧 + 转写帧描述 + 音频转录
.xml专用 XMLChunker按结构单元(SECTION/Article)切,保留 breadcrumbs
PDF / Office 等复杂格式Docling(本地或远程 GPU API)版式/表格/OCR 都要

判断逻辑集中在 _parse_document(core/parser/morphik_parser.py:584):先试纯文本、再试 Excel 快路、再落到 Docling。

2.2 Excel 快路(一个务实的优化)

Docling 解析 Excel 又慢又容易把「标签」和「数值」拆散。Morphik 干脆用 openpyxl 手写一个转 markdown 表格的函数:

# core/parser/morphik_parser.py:381 _parse_excel_to_markdown(简化摘录)
wb = openpyxl.load_workbook(io.BytesIO(file), data_only=True, read_only=True)
for sheet_name in wb.sheetnames:
parts.append(f"## {sheet_name}\n")
# 逐行输出 | a | b | c |,首行后补一行 | --- | --- |

重点看:它保证「一行里的标签和值不被打散」,这对表格类 RAG 的检索质量很关键(注释里明说了 labels and values stay on the same row)。

2.3 Docling 的兜底与「深度解析」

Docling 转换器初始化很贵,所以在类级别缓存:_get_docling_converter(core/parser/morphik_parser.py:239)。若普通解析产出为空,还有一条更贵的兜底:parse_file_to_text_deep(core/parser/morphik_parser.py:628)——Office 先用 LibreOffice(soffice)转 PDF(_convert_office_to_pdf_bytes,:294),再用开启 EasyOCR + 高精度表格 + 高分辨率图像的 _build_deep_pdf_converter(:346)重跑。

3. 切分:把长文本切成 chunk

解析出的文本要切成小块(chunk)才好检索。Morphik 自己实现了一个递归字符切分器 RecursiveCharacterTextSplitter(core/parser/morphik_parser.py:54),替代了 langchain 的版本(注释第 24 行说明)。

思路: 按分隔符优先级递归切,尽量在「段落 > 换行 > 句号 > 空格 > 字符」这种自然边界断开,再处理重叠(overlap)。

# 示意,非源码:递归切分的核心想法
separators = ["\n\n", "\n", ". ", " ", ""] # 从粗到细
def split(text, seps):
if len(text) <= chunk_size:
return [text] # 够短,直接返回
sep = seps[0] # 先用最粗的分隔符
pieces = accumulate(text.split(sep)) # 累积到接近 chunk_size 就切
# 还太大的块,用更细的分隔符递归
return flatten(split(p, seps[1:]) if too_big(p) else [p] for p in pieces)

真实实现见 _split_recursive(core/parser/morphik_parser.py:65);重叠处理在同函数尾部(:97-106),把上一块的尾部 chunk_overlap 个字符拼到下一块开头。

3.1 上下文切分(可选,提升检索质量)

ContextualChunker(core/parser/morphik_parser.py:110)实现了 Anthropic 的「contextual retrieval」思路:给每个 chunk 前面加一句 LLM 生成的定位说明,让孤立 chunk 也知道自己在整篇文档里的位置。

原 chunk: "其抗拉强度为 500 MPa。"
加上下文后:"本节讨论 14-A 螺丝的材料规格;其抗拉强度为 500 MPa。"

它对每个 chunk 调一次 LLM(_situate_context,:149),把返回的 context 用 f"{context}; {chunk.content}" 拼进内容(:190)。默认关闭(morphik.tomluse_contextual_chunking = false),因为每 chunk 一次 LLM 调用很贵。

4. 双路 embedding:一份 chunk,两种向量

这是 Morphik 摄入的关键分叉。同一批 chunk 会被算两套 embedding:

模型产物存哪
普通路LiteLLM(如 OpenAI/Ollama 文本 embedding)每 chunk 一个单向量pgvector 单向量库
ColPali 路ColQwen2.5 视觉多向量每 chunk 一组多向量多向量库(见第 2 章)

普通路在 _process_chunks_and_embeddings(core/services/ingestion_service.py:1146):embed_for_ingestion(chunks) 出单向量。

ColPali 路在 _process_colpali_embeddings(core/services/ingestion_service.py:1167):只有 use_colpali and settings.ENABLE_COLPALI 且模型/库都在时才跑(:1180)。它先把文件转成图片 chunk(PDF 每页渲染成图),再算多向量——细节见下一章。

为什么要两套?因为检索时可以按需选路:纯文本问答走便宜的单向量;视觉密集文档走 ColPali。retrieve 侧的「四配置」在 03-retrieval-and-answer.md 讲。

5. 存储:带重试的双写

算完向量,_store_chunks_and_doc(core/services/ingestion_service.py:1236)把两套 chunk 分别写进两个库,每次写都有指数退避重试(store_with_retry,:1251),专门处理「连接被关」这类瞬时数据库错误。

6. 一句话小结

摄入 = 异步队列 + 按类型分流的解析 + 递归切分 + 双路 embedding + 带重试的双写。设计主轴是「贵活儿全丢后台,前台秒回」,以及「为不同文件/不同检索需求各留一条最优路径」。