首页 / 资讯中心 / 文章详情

手写RAG后再读LangChain源码:从零理解检索增强生成

手写RAG后再读LangChain源码:从零理解检索增强生成 ★ FEATURED ARTICLE
手写一遍 RAG再回头看 LangChain 的源码那种感觉就像自己先照着菜谱炒了一盘菜再去米其林后厨看人家怎么标准化出餐——每一个封装、每一层抽象你都能对上号。我最初接触 LangChain 的时候被它那一堆 Chain、Retriever、Memory、Agent 的类名绕得头晕文档翻来覆去看不明白它到底在解决什么问题。后来索性把 LangChain 扔到一边用最朴素的方式从零搭了一个 RAG 流程文档切分、向量化、存库、检索、拼 Prompt、调模型生成。等这套东西跑通之后再回头读 LangChain 的代码突然就通了——原来它做的每一件事我都在手写版本里踩过对应的坑。这篇内容适合两类人一类是正在用 LangChain 但总觉得“会用不会懂”的开发者另一类是想搞明白 RAG 到底怎么回事、不想一上来就被框架绑架的初学者。我会把从零手写 RAG 的完整过程拆开讲再逐层对照 LangChain 的设计把那些看起来神秘的抽象还原成最朴素的工程决策。全程不堆术语能跑代码的地方给代码能说人话的地方不说黑话。1. 为什么先手写再学框架1.1 框架带来的“理解负债”我见过太多人学 LangChain 的路径是这样的找一个 Demo复制粘贴跑通了然后换个场景就懵了。问题出在哪出在框架把太多决策替你做了而你并不知道这些决策的存在。比如文本怎么切、切多大、重叠多少、向量用什么模型、检索取几条、取出来的内容怎么塞进 Prompt——这些全是 RAG 效果的关键变量但 LangChain 用默认参数把它们藏起来了。这就好比学开车直接上手自动挡油门刹车方向盘你都会但发动机怎么出力、变速箱什么时候换挡你完全没概念。平时开没问题一旦车出毛病或者要开手动挡就抓瞎了。手写一遍 RAG就是逼自己去理解“发动机怎么工作”。你会亲手决定 chunk_size 是 500 还是 1000会亲眼看到切分不当导致检索出来的内容驴唇不对马嘴会自己算 token 预算决定塞几段上下文。这些经验是看多少篇框架文档都换不来的。提示手写版本不需要追求生产级健壮性目标是“把每个环节都摸一遍”。用几十行代码跑通主流程比读一万字框架源码注释管用。1.2 手写版和框架版的本质差异很多人以为 LangChain 是“RAG 的实现”其实更准确的说法是LangChain 是“RAG 各环节的标准化接口集合”。它本身不发明算法它做的是把“切分、嵌入、存储、检索、生成”这些步骤抽象成统一的组件让你能像搭积木一样替换其中任何一块。手写版和框架版的核心差异不在功能而在耦合度和可替换性。手写版里切分逻辑、检索逻辑、Prompt 拼接逻辑全糊在一起改一处可能牵动全身框架版通过接口把每块隔离开换一个向量库、换一个嵌入模型只要实现对应接口就行。理解了这一点你就明白 LangChain 那些看似冗余的抽象类到底在干嘛——它们是在为“可替换”付的税。对比维度手写版框架版LangChain 类代码量少几十到一两百行多但结构清晰可替换性差改一处动全身好组件即插即换学习成本前期低后期理解成本高前期高后期扩展成本低调试难度低逻辑全在眼前中需要理解抽象层适合场景学习原理、小规模验证生产环境、多场景复用这张表不是要分高下而是想说清楚先手写是为了把“理解负债”还掉再学框架是为了把“工程效率”提上来。顺序反了框架就成了黑箱。1.3 手写 RAG 的最小可行路径我给自己定的目标是不引入任何 RAG 专用框架只用最基础的库把“问一个问题从一堆文档里找到相关片段拼成上下文让模型回答”这条链路跑通。技术选型上尽量朴素文本切分自己写函数嵌入用通用的文本嵌入接口向量存储先用内存里的列表加余弦相似度生成直接调对话模型接口。这条路径的好处是每一步都透明。切分函数是我自己写的所以我知道它按什么规则切相似度是我自己算的所以我知道为什么某段文本排第一Prompt 是我自己拼的所以我知道模型看到的到底是什么。等这些全部跑通再去看 LangChain 的RecursiveCharacterTextSplitter、VectorStore、Retriever每一个都能对应上我手写的那几行代码理解起来毫无障碍。2. 手写 RAG 的四个核心环节拆解2.1 文档切分最容易被低估的一步RAG 效果好不好切分占了一半功劳。我一开始图省事按固定字符数硬切结果切出来的片段经常从句子中间断开检索出来的内容读着别扭模型理解也受影响。后来改成按段落优先、句子次之、实在不行才按字符切的策略效果立竿见影。手写切分函数的核心逻辑是维护一个“分隔符优先级列表”先尝试用双换行段落切如果某段还是太长再用单换行行切再不行用句号、问号、感叹号切最后才退化成按固定长度硬切。这个思路其实就是 LangChain 里RecursiveCharacterTextSplitter的核心思想只不过我是自己踩坑之后想出来的看到它的实现时有种“英雄所见略同”的爽感。def split_text(text, chunk_size500, overlap50): separators [\n\n, \n, 。, , , ., , ] chunks [] # 简化版递归切分逐级尝试分隔符 def _split(segment, sep_index): if len(segment) chunk_size: if segment.strip(): chunks.append(segment.strip()) return if sep_index len(separators): # 兜底按固定长度硬切 for i in range(0, len(segment), chunk_size - overlap): piece segment[i:i chunk_size] if piece.strip(): chunks.append(piece.strip()) return sep separators[sep_index] parts segment.split(sep) if sep else list(segment) buffer for part in parts: candidate buffer sep part if buffer else part if len(candidate) chunk_size: buffer candidate else: if buffer.strip(): chunks.append(buffer.strip()) buffer part if buffer.strip(): chunks.append(buffer.strip()) _split(text, 0) return chunks关于 chunk_size 和 overlap 怎么定我的经验是chunk_size 取决于你的文档密度和模型上下文窗口。技术文档信息密度高500 到 800 字符比较合适叙述性内容可以放到 1000 左右。overlap 设成 chunk_size 的 10% 到 15%目的是让跨片段的信息不至于在边界处丢失。这两个参数没有标准答案必须拿你的真实文档试看检索出来的片段是否“自洽”——也就是单独拿出来读意思是否完整。注意切分时一定要保留一定的重叠否则一个完整的论述被切成两半检索时可能只命中后半段前半段的铺垫就丢了模型回答会缺前因。2.2 向量化与存储把文本变成可比较的数字文本变成向量本质是把语义映射到一个高维空间里语义相近的文本在空间里距离也近。手写版里我用最直接的方式调嵌入接口拿到每段文本的向量存成一个列表同时保留原文和元数据来源、片段序号。检索时把问题也向量化然后算余弦相似度排序。余弦相似度的计算很朴素就是两个向量点积除以模长乘积。这里有个细节值得说向量一定要先归一化归一化之后余弦相似度就等价于点积计算更快数值也更稳定。我一开始没归一化遇到过长文本向量模长特别大、相似度被“放大”的问题排序结果失真排查了半天才发现是没归一化。import math def cosine_similarity(vec_a, vec_b): dot sum(a * b for a, b in zip(vec_a, vec_b)) norm_a math.sqrt(sum(a * a for a in vec_a)) norm_b math.sqrt(sum(b * b for b in vec_b)) if norm_a 0 or norm_b 0: return 0.0 return dot / (norm_a * norm_b) def retrieve(query_vec, doc_vectors, top_k3): scored [] for idx, doc_vec in enumerate(doc_vectors): score cosine_similarity(query_vec, doc_vec) scored.append((score, idx)) scored.sort(reverseTrue) return scored[:top_k]存储这块手写版用内存列表完全够用几百上千个片段毫无压力。但一旦文档量上到几万、几十万线性扫描就慢了这时候才需要向量数据库。理解了这个演进逻辑你就明白为什么 LangChain 要抽象出VectorStore接口——它是在为“从内存列表平滑迁移到专业向量库”留口子。手写版里那个retrieve函数换成向量库的similarity_search逻辑一模一样。2.3 检索策略不只是“取最相似的几条”最朴素的检索就是取相似度最高的 top_k 条但我实际用下来发现纯向量检索有两个明显短板。一是对精确匹配不敏感比如用户问某个具体的编号、专有名词向量检索可能召回一堆语义相近但没命中关键词的片段二是 top_k 取小了容易漏取大了又塞进无关内容干扰模型。我的改进办法是混合检索向量相似度算一路关键词匹配比如简单的词频或子串匹配算另一路两路结果加权融合。这个思路对应 LangChain 里的EnsembleRetriever。手写版里我实现得很粗糙但原理是通的——向量负责“语义相关”关键词负责“字面命中”两者互补。另一个实用技巧是加一个相似度阈值。低于阈值的片段直接丢弃宁可少给上下文也不给错误上下文。我试过不设阈值结果模型经常被不相关片段带偏回答里混进文档里根本没提的内容。设了阈值之后回答的“忠实度”明显提升。2.4 Prompt 拼接与生成把检索结果喂给模型检索出片段之后怎么拼进 Prompt 也有讲究。我最初的拼法是简单粗暴地把片段用换行连起来前面加一句“根据以下内容回答”。结果模型有时候会把不同片段的信息混在一起甚至编造片段里没有的细节。后来改成给每个片段加编号和来源标注并在指令里明确要求“只依据给定片段回答无法回答时说明”效果好很多。def build_prompt(query, retrieved_chunks): context_parts [] for i, chunk in enumerate(retrieved_chunks, 1): context_parts.append(f[片段{i}] {chunk}) context \n\n.join(context_parts) prompt f你是一个严谨的问答助手。请仅依据下面提供的片段回答问题。 如果片段中没有足够信息请直接说明“根据现有资料无法回答”不要编造。 {context} 问题{query} 回答 return prompt这里的关键是约束模型的自由度。RAG 的价值在于“有据可依”如果 Prompt 不加以约束模型很容易退化成自由发挥那 RAG 就白做了。我在指令里反复强调“仅依据片段”“不要编造”就是为了把模型锚定在检索结果上。这个思路在 LangChain 里对应的是各种 PromptTemplate 的设计本质都是通过结构化指令控制模型行为。3. 对照 LangChain每个抽象都能对上号3.1 TextSplitter我手写的切分函数就是它的简化版LangChain 的RecursiveCharacterTextSplitter干的事和我上面那个split_text函数几乎一模一样维护分隔符列表递归尝试直到片段小于 chunk_size。区别在于它处理了更多边界情况比如分隔符保留、长度计算方式按字符还是按 token、元数据传递等。看懂它的关键在于理解“递归”二字。它不是一次性按某个分隔符切完而是切完之后检查每段长度超长的继续用下一级分隔符切直到所有片段都满足长度要求。这个设计的好处是尽量保持语义完整性——优先在段落边界切实在不行才退到句子、字符。我手写版里那个_split递归函数就是它的骨架。3.2 Embeddings 与 VectorStore接口隔离的典范LangChain 把“把文本变成向量”和“把向量存起来并支持检索”拆成了两个独立接口Embeddings和VectorStore。这个拆分乍看多余实际非常必要。因为嵌入模型和向量库是两个完全独立的选型维度你可以用 A 家的嵌入配 B 家的向量库也可以随时替换其中任何一个而不影响另一个。手写版里我把这两件事糊在一起嵌入和存储都在一个函数里完成。一旦想换嵌入模型就得改存储逻辑想换存储方式又得动嵌入部分。LangChain 的接口隔离把这个耦合解开了。理解这一点你就明白为什么它的VectorStore要定义add_texts、similarity_search这些方法——它是在约定“任何向量库只要实现这几个方法就能接入整个生态”。3.3 Retriever检索策略的可插拔封装Retriever是 LangChain 里我觉得设计得最巧妙的一层。它把“给定查询返回相关文档”这个动作抽象成一个统一接口至于底层是向量检索、关键词检索、混合检索还是别的什么调用方完全不用关心。这就好比你去餐厅点菜你只管说“来一份红烧肉”后厨是蒸是煮是炸你不需要知道。手写版里我的检索逻辑是写死的想换策略就得改代码。LangChain 通过Retriever接口让检索策略变成可替换的组件。EnsembleRetriever就是混合检索的实现它内部组合多个 Retriever按权重融合结果。我手写版里那个“向量加关键词加权”的粗糙实现本质上就是它的简化版。3.4 Chain把多个环节串成流水线Chain是 LangChain 最核心也最容易被误解的概念。很多人以为 Chain 是什么高深的东西其实它就是“把多个处理步骤按顺序串起来”的抽象。一个最基础的 RAG Chain就是“检索 → 拼 Prompt → 调模型 → 解析输出”这条流水线。手写版里这条流水线是我用一个函数从头写到尾的中间任何一步想加日志、加缓存、加异常处理都得改这个函数。Chain 的价值在于把每一步拆成独立节点节点之间通过统一的数据结构传递这样加日志、加缓存、加分支就变成了“在节点之间插入新节点”而不是“改主流程”。理解了这一点再看 LangChain 那些SequentialChain、TransformChain就不会觉得神秘了。4. 实操中踩过的坑与排查实录4.1 检索结果“看起来相关但答非所问”这是我最常遇到的问题。检索出来的片段字面上确实和问题沾边但模型就是答不到点子上。排查下来八成是切分粒度的问题。片段太大里面混了多个主题模型抓不住重点片段太小信息不完整模型没法组织出完整答案。我的排查套路是先把检索出来的片段原样打印出来人工读一遍问自己“如果我只看到这几段能不能回答这个问题”。如果人读了都觉得勉强那模型肯定也不行。然后针对性调整 chunk_size 和 overlap重新切分、重新嵌入、重新检索对比效果。这个过程很笨但最有效。现象可能原因排查动作解决方向检索片段字面相关但答非所问切分粒度过大主题混杂打印片段人工阅读减小 chunk_size提高切分精度回答缺关键信息切分过小或重叠不足检查片段是否完整增大 overlap或改用段落切分检索不到明显相关的内容嵌入模型与领域不匹配换模型对比召回换用领域适配的嵌入模型回答混入文档外内容Prompt 约束不足检查 Prompt 指令强化“仅依据片段”约束相似片段排序异常向量未归一化检查向量模长统一归一化后再算相似度4.2 向量维度不一致导致的“隐形崩溃”有一次我中途换了嵌入模型忘了重新生成所有文档的向量结果新旧向量混在一起维度对不上相似度计算直接报错。更隐蔽的情况是维度碰巧一样但语义空间不同算出来的相似度毫无意义检索结果乱七八糟还不报错排查起来极其痛苦。这个坑的教训是嵌入模型和向量库必须版本绑定。换嵌入模型必须全量重新嵌入不能新旧混用。LangChain 在这方面通过把 Embeddings 作为 VectorStore 的构造参数传入某种程度上强制了这种绑定关系——你换嵌入模型就得重新构建 VectorStore。手写版里没有这层约束全靠自己记着反而容易出事。4.3 上下文塞太满反而降低回答质量我一度以为检索的片段越多越好top_k 设到 10结果模型回答质量不升反降。原因是上下文太长模型注意力被稀释关键信息淹没在无关内容里。而且很多模型对超长上下文中间部分的利用率会下降塞得越满真正有用的信息越可能被“忽略”。后来我把 top_k 降到 3 到 5同时加了相似度阈值过滤只保留真正相关的片段。上下文短了模型反而答得更准。这个经验让我明白RAG 的关键不是“给模型更多信息”而是“给模型更准的信息”。宁缺毋滥在 RAG 里是真理。提示判断 top_k 是否合适可以看检索结果的相似度分布。如果第 4、5 条的相似度已经明显掉档说明后面的都是凑数的取前几条就够了。4.4 手写版迁移到框架版时的“水土不服”手写版跑通之后我尝试把同样的逻辑用 LangChain 重写结果遇到几个不适应的地方。一是它的默认参数和我手写版不一样比如默认 chunk_size 是 1000、默认分隔符列表更长直接套用效果和我调好的版本有差距得手动覆盖。二是它的数据流转用的是Document对象我手写版里就是普通字符串加字典转换的时候要额外写适配代码。这些“水土不服”恰恰是理解框架的好机会。每遇到一处不一致我就去翻它的源码看默认值怎么来的、为什么这么设计。翻着翻着就发现它的每个默认值背后都有大量实践支撑只是文档里没写清楚。比如默认分隔符列表覆盖了中英文常见标点就是为了开箱即用适配多语言场景。理解这些设计意图之后用起来就顺手多了。5. 从手写到框架的迁移心得5.1 先跑通再抽象别反过来我的建议很明确如果你还没手写过一遍 RAG先别急着上 LangChain。手写一遍把切分、嵌入、检索、生成每个环节都亲手实现一次哪怕代码很丑、性能很差。这个过程建立的直觉是后面用框架时判断“它为什么这么设计”的基础。反过来如果你已经熟练使用 LangChain但总觉得心里没底那就抽个周末把手写版补上。你会发现很多之前“知其然不知其所以然”的地方突然就通了。框架是别人抽象好的结果手写是理解抽象的过程两者缺一不可。5.2 框架选型的判断标准手写之后我对框架选型有了更清晰的判断。如果只是做个小 Demo 验证想法手写几十行代码就够了引入框架反而增加负担。如果是生产环境、需要多场景复用、团队协作那框架的接口隔离和可替换性就值回票价了。具体到 LangChain我觉得它最适合的场景是“需要频繁替换组件”的项目。比如今天用 A 向量库、明天换 B 嵌入模型、后天加个重排序这种频繁变动的需求框架的抽象层能省大量适配工作。但如果你的技术栈很固定就是一套嵌入加一个向量库用到死那手写一套精简版可能更可控。5.3 一个实用的学习路径我自己总结的路径是这样的先用最朴素的库手写一个能跑通的 RAG重点理解切分和检索这两个环节因为它们是效果的关键。跑通之后拿真实文档试观察检索结果调参数感受参数变化对效果的影响。等这套流程烂熟于心再去读 LangChain 对应组件的源码把它的实现和自己的手写版对照着看。对照的时候重点关注三件事它的默认参数是什么、为什么这么设它的接口怎么定义、为什么这么拆它的异常处理和数据校验做了哪些、为什么需要。这三件事想明白了框架就不再是黑箱而是一套你能驾驭的工具。最后分享一个我自己的小习惯每次用框架遇到“这玩意儿到底在干嘛”的疑惑时就试着用十行以内的手写代码复现它的核心逻辑。能复现出来说明真懂了复现不出来说明还有盲区。这个习惯帮我啃下了不少看似复杂的抽象也让我对“框架只是工具原理才是根本”这句话有了切身体会。
阅读完成 · 觉得有帮助?
咨询建站