大东西别一次性处理,切成可控的小块,再逐块流转。这句话能同时解释两件看着不搭界的事:一个是分块读文件 (下载大视频、LLM 逐字输出),一个是分块切文本(RAG 入库前的预处理)。前者解决的是「内存装不下、用户等得久」,后者解决的是「整篇塞不进窗口、语义被稀释」。本文把这两棒拆开讲透,并给出工程上已经被反复验证的默认配置。
一、流式响应:大文件别一次塞进内存
1.1 为什么必须流式
三个独立的理由,缺一个就够你上流式:
- 内存有限 :一个 500MB 的视频,用
f.read()一把读进内存再返回,进程直接 OOM 崩掉。流式永远只在内存里留一小块。 - 首字节时间(TTFB):用户要等服务器把整份文件读完、算完,才能收到第一个字节。大文件下这种「白等」是体验杀手。
- 背压(Backpressure)天然成立:流式不是「服务器拼命推、客户端拼命收」,而是客户端按自己的消费速度来取。传输层就绪了,生成器才产出下一块------这种天然的流速匹配,是一次性返回根本给不了的。
类比最直观:看视频是边下边播,不是等整部下完再从头看。文件下载接口同理,该边读边发,不该读完再发。
1.2 生成器 yield 到底改变了什么
普通函数 return 是「攒齐了,一次性交差」;生成器 yield 是「走走停停,产出一份、让调用方处理一份,自己挂起等下次」。关键区别在惰性(lazy)------它不积累全部结果,只在被索取时才算下一份。
python
# app/api/routes/files.py(流式下载最小实现,示意)
def read_chunk(file_path: str, chunk_size: int = 64 * 1024):
"""分块读取文件,每次只吐 chunk_size 字节(默认 64KB)"""
with open(file_path, "rb") as f:
while True:
data = f.read(chunk_size)
if not data:
break
yield data # 内存里永远只有这一小块
循环里每次只读 64KB,yield 出去。无论文件多大,进程占用的内存都恒定在这一个 chunk 的量级上。
1.3 StreamingResponse 怎么接住生成器
StreamingResponse 接收的「流」可以是普通生成器,也可以是 async 生成器。这里有一个被多数教程漏掉的关键点:当你传一个普通(同步)生成器时,Starlette 会把它丢进线程池(iterate_in_threadpool)去跑,而不是阻塞主事件循环 。所以即便你用了阻塞的 open() + f.read(),FastAPI 依然能异步对外服务------它在背后帮你做了线程隔离。
python
# app/api/routes/files.py(端点与安全防护,示意)
@router.get("/download/{filename}")
def download(filename: str):
path = os.path.join(UPLOAD_DIR, os.path.basename(filename)) # ① 路径穿越防护
if not os.path.exists(path):
raise HTTPException(status_code=404, detail="文件不存在")
return StreamingResponse(
read_chunk(path),
media_type="application/octet-stream",
headers={"Content-Disposition": f"attachment; filename={filename}"},
)
几个工程要点:
- ① 路径穿越防护 :
os.path.basename(filename)把../../etc/passwd这类恶意路径剥成纯文件名,防止读系统文件。这是流式下载接口的安全底线,丢不得。 - chunk 大小:默认 64KB 对普通接口足够;纯大文件下载可提到 1MB,减少系统调用次数。
Content-Disposition:attachment触发浏览器下载并指定文件名;换成inline则直接在页面内预览(如播放 mp4)。配合正确的media_type(MIME)效果更佳。- 真·异步 :如果你追求极致的非阻塞 I/O,用
aiofiles做async生成器,避免任何线程切换开销。普通场景用同步生成器 + 线程池足矣。
1.4 流式不止于下载:LLM 的逐字输出
同一个心智的第二张脸是 LLM 流式输出 ------token 一个一个往外蹦,前端边收边渲染,用户看到的是「打字机效果」。底层同样是生成器 + StreamingResponse(常以 SSE/text/event-stream 承载)。
| 现象 | 真相 |
|---|---|
| 明明用了 StreamingResponse,客户端的进度条还是卡住不动 | 反代 / 负载均衡器默认会缓冲响应,把好不容易拆好的块又攒成大块才发;需要关掉 buffering 或启用 chunked transfer |
| 文本流比二进制流慢很多 | 没开 GZip;文本型流启用 GZipMiddleware(minimum_size 设小一点)能显著压缩体积 |
| 小 JSON 接口要不要也流式 | 不必。小响应直接 return 普通响应体,流式反而增加复杂度,得不偿失 |
小结 :流式 = 生成器分块产出 + 响应逐块发送。什么时候必须用? 大文件下载、LLM 逐字输出这两类。它的核心价值是「恒定内存 + 更早的首字节 + 天然背压」。实现上普通生成器就够,Starlette 会替你管线程;只有瓶颈在 I/O 时才上
aiofiles。
二、文本切分:RAG 流水线的第一棒
2.1 为什么不能把整份文档直接做 Embedding
两个原因,第二个更隐蔽:
- 上下文窗口限制:一万字的文档塞不进 2000 字的窗口,单块 Embedding 直接被截断或稀释。
- 语义稀释 :整篇一次 Embedding,等于把一桌菜打成一杯糊糊。检索时「数据库经验」这种关键信号被平均掉,哪块都沾点、哪块都不准。
正确打法是先切分,每块独立算向量,提问时只召回最相关的几块拼进 Prompt。这是 RAG 质量的最大杠杆 ------业内共识是工程优先级 分块策略 > Embedding 模型选择 > 向量数据库选型。很多团队在向量库上纠结半天,结果分块一塌糊涂,方向跑偏了。
2.2 核心矛盾:切大还是切小
| 切太大(如 2048 token) | 切太小(如 128 token) |
|---|---|
| 多主题混一块,语义被稀释 | 一句完整的话被腰斩,上下文丢失 |
| 检索「啥都能命中一点」但精准度差 | LLM 拿到碎片答不出完整答案 |
| 浪费 token 和上下文窗口 | 检索噪音多,召回碎片化 |
目标是在「精准」和「上下文完整」之间找到甜点。
2.3 chunk_size 与 chunk_overlap 怎么定
经验区间(来自 Chroma、NVIDIA 等多份基准):
- chunk_size :256--1024 token。通用文档默认 400--512 token;FAQ / 短问答 256--512;长文(论文、技术手册)512--1024。
- chunk_overlap :10%--20% 的 chunk_size。Chroma 的 472-query 基准里,「递归切分 512 token + 10% overlap」拿到 88.5% recall,和最贵的 LLM 语义切分只差 3 个点,且零额外成本------这是有数据支撑的默认起点。
token 计数比字符计数更准 :用 tiktoken 的 cl100k_base 做 length_function,而不是 len()。原因是 token 和字符不是 1:1,且非英文差异巨大------同一句话,土耳其语/日语比英文多 2.7 倍 token,阿拉伯语多 3.9 倍(中文同理)。用字符数切,非英文块的语义量会被悄悄压薄。
overlap 的类比:像全景照片拼接,相邻两张要重叠一部分,否则接缝处正好有个人被劈成两半------上半身在左图、下半身在右图,哪张都认不出他。文本同理,重叠让边界内容两块都有,检索时任一块都能带出完整语义。
2.4 切分策略全家福
别只盯着「按字符滑窗」。生产里按成熟度从低到高有一整排打法:
| 策略 | 做法 | 适用 | 代价 |
|---|---|---|---|
| 固定大小 | 每 N 字符/token 一刀切,可选 overlap | 快速基线 | 可能从句子中间切开 |
| 递归字符切分 | 按 \n\n → \n → . → 优先级找边界 |
生产默认,通用文档 | 极低 |
| 语义切分 | 给每句算 Embedding,相似度骤降处切 | 结构混乱 / 主题杂的语料 | 入库时要额外跑 Embedding,慢且贵 |
| 结构感知 | 按 Markdown / HTML 的标题层级切 | 技术文档、网页 | 依赖良好结构 |
| 父子 / 层级 | 小「子块」入库检索,大「父块」喂 LLM | 既要精准检索又要完整上下文 | 实现稍复杂 |
| Late Chunking | 长上下文模型先整篇 Embedding,再 pooling 成块向量 | 解决「它指代谁」问题(如 jina-embeddings-v3) | 模型门槛高,尚非主流 |
递归字符切分为什么是默认 :它先尝试在段落边界切,切不动再降级到句子、词、字符,最大程度保住语义单元。LangChain 的 RecursiveCharacterTextSplitter(separators=["\n\n","\n",". "," ",""]) 就是这个思路。绝大多数 flat 散文,从这招起步就对了。
2.5 我们的实现:段落优先 + 滑窗 overlap
具体到代码,采用「段落优先」策略:
- 优先按段落切 (
\n\n是天然语义边界,段内主题相对集中)。 - 超长段落再按字符滑窗切。
- 段间不重叠------这是我们的设计选择:短段落本身是完整语义单元,段落间再重叠是纯冗余(注意 LangChain 在段落偏短时仍会跨段重叠,两种取舍都合理,看语料)。
- 每块带上
chunk_index(全局连续)+source元数据,保证检索结果可追溯------将来能说清「这段话来自第几块、什么来源」。
滑窗的灵魂只有一行:start = end - chunk_overlap。下一块不从 end 起步,而是往回吐 overlap 个字符------这就是相邻块重叠的来源。
python
# app/rag/chunker.py --- split_text 核心实现(示意)
def split_text(text, chunk_size=800, chunk_overlap=100, source="profile"):
paras = [p.strip() for p in text.split("\n\n") if p.strip()]
raw_chunks = []
for para in paras:
if len(para) <= chunk_size:
raw_chunks.append(para) # 短段落整段保留
else:
start = 0
while start < len(para):
end = min(start + chunk_size, len(para))
raw_chunks.append(para[start:end])
if end == len(para):
break
start = end - chunk_overlap # 灵魂:下一块回退 overlap
return [{"chunk_text": c, "chunk_index": i, "source": source}
for i, c in enumerate(raw_chunks)]
2.6 别凭感觉调:用 eval set 验证
切分参数没有「万能值」,只有「对你的语料最合适的值」。业内铁律:先建 20--50 条真实用户问题的 eval set,再动 splitter 。用 hit@5、MRR 打分,每次只改一个变量(size / overlap / strategy),跑前跑后对比。没有 eval set,你是在凭感觉调参。
两个常被忽略的增强点:
- 元数据过滤:把来源、章节、时间戳写进元数据,检索时能用「只搜 2024 年文档」做预过滤,精度大升。
- 上下文富化:把章节标题、文档名前缀到每块上(Anthropic 实测失败率从 5.7% 降到 3.7%),比盲目改切分尺寸更划算。
小结:切分是 RAG 的第一棒,下游 Embedding 和向量库都靠它供干净的块。默认从「递归字符切分 + 512 token + 10% overlap」起步,按 token 计数,宁可先上 eval set 再微调。策略优先级:分块 > 模型 > 向量库。
总小结 :本文两件事------流式响应和文本切分------内核是同一个心智:大东西别一次性处理,切成可控小块,逐块流转 。一个分块读文件 (生成器 + StreamingResponse,恒定内存、天然背压),一个分块切文本 (段落优先 + 滑窗 overlap,喂给 RAG 干净的语义单元)。工程上记得两条默认配置:流式下载用 64KB 分块 +
basename防穿越;RAG 切分用递归字符切分 512 token / 10% overlap 起步,并用 eval set 收口。把这两棒接好,RAG 链路就稳了前半段。