lllin000_PaperForge/docs/plans/pr9b-embed-parallel-encode.md
LLLin000 45022c3e16 docs: archive superpowers specs/plans, commit PR9A-C plan files
- Archive 173 design/plan/analysis docs from docs/superpowers/ →
  docs/archive/superpowers/ (all features shipped)
- Commit 5 PR9A-C plan files to docs/plans/ (historical record)
- Rebuild plugin bundle (main.js: 2172→49 lines, minified)
- Fix settings.ts type assertion
2026-07-09 00:39:39 +08:00

10 KiB
Raw Permalink Blame History

PR9B: Embed Parallel Encode

Depends on: PR9A (resume/rebuild correctness)
Scope: 并行 embedding API encode + 串行 ChromaDB write
不改变 resume/selection 逻辑,只加速


瓶颈分析

当前时序(--force, 729 papers

Total: ~30 min
  Per paper: ~2.5s
    API encode:              ~1.5s    ← 纯网络 I/O可并行
    ChromaDB write:          ~0.8s    ← HNSW 构建,必须串行
    Metadata + state:        ~0.2s

目标

4 worker 并行 → 729 ÷ 4 × (1.5s + 0.8s) ≈ 10 min~3× 加速)


设计原则

1. paper 级原子性:同一篇论文的 body/object/legacy 全部 encode 成功后才 replace
2. prepare 在主线程(读 DB、chunk fulltext不参与并行
3. encode 在线程池(纯网络 I/O每个 worker 创建自己的 provider
4. write 在主线程串行ChromaDB HNSW 构建不是线程安全)
5. delete old vectors 在 encode 成功后、write 前执行

核心 contract

A paper is deleted and rewritten only after ALL payloads for that paper
have been successfully encoded.

数据结构

@dataclass
class EmbeddingPayload:
    """准备阶段产出的载荷texts 和 metadatas未 encode。"""
    collection_name: str
    texts: list[str]
    ids: list[str]
    metadatas: list[dict]

@dataclass
class EncodedPayload:
    """encode 后的载荷:包含 embeddings。必须带 texts不可从 metadata 反推)。"""
    collection_name: str
    texts: list[str]
    ids: list[str]
    metadatas: list[dict]
    embeddings: list[list[float]]

@dataclass
class PaperEmbeddingJob:
    """一篇论文的所有载荷,打包成一个 job。"""
    paper_id: str
    payloads: list[EmbeddingPayload]

@dataclass
class PaperEncodedBundle:
    """一篇论文 encode 完成后的结果。"""
    paper_id: str
    payloads: list[EncodedPayload]
    chunk_count: int

三段式 pipeline

Phase 1: PREPARE主线程串行

为每篇论文准备 13 个 EmbeddingPayloadlegacy / body / object分别对应三个 collection。

def prepare_payloads_for_entry(
    vault: Path, entry: dict
) -> list[EmbeddingPayload] | None:
    """为一篇论文准备所有 payload。不写 ChromaDB不改状态不删 vectors。"""
    key = entry.get("zotero_key")
    payloads = []

    has_body = _has_body_units_in_db(vault, key)
    has_object = _has_object_units_in_db(vault, key)

    if has_body or has_object:
        if has_body:
            body_units = get_body_units_for_embedding(vault, key)
            payloads.append(prepare_body_payload(key, body_units))
        if has_object:
            object_units = get_object_units_for_embedding(vault, key)
            payloads.append(prepare_object_payload(key, object_units))
    else:
        # Legacy path
        fulltext_rel = entry.get("fulltext_path", "")
        if not fulltext_rel:
            return None
        fulltext_path = vault / fulltext_rel
        chunks = chunk_fulltext(fulltext_path)          # chunk 在主线程做
        if chunks:
            payloads.append(prepare_legacy_payload(key, chunks))

    return payloads if payloads else None

prepare_legacy_payload()

def prepare_legacy_payload(zotero_key: str, chunks: list[dict]) -> EmbeddingPayload:
    texts = [c["text"] for c in chunks]
    ids = [f"{zotero_key}_{c['chunk_index']}" for c in chunks]
    metadatas = [
        {
            "paper_id": zotero_key,
            "section": c.get("section", ""),
            "page_number": c.get("page_number", 0),
            "chunk_index": c.get("chunk_index", i),
            "token_estimate": c.get("token_estimate", 0),
        }
        for i, c in enumerate(chunks)
    ]
    return EmbeddingPayload(
        collection_name="paperforge_fulltext",
        texts=texts, ids=ids, metadatas=metadatas,
    )

prepare_body_payload()

def prepare_body_payload(zotero_key: str, body_units: list[dict]) -> EmbeddingPayload:
    current_hash = compute_body_units_hash(body_units)
    texts = [u["unit_text"] for u in body_units]
    ids = [u["unit_id"] for u in body_units]
    metadatas = [
        {
            "paper_id": zotero_key,
            "section_path": u.get("section_path", ""),
            "unit_id": u["unit_id"],
            "unit_kind": "body",
            "body_units_hash": current_hash,
            "retrieval_policy_version": RETRIEVAL_POLICY_VERSION,
            "token_estimate": u.get("token_estimate", 0),
        }
        for u in body_units
    ]
    return EmbeddingPayload(collection_name="paperforge_body", texts=texts, ids=ids, metadatas=metadatas)

prepare_object_payload() 同理

collection_name="paperforge_objects"metadata 包含 unit_kind="object", object_kind, object_label, object_units_hash

向下兼容

def embed_body_units(vault, key, body_units):
    payload = prepare_body_payload(key, body_units)
    encoded = _encode_payload(vault, payload)
    _write_encoded_payload(vault, encoded)
    return len(body_units)

def embed_paper(vault, key, chunks):
    payload = prepare_legacy_payload(key, chunks)
    encoded = _encode_payload(vault, payload)
    _write_encoded_payload(vault, encoded)
    return len(chunks)

旧函数保留为兼容 wrapper不破坏已有调用。

Phase 2: ENCODE线程池并行

def _encode_payload(vault: Path, payload: EmbeddingPayload) -> EncodedPayload:
    """纯函数encode texts → embeddings。每个 worker 内创建自己的 provider。"""
    from paperforge.embedding.providers.openai_compatible import OpenAICompatibleProvider
    provider = OpenAICompatibleProvider(vault)
    embeddings = provider.encode(payload.texts)
    return EncodedPayload(
        collection_name=payload.collection_name,
        texts=payload.texts,
        ids=payload.ids,
        metadatas=payload.metadatas,
        embeddings=embeddings,
    )

def _encode_paper_job(vault: Path, job: PaperEmbeddingJob) -> PaperEncodedBundle:
    """将一篇论文的所有 payload 顺序 encodeworker 线程内)。"""
    encoded_payloads = []
    total_chunks = 0
    for payload in job.payloads:
        encoded = _encode_payload(vault, payload)
        encoded_payloads.append(encoded)
        total_chunks += len(payload.ids)
    return PaperEncodedBundle(
        paper_id=job.paper_id,
        payloads=encoded_payloads,
        chunk_count=total_chunks,
    )

注意:

  • OpenAICompatibleProvider 在线程内创建,不共享 client
  • 同一篇论文的多个 payloadbody + object在同一个 worker 线程内顺序 encode
  • 一篇论文的所有 payload 全部 encode 成功后才返回 → paper 级原子

Phase 3: WRITE主线程串行

def _write_encoded_payload(vault: Path, encoded: EncodedPayload):
    """写入 ChromaDB。主线程串行HNSW 构建不是线程安全。"""
    col = get_collection(vault, name=encoded.collection_name)
    col.add(
        ids=encoded.ids,
        embeddings=encoded.embeddings,
        documents=encoded.texts,
        metadatas=encoded.metadatas,
    )

主流程

from concurrent.futures import ThreadPoolExecutor, as_completed
from itertools import batched

BATCH_SIZE = 8      # 每批最多 8 篇 in-flight
MAX_WORKERS = 4     # 并行 encode 线程数

papers_embedded = 0
chunks_embedded = 0

# Phase 1: PREPARE主线程
# resume/hash skip 已在主线程完成
jobs: list[PaperEmbeddingJob] = []
for entry in papers_iter:
    payloads = prepare_payloads_for_entry(vault, entry)
    if payloads:
        jobs.append(PaperEmbeddingJob(
            paper_id=entry.get("zotero_key"),
            payloads=payloads,
        ))

# Phase 2+3: ENCODE线程池+ WRITE主线程
with ThreadPoolExecutor(max_workers=MAX_WORKERS) as pool:
    for batch in batched(jobs, BATCH_SIZE):
        futures = [
            pool.submit(_encode_paper_job, vault, job)
            for job in batch
        ]

        for future in as_completed(futures):
            try:
                bundle = future.result()    # 如果抛异常,不删旧 vectors
            except Exception as e:
                # 不处理这篇,继续下一篇(旧 vectors 还在)
                logger.error(f"Encode failed for {bundle.paper_id}: {e}")
                continue

            # encode 全部成功后才 replace old vectors
            delete_paper_vectors(vault, bundle.paper_id)

            for payload in bundle.payloads:
                try:
                    _write_encoded_payload(vault, payload)
                except Exception as e:
                    # write 失败 → 已进入 replace 状态
                    # 写 failed state下一次 resume 用 hash 修复
                    logger.error(f"Write failed for {bundle.paper_id}: {e}")
                    mark_vector_build_state(vault, status="failed", ...)
                    raise

            chunks_embedded += bundle.chunk_count
            papers_embedded += 1
            mark_vector_build_state(vault, current=papers_embedded, ...)

边界处理

情况 行为
API key invalid provider.encode() 抛异常 → future.result() 抛 → 不删旧 vectors跳过这篇
某篇 body encode 成功、object encode 失败 worker 线程抛异常 → bundle 不返回 → 旧 vectors 完整保留
_write_encoded_payload 失败 已执行 delete → 写 failed state
线程池满载 batched(jobs, BATCH_SIZE) 限制 in-flight 数量
provider 被多个 worker 共享? 不会,每个 worker 内部创建自己的 provider
ChromaDB add() 异常 主线程 catch → 写 failed state → 当前批次的 futures 取消

文件清单

File Change
paperforge/embedding/builder.py 新增 prepare_legacy_payload(), prepare_body_payload(), prepare_object_payload()embed_body_units/embed_paper 保留为兼容 wrapper
paperforge/embedding/__init__.py 导出新函数(如需要)
paperforge/commands/embed.py 主循环改为三段式 pipeline + ThreadPoolExecutor

风险

风险 缓解
httpx.Client 跨线程安全? 每个 worker 创建独立 provider不共享 client
ChromaDB add() 线程安全? 只在主线程串行调用
API rate limit MAX_WORKERS=4 参数化,可调小
batched 需要 Python 3.12 如果 3.12 以下手动实现 batched()
legacy chunk_fulltext 本身慢 prepare 在主线程做,如果成为瓶颈再考虑并行 prepare