☰
从零手搓AI工程:不依赖框架实现RAG全链路
2026/11/3 21:07:16 网站建设 项目流程

1. 从零搭建AI工程能力:为什么“手搓一遍”比调包更值钱

这两年AI应用层的工具链成熟得吓人,LangChain、LlamaIndex、各种Agent框架几乎把能封装的都封装了。打开文档,几行代码就能跑通一个RAG问答;复制一段示例,十分钟搭出一个对话机器人。表面上看,门槛被拉到了地板,但真正做过线上项目的人心里都清楚——能跑通Demo和能扛住生产,中间隔着一整条工程化的鸿沟。

ai-engineering-from-scratch这个标题,说的就是一件很“笨”但很值钱的事:不依赖高层框架,从最底层的原理和最小依赖开始,把AI工程里那些关键环节自己实现一遍。它不是一个具体的开源库,而更像一种学习路径和工程方法论——把检索、向量化、提示编排、上下文管理、评测这些模块,用最朴素的方式亲手搭起来,搞清楚每一步到底发生了什么。

我为什么觉得这件事值得做?因为我在实际项目里踩过太多“黑盒坑”。用框架时一切正常,一旦效果不达预期,你根本不知道问题出在切分策略、嵌入模型、还是检索排序上。框架帮你省了写代码的时间,但也顺手把可观测性和可调试性一起藏了起来。当你被迫从零实现一遍,那些原本模糊的概念会瞬间变得具体:为什么chunk大小要这么设、为什么top-k不能拍脑袋定、为什么重排序能救回一大半的召回质量。

这篇内容适合三类人:一是刚入行AI应用、只会调API但想搞懂底层机制的开发者;二是被框架“惯坏”后遇到瓶颈、想重建技术判断力的工程师;三是准备做技术选型、需要评估各环节真实成本的团队负责人。我会按一个完整的从零实现路径来讲,从整体设计思路,到每个核心模块的细节和实操,再到我实际踩过的坑和排查方法。全程不依赖重型框架,代码能跑、参数有依据、坑有出处。

2. 整体设计与思路拆解:为什么选择“最小依赖”路线

2.1 核心思路:把AI应用拆成可独立验证的积木

从零做AI工程,第一步不是写代码,而是想清楚一个AI应用到底由哪些独立环节组成。我的拆法是五块积木:文档加载与切分、向量化与索引、检索与重排序、提示编排与生成、评测与回归。这五块每一块都可以单独测试、单独替换、单独优化,彼此之间通过清晰的数据结构通信。

为什么强调“独立验证”?因为AI系统最大的痛点是效果归因困难。用户说“答得不准”,可能是切分把关键信息切断了,可能是嵌入模型对领域词汇不敏感,可能是检索召回了无关内容,也可能是提示词把上下文用错了。如果这些环节耦合在一个黑盒里,你只能靠猜。拆成积木后,你可以固定其他四块,只动一块,用同一批测试用例对比效果——这才是工程化的调试方式。

我选择最小依赖路线,具体是指:向量计算用NumPy手写余弦相似度,不引入向量数据库;文本切分自己写规则,不用框架的splitter;检索排序自己实现,不调现成的rerank服务。这样做不是为了“炫技”,而是为了让每个环节的行为完全透明。等你把原理跑通、知道每个参数的影响后,再换成生产级组件,你会有明确的选型依据,而不是被文档牵着走。

2.2 方案选型背后的取舍逻辑

有人会问:都2024年了,为什么还要手写余弦相似度?直接用FAISS或者向量数据库不香吗?这里要区分学习阶段和生产阶段。学习阶段的目标是建立心智模型,手写一遍能让你彻底理解“向量检索本质就是高维空间里的最近邻搜索”这件事,理解为什么归一化很重要、为什么内积和余弦在归一化后等价。等你理解了,生产阶段当然该用成熟组件。

