手写一遍 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_size=500, overlap=50): 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_k=3): scored = [] for idx, doc_vec in enumerate(doc_vectors): score = cosine_similarity(query_vec, doc_vec) scored.append((score, idx)) scored.sort(reverse=True) 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 对应组件的源码,把它的实现和自己的手写版对照着看。
对照的时候重点关注三件事:它的默认参数是什么、为什么这么设;它的接口怎么定义、为什么这么拆;它的异常处理和数据校验做了哪些、为什么需要。这三件事想明白了,框架就不再是黑箱,而是一套你能驾驭的工具。
最后分享一个我自己的小习惯:每次用框架遇到“这玩意儿到底在干嘛”的疑惑时,就试着用十行以内的手写代码复现它的核心逻辑。能复现出来,说明真懂了;复现不出来,说明还有盲区。这个习惯帮我啃下了不少看似复杂的抽象,也让我对“框架只是工具,原理才是根本”这句话有了切身体会。