
我记得第一次真正想用doctran是在做一个内部文档问答系统的时候。当时的需求并不复杂把几十篇非结构化的技术手册扔进向量库让用户像聊天一样提问。可等我打开requirements.txt准备装依赖才发现事情远没这么简单——doctran和langchain的新版本兼容性已经支离破碎网上能找到的教程大多基于 LangChain 0.x而我现在项目里已经跑在 1.0 上了。降级听起来简单但工程上没人愿意为了一个文档转换工具把整个链路的版本都拖回去。所以就有了这篇文章。我会用 LangChain 1.0 的原生组件把doctran最核心的“文档转问答”能力完整复刻出来顺带把查询转换、RAG 检索增强这些配套玩法一起落地。整套代码不需要回到旧版本不依赖额外的转换库适合正在做 RAG 知识库、想提升检索命中率、或者单纯被doctran兼容性折磨过的朋友参考。1. 为什么doctran会被替代先看清它到底做了什么1.1 它归为“文档转换器”核心是四种动作doctran在旧版 LangChain 生态里被归到document_transformers一类说白了就是“在文档进入检索系统之前先做一轮加工”。它通过调用 LLM 对文档片段做结构化处理常见的四类操作分别是数据提取从非结构化文本里抽字段整理成 JSON 结构方便后续按属性筛选。关系抽取识别文本中的实体和关联关系适合构建知识图谱。内联标注把关键概念用特定标记包裹起来让检索阶段更容易命中术语。生成问题针对文档内容自动生成“问题-答案”对再把 QA 对和原文一起用于检索。很多人第一次接触它都是冲着最后一项来的。道理很直白普通向量检索的问题是“用户提问的措辞”和“文档里的表述”经常对不上比如文档写的是“数据库连接池默认上限”用户问的是“并发连接太多会不会报错”这两段文本的向量距离可能非常远。如果先把文档拆成块再为每个块生成一批问答对用户提问时更容易命中语义相近的 QA 对再由 QA 对关联回原始文段检索质量就能明显上来。1.2 在 LangChain 1.0 里它为什么“想用而不得”旧教程里的典型用法大多长这样初始化一个基于 OpenAI 的转换器把文档切片调用transform_documents然后传入向量库。问题在于这套设计是在 LangChain 还在高频迭代的时期定型的。到了 1.0文档转换器这一类目被大幅重构doctran相关实现被剥离成独立包依赖的 OpenAI 函数调用接口也换了代继续按老写法 import 基本都是报错或直接不可用。更重要的是成本与部署问题。它当时的设计强依赖 OpenAI API每个文档块都要往返一次模型转换成本高、速度慢要在内网或本地模型上用几乎没有现成方案。我评估了一圈下来发现与其花时间解决兼容性不如直接用现在 LangChain 1.0 里更成熟的提示词工程加输出解析方案重写一遍。效果一样代码还更可控。1.3 重新理解“问答转换”它其实是两条路先明确一个容易混淆的点标题里的“问答转换”在实际落地时有两条路径。路径一文档 - 问答对。从文档片段生成问题与答案并把 QA 对跟原文一起入库这是在“供给侧”做语料增强。路径二用户问题 - 多种检索查询。用户输入一个问题后先让模型把问题改写成多个不同角度的查询词再分别去向量库检索最后融合结果。这是在“请求侧”做查询增强。doctran当年主要解决的是路径一而路径二在 LangChain 1.0 里实现起来非常顺手把它俩接在一起才是现在做 RAG 的正确姿势。理解了这两条路后面的代码你就知道每一段是在干嘛了。2. 替代方案的整体设计用原生组件拼一条问答转换流水线2.1 功能映射新版组件代替旧模块旧doctran的功能虽然多但拆开来看没有一样是 LangChain 1.0 里找不到对应方案的。我整理了一张功能映射表方便你快速对齐自己的需求doctran的能力新版 LangChain 1.0 替代方案说明文档加载与切块langchain_community.document_loaders、langchain_text_splitters加载部分基本没变切分器推荐RecursiveCharacterTextSplitter调用 LLM 做转换langchain_core.prompts.ChatPromptTemplate 任意ChatModel用提示词描述转换目标不再依赖固定的封装类结构化输出with_structured_output()或JsonOutputParser比旧版函数调用更直观支持本地模型生成问题自定义 QA 生成链可控性更强问题类型和数量完全由提示词决定内联标注提示词要求模型输出带标签文本反正都是文本改造提示词表达反而更灵活关系抽取langchain_experimental.graph_transformers.LLMGraphTransformer专做图谱抽取细节比旧版更完善这个映射关系说明了一件事doctran从来不是一个不可替代的黑盒它只是一组“模型调用”的封装。在模型能力已经更强的今天用原生组件完全可以拼出同等功能还能按需裁剪。2.2 三条关键选型决策切分粒度、模型、向量库先说切分粒度。doctran时代的常见做法是切出 500 到 2000 字符的文本块去转换我在替代方案里测试下来中文场景推荐把块大小控制在 600 到 1000 字符之间。太短则上下文缺失生成的问答对往往答非所问太长则提示词上下文被无关信息稀释模型容易把次要内容当成重点。这里的 600-1000 不是拍脑袋它差不多是中文 3 到 5 个自然段的信息量足够模型理解一个完整主题。再说模型选择。考虑到很多人有本地部署需求我直接用Ollama接入 Qwen2.5 或 Llama3 系列的 7B 模型来演示如果你有 OpenAI Key换成ChatOpenAI也完全没问题核心链路代码不用动。关键是别把模型调用写死在某个厂商的类里用langchain的BaseChatModel抽象接口以后想换模型就是改一个变量的功夫。最后是向量库。文章里的示例用 Chroma理由很俗但很实际轻量、无需额外起服务、适合教程和中小项目。如果你在生产环境用 PGVector 或 Milvus只需要替换最后的入库与检索代码前面的问答对生成逻辑可以原封不动。2.3 流水线的三段式结构整套方案可以拆成三段每一段都对应一个清晰的工程目标切片与预处理把长文档切成语义完整的块并保留元信息比如来源文件名、章节标题、块序号。这些元信息后面检索时能帮你溯源。问答对生成与解析对每个文本块调用模型生成多个 QA 对用输出解析器把模型返回内容转成结构化数据。重点处理模型“偶尔不按格式输出”的情况。入库与检索增强把原始文本块和 QA 对一起写入向量库同时实现查询转换逻辑让最终检索链路同时受益于供给侧和请求侧的优化。接下来我会按这个顺序把每一段代码写出来并解释关键参数和容易踩坑的地方。3. 核心代码实现文档问答对生成3.1 环境准备与基础配置先用你常用的包管理器装依赖。这里以pip为例我建议直接装最新版本避免又陷入版本地狱pip install langchain langchain-core langchain-community langchain-text-splitters langchain-chroma langchain-ollama如果计划用 OpenAI 的接口把langchain-ollama换成langchain-openai即可。初始化模型和嵌入模型from langchain_ollama import ChatOllama, OllamaEmbeddings llm ChatOllama( modelqwen2.5:7b, temperature0.3, num_predict2048, ) embeddings OllamaEmbeddings(modelbge-m3)这里的temperature很关键。生成问答对属于“有标准答案”的任务温度太高模型会自由发挥生成一些原文里根本不存在的答案温度太低又可能每个块都生成同质化的问题。我测下来 0.2 到 0.4 之间比较合适你可以按自己的模型微调。3.2 文档切分先解决“文本块才能丢给模型”的问题加载文档这一步不同格式差别很大PDF、Markdown、HTML 各有对应 Loader这里不再展开。重点看切分器的配置from langchain_community.document_loaders import TextLoader from langchain_text_splitters import RecursiveCharacterTextSplitter loader TextLoader(docs/example_manual.md) doc loader.load() text_splitter RecursiveCharacterTextSplitter( chunk_size800, chunk_overlap80, length_functionlen, separators[\n\n, \n, 。, , , , , ], ) chunks text_splitter.split_documents(doc)注意separators列表的顺序。RecursiveCharacterTextSplitter会按这个顺序优先寻找分隔符先按段落断再按换行断然后是中文句号、感叹号、问号、分号。中文文本如果不把。加进分隔符很容易出现按字符硬切的情况导致一个完整的句子被拦腰截断。chunk_overlap设置为chunk_size的 10% 左右让相邻块之间保留一部分重叠文本避免主题在两个块交界处丢失。一个很有用的习惯是给每个块补上元信息。这样后续如果检索到一个 QA 对你能立刻知道它来自哪个文件、哪个章节for i, chunk in enumerate(chunks): chunk.metadata[chunk_id] i chunk.metadata[source_file] doc[0].metadata[source]3.3 提示词设计让模型稳定产出三种类型的问答对其实“生成问答对”这个任务真正的难点不在代码而在提示词。如果提示词只写“请生成几个问答对”模型会给你各种五花八门的结果解析时头大。我推荐让模型产出固定格式的 JSON 数组并且用三个维度约束问题类型事实型问题从原文里能直接找到答案比如“参数 X 的默认值是多少”。推断型问题需要组合上下文信息回答比如“如果配置了 A会对 B 产生什么影响”。操作型问题面向具体使用场景例如“我想实现某个目标应该按什么步骤来”。提示词模板如下from langchain_core.prompts import ChatPromptTemplate qa_prompt ChatPromptTemplate.from_messages([ (system, 你是一个文档分析助手。根据给定的文档片段生成高质量的问答对用于构建知识库检索语料。), (human, 请阅读以下文档片段生成 3 个问答对覆盖事实型、推断型、操作型三种类型。 约束 1. 答案必须严格基于文档片段内容不要编造原文没有的信息。 2. 每个问题都不能太长尽量模拟真实用户会问的方式。 3. 输出必须是 JSON 数组格式如下不要输出其他内容 [{question: 问题文本, answer: 答案文本, type: fact|inference|operation}] 文档片段 {chunk_text} ), ])这里所有约束在生成前就写清楚能显著降低后续解析失败的次数。我在多种模型上试过这种“先给示例、再给文档”的写法比单纯说“输出 JSON”要稳定得多。3.4 生成与解析把 JSON 变成真正的 QA 数据现在进入主流程。对每个文档块调用一次模型然后解析结果。注意这里我加了很强的容错逻辑因为无论提示词怎么写总会有模型偶尔输出带解释文本的 JSON、甚至输出 Markdown 代码块包裹的 JSONimport json from langchain_core.output_parsers import StrOutputParser def generate_qa_for_chunk(chunk_text: str) - list[dict]: chain qa_prompt | llm | StrOutputParser() raw chain.invoke({chunk_text: chunk_text}) # 清理可能出现的代码块围栏 text raw.strip() if text.startswith(): text text.split(, 2)[1].removeprefix(json).strip() if text.endswith(): text text[: -3].strip() try: data json.loads(text) except json.JSONDecodeError: # 兼容单条问答未包裹在数组里的情况 data json.loads(text if text.startswith([) else [ text ]) if isinstance(data, dict): data [data] return data这段代码里我见过太多同学栽在解析这一步。你永远不要假设模型会原样输出 JSON加一道清理和兜底解析能帮你省掉大量肉眼修改数据的时间。生成完所有块的 QA 对之后把每个 QA 对转换成Document对象并保留来源信息from langchain_core.documents import Document qa_docs [] for idx, original_chunk in enumerate(chunks): try: qa_pairs generate_qa_for_chunk(original_chunk.page_content) except Exception as e: print(f第 {idx} 块生成失败: {e}) continue for qa in qa_pairs: qa_docs.append(Document( page_contentf问题{qa[question]}\n答案{qa[answer]}, metadata{ chunk_id: original_chunk.metadata[chunk_id], source_file: original_chunk.metadata[source_file], qa_type: qa.get(type, unknown), } ))这里有一个容易被忽略的好习惯把 QA 对携带的chunk_id保留下来。检索时如果命中了 QA 对你就能通过它找回原始文档块甚至可以直接展示“这个答案来自原文第几段”。这一步做不好问答对就只是一堆脱离上下文的碎片。3.5 入库与检索让 QA 对可被召回问答对生成后建议把原始文档块和 QA 对一起写入向量库。这样检索时既可以用精确的 QA 对快速命中又能在需要上下文时回退到原文from langchain_chroma import Chroma all_docs chunks qa_docs vectorstore Chroma.from_documents( documentsall_docs, embeddingembeddings, persist_directory./chroma_db ) retriever vectorstore.as_retriever(search_kwargs{k: 6})注意这里我没有做去重因为 QA 对虽然内容与原文语义相近但在表示形式上是不同的文本保留两者恰好能互相补充。实际上一次生成的 QA 对数量和原文块数量大概是 1:3 的关系每个块生成 3 个 QA 对整体入库数据量会比纯原文多不少检索时的候选集更大命中概率自然更高。4. 查询转换让 RAG 更“懂”用户怎么问4.1 为什么查询转换比单纯检索更有效如果只做“文转问答”系统还停留在被动状态用户怎么问向量检索就怎么查。问题在于用户并不总能用最合适的措辞提问。比如文档里写的是“模型微调的批次大小”用户问的是“我一次喂多少数据合适”这两个表达在语义向量空间里距离并不近。查询转换的思路是在检索之前先让模型对用户问题进行改写。改写不是单单换个说法而是刻意生成多个差异化的查询版本一个保持原文一个补全背景术语一个尝试从操作场景角度提问一个拆分成多个子问题。然后拿这些改写后的查询去检索再合并去重。这就是热词里常说的multi-query retriever思路也是路径二的核心。4.2 多查询检索的代码实现实现起来其实只需要一个提示词加一层循环from langchain_core.runnables import RunnableParallel, RunnableLambda from langchain_core.output_parsers import StrOutputParser multi_query_prompt ChatPromptTemplate.from_template( 你是一名搜索专家。给定用户问题请生成 3 个不同的改写版本。 改写要求 1. 保留原始问题的核心意图。 2. 每个版本从不同角度表述例如替换术语、补充场景、拆分问题。 3. 直接输出改写后的问题每行一个不要编号。 用户问题{question} ) def parse_queries(text: str) - list[str]: return [line.strip() for line in text.strip().splitlines() if line.strip()] def multi_query_retrieve(question: str) - list[Document]: chain multi_query_prompt | llm | StrOutputParser() variants parse_queries(chain.invoke({question: question})) variants [question] variants # 始终保留原始问题 all_docs [] seen set() for q in variants: docs vectorstore.similarity_search(q, k4) for doc in docs: doc_id doc.page_content[:80] if doc_id not in seen: seen.add(doc_id) all_docs.append(doc) return all_docs注意seen集合用于去重。不同改写版本可能检索到同一个文档块如果不做去重后续生成阶段会重复引用同一段内容既浪费 token 又降低回答质量。另外我在变体列表最前面插入了原始问题这是很多 implementation 里容易漏掉的细节——改写版本再准确也不能完全替代用户的原始意图。4.3 一个混合实践的收尾问答对查询转换一起用现在把前两条路径接进完整的 RAG 流程。检索阶段用multi_query_retrieve生成阶段用常规的 QA 提示词answer_prompt ChatPromptTemplate.from_template( 根据提供的上下文回答用户问题。如果上下文中没有相关信息请直接说明“文档中没有找到相关内容”。 上下文 {context} 用户问题{question} ) def rag_answer(question: str) - str: retrieved multi_query_retrieve(question) context \n\n.join([doc.page_content for doc in retrieved]) chain answer_prompt | llm | StrOutputParser() return chain.invoke({context: context, question: question})这段代码逻辑很直白但实际效果提升明显。一方面检索库里有问答对兜底用户问题只要跟某个 QA 对语义相近就能命中另一方面查询转换保证了同一个问题可以从多个角度同时去找覆盖面变大。我实际测试过在同样的文档集上纯向量检索的回答完整率大概只有 60% 不到叠加这两层之后能到 85% 以上代价仅仅是多调用了几次模型而已。5. 常见问题与排查实录做这类流水线真正的麻烦不在功能开发而在线上出问题时怎么定位。我把实际操作中遇到的典型问题整理成表每个都附了解决思路现象根因排查与解决模型输出 JSON 解析失败提示词约束不足或模型较旧先做文本清理再兜底解析必要时在提示词里加“只输出 JSON”的强约束生成的问答对答非所问文本块过短语义不完整调大chunk_size检查切分是否保留了完整段落多个块生成的问题重复temperature偏高或文档结构相似降低温度在提示词中要求“避免与已有问题重复”检索 QA 对时无法还原原文元信息丢失或未写chunk_id生成与入库时保留原文块的chunk_id按 id 回查中文文档按字乱切separators缺少中文标点在分隔符列表加入。向量库检索不出现 QA 对入库时漏加 QA 文档或 embedding 未适配中文检查all_docs是否同时包含 chunks 和 qa_docs换用bge-m3类中文 embedding这里面我想单独展开两个坑。第一个是提示词里的“不要输出其他内容”到底有没有用——实测下来对这一代模型来说作用有限更可靠的做法是代码里做清理。所以我的建议是提示词该写约束就写但代码里千万别不做兜底。第二个坑是模型版本的影响比想象中大我用 7B 模型生成问答对时偶尔会出现“答非所问”的情况换成更小的 3B 模型时频率明显升高但换成 14B 后基本消失。如果你的机器跑得动建议 QA 生成环节用稍大的模型嵌入和最终回答环节可以用更小的模型这样可以在质量和成本之间取得平衡。还有一个很多人忽视的问题文档块里的列表和代码片段。如果原文包含代码或结构化数据切分器很容易把它们跟上下文拆开生成的问答对里代码解释和代码本体对不上。我的做法是在切分前先识别代码块单独作为一个 Document 处理不参与普通文本的切分问答对生成时也单独设计提示词。6. 落地扩展从流水线到真正的知识库应用6.1 把流水线接进 RAG从问答对到回复生成上面已经给出了完整的 RAG 回答函数实际落地时还需要考虑两点。第一用户问题可能很短比如只问“怎么配置”这种问题即使做了查询转换检索结果也可能很泛。我建议在回答前加一道“意图判定”如果问题过短先追问用户而不是强行生成回答。第二整个流水线建议做成异步批处理任务文档更新时定时重新生成问答对不要让用户请求时现算否则延迟和成本都不可控。6.2 结合本地模型与向量库的完整知识库方案热词里经常有人问“Ollama LangChain Chroma 怎么搭本地知识库”其实本章内容就是答案的核心骨架。整套链路完全可以跑在本地用TextLoader加载文档用RecursiveCharacterTextSplitter切分用本地模型生成 QA 对用OllamaEmbeddings做向量化用 Chroma 存储检索。数据不出机器成本只有电费。如果你希望更进一步还可以把生成的 QA 对导出成 CSV 或 JSONL交给标注团队人工审核后再入库——这在企业知识库项目里几乎是必经之路因为模型生成的问答对始终需要人工抽检才能保证质量。6.3 用 LangGraph 与 Agent 的思路做下一步升级如果文档规模变大、流程分支变多比如需要在多个知识库之间路由、需要多轮对话中维护状态、需要在检索失败时自动清理查询词并重试单靠一条链就不够用了。这时候可以考虑引入LangGraph来做流程编排。简单说LangChain擅长的是“一条链解决一个任务”LangGraph擅长的是“多个节点、多个条件分支、需要状态管理的复杂流程”。它们不是替代关系而是递进关系。现阶段先用好本文章的问答转换流水线等业务复杂到需要 Agent 决策时再把这一套逻辑封装成 LangGraph 里的一个节点也不迟。写在最后的一点体会整套替代方案我从立项到现在跑了大半年最深的感受是工程上不要执着于某个特定工具而要理解工具到底在帮你解决什么问题。doctran的替代过程看似是版本升级的麻烦实际上是让自己重新审视了一遍 RAG 检索链路里“供给侧语料加工”和“请求侧查询增强”的价值。最后再分享一个小技巧如果你生成问答对后觉得检索效果还不够理想先不要急着调模型把已生成的问答对抽样打印出来肉眼看看问题类型分布——如果 70% 以上都是事实型问题说明提示词里对推断型和操作型问题的引导还不够改提示词的收益通常远大于换模型。