再比如切分策略。框架通常提供按字符、按token、按递归分隔符等几种模式,但它们不会告诉你为什么你的场景该用哪种。自己实现一遍后你会发现:按固定字符切分简单但会切断语义;按段落切分保留语义但长度不可控;递归切分是折中但分隔符优先级需要针对你的文档类型调。这些认知,只有亲手写过、对比过不同策略的召回效果,才会真正长在身上。

还有一个关键取舍是评测先行。很多团队是先做功能再想评测,结果做完了不知道好不好。我的做法是从第一天就建一个小的评测集,哪怕只有二三十条问答对,每次改动都跑一遍。这个评测集不需要多完美,它的价值在于提供相对比较的基准——你改了切分参数,分数涨了还是跌了,一目了然。这是从零做AI工程最容易被忽略、但回报最高的一步。

2.3 整体数据流设计

把五块积木串起来,完整的数据流是这样的:原始文档经过加载和切分,变成一批文本块;每个文本块经过嵌入模型变成向量,存进一个简单的索引结构;用户提问时,问题也被嵌入成向量,在索引里检索出top-k相关块;这些块经过重排序精排后,按相关性拼进提示词;最后交给生成模型产出回答。评测环节则贯穿始终,对每个阶段的输出都能单独打分。

这个数据流看起来简单,但每一环都有讲究。比如索引结构,我一开始用最简单的列表存所有向量,检索时全量算相似度。文档少的时候没问题,上千个块之后延迟就上来了。这时候你自然会想到用近似最近邻算法,也就理解了为什么生产环境需要专门的向量数据库。这种“先遇到问题再解决”的路径,比直接上重型组件更能建立技术判断力。

3. 核心细节解析与实操要点:五个模块的深水区

3.1 文档切分:被低估的效果杀手

切分是整条链路里最不起眼、但影响最大的环节。我见过太多项目,检索效果差,最后发现是切分把答案切成了两半,检索时只召回了一半,生成模型自然答不全。

自己实现切分,核心要处理三件事:切分单位、块大小、重叠策略。切分单位我推荐按语义边界优先——段落、句子、列表项,实在不行再按字符硬切。块大小没有万能值,但有个经验公式:块大小应该略大于你期望回答所依赖的最小完整语义单元。比如FAQ场景,一个问答对就是一个单元,块大小设成能容纳一个完整问答即可;长文档摘要场景,块可以大一些,保留更多上下文。

重叠策略是很多人忽略的点。块之间留10%到20%的重叠,能有效缓解“答案正好卡在边界”的问题。但重叠不是越多越好,重叠太多会导致检索结果高度重复,浪费上下文窗口。我实测下来,重叠比例设在块大小的15%左右是个比较稳的起点,然后根据评测结果微调。

def split_by_semantic(text, max_len=500, overlap=75): # 先按段落切,再合并到接近max_len paragraphs = [p.strip() for p in text.split("\n\n") if p.strip()] chunks, current = [], "" for p in paragraphs: if len(current) + len(p) <= max_len: current += ("\n\n" if current else "") + p else: if current: chunks.append(current) # 处理超长段落,按句子再切 if len(p) > max_len: sentences = p.replace("。", "。\n").split("\n") buf = "" for s in sentences: if len(buf) + len(s) <= max_len: buf += s else: chunks.append(buf) buf = s current = buf else: current = p if current: chunks.append(current) # 加重叠 result = [] for i, c in enumerate(chunks): if i > 0: c = chunks[i-1][-overlap:] + c result.append(c) return result

注意:重叠部分会让块的实际长度超过max_len,如果你的嵌入模型有硬性token上限,记得把重叠算进去,否则会被静默截断,导致尾部信息丢失。

3.2 向量化与索引:理解相似度的本质

嵌入模型把文本变成向量,这一步的选型直接决定检索质量的上限。从零实现时,我建议先用一个通用的开源嵌入模型跑通流程,重点理解向量维度、归一化、相似度度量这三件事的关系。

