长文本 RAG 的几个反直觉设计
长文本 RAG 的几个反直觉设计:按场景切片、不设相似度硬阈值、强约束直查 DB、embedding 维度锁死与本地 FAISS。
长文本 RAG 的几个反直觉设计
适用场景:小说、长报告、剧本等"连续叙事 + 强一致性约束"的 RAG 系统。
一、背景
做长文本写作助手的 RAG 时,很多"标准答案"并不适用。下面是我最终落地的几个反直觉决策。
二、不按字符硬切,按场景切片
RecursiveCharacterTextSplitter 按 \n\n 递归切,对通用文档好,但会把小说的对话/情节从中间切断。我们按"场景标记"切分,无标记时回退按段落聚合成 ≤1200 字块(远小于模型 8k 上限、粒度细、不丢尾):
def _split_by_scenes(text):
scenes = _parse_scene_marks(text) # 形如 "#### 3" 的场景标记
if not scenes:
return _split_prose_fallback(text, max_chars=1200)
return scenes
"约定优先、无约定有兜底"——既尊重结构化写作,又兼容自由散文。
三、不设相似度硬阈值
常见做法是 score < threshold 过滤低质量召回。但我们不设置硬阈值,而是召回 top_k * 4 候选,再用 cross-encoder reranker 精排裁剪到 top_k。rerank 本身就是"软阈值",把不相关排到后面丢弃:
candidates = store.similarity_search_with_score(query, k=top_k * 4)
if use_rerank and len(candidates) > top_k:
candidates = rerank(query, candidates, top_n=top_k) # 失败回退原序
四、强信号不走向量,直查 DB
人设 / 时间线这类"硬约束"一旦漏召就是人设崩坏。所以大纲、时间账、open 状态的伏笔,不进语义召回,而是结构化直查 DB 注入 prompt。向量检索只负责"软性相关补充"。
铁律:硬约束走 DB 精确注入(不依赖召回),软相关走向量 + rerank。
五、embedding 维度锁死
bge-m3 固定 1024 维。一旦切模型,所有索引报废、需全量重建。所以早期就锁死模型,批量入库预计算向量一次写入,避免重复调 API:
embs = await client.embeddings.batch_create(texts, batch_size=16) # 预计算
store.add_embeddings(text_embeddings=embs, ids=ids) # 直接落向量,不二次调用
六、本地 FAISS 而非托管向量库
数据量在万级以内,FAISS 零运维、磁盘可备份、冷启动快。每业务一个 collection 物理隔离,重建索引时"建空占位 → 删占位 → 全量写 → 覆盖",清掉旧派生脏向量:
dummy = Document(page_content="__init_placeholder__", metadata={})
store = FAISS.from_documents([dummy], embedding)
store.delete([store.index_to_docstore_id[0]]) # 删占位
if docs:
store.add_documents(docs, ids=ids) # 全量写(稳定 id)
faiss_save_sync(store, faiss_dir, name) # 磁盘覆盖
七、复盘
RAG 没有银弹。长文本场景最容易被忽略的两点:
- 切分要贴合业务语义(场景/段落),别迷信通用递归切;
- 强一致性信号必须从检索里摘出来,靠"召回 + 阈值"保证不了零漏召。
能讲清"为什么不用标准做法"比"用了什么"更显架构思考。