在 MacBook Pro M2 Max(12 核 CPU、96 GB 统一内存)上,用 Ollama 运行 nomic-embed-text 对 5000 份 PDF 文档(约 200 万 token)进行 RAG 索引重建,预计 30 分钟,实际跑了 6 小时。检查发现平均每秒只处理 15 个文档,而同样的配置下 embedding 本身只需要 0.1 秒/文档。瓶颈不在 GPU/CPU 计算,而在 Python 主循环的串行等待、向量写入的磁盘 IO 和文档分块的低效实现上。
常见原因
1. embedding 请求串行发送,没有利用异步并发
最常见的瓶颈。典型的慢速代码是单线程循环:for doc in docs: embed(doc) 每次调用需要等待网络往返和模型计算,5000 份文档 × 0.1 秒 = 8 分钟,但实际往往更慢,因为 Python 还有调度开销。如果 Ollama 在同一台机器上,HTTP 往返本身就需要 5-10 ms。
怎么判断:用 time 命令测量单次 embedding 调用延迟;然后估算:总文档数 × 单次延迟,与实际运行时间对比。若实际时间远大于估算,说明有额外的串行等待。
2. 向量数据库每条记录单独写入,磁盘 IO 成为瓶颈
ChromaDB、FAISS、Qdrant 等向量库在处理单条 insert 时都有较大的开销(索引更新、磁盘刷新)。若每嵌入一条就立即写入数据库,IO 开销可能占总时间的 50% 以上。
怎么判断:使用 iotop 或 iostat -x 1 监控磁盘写入速率;若索引构建期间磁盘写入持续满载(对 SSD 来说是写入带宽接近 1-2 GB/s),就是写入瓶颈。
3. 文档分块(chunking)使用了低效的逐字符切割
某些 RAG 框架使用 RecursiveCharacterTextSplitter 时对超大文本文件进行了过于细粒度的分块,生成了数十倍于原文档数量的 chunk,成倍增加了 embedding 次数。
怎么判断:在索引开始前统计 chunk 总数(len(chunks))与原文档数的比值。若比值超过 50(5000 文档 → 25 万 chunk),检查 chunk_size 和 chunk_overlap 设置是否合理。
4. PDF 解析本身是瓶颈(OCR 或大文件)
含有图片的 PDF 使用 OCR(如 pytesseract)解析,单页 OCR 需要 1-5 秒,200 页文档 = 200-1000 秒。这个阶段甚至发生在 embedding 之前,容易被忽视。
怎么判断:在 PDF 解析阶段插入计时日志,统计解析和 embedding 各自占用的时间比例。
5. nomic-embed-text 在 Ollama 中以 CPU 模式运行
Apple Silicon 上,nomic-embed-text 默认用 Metal GPU 加速,但若 Ollama 检测到显存不足(同时还在运行聊天模型),可能切换到 CPU 模式,embedding 速度下降 3-5 倍。
怎么判断:索引构建期间执行 ollama ps,查看 nomic-embed-text 是否在运行,以及 Size 列显示的是 GPU 还是 CPU;用 sudo powermetrics --samplers gpu_power -i 1000 查看 GPU 利用率。
6. 向量库使用 HNSWlib,ef_construction 设置过大
在构建 HNSW 索引时,ef_construction 参数控制索引质量,默认值通常是 200。对于几百万个向量,高 ef_construction 会使索引构建时间是线性 flat 索引的 10-20 倍。
怎么判断:检查 ChromaDB 或自定义 HNSW 配置中的 ef_construction 值;改用 flat 索引(仅余弦相似度搜索)在构建阶段测速,若明显更快说明 HNSW 构建是瓶颈。
最短修复路径
Step 1:用异步并发替换串行 embedding 循环
import asyncio
import httpx
from typing import List
async def batch_embed_async(texts: List[str], concurrency: int = 8) -> List[List[float]]:
semaphore = asyncio.Semaphore(concurrency)
async def embed_one(text: str) -> List[float]:
async with semaphore:
async with httpx.AsyncClient(timeout=120) as client:
resp = await client.post(
"http://localhost:11434/api/embeddings",
json={"model": "nomic-embed-text", "prompt": text}
)
return resp.json()["embedding"]
return await asyncio.gather(*[embed_one(t) for t in texts])
# 100 条并发比 1 条串行快约 8-10 倍(受模型推理速度限制)
embeddings = asyncio.run(batch_embed_async(chunks, concurrency=8))
Step 2:批量写入向量数据库
import chromadb
client = chromadb.PersistentClient(path="./chroma_db")
collection = client.get_or_create_collection("docs")
# 批量写入,而非逐条 add
WRITE_BATCH_SIZE = 500
for i in range(0, len(all_chunks), WRITE_BATCH_SIZE):
batch_chunks = all_chunks[i:i + WRITE_BATCH_SIZE]
batch_embeddings = asyncio.run(batch_embed_async(
[c.page_content for c in batch_chunks], concurrency=8
))
collection.add(
ids=[f"doc_{i+j}" for j in range(len(batch_chunks))],
embeddings=batch_embeddings,
documents=[c.page_content for c in batch_chunks],
metadatas=[c.metadata for c in batch_chunks]
)
print(f"Progress: {i + len(batch_chunks)}/{len(all_chunks)}")
Step 3:优化文档分块参数
from langchain.text_splitter import RecursiveCharacterTextSplitter
# 优化前(过细)
# splitter = RecursiveCharacterTextSplitter(chunk_size=200, chunk_overlap=50)
# 优化后(合理粒度)
splitter = RecursiveCharacterTextSplitter(
chunk_size=1000, # nomic-embed-text 支持 8192 token,1000 字符安全
chunk_overlap=200, # 重叠 20%,保留上下文
length_function=len,
)
# 统计 chunk 数量
chunks = splitter.split_documents(docs)
print(f"文档数: {len(docs)}, Chunk 数: {len(chunks)}, 平均每文档: {len(chunks)/len(docs):.1f}")
# 合理范围:5-20 chunks/doc,超过 50 需要重新评估
Step 4:离线预处理 PDF,跳过 OCR 瓶颈
# 使用 pdftotext 批量提取纯文本(比 PyPDF2 快 10 倍,OCR 除外)
for pdf in ./docs/*.pdf; do
txt="${pdf%.pdf}.txt"
pdftotext "$pdf" "$txt" 2>/dev/null || echo "Failed: $pdf"
done
# 然后直接读取 txt 文件进行索引,跳过 PDF 解析
Step 5:评估增量索引可行性
import hashlib
import json
import os
def should_reindex(doc_path: str, cache_file: str = ".index_cache.json") -> bool:
"""检查文件是否已经被索引且内容未变化"""
cache = {}
if os.path.exists(cache_file):
with open(cache_file) as f:
cache = json.load(f)
with open(doc_path, 'rb') as f:
file_hash = hashlib.md5(f.read()).hexdigest()
return cache.get(doc_path) != file_hash
# 只对新增或修改的文件重新索引
docs_to_index = [d for d in all_docs if should_reindex(d)]
print(f"需要重新索引: {len(docs_to_index)}/{len(all_docs)} 个文件")
预防建议
- 设计 RAG 系统时从一开始就使用增量索引,避免每次全量重建。
- 将 embedding 服务与聊天服务分开部署(不同端口或不同机器),避免两者争用 GPU 资源。
- 在索引脚本中加入详细的阶段计时日志:解析耗时、embedding 耗时、写入耗时分别统计,方便定位瓶颈。
- 对 5000 份以上文档,优先考虑先离线生成所有 embedding 向量保存到 numpy 文件,再一次性批量写入向量库。
- chunk_size 设置参考 embedding 模型的有效窗口大小,通常 512-1024 token 是最优性价比。
- 定期用 1000 份文档做基准测试,记录耗时;系统变更后对比基准,提前发现性能退步。
- 对于 HuggingFace sentence-transformers,直接用
model.encode(texts, batch_size=64, show_progress_bar=True)可以充分利用 GPU batch 推理,比 Ollama API 快 3-5 倍。
常见问答 (FAQ)
Q: 用 sentence-transformers 直接调用比通过 Ollama API 快多少? A: 通常快 3-8 倍。Ollama API 有 HTTP 往返开销和单线程处理限制;sentence-transformers 直接在 Python 进程内调用模型,可以充分利用 GPU batch 推理。对性能要求高的场景推荐直接用 sentence-transformers。
Q: ChromaDB vs FAISS vs Qdrant,哪个索引构建最快? A: 对于 100 万以内向量,FAISS 的 flat 索引(IndexFlatL2)构建最快(几乎瞬间),但搜索是线性扫描;ChromaDB 用 HNSWlib 构建慢但搜索快;Qdrant 在两者之间。推荐:开发阶段用 FAISS flat,生产阶段换 Qdrant 或带 HNSW 的 ChromaDB。
Q: 索引构建能多进程并行吗?
A: 可以,用 multiprocessing.Pool 并行处理文档解析和分块;但 embedding 服务的并发访问需要注意,确保向量库的写入是线程安全的。ChromaDB 的本地模式不支持多进程并发写入,推荐先多进程生成 embedding,再单进程批量写入。
Q: 5000 份 PDF 的 RAG 系统多久重建一次是合理的? A: 对于内容相对稳定的知识库,每周或每月全量重建一次足够;对于高频更新的场景,应该实现增量索引,只对新增和修改的文档进行 embedding 和写入,不触碰已有索引。
相关阅读
- 本地 embedding 服务在 batch 请求下崩溃
- 本地模型冷启动后首 token 极慢
- Ollama 探测不到 GPU,全跑在 CPU
- vLLM 报 context length exceeded
- 多 GPU 没分配上,模型只跑在卡 0
标签: #local-llm #ollama #排查