AI 应用工程实录

从手写 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

这几行里藏着三个决策,每个都是权衡:

  1. 按什么切 ------按 ---(语义边界)切,而不是按固定字数。因为按字数切会把一句话、一个完整的知识点切成两半,检索出来是残缺的。
  2. 太短的丢弃len < 30)------只有标题、没内容的片段,检索出来是噪声。
  3. 记住 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 实录:正例拉近、负例推远,以及没学好的那一对
相关推荐
猫头_7 小时前
AI 流式传输工程指南:有了 EventSource 为何还要 Fetch?
javascript·http·llm
SyMind8 小时前
如何做好 AI 的挂件
llm·ai编程
GPUStack10 小时前
Day 0 实测|在 GPUStack 上部署 Inkling-BF16:8 卡 H20-141G 推理性能测试
ai·大模型·llm·gpu·vllm·gpu集群·sglang·gpustack
带刺的坐椅11 小时前
Solon TeamAgent 协作协议:从 SEQUENTIAL 流水线到 HIERARCHICAL 主管团队
java·ai·llm·agent·solon
Token炼金师11 小时前
自主的引擎:ReAct、MCP、多 Agent、Workflow 与沙箱护栏 —— Agent 与工具六器
人工智能·深度学习·llm
咪库咪库咪11 小时前
qdrant
llm
xing-xing12 小时前
魔搭社区(ModelScope)下载模型文件
python·jupyter·llm
AINative软件工程13 小时前
LLM 应用的回滚工程实践:Prompt、模型与配置变更出问题时如何快速恢复
后端·llm·ai编程