从手写 RAG 到理解 LangChain:为什么我先绕了"远路"
「AI 应用工程实录」系列 · 01 学 RAG 时,我为什么放着现成框架不用,偏要自己手写两版检索。
一个反直觉的选择
学 RAG(检索增强生成)时,我面前有两条路:
- 快的:直接上 LangChain,十几行代码就能跑通一个知识库问答。
- 慢的:用原生 SDK,自己写文档切分、自己做检索、自己拼 prompt。
我选了慢的,而且慢得比较彻底------检索我先写了个关键词版 ,跑通、发现问题,再升级成向量语义版。
现在回头看,正是这两版之间的落差,让我真正理解了 RAG 里每个部件"为什么存在"。这篇就把这个过程讲清楚。
RAG 的骨架:三段式
不管框架怎么封装,RAG 拆开都是这三段:
css
建库(离线):文档 → 切分 chunk → (embedding)→ 存起来
检索(在线):问题 → 找出最相关的 top-k 片段
生成(在线):检索片段 + 问题 → 拼 prompt → LLM 回答
框架的价值是把这三段包成 retriever.invoke() 一行。但也正是这一行,让你不必理解里面发生了什么。手写,就是逼自己把每一段都想清楚。
第一个部件:切分,不是"切开"这么简单
我最初对"切分"的理解就是把长文档切成小段。写的时候才发现每个决策都得有理由。
我的文档是 Markdown 笔记,用 --- 分隔知识卡片,所以第一版我按 --- 切:
python
def chunk_documents(docs):
chunks = []
for doc in docs:
sections = doc["content"].split("\n---\n")
for i, section in enumerate(sections):
section = section.strip()
if len(section) < 30: # 太短的片段丢弃,没检索价值
continue
chunks.append({
"id": f"{doc['path']}#chunk{i}",
"text": section,
"source": doc["path"], # 记住来源,之后要做引用溯源
})
return chunks
这几行里藏着三个决策,每个都是权衡:
- 按什么切 ------按
---(语义边界)切,而不是按固定字数。因为按字数切会把一句话、一个完整的知识点切成两半,检索出来是残缺的。 - 太短的丢弃 (
len < 30)------只有标题、没内容的片段,检索出来是噪声。 - 记住
source------这一步当时觉得多余,后来做"引用溯源"(让回答标注出处)时才发现,来源必须在切分入库时就带上,事后补不了。
切太大 :检索粒度粗,一个片段里混入无关内容,稀释相关性,还可能超模型上下文。切太小 :语义断裂。这个平衡没有标准答案,取决于你的文档结构------这是手写才会逼你面对的问题,框架的默认 chunk_size=1000 把它藏起来了。
第二个部件:检索------先用关键词,再换向量
第一版我用关键词匹配做检索:把问题拆成词,去文档片段里数命中几个词,命中多的排前面。听起来简单,但中文让它变得很麻烦,我一路打了三个补丁才让它勉强能用。
补丁一:停用词过滤。
python
stop_words = {"什么", "是", "的", "了", "吗", "怎么", "如何", "为什么", "用", "有"}
query_words = set(w for w in raw_words if w not in stop_words and len(w) > 1)
用户问"什么是 temperature",如果不过滤,"什么""是"会去跟每个片段匹配------而几乎每篇文档都有"是""的"这种词,结果所有片段都被判成"命中",分数普遍抬高,真正相关的反而排不出来。停用词就是这些"哪都有、但没区分度"的词,必须先删掉,只留"temperature"这种有意义的词去匹配。
补丁二:英文词单独提取。
python
english_words = set(re.findall(r"[a-zA-Z_]{2,}", query))
query_words.update(w.lower() for w in english_words)
这是中文分词的坑。中文没有空格,我是靠 split()(按空格切)分词的,但"怎么用 temperature 参数"这种中英混排,按空格切容易把英文词切坏或和中文粘在一起。所以额外用正则把连续英文字母抠出来,保证 temperature、streaming 这类技术术语一定能被当成一个完整关键词------而我的笔记里最重要的匹配目标恰恰就是这些技术词。
补丁三:标题命中加权。
python
score = sum(1 for word in query_words if word in text_lower) # 正文命中 +1
first_line = chunk["text"].split("\n")[0].lower()
score += sum(2 for word in query_words if word in first_line) # 标题命中 +2
一个词出现在标题里,比出现在正文里更能说明"这段就是讲这个的"。查"temperature",标题就叫"temperature 参数调优"的片段,肯定比正文里顺便提一句的更相关。所以标题命中要加权,让"主题就是这个"的片段排到前面。
三个补丁打完,关键词检索才勉强对中文和技术笔记"能用"。但你能感觉到------代码越写越像在打补丁,每个都是为了绕开关键词匹配的先天缺陷。
然后我撞了墙 :用户问"怎么让 AI 输出 JSON",但我的笔记里那段标题是"结构化输出",正文写的是"让模型返回固定格式"------没有一个字面词命中。关键词检索直接漏掉了这段最相关的内容。
这就是关键词检索的本质缺陷:它匹配"字面",不匹配"意思"。 而用户提问用的词,几乎永远和文档里的词不完全一样。
第三个部件:向量检索解决的正是这个
撞墙之后,我把检索换成向量语义版(ChromaDB + sentence-transformers):
python
embedding_fn = SentenceTransformerEmbeddingFunction(model_name="all-MiniLM-L6-v2")
collection = chroma_client.create_collection(name="notes", embedding_function=embedding_fn)
collection.add(ids=..., documents=[c["text"] for c in chunks], metadatas=...)
# 检索:不再数关键词,而是算语义相似度
results = collection.query(query_texts=[question], n_results=3)
代码量反而更少了------之前那一堆停用词、分词补丁全删了。而效果的差别是本质的:
同样问"怎么让 AI 输出 JSON",向量版能匹配到"结构化输出"那段------因为在向量空间里,"输出 JSON"和"结构化输出"的语义是接近的,即使一个字面词都不重合。
这一刻我才真正理解了 embedding 和向量检索存在的意义:它把"匹配字面"升级成了"匹配语义"。 这个概念我之前看文档看过很多遍,但只有先用关键词版撞了墙,才真的懂它在解决什么问题。
再看 LangChain:原来每个封装都是我手写过的东西
走完这条"远路"再打开 LangChain,我的感受不是"一堆新概念",而是"这个我写过,原来它叫这名字":
| LangChain 组件 | 我手写过的 |
|---|---|
TextSplitter |
我那个按 --- 切、过滤短片段的 chunk_documents |
Embeddings + VectorStore |
ChromaDB + sentence-transformers 那一段 |
Retriever |
collection.query(query_texts=..., n_results=3) |
PromptTemplate |
我拼的 f"参考资料:{context}\n\n问题:{question}" |
| Chain(` | ` 管道) |
甚至 JD 里那个听起来很唬人的 ReAct(Reason + Act),我发现就是我手写 Agent 时那个"想一步、做一步、看结果再想"的循环的学名。
手写过之后,LangChain 对我不再是魔法。我能判断它每一层封装在帮我省什么,也能判断------什么时候它反而是负担。
框架不是银弹:什么时候我不用它
理解了它在做什么,也就理解了它的代价。LangChain 抽象层多、封装重,简单任务用它反而要绕好几层。所以我的判断线是:
- 应用复杂、组件多、要接生态里现成的工具 → 用 LangChain,省重复代码。
- 简单任务、要精细控制每一步、想让依赖轻 → 直接原生 SDK 更直观。
关键是,这个判断能力来自我先手写过。只会用框架的人,遇到框架搞不定的场景就卡住;懂底层的人,能随时"降到"原生 SDK 己写。
给同路人的一句话
如果你也在学 RAG,我的建议是:别一上来就 retriever.invoke()。先手写一版关键词检索,撞一次"字面匹配不到语义"的墙,再换向量检索。 那堵墙,会让你对 embedding 的理解,比读十篇原理都深。
先懂原理,再谈效率。
本文是「AI 应用工程实录」系列第 1 篇。这个系列每篇都基于真实做过的项目和踩过的坑,只讲"实际怎么做、为什么这么做、边界在哪"。
系列其他文章:
- 02 · 别让 AI 自己决定"要不要发出去":我给 AI 系统设计的三层风险兜底
- 03 · 48h 做一个 RAG 知识库:一个 FDE 的取舍笔记
- 04 · 微调 embedding 实录:正例拉近、负例推远,以及没学好的那一对