你有 15,000 个 markdown 文件要为本地 RAG 系统建索引,用的是 Ollama 上的 nomic-embed-text,或者通过 sentence-transformers 跑 bge-large-en-v1.5。任务一启动,四个小时过去了还剩 3,000 个文档没处理。照这个速度,一次全量重建要 6-8 小时,根本谈不上每天做增量更新。embedding 模型跑在一张 4090 上,单条 embedding 不到 10ms,但整体墙钟速度只有 50 文档/分钟。
最快的修复思路:吞吐量的损失几乎从来不在模型本身,而是藏在三个地方,按影响从大到小排:(1) 每次调用只 embed 一条,而不是按 32-128 一批;(2) 上次跑过、内容没变的文档又被重新 embed 了一遍;(3) 向量库一行一行地写,而不是一次批量 upsert。把这三点解决掉,原本 6 小时的重建通常能降到 15 分钟以内。动手改代码前,先用下面的表格对号入座,找到自己属于哪一档。
先对号入座
| 你观察到的现象 | 最可能的原因 | 跳到 |
|---|---|---|
| 发 1 条和发 64 条,单条耗时几乎一样 | batch_size=1,每次调用的固定开销没被摊薄 | Step 1 |
| 每次重建都把整个语料重新 embed,哪怕几乎没改 | 没有基于内容 hash 的变更检测 | Step 2 |
| embedding 很快跑完,但写入要好几个小时 | 向量库逐条 insert | Step 3 |
| chunk 总数是文档数的 20-50 倍 | chunk 太小 | Step 4 |
索引期间 nvidia-smi 显示 GPU 占用 0% | embedding 模型回退到了 CPU | 见下方原因 |
| 还没开始 embedding,第一阶段就很慢 | NFS/SMB/S3 上的文件读取 I/O | 见下方原因 |
常见原因
按影响从大到小排列。
1. batch 大小为 1 —— 一次只 embed 一个文档
很多 LangChain、LlamaIndex 的 embedding 集成默认就是每次调用只处理一条。单条请求和 64 条一批,承担的 GPU 启动开销和 HTTP 往返开销是一样的,于是这份固定开销你付了 64 次而不是 1 次。在本机通过 Ollama HTTP API,单条 embedding 调用平均要 10-15ms 的网络加调度开销,把吞吐量卡在 60-70 文档/分钟,跟模型本身有多快毫无关系。
怎么判断:在 embedding 调用外面加个计时器。如果发 1 条和发 64 条时单条耗时几乎不变,说明你在为每一条单独支付固定开销,而没有把它摊薄到一整批上。
2. 重新 embed 那些上次以来根本没变过的文档
每次全量重建都会把所有文档重新 embed,哪怕其中 95% 没动过。对一个 50,000 chunk 的语料,这意味着每跑一次就白白浪费 47,500 次 embedding。
怎么判断:检查索引代码里有没有基于内容 hash 或修改时间的判断。如果它直接 embed_documents(all_chunks)、没有先过滤出变更过的 chunk,那每次运行都是全量重 embed。
3. 向量库写入串行化 —— 一条一条插入 embedding
FAISS、Chroma、Qdrant 都支持批量 upsert。如果你的代码在循环里 collection.add(embedding, id=doc_id),每次调用都要拿写锁、提交、再释放 —— 每一行都来一遍。50,000 次单条 add 的循环,可能比一次 collection.upsert(embeddings_list, ids=ids_list) 慢上 100 倍。截至 2026 年 6 月,Chroma 单次调用超过 5,461 条会直接报错(ValueError: Cannot submit more than 5,461 embeddings at once),所以你仍然需要分批,只是分成大批,而不是一条条来。
怎么判断:把向量库写入阶段和 embedding 阶段分开计时。如果 embedding 只要 10 分钟、写入却要 3 小时,那串行写入就是瓶颈。
4. 分块策略产生了太多碎 chunk
如果切分器用 50 token 的 chunk、10 token 的 overlap,一个 10 页的文档会炸成 400 多个 chunk。一个 50 token 的 chunk 和一个 500 token 的 chunk,embedding 开销是一样的,于是你为了边际上微弱的检索收益,干了大约 10 倍的活。
怎么判断:统计全语料的 chunk 总数,除以文档数。如果普通文档平均超过 20-30 个 chunk,说明 chunk 太小了。
5. embedding 模型回退到 CPU 运行
如果 embedding 模型跑在 CPU 而非 GPU 上,吞吐量会掉 20-100 倍。常见情形:Ollama 的 embedding 模型和聊天模型共用一张 GPU,在显存吃紧时被挤出去;或者 sentence-transformers 模型因为没设置 device,悄悄回退到了 CPU。
怎么判断:索引期间跑 nvidia-smi(或 ollama ps,它会打印一列 PROCESSOR,显示 GPU、CPU 或两者拆分)。如果 embedding 模型 GPU 利用率是 0% 或显示 100% CPU,那它就在 CPU 上跑。
6. 大型或远程语料的文件读取 I/O
如果文档放在网络共享上(NFS、SMB,或通过 rclone 挂载的 S3),读取 15,000 个文件本身就会在任何 embedding 开始之前引入延迟。如果你不分阶段计时,读取瓶颈看起来和 embedding 瓶颈一模一样。
怎么判断:只给读取阶段计时:time find /path/to/docs -name '*.md' -exec wc -c {} +。如果光是这一步就要好几秒以上,那磁盘或网络 I/O 就是问题的一部分。
最短修复路径
Step 1:改用大批量 embedding 调用
用 sentence-transformers 时,把整个列表一次性传进去,并显式设置 batch_size(encode 默认只有 32):
from sentence_transformers import SentenceTransformer
model = SentenceTransformer("BAAI/bge-large-en-v1.5", device="cuda")
model.max_seq_length = 512
# 错误:一条一条来
# embeddings = [model.encode(chunk) for chunk in chunks]
# 正确:整个列表一次传入,GPU 内部自动分批
embeddings = model.encode(
chunks,
batch_size=64, # 显存够的话可提到 128
show_progress_bar=True,
convert_to_numpy=True,
normalize_embeddings=True,
)
用 Ollama 时,调 /api/embed(当前端点,不是已弃用的 /api/embeddings)。它的 input 字段接受数组,返回一个 embeddings 数组,且向量已经做过 L2 归一化:
import requests
def embed_batch(texts: list[str]) -> list[list[float]]:
resp = requests.post(
"http://localhost:11434/api/embed",
json={
"model": "nomic-embed-text",
"input": texts,
"options": {"num_ctx": 8192}, # 见下方说明
},
timeout=120,
)
return resp.json()["embeddings"] # 复数,每个 input 对应一个向量
batch_size = 64
all_embeddings = []
for i in range(0, len(chunks), batch_size):
all_embeddings.extend(embed_batch(chunks[i:i + batch_size]))
这里有两个坑很多人会踩,截至 2026 年 6 月都仍然成立:
nomic-embed-text支持 8192 token 的上下文,但 Ollama 的模型卡把num_ctx默认成了 2048。如果你的 chunk 偏长又没设num_ctx,超出部分会被悄悄截断。传上面那样的"options": {"num_ctx": 8192}才能用满整个窗口。- 从一个客户端发一个很大的
input数组,本身并不会给你服务端的并行。Ollama 的OLLAMA_NUM_PARALLEL默认是 1,超过这个数的并发请求只会排队。对批量索引来说,真正的提速来自上面那种”每次调用一个大批量”,而不是发很多条并发的小请求。
Step 2:加入基于内容 hash 的变更检测
import hashlib, json, pathlib
def compute_hash(text: str) -> str:
return hashlib.sha256(text.encode()).hexdigest()[:16]
hash_file = pathlib.Path(".index_hashes.json")
hashes = json.loads(hash_file.read_text()) if hash_file.exists() else {}
changed_chunks, changed_ids = [], []
for chunk_id, chunk_text in zip(all_ids, all_chunks):
new_hash = compute_hash(chunk_text)
if hashes.get(chunk_id) != new_hash:
changed_chunks.append(chunk_text)
changed_ids.append(chunk_id)
hashes[chunk_id] = new_hash
print(f"Re-embedding {len(changed_chunks)} of {len(all_chunks)} chunks")
hash_file.write_text(json.dumps(hashes))
要对 chunk 文本算 hash,而不是对整个文件 —— 大文件里改一行,应该只重 embed 被改到的那几个 chunk,而不是整篇文档。
Step 3:批量写入向量库
import chromadb
client = chromadb.PersistentClient(path="./chroma_db")
collection = client.get_or_create_collection("docs")
# 错误:循环里一条一条加
# for id, emb, doc in zip(ids, embeddings, documents):
# collection.add(ids=[id], embeddings=[emb], documents=[doc])
# 正确:分批 upsert,单批保持在 Chroma 的 5,461 条上限以下
CHROMA_MAX_BATCH = 5000
for i in range(0, len(changed_ids), CHROMA_MAX_BATCH):
collection.upsert(
ids=changed_ids[i:i + CHROMA_MAX_BATCH],
embeddings=all_embeddings[i:i + CHROMA_MAX_BATCH],
documents=changed_chunks[i:i + CHROMA_MAX_BATCH],
)
用 upsert 而不是 add,这样在某次中途失败后重跑时是覆盖写入,而不会因为 ID 重复报错。
Step 4:增大 chunk 大小,压低 chunk 总数
from langchain.text_splitter import RecursiveCharacterTextSplitter
splitter = RecursiveCharacterTextSplitter(
chunk_size=512, # 原来是 100 —— chunk 数大约减少到 1/5
chunk_overlap=64, # 约 12% 的重叠,足够保持检索连贯性
length_function=len,
)
Step 5:用生产者/消费者流水线让 embedding 和写入并行
在 GPU embed 下一批的同时,上一批正在被写入。一个有界的小队列能让两端都不闲着,又不会让内存无限增长。
import concurrent.futures, queue
embed_queue = queue.Queue(maxsize=10)
def embedding_producer(chunks, ids, batch_size=64):
for i in range(0, len(chunks), batch_size):
batch = chunks[i:i + batch_size]
batch_ids = ids[i:i + batch_size]
vecs = model.encode(batch, normalize_embeddings=True)
embed_queue.put((batch_ids, batch, vecs.tolist()))
embed_queue.put(None) # 哨兵
def vectorstore_consumer():
while True:
item = embed_queue.get()
if item is None:
break
ids, texts, vecs = item
collection.upsert(ids=ids, embeddings=vecs, documents=texts)
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as ex:
ex.submit(embedding_producer, changed_chunks, changed_ids)
ex.submit(vectorstore_consumer)
如何确认已经修好
- 做一次计时的全量重建,盯着文档/分钟的速率。做完 Step 1-3,一个原本要 6-8 小时的语料,在单张 4090 上应该能压到大约 10-20 分钟。
- 在不改动任何文档的情况下再跑一遍索引。那行 “Re-embedding N of M chunks” 应该打印
0 of M,整次运行几秒钟就结束 —— 这就证明变更检测生效了。 - 运行期间确认
nvidia-smi显示 embedding 模型贴着 100% GPU,既不空闲、也没在 CPU 上跑。 - 抽查 Step 4 之后 chunk 总数确实下降了(在切分前后各打印一次
len(chunks))。
预防建议
- 调
model.encode()时总是显式设置batch_size,调/api/embed时总是把input传成数组 —— 永远别依赖单条的默认行为。 - 项目一开始就把基于内容 hash 的变更检测设计进去;事后补会牵涉一次完整的元数据迁移。
- RAG 索引的 chunk 大小保底设 256 token,除非你有明确理由要更小。
- 把 chunk 的 hash、ID、embedding 存进结构化存储(SQLite 或 Parquet),这样局部重建可以续上,不必整批重做。
- 第一天就把 embedding 耗时和向量库写入耗时分开计时 —— 瓶颈很少在你最初以为的地方。
- 用
device="cuda"把 embedding 模型钉在 GPU 上(或确认ollama ps显示GPU),每次大规模运行前都查一下。 - 语料和向量库都放在本地 SSD 上;大语料下 NFS 挂载路径可能把吞吐量直接砍半。
常见问答 (FAQ)
Q:大规模本地 embedding,该用 Ollama 还是 sentence-transformers?
A:对每晚跑的批量索引,sentence-transformers 配 device="cuda"、batch_size=64-128 通常更快,因为它完全省掉了 HTTP 往返,让 GPU 在进程内批处理。Ollama 更适合交互式使用和统一的服务端,它的 /api/embed 数组输入也把差距缩小了大半 —— 但要追求最快的重建,还是在进程内直接调用模型。
Q:我的 Ollama embedding 即便用了批量 input 数组还是慢,为什么?
A:截至 2026 年 6 月通常是两个原因。其一,OLLAMA_NUM_PARALLEL 默认是 1,所以猛发很多条并发的小请求只会让它们排队 —— 改成发更少、更大的批。其二,embedding 模型可能在聊天模型占用显存时被挤到了 CPU;查一下 ollama ps 那列 PROCESSOR。
Q:什么样的 chunk 大小检索质量最好? A:多数基准测试落在每 chunk 256-512 token 这个召回最优区间。低于 128 token,chunk 往往缺乏足够上下文、语义不够;高于 1024 token,关键句又容易被周围文本淹没。
Q:本地 RAG 写入,FAISS 比 Chroma 快吗?
A:纯批量插入上,内存里建好再存盘的 FAISS 索引(IndexFlatL2 或 IndexHNSWFlat)比 Chroma 快 10-50 倍,因为 Chroma 额外有元数据索引和 SQLite 写入开销。如果你用不到 Chroma 的元数据过滤,FAISS 配 numpy 批量 add 在原始吞吐上更划算。
Q:文档比 embedding 模型的最大序列长度还长怎么办?
A:把 chunk 切到能装进窗口,并且主动设定窗口大小 —— nomic-embed-text 允许 8192 token,但 Ollama 不手动调高就会把 num_ctx 卡在 2048。对特别长的源文档,用分层索引:既 embed 每个 chunk,也 embed 一段整篇文档的简短摘要。
相关阅读
- 本地 embedding 服务在 batch 请求下崩溃
- 本地模型冷启动后首 token 极慢
- vLLM 报 context length exceeded
- Ollama 模型下载卡在某个百分比
- 多 GPU 没分配上,模型只跑在卡 0
标签: #local-llm #ollama #排查