向量维度决定了表达能力,但不是越高越好。高维向量检索慢、存储大,而且很多模型的高维部分信息量并不高。归一化是把向量长度统一到1,这样内积就等于余弦相似度,计算更简单。相似度度量里,余弦相似度关注方向、欧氏距离关注绝对位置,文本检索通常用余弦更合适,因为文本的“语义方向”比“绝对位置”更有意义。

索引结构从零实现,我推荐先用一个二维NumPy数组存所有向量,检索时做矩阵乘法一次性算出所有相似度。这个方案在几千个块以内完全够用,而且代码极简、行为完全透明。

import numpy as np class SimpleIndex: def __init__(self): self.vectors = None self.texts = [] def add(self, vectors, texts): vectors = np.array(vectors, dtype=np.float32) # 归一化,让内积等价于余弦相似度 norms = np.linalg.norm(vectors, axis=1, keepdims=True) vectors = vectors / np.clip(norms, 1e-8, None) if self.vectors is None: self.vectors = vectors else: self.vectors = np.vstack([self.vectors, vectors]) self.texts.extend(texts) def search(self, query_vec, top_k=5): q = np.array(query_vec, dtype=np.float32) q = q / np.clip(np.linalg.norm(q), 1e-8, None) scores = self.vectors @ q # 归一化后内积即余弦 idx = np.argsort(-scores)[:top_k] return [(self.texts[i], float(scores[i])) for i in idx]

提示:归一化时一定要加一个极小值防止除零,我见过因为某个向量全零导致整个检索返回NaN的案例,排查了半天。

3.3 检索与重排序:召回和精排是两件事

检索分两个阶段:召回和精排。召回阶段追求“不漏”,用向量相似度快速从海量块里捞出候选;精排阶段追求“准”,用更重的模型对候选重新打分排序。很多人把这两步混为一谈,结果要么召回不够、要么排序不准。

从零实现时,召回用上面的SimpleIndex就够了。精排我推荐引入一个交叉编码器(cross-encoder)的思路:把查询和候选块拼在一起送进模型打分,而不是分别编码后算相似度。交叉编码器能捕捉查询和文档之间的交互信息,精度明显高于双塔式的向量相似度,代价是慢——所以只用在top-k的少量候选上。

如果不想引入额外模型,也可以用一些轻量的重排信号:关键词覆盖率、块的位置权重、块的长度惩罚等。我实测下来,简单的关键词覆盖率重排就能把召回质量提升一截,因为向量相似度有时会漏掉字面匹配这种强信号。

def rerank(query, candidates, keyword_weight=0.3): query_terms = set(query.lower().split()) scored = [] for text, vec_score in candidates: text_terms = set(text.lower().split()) overlap = len(query_terms & text_terms) / max(len(query_terms), 1) final = (1 - keyword_weight) * vec_score + keyword_weight * overlap scored.append((text, final)) return sorted(scored, key=lambda x: -x[1])

3.4 提示编排:上下文不是越多越好

检索出一堆块之后,怎么塞进提示词,直接决定生成质量。新手常犯的错是把top-k全部拼进去,觉得信息越多越好。实际上上下文过长会稀释关键信息,还可能引入矛盾内容,让模型无所适从。

我的做法是:先按相关性排序,从高到低逐个加入,直到接近上下文预算就停。同时给每个块加上来源标记,方便模型引用,也方便你排查。提示词结构上,我习惯把指令放前面、上下文放中间、问题放最后,这样模型在生成时离问题最近,注意力更集中。

还有一个细节是指令要明确告诉模型“只用给定上下文回答”,并给出“如果上下文没有答案就直说”的兜底。这一条能大幅降低幻觉率,我实测下来比任何花哨的提示技巧都管用。

3.5 评测:没有评测就没有优化

评测是从零做AI工程里最容易被跳过、但最不能跳过的一环。我的评测集构建方法是:从真实文档里挑出二三十个有代表性的问题,人工标注每个问题的标准答案和应该被召回的关键块。这样你既能评生成质量,也能评检索质量。

