Langchain4j+RAG踩坑记录
Langchain4j+RAG踩坑记录,包括文件解析、编码问题、大文件内存溢出等。
一、文件解析
简单文件(text、md)使用以下方式读取:
Files.readString(file.toPath(), StandardCharsets.UTF_8)
doc 文件转 docx 文件走 python-docx 解析,复杂文件(pdf、xlsx、图片)走多模态解析 minerU 或其他第三方。
踩坑点
-
PDF 解析后残留 HTML 标签:pdf 解析之后会有很多 html 标签,需要进一步清洗,否则会影响召回质量。常见的有
<p>、<table>、<span>等,建议用正则或 Jsoup 统一清洗。 -
PDF 表格解析丢失结构:大部分 PDF 解析器对表格支持很差,要么把表格拆成零散文本,要么直接丢失。如果文档中表格是核心信息,建议单独处理:用 Camelot / Tabula 专门提取表格,再转成结构化文本或 Markdown 表格格式。
-
PDF 分栏/多栏布局:学术论文、报纸等分栏排版,解析器经常把左右两栏内容交叉拼接,导致语义完全错乱。这种场景需要用支持版面分析的解析器(如 minerU 的版面识别模式)。
-
doc 转 docx 的坑:老版
.doc格式 Langchain4j 不直接支持,需要先转成.docx。可以用 Apache POI 或 LibreOffice 命令行转换,但转换过程中可能丢失格式、图片、特殊字符,转换后一定要校验内容完整性。 -
编码问题:Windows 环境下读取文件默认用 GBK,如果文件是 UTF-8 编码会出现乱码。务必显式指定
StandardCharsets.UTF_8,不要依赖系统默认编码。 -
大文件内存溢出:几百 MB 的 PDF 一次性加载到内存可能 OOM,建议流式读取或分页解析,Langchain4j 的
DocumentParser支持按页处理。 -
图片中的文字:扫描件 PDF 本质是图片,普通解析器提取不到文字。需要走 OCR 流程,或者直接用多模态大模型(如 GPT-4V)识别图片内容。
-
文件名和路径含中文/空格:Java 中文件路径包含中文或空格时,
File.toPath()和 URL 编码可能出问题,建议统一用Paths.get()并做 URI 编码处理。
二、切片
解析完成之后需要进行切片,切片质量直接决定召回上限。
踩坑点
-
chunk_size 不是越大越好:很多人觉得 Chunk 大一点上下文更完整,但过大的 Chunk 会引入大量噪声,导致检索精准度下降。中文场景建议 300~800 字符,具体需要根据文档类型实测调优。
-
chunk_overlap 设为 0:为了省空间把 overlap 去掉,结果关键信息正好在切分边界处被截断,前后两个 Chunk 各丢失一半语义。overlap 建议设为 chunk_size 的 10%~20%。
-
表格被切碎:表格是最容易出问题的内容,按固定长度切分很容易把一个表格拆成两半,上下文完全丢失。建议:表格尽量整表保留为一个 Chunk,或者将表格转成自然语言描述后再切分。
-
代码块被截断:技术文档中的代码片段如果从中间截断,检索到的代码无法直接使用。Langchain4j 中可以用
DocumentSplitters.recursiveDocumentSplitter并设置合适的分隔符优先级。 -
列表项被拆散:Markdown 中的有序/无序列表,如果从中间切分,检索到的 Chunk 只有半截列表,语义不完整。建议在分隔符中优先使用
\n\n(双换行),让列表尽量保持完整。 -
切片后丢失文档元数据:切片后如果不保留文档标题、章节路径等元数据,检索时无法过滤,也无法在 Prompt 中提供来源信息。Langchain4j 中
Document.split()会自动继承父文档的 metadata,但自定义切分逻辑时容易忽略。 -
所有文档用同一套参数:不同类型的文档(合同、技术手册、FAQ)最优参数差异很大,建议按文档类型分组调参,而不是一刀切。
代码示例:
DocumentSplitter splitter = DocumentSplitters.recursive(
500,
100,
List.of("\n\n", "\n", ".", " ", "")
);
List<Document> chunks = splitter.split(document);
三、召回
召回要进行多路召回和精确检索。
踩坑点
-
只走向量检索,关键词匹配不到:向量检索擅长语义匹配,但对精确关键词(如产品型号 “XQ-2000”、合同编号 “HT-2024-001”)召回率很低。必须配合关键词检索(BM25)做混合检索,Langchain4j 中可以用
EmbeddingStoreIngestor+ 自定义关键词索引实现。 -
Embedding 模型选错:中文场景如果用了英文 Embedding 模型(如
text-embedding-ada-002),中文语义理解能力很弱,召回质量直接拉垮。中文场景优先选bge-large-zh、m3e-base等中文优化模型。 -
Embedding 模型和 LLM 不匹配:存储时用的 Embedding 模型和检索时用的不一致,向量空间不同,检索结果全是噪声。存入和检索必须用同一个 Embedding 模型。
-
top_k 设太大或太小:top_k 太小(如 1 ~ 2)可能漏掉关键信息,太大(如 20+)会引入大量噪声且消耗 Token。建议从 3~5 开始,根据效果调整。
-
相似度阈值不设:只按 top_k 返回结果,不设相似度阈值,导致返回的全是不相关内容。建议设置
minScore阈值(如 0.7),低于阈值的直接丢弃。 -
向量数据库选型踩坑:FAISS 不支持分布式和持久化,生产环境慎用;Milvus 配置复杂,入门成本高;Chroma 功能有限。开发阶段用 FAISS 快速验证,生产环境根据规模选 Milvus 或 Pinecone。
-
长文本检索慢:文档量大时(百万级 Chunk),向量检索延迟可能从毫秒级涨到秒级。建议:建好索引(HNSW/IVF)、控制 Chunk 数量、必要时做分区检索。
-
多路召回结果未去重:向量检索和关键词检索可能返回相同文档的不同 Chunk,不去重会浪费 Token 且重复内容干扰 LLM。建议按文档 ID + Chunk 位置去重。
四、Query 改写
用户输入存在语义鸿沟、意图模糊等问题,所以要进行 Query 改写。改写又分为短语句改写和长语句改写,短语句改写进行语义扩充和同义词替换,长语句改写进行语义解释。
踩坑点
-
改写后偏离原意:LLM 改写时可能“过度发挥”,把用户的查询改成了另一个意思。改写 Prompt 中必须强调“保持原始意图不变,只做语义补充”,并在改写结果中保留原始 Query。
-
短查询改写过度:用户输入“报销”被改写成“公司差旅费用报销流程及审批制度”,引入了原文中不存在的信息。短查询改写应该只做同义词扩充和语义补全,不要添加具体细节。
-
改写增加延迟:每次检索前都调一次 LLM 做改写,首字响应时间直接翻倍。建议:简单查询不改写,只对短查询(<5 个字)或识别到意图模糊的查询触发改写。
-
多轮对话上下文丢失:用户说“它支持什么格式?”,单独看这个 Query 完全不知道“它”指什么。多轮对话场景必须把历史对话拼接到 Query 中,Langchain4j 中可以用
ChatMemory维护上下文。 -
HyDE 的幻觉问题:HyDE(假设性文档嵌入)让 LLM 先生成一个“假设性回答”,再用这个回答去做向量检索。如果 LLM 生成的回答偏离事实,检索方向就完全错了。HyDE 适合事实性强的查询,不适合开放性问题。
-
改写次数过多:有人为了提高召回率,把一个 Query 改写成 5~10 个变体,每个都去检索一次,结果延迟飙升、结果冗余。建议改写 2~3 个变体即可,多了收益递减。
五、重排序(Reranker)
多路召回后,结果来自不同检索通道,排序不一致,需要重排序把最相关的排到前面。
踩坑点
-
跳过重排序直接用向量相似度排序:向量相似度只能衡量“语义接近”,不能衡量“对查询的真正相关性”。比如检索“如何退款”,返回的第一条可能是“退款政策概述”,而用户真正需要的是“退款操作步骤”。Reranker 用交叉编码器对 Query 和每个 Chunk 做精细匹配,排序质量远高于向量相似度。
-
Reranker 模型和 Embedding 模型混淆:Reranker 是交叉编码器(Cross-Encoder),输入是 Query+Document 对,输出是相关性分数;Embedding 是双编码器(Bi-Encoder),分别编码后算相似度。两者不能互相替代,Reranker 更准但更慢。
-
对全量候选做 Rerank:如果有 1000 个候选 Chunk,全部送进 Reranker 会非常慢。正确做法是:先用向量检索粗排取 top_k(如 20~50),再对这少量候选做 Rerank 精排。
-
Reranker 调用成本高:每个 Chunk 都要过一次模型推理,候选数多时成本不可忽视。建议控制 Rerank 候选数量在 20 以内,或使用轻量级 Reranker(如
bge-reranker-base)。
六、LLM 生成
检索结果拼入 Prompt 后交给 LLM 生成最终回答。
踩坑点
-
Prompt 中不设边界:如果不告诉 LLM “只根据上下文回答”,LLM 会用自身知识补充,产生幻觉。Prompt 中必须加约束:如“请仅根据以下参考资料回答,如果资料中没有相关信息,请回答’未找到相关信息’”。
-
检索内容拼太多:把 top_k=20 的结果全部塞进 Prompt,Token 消耗巨大,LLM 也容易被无关内容干扰。建议控制在 3~5 个 Chunk,总 Token 不超过模型上下文窗口的 50%。
-
不标注来源:生成的回答不标注引用了哪个文档/章节,用户无法验证可信度。建议在 Prompt 中要求 LLM 标注来源,或在 Chunk 元数据中保留文档标题和页码。
-
上下文窗口不够:长文档检索 + 多轮对话历史,很容易撑爆上下文窗口。建议:对话历史只保留最近几轮,检索结果精简后再拼入,必要时用支持长上下文的模型。
-
Langchain4j 的 Token 溢出:Langchain4j 默认不会自动截断超长 Prompt,会直接报错。需要在构建
ChatMessage时手动计算 Token 数并截断,或使用TokenStream做流式输出。
代码示例:
String systemPrompt = """
你是一个知识库问答助手,请严格根据以下参考资料回答问题。
如果参考资料中没有相关信息,请直接回答"未找到相关信息",不要编造答案。
回答时请标注引用来源。
""";
ChatLanguageModel llm = OpenAiChatModel.withApiKey(apiKey);
AiServices.create(QAInterface.class, llm)
.chat(systemPrompt, userQuery, retrievedContext);
七、工程部署
踩坑点
-
Embedding 服务高延迟:每次检索都要调 Embedding 接口把 Query 向量化,如果 Embedding 服务响应慢,整个链路延迟就上去了。建议:本地部署 Embedding 模型(如 ONNX Runtime 跑
bge-large-zh),或对高频 Query 做缓存。 -
向量库和业务库数据不一致:文档更新了但向量库没同步更新,检索到的还是旧内容。建议:文档变更时触发增量索引更新,或者定时全量重建索引。
-
Langchain4j 版本迭代快:API 变动频繁,升级版本时可能编译报错。建议:锁定版本号,不要用 SNAPSHOT,升级前看 Release Notes。
-
并发写入向量库冲突:多线程同时写入向量库可能导致数据损坏或索引异常。建议:写入操作加锁或走消息队列串行化。
-
日志和可观测性缺失:RAG 链路长,出了问题很难定位是解析、切片、检索还是生成环节的锅。建议:每个环节记录输入输出日志,包括原始 Query、改写后 Query、检索结果、Rerank 排序、最终 Prompt,方便排查。
-
没有兜底策略:检索结果为空或全部不相关时,LLM 仍然会硬编一个回答。建议:检测到检索结果为空时,直接返回“未找到相关信息”,而不是让 LLM 自由发挥。
八、整体链路优化建议
-
先跑通再优化:先用最简单的方案(固定切片 + 向量检索 + 无改写)跑通全链路,确认数据流通,再逐步替换高级策略。
-
逐环节评估:不要一次性改多个环节,否则不知道是哪个改动带来的效果变化。建议:固定其他环节,只调一个变量,用标注数据集评估。
-
构建评估集:准备 50~100 个 Query + 期望答案的评估集,每次调优后跑一遍,用召回率和回答准确率量化效果。
-
关注端到端延迟:RAG 链路涉及解析→切片→向量化→检索→Rerank→LLM 生成,每个环节都有延迟,生产环境需要做性能预算,比如总延迟控制在 3 秒以内。
九、总结
| 环节 | 关键要点 |
|---|---|
| 文件解析 | 显式指定 UTF-8 编码,PDF 表格单独处理,扫描件走 OCR |
| 切片 | chunk_size 300~800,overlap 10%~20%,表格整表保留 |
| 召回 | 向量检索 + BM25 混合,中文用 bge-large-zh,设置相似度阈值 |
| Query改写 | 短查询语义扩充,保留原始意图,控制改写次数(2~3个) |
| Reranker | 先粗排后精排,候选数控制在 20 以内 |
| LLM生成 | 设置边界约束,控制 Chunk 数量(3~5个),标注来源 |
| 工程部署 | 本地部署 Embedding,数据同步,完善日志 |
RAG 系统的优化是一个迭代过程,建议从简单方案开始,通过评估集量化效果,逐步优化各个环节。