
1. 从零搭建AI工程能力为什么我劝你别一上来就调包这两年“AI工程”这个词被说得太多了多到有点变味。招聘JD上写着“熟悉AI工程化落地”点进去一看要求会调OpenAI接口、会写Prompt、会用LangChain搭个Demo。说实话这些东西花一个周末就能跑通但它们离真正的AI工程差着十万八千里。我见过太多团队Demo阶段惊艳全场一上生产环境就原形毕露延迟飙到十几秒、Token成本失控、模型输出不稳定、检索召回率惨不忍睹。问题出在哪出在大家把“会用工具”当成了“懂工程”。ai-engineering-from-scratch这个标题我理解它想表达的核心诉求是抛开那些封装好的高级框架从最底层开始把AI工程涉及的关键环节一个一个亲手搭出来。这就像学做菜你可以直接买料理包加热也可以从切菜、调火候、配香料开始练。前者能让你快速吃上一顿后者才能让你在厨房里真正说了算。这篇文章适合谁看适合那些已经会用现成API调模型但总觉得心里没底、想搞清楚“黑盒子里到底发生了什么”的开发者也适合带团队的技术负责人你需要知道AI系统里哪些环节是真正脆弱的哪些地方值得投入工程资源去加固。我自己的经历是早期做RAG应用时直接拿LangChain的默认配置往上怼结果检索出来的东西驴唇不对马嘴。后来逼着自己把向量化、索引构建、相似度计算、重排序这几个环节全部手写了一遍才发现问题出在文本分块的粒度和嵌入模型的选择上——这些细节在高级框架里全被一行RetrievalQA.from_chain_type()给藏起来了。所以这篇文章不会教你“三行代码搭建智能问答”而是带你从最基础的组件开始理解每一个决策背后的权衡。2. 核心模块拆解AI工程到底要“从零”造哪些轮子2.1 文本向量化把语言变成数学的第一步任何AI工程系统只要涉及非结构化文本第一步几乎都是把文本转成向量。这个环节看起来简单——调个Embedding API就完事了——但里面的坑多到能写一本书。从零实现的意思是你得理解向量化模型的基本原理知道为什么有的模型输出768维有的输出1536维维度高低对后续检索有什么影响。我自己的做法是先用一个轻量级的本地模型比如基于Sentence-BERT架构的小模型跑通全流程把文本→向量的管道搭起来。这里的关键决策是用本地模型还是API本地模型的好处是数据不出域、没有调用成本、延迟可控坏处是效果通常不如大厂API而且需要自己管理模型加载和推理资源。API的好处是效果稳定、免运维坏处是按量计费大规模索引时成本会线性增长。具体操作上我会先写一个TextVectorizer类封装三个核心方法encode(texts)批量编码、similarity(vec_a, vec_b)计算余弦相似度、dimension()返回向量维度。用本地模型的话底层可以用transformers库加载一个预训练模型然后取[CLS]位置的输出或者做均值池化。这里有个细节不同模型的池化策略不一样有的用CLS token有的用均值池化有的用最后隐藏状态的最大池化。选错了池化方式向量质量会差很多。我一般会先看模型卡上的说明如果没有明确写就自己写个小脚本对比几种池化方式在语义相似度任务上的表现。注意向量化模型的输入长度限制是个硬约束。大部分模型最大支持512个token超过部分会被截断。如果你的文本块超过这个长度要么换支持长文本的模型要么在分块阶段就控制好粒度。2.2 向量索引与检索别小看“找相似”这件事向量化之后下一步是把向量存起来并能快速检索。很多人直接用NumPy数组做暴力搜索小规模数据几千条没问题一旦上到百万级查询延迟就无法接受了。从零实现索引意味着你要理解近似最近邻ANN搜索的基本原理知道HNSW、IVF、PQ这些算法分别在解决什么问题。我建议从最简单的暴力搜索开始写一个BruteForceIndex类内部维护一个向量矩阵查询时计算查询向量与所有向量的相似度返回Top-K。这个实现虽然慢但它是验证后续所有优化是否正确的基础。然后可以逐步引入更复杂的索引结构。比如HNSW分层可导航小世界的核心思想是构建多层图结构上层稀疏用于快速跳转下层密集用于精确查找。自己实现一个简化版HNSW并不难关键是要理解ef_construction和ef_search这两个参数对召回率和延迟的影响。这里有个经验索引构建时的参数和查询时的参数需要分开调优。构建时追求图的质量可以设置较大的ef_construction查询时追求速度可以适当降低ef_search。我一般会先用小批量数据做参数扫描画出召回率-延迟曲线找到拐点后再应用到全量数据上。2.3 文本分块最容易被忽视却最影响效果的一环文本分块是RAG系统里最不起眼但最致命的环节。分块太大检索时引入噪声分块太小语义不完整。从零实现分块器意味着你要自己控制分块策略而不是依赖框架的默认行为。我常用的分块策略是“递归字符分割语义边界检测”。具体来说先按段落分如果段落超过阈值再按句子分句子还超就按字符硬切。但纯规则分块有个问题它不知道语义边界在哪里。比如一个技术文档里代码块和说明文字应该尽量分在一起硬切会把它们拆散。我的改进方法是在分块前先做一次简单的语义相似度计算相邻句子如果相似度高就合并相似度低就断开。这个相似度可以用向量化模型算也可以用更轻量的TF-IDF。分块大小的选择上没有万能答案。我的经验是问答类任务块大小控制在256-512个token摘要类任务可以放宽到1024代码检索按函数或类来分块效果最好。你可以写一个ChunkEvaluator用一组标注好的问答对来评估不同分块策略的检索命中率用数据说话。2.4 重排序让检索结果从“差不多”变成“刚刚好”向量检索返回的Top-K结果往往前几个还行后面的就明显跑偏了。重排序Rerank的作用就是对初筛结果做二次精排。从零实现重排序可以用交叉编码器Cross-Encoder的思路把查询和候选文档拼在一起输入模型直接输出相关性分数。这种方式比向量内积更准但计算量大所以只适合对少量候选做精排。我一般会先用向量检索召回Top-50然后用一个轻量级的交叉编码器对50个候选打分取Top-5送给下游。交叉编码器可以用transformers里的序列分类模型输入格式是[CLS] query [SEP] document [SEP]输出相关性logits。这里的关键是训练数据的构造——如果没有标注数据可以用查询和正负样本的对比学习来微调。提示重排序模型的选择要和业务场景匹配。通用领域的重排序模型在垂直领域如医疗、法律上效果会打折扣有条件的话用领域数据微调一下效果提升非常明显。3. 实操全流程从零搭建一个可用的检索增强生成系统3.1 环境准备与依赖选择动手之前先把环境理清楚。我的建议是Python 3.10以上PyTorch 2.0以上transformers库用最新稳定版。不需要装LangChain、LlamaIndex这些高级框架但可以装numpy、scipy、scikit-learn做基础计算装faiss-cpu作为向量检索的基线对比虽然我们要自己实现但有个参照物方便验证正确性。硬件方面如果只是跑通流程CPU就够了。本地跑小模型做向量化8核CPU、16G内存能处理十万级文档。如果要上百万级建议至少有一张显存8G以上的GPU用于加速向量化和重排序。存储方面向量数据用NumPy的.npy格式存就行简单直接元数据用SQLite或JSON Lines方便和向量做关联。我习惯把项目结构分成几个模块vectorizer/放向量化相关代码index/放索引实现chunker/放分块逻辑reranker/放重排序pipeline/放串联全流程的代码。每个模块单独写测试确保输入输出符合预期。这样调试的时候能快速定位问题出在哪个环节。3.2 向量化模块的代码实现与参数调优先写向量化模块。我用transformers加载一个轻量级模型比如all-MiniLM-L6-v2这个模型只有6层输出384维向量在CPU上跑得飞快效果也够用。核心代码如下from transformers import AutoTokenizer, AutoModel import torch import numpy as np class TextVectorizer: def __init__(self, model_namesentence-transformers/all-MiniLM-L6-v2): self.tokenizer AutoTokenizer.from_pretrained(model_name) self.model AutoModel.from_pretrained(model_name) self.model.eval() def encode(self, texts, batch_size32, normalizeTrue): all_embeddings [] for i in range(0, len(texts), batch_size): batch texts[i:ibatch_size] inputs self.tokenizer(batch, paddingTrue, truncationTrue, max_length512, return_tensorspt) with torch.no_grad(): outputs self.model(**inputs) # 均值池化 attention_mask inputs[attention_mask] token_embeddings outputs.last_hidden_state input_mask_expanded attention_mask.unsqueeze(-1).expand(token_embeddings.size()).float() sum_embeddings torch.sum(token_embeddings * input_mask_expanded, 1) sum_mask torch.clamp(input_mask_expanded.sum(1), min1e-9) embeddings sum_embeddings / sum_mask if normalize: embeddings torch.nn.functional.normalize(embeddings, p2, dim1) all_embeddings.append(embeddings.numpy()) return np.vstack(all_embeddings)这段代码里均值池化是关键。我试过用CLS token效果不如均值池化稳定尤其是在长文本上。归一化也很重要归一化之后余弦相似度就等价于内积计算更方便。max_length512是模型的硬限制超过的会被截断所以分块阶段要控制好。参数调优方面batch_size对速度影响很大。CPU上32比较合适GPU上可以开到128甚至256。如果显存不够就调小。另外normalize建议始终设为True除非你有特殊需求。3.3 索引构建与检索的完整实现索引模块我实现两个版本暴力搜索和简化版HNSW。暴力搜索作为基线HNSW作为生产可用版本。暴力搜索很简单class BruteForceIndex: def __init__(self, dimension): self.dimension dimension self.vectors None self.metadata [] def add(self, vectors, metadata_list): if self.vectors is None: self.vectors vectors else: self.vectors np.vstack([self.vectors, vectors]) self.metadata.extend(metadata_list) def search(self, query_vector, top_k5): # query_vector: (dimension,) scores np.dot(self.vectors, query_vector) top_indices np.argsort(scores)[::-1][:top_k] return [(self.metadata[i], scores[i]) for i in top_indices]HNSW的实现稍微复杂一些核心是构建多层图。我简化一下只实现单层图加跳表式的上层结构。关键参数是M每个节点的最大连接数和ef_construction构建时的候选队列大小。M越大图越密召回率越高但内存占用越大ef_construction越大构建越慢但图质量越好。我的经验值是M16、ef_construction200在百万级数据上召回率能到95%以上。检索的时候从上层入口点开始贪心地向距离查询向量更近的邻居移动直到找不到更近的为止然后下沉到下一层。这个过程自己实现一遍对理解ANN搜索的本质非常有帮助。3.4 分块策略的代码落地与效果验证分块模块我写了一个RecursiveChunker核心逻辑是class RecursiveChunker: def __init__(self, max_chunk_size512, overlap50): self.max_chunk_size max_chunk_size self.overlap overlap def chunk(self, text): # 先按段落分 paragraphs text.split(\n\n) chunks [] current_chunk for para in paragraphs: if len(current_chunk) len(para) self.max_chunk_size: current_chunk para \n\n else: if current_chunk: chunks.append(current_chunk.strip()) # 如果单个段落就超长按句子分 if len(para) self.max_chunk_size: sentences para.split(。) temp for sent in sentences: if len(temp) len(sent) self.max_chunk_size: temp sent 。 else: chunks.append(temp.strip()) temp sent 。 if temp: chunks.append(temp.strip()) else: current_chunk para \n\n if current_chunk: chunks.append(current_chunk.strip()) return chunks这个实现比较粗糙但能跑通。效果验证的方法是准备20个问题每个问题对应一段标准答案看检索出来的Top-3块里有没有包含标准答案。我实测下来max_chunk_size512、overlap50在技术文档上表现不错命中率能到85%左右。如果换成法律条文块大小要调小到256因为法律条文语义密度高块太大会引入无关信息。3.5 重排序模块的集成与性能权衡重排序模块我用一个交叉编码器实现from transformers import AutoTokenizer, AutoModelForSequenceClassification class CrossEncoderReranker: def __init__(self, model_namecross-encoder/ms-marco-MiniLM-L-6-v2): self.tokenizer AutoTokenizer.from_pretrained(model_name) self.model AutoModelForSequenceClassification.from_pretrained(model_name) self.model.eval() def rerank(self, query, candidates, top_k3): pairs [(query, cand) for cand in candidates] inputs self.tokenizer(pairs, paddingTrue, truncationTrue, max_length512, return_tensorspt) with torch.no_grad(): scores self.model(**inputs).logits.squeeze(-1) ranked sorted(zip(candidates, scores.tolist()), keylambda x: x[1], reverseTrue) return ranked[:top_k]这个模型很小6层CPU上跑50个候选大概200毫秒可以接受。如果延迟要求更严可以减到Top-20候选或者换更小的模型。重排序的收益很明显我实测在向量检索Top-50的基础上做重排序最终Top-3的准确率比直接用向量检索Top-3提升了20个百分点以上。4. 踩坑实录那些只有亲手搭过才会遇到的问题4.1 向量维度不匹配导致的静默失败这个问题我遇到过两次。一次是换了向量化模型从384维换到768维但索引里存的还是旧向量查询时NumPy广播机制没报错但算出来的相似度全是乱的。另一次是分块后有些块是空的编码出来是零向量和任何查询的相似度都是0导致检索结果里混入无关内容。排查方法是在索引构建和查询的入口都加维度断言空文本直接跳过不编码。注意向量数据库或索引在持久化时一定要把向量维度一起存下来。加载时先校验维度不匹配就报错别让错误静默传播。4.2 分块边界切断语义导致的检索失灵有个案例我印象很深用户问“如何配置超时时间”知识库里有段代码示例但分块时把代码和上面的说明文字切开了。检索时查询命中了说明文字那块但代码块没被召回导致生成的答案不完整。解决办法是在分块时检测代码块标记比如确保代码块和它前面的说明文字分在同一块里。如果代码块太长就在代码块内部按函数切分但保留函数签名和注释。4.3 重排序模型与业务领域不匹配通用重排序模型在垂直领域会“水土不服”。我做过一个医疗问答的测试通用模型把一段包含“禁忌症”的文档排到了后面而把一段泛泛而谈的概述排到了前面。后来用领域数据微调了重排序模型Top-1准确率从62%提升到了89%。微调数据不需要太多500-1000个(query, positive, negative)三元组就够了。构造方法是用向量检索召回Top-20人工标注哪些是真正相关的然后正样本和负样本就有了。4.4 批量编码时的内存溢出批量编码时如果batch_size设得太大或者文本特别长很容易OOM。我的经验是先估算一下batch_size * max_length * hidden_size * 4字节比如32 * 512 * 384 * 4 ≈ 25MB这是模型激活值的大致占用。如果显存或内存不够就调小batch_size。另外用torch.no_grad()包裹推理过程能省不少内存。4.5 相似度分数阈值设定的玄学向量检索返回的相似度分数多少算“相关”这个阈值很难定。我的做法是用一组标注数据画出准确率-召回率曲线找到F1最高的阈值点。但实际应用中我倾向于把阈值设得宽松一些多召回一些候选靠重排序来精筛。因为漏召回把相关文档排除了比多召回引入无关文档的代价更高——前者直接导致答案错误后者只是增加一点噪声。5. 性能优化与扩展思路从能用走向好用5.1 向量化加速量化与蒸馏如果向量化是瓶颈有两个方向可以优化。一是量化把FP32的模型权重转成INT8推理速度能提升2-3倍精度损失通常在1%以内。用torch.quantization就能做。二是知识蒸馏用大模型教小模型让小模型在特定任务上逼近大模型的效果。我试过用all-MiniLM-L6-v2蒸馏一个更小的4层模型在检索任务上召回率只降了2个百分点但速度翻倍。5.2 索引压缩乘积量化PQ的实战效果百万级向量用FP32存储光向量数据就占1.5GB100万 * 384维 * 4字节。用乘积量化可以把每个向量压缩到几十字节。PQ的原理是把向量切成若干子段每个子段用聚类中心编号表示。查询时用查表的方式计算近似距离。我实测下来PQ能把内存占用降到原来的1/10召回率损失在5%左右。如果对内存敏感这个 trade-off 很划算。5.3 缓存策略让重复查询不再重复计算生产环境里很多查询是重复的。我加了一层查询缓存把查询文本哈希后作为key检索结果作为value设置TTL比如1小时。命中缓存时直接返回延迟从几百毫秒降到几毫秒。缓存用LRU策略淘汰内存占用可控。注意缓存key要包含查询文本和Top-K参数因为不同Top-K的结果不一样。5.4 异步流水线把串行变并行整个RAG流程是分块→向量化→索引→检索→重排序→生成。其中向量化和索引构建可以离线做检索和重排序必须在线做。在线部分检索和重排序可以并行检索召回Top-50的同时把查询向量化好重排序等检索结果出来后再做。用asyncio或线程池把这两个阶段重叠起来端到端延迟能降低30%左右。6. 我个人的几条实战心得第一别迷信“端到端”框架。LangChain、LlamaIndex这些工具在快速验证阶段很好用但一旦要调优你会发现它们把太多细节藏起来了。自己从零搭一遍每个环节都亲手调过参数心里才有底。我现在的做法是用框架做原型用自研代码做生产。第二评估驱动开发。没有评估指标调参就是盲人摸象。我每个模块都会写一个小的评估脚本向量化看召回率K分块看命中率重排序看NDCG。这些指标不用很精确但能告诉你方向对不对。第三数据质量大于模型选择。我见过太多团队在模型选型上纠结几周却不愿意花半天时间清洗一下文档。实际上把文档里的乱码、重复段落、无关广告去掉效果提升比换个更大的模型明显得多。第四从简单开始逐步加复杂度。先跑通暴力搜索小模型再换HNSW重排序。每一步都验证效果确保复杂度增加带来了正向收益。我见过有人一上来就上分布式向量数据库、多路召回、图索引结果调了两个月还没跑通最后发现是分块没做好。第五留好日志和监控。线上系统出问题时你需要知道是检索没召回、还是重排序排错了、还是生成模型胡说了。我在每个环节都打了日志查询文本、召回文档ID、相似度分数、重排序分数、最终生成的答案。出问题时顺着日志一路查下去很快就能定位。最后分享一个小技巧如果你不确定分块大小设多少可以先用一个极小的值比如128和一个极大的值比如2048各跑一遍看检索结果的质量差异。通常你会发现质量对分块大小不是特别敏感但对“是否在语义边界切分”非常敏感。所以与其纠结块大小不如把精力花在语义边界检测上。