评测指标上,检索用召回率(关键块有没有被召回)和MRR(关键块排在第几位),生成用人工打分或用一个强模型做裁判。每次改动只动一个变量,跑一遍评测,看分数变化。这套流程听起来笨,但它是唯一能让你**从“感觉好像好了点”变成“确实好了X%”**的方法。

4. 实操过程与核心环节实现:从空目录到能跑的问答系统

4.1 环境准备与依赖选择

从零做,依赖就要克制。我的最小依赖清单是:NumPy做向量计算,一个嵌入模型的客户端(可以是本地模型也可以是API),一个生成模型的客户端,再加一个简单的HTTP框架如果需要暴露接口。不需要LangChain,不需要向量数据库,不需要复杂的配置管理。

为什么这么克制?因为每多一个依赖,就多一层你不理解的抽象。学习阶段的目标是理解,不是效率。等你把整条链路跑通、知道每个环节的瓶颈在哪,再按需引入组件,那时候你是带着明确目的去选的,而不是被框架的默认行为绑架。

环境搭好后,先写一个最小的端到端脚本:读一个文档、切分、嵌入、存索引、检索、拼提示、生成。这个脚本可能只有一百多行,但它跑通的那一刻,你就拥有了一个完全可控的AI应用骨架。

4.2 端到端流程的关键参数计算

参数不能拍脑袋,要有计算依据。以块大小为例,假设你的嵌入模型上下文上限是512个token,中文大致1个token对应1.5到2个字符,那么块大小上限大约是512乘以1.5再打个折,取600到700字符比较稳。再考虑重叠15%,实际块大小设500到600字符,重叠75到90字符。

top-k怎么定?看你的上下文预算。假设生成模型上下文窗口是8k token,提示词模板和问题占1k,剩下7k给上下文。每个块平均500字符约300 token,那么最多能放23个块。但实际不需要放满,我通常从top-5开始,根据评测结果调整。top-k不是越大越好,超过某个点后,加入的都是低相关块,反而拉低质量。

重排序的候选数一般是top-k的3到5倍。比如最终要5个块,召回阶段取20个候选,精排后取前5。这样既保证召回率,又控制精排成本。

4.3 一次完整的调试记录

我拿一份技术文档做测试,第一版跑出来效果很差,答非所问。排查过程是这样的:先看检索结果,发现召回的块和问题相关性很低。于是单独测嵌入模型,发现它对文档里的专业术语不敏感,通用模型对领域词汇的语义捕捉确实弱。

换了一个在技术语料上表现更好的嵌入模型后,召回明显改善。但还有问题:有些答案明明在文档里,却没被召回。检查切分结果,发现答案被切成了两半,前半段在一个块里,后半段在另一个块里,检索只召回了其中一个。调整切分策略,让块边界尽量落在段落边界,并加大重叠,问题解决。

最后一轮优化是重排序。加入关键词覆盖率重排后,原本排在第7、第8的关键块被提到了前3,生成质量又上一个台阶。整个过程我改了三次切分、换了两次嵌入模型、调了一次重排权重,每一步都有评测数据支撑,不是瞎调。

4.4 把系统跑起来的完整代码骨架

# 1. 加载与切分 raw = open("doc.txt", encoding="utf-8").read() chunks = split_by_semantic(raw, max_len=550, overlap=80) # 2. 嵌入与索引 vectors = [embed(c) for c in chunks] index = SimpleIndex() index.add(vectors, chunks) # 3. 检索与重排 q_vec = embed(question) candidates = index.search(q_vec, top_k=20) ranked = rerank(question, candidates)[:5] # 4. 提示编排 context = "\n\n".join(f"[来源{i+1}] {t}" for i, (t, _) in enumerate(ranked)) prompt = f"""仅根据以下上下文回答问题,上下文没有的信息不要编造。 如果无法从上下文得出答案,直接说“根据现有资料无法回答”。 上下文: {context} 问题:{question} """ # 5. 生成 answer = generate(prompt) print(answer)

这段骨架没有任何框架依赖,每一行你都能看懂、能改、能调试。这就是从零做AI工程的价值——你拥有完整的控制权和可观测性。

5. 常见问题与排查技巧实录:那些文档不会告诉你的坑

5.1 检索效果差的排查顺序

检索效果差是最常见的问题,排查要有顺序,别乱试。我的排查顺序是:先看切分结果,确认答案所在的块是否完整;再看嵌入模型,用几个已知相关的查询-文档对测相似度,看模型是否能区分;最后看相似度计算,确认归一化和度量方式没问题。

这个顺序的逻辑是从上游到下游,因为上游的问题会污染下游的所有环节。切分错了,再好的嵌入模型也救不回来;嵌入模型不行,再精细的重排也是垃圾进垃圾出。我见过有人一上来就调top-k和重排权重,结果根因在切分,白折腾半天。

5.2 生成质量不稳定的应对

生成质量忽好忽坏,通常是上下文不稳定导致的。同一个问题,两次检索召回的块略有不同,生成结果就差异很大。解决办法有两个:一是降低检索的随机性,确保相同问题召回相同块;二是在提示词里强化约束,让模型严格基于上下文。

还有一个隐蔽原因是上下文里的矛盾信息。如果召回的块里有相互矛盾的内容,模型会摇摆。这时候要么提高重排精度,把低质量块过滤掉,要么在提示词里明确告诉模型“如果上下文有冲突,以更具体、更近期的信息为准”。

5.3 常见问题速查表

现象可能原因排查方法解决方向
答非所问检索召回无关块打印召回块人工检查换嵌入模型或调切分
答案不完整答案被切分切断检查切分边界加大重叠或按语义切
幻觉严重上下文不足或指令不清检查提示词约束强化“仅用上下文”指令
响应慢全量相似度计算计时各环节引入近似检索或缓存
结果不稳定检索随机性固定随机种子确保检索确定性
专业术语检索差通用嵌入模型不敏感测领域词相似度换领域模型或加关键词重排

5.4 几个我踩过的坑

第一个坑是嵌入模型的token上限被静默截断。有些模型超长输入不报错,直接截断,导致块尾信息丢失。解决办法是在嵌入前先按token数检查,超长的块先切短。

第二个坑是归一化遗漏。有一次我忘了归一化,用内积当相似度,结果长向量天然得分高,检索全被长块霸占。归一化后问题消失。这个坑很隐蔽,因为不归一化也能跑,只是结果悄悄变差。

第三个坑是评测集泄露。我一开始用评测集里的问题去调参数,调着调着分数很高,但换一批新问题就崩了。后来我把评测集分成调参集和验证集,调参只用调参集,验证集只在最后跑,才得到真实的效果评估。

经验:调参时一定要留一手,别把所有数据都用来调,否则你调出来的是对这批数据的过拟合,不是真实能力。

6. 从零实现之后:什么时候该引入重型组件

把整条链路手写一遍之后,你会对每个环节的瓶颈有清晰认知,这时候再决定引入什么组件,就是有的放矢。如果检索延迟成为瓶颈,引入近似最近邻或向量数据库;如果嵌入质量不够,换更强的模型或做领域微调;如果重排精度不够,引入交叉编码器;如果评测太慢,搭建自动化评测流水线。

关键区别在于:这时候你是带着问题和数据去选型的,而不是被框架的默认行为牵着走。你知道向量数据库解决的是检索速度问题,知道交叉编码器解决的是排序精度问题,知道每个组件的引入会带来什么收益、什么成本。这种判断力,才是从零做AI工程真正要收获的东西。

我个人在实际操作中的体会是,手写一遍花的时间远比想象中少,但带来的认知提升远超预期。那些原本在框架文档里看不懂的参数,自己实现一遍后全明白了;那些原本靠猜的调优,现在有了明确的因果链。如果你正在做AI应用,又总觉得隔着一层纱,我强烈建议你找个周末,把这条链路从零搭一遍。搭完之后再回头看那些框架,你会发现它们不再是黑盒,而是一堆你熟悉的积木的封装。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询