ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

从零构建AI工程能力:向量检索与推理服务化实战指南

2026/10/3 9:35:32 拓冰建站 浏览量
从零构建AI工程能力:向量检索与推理服务化实战指南 1. 从零搭建AI工程能力为什么我劝你别一上来就调包这两年AI应用开发的门槛肉眼可见地在降低一个刚入门的开发者花一个下午就能用现成的框架跑通一个对话机器人。但我在团队里带过不少新人也面试过大量号称“做过AI项目”的候选人发现一个很普遍的现象模型能跑起来但一旦线上出问题或者需要针对业务做深度定制很多人就完全不知道从哪里下手了。这就是我特别想聊“ai-engineering-from-scratch”这个方向的原因——它不是让你重复造轮子而是让你真正理解轮子是怎么转的。所谓从零构建AI工程能力核心不是让你手写一个Transformer出来跟大厂模型对标而是让你具备一种“拆解到底层”的思维方式。你知道一个向量数据库的索引是怎么组织的知道一次推理请求从输入到输出中间经过了哪些环节知道显存为什么会在某个batch size下突然爆掉。这些东西光靠调API是永远学不到的。这篇文章适合那些已经会用现成工具、但想真正搞懂AI系统内部运转机制的开发者也适合刚入行、想打好地基的新人。我会从整体设计思路讲到具体实操把我在实际项目中踩过的坑和总结的经验都摊开来说。2. 整体设计思路从零构建到底该“零”到什么程度2.1 先想清楚你要的是理解还是复现很多人一听到“from scratch”就热血上头觉得要从矩阵乘法开始写起。我一开始也这么想过后来发现这条路对绝大多数人来说是浪费时间。你真正需要的是分层理解最底层是数学原理和硬件执行中间层是框架和算子最上层是应用和业务逻辑。从零构建AI工程能力合理的做法是选择一层作为你的“深潜区”其他层保持“能看懂、能调试”的程度就够了。举个例子你做RAG应用那你的深潜区应该是检索和向量化这一块。你需要搞懂embedding模型是怎么把文本映射到向量空间的余弦相似度为什么在高维空间里表现和直觉不一样近似最近邻搜索的索引结构是怎么在精度和速度之间做权衡的。而Transformer的具体注意力实现你只要能看懂论文里的公式、知道它大概在干什么就行不需要自己手写CUDA kernel。这个取舍非常关键因为人的精力是有限的什么都想从零搞最后什么都搞不深。我在实际带项目的时候会给新人画一个三层能力模型。第一层是“会用”能调库、能跑通demo。第二层是“会改”能根据业务需求修改参数、替换组件、优化流程。第三层是“会造”能在没有现成方案的时候自己设计并实现一个可用的模块。从零构建的目标是把你从第一层推到第三层但只需要在核心链路上推到第三层外围的辅助环节保持在第二层就足够了。2.2 技术选型的底层逻辑为什么我最终选了这套组合在从零构建的过程中技术选型是最容易让人纠结的地方。我试过很多组合最后沉淀下来一套比较稳的方案。语言层面用Python这个没什么好说的生态最全。但我要强调的是不要因为Python简单就忽略工程规范。我在早期项目里吃过亏脚本写得飞起变量命名随意结果三个月后自己都看不懂了。后来我强制自己用类型注解用dataclass组织配置用pydantic做数据校验代码的可维护性直接上了一个台阶。框架层面我建议从零构建的阶段先用PyTorch不要一上来就用那些高度封装的训练框架。原因很简单PyTorch的代码更接近Python原生逻辑你能清楚地看到每一步在做什么。等你对训练流程、梯度更新、数据加载这些环节都了然于胸了再去用那些封装好的工具你才知道它们帮你省掉了什么也才知道出了问题该去哪里找原因。向量检索这块我一开始用的是faiss后来在需要持久化和分布式支持的场景下换成了milvus。faiss适合单机、离线、对性能要求极高的场景它的索引类型非常丰富IVF、HNSW、PQ这些你都可以手动调参。milvus则更适合线上服务自带持久化、水平扩展和一套相对完整的运维接口。选哪个取决于你的场景但我的建议是从零构建的阶段先用faiss因为它逼着你去理解索引的底层结构等你把faiss玩明白了再用milvus就是水到渠成的事。2.3 项目结构设计别让代码变成一锅粥从零构建的项目最容易出现的问题就是代码结构混乱。我见过太多项目所有逻辑都堆在一个main.py里几百行下去函数之间互相调用改一个地方崩三个地方。我的做法是从第一天就按模块划分哪怕一开始每个模块只有几行代码。一个典型的AI工程项目我会分成这几个目录data目录放数据处理相关的脚本和配置models目录放模型定义和加载逻辑retrieval目录放检索相关的实现serving目录放服务化和接口层utils目录放通用工具。每个目录下再按功能细分文件。这样做的好处是当你想替换某个组件的时候你只需要动对应的目录不会牵一发而动全身。配置管理也是从零构建时必须认真对待的事情。我早期项目里把超参数硬编码在代码里结果每次调参都要改代码、重新提交、重新部署效率极低。后来我改用YAML配置文件加环境变量覆盖的方式本地开发用一套配置线上部署用另一套通过环境变量来区分。这个习惯让我在后续的项目迭代中省了大量时间。3. 核心细节解析从零构建中最容易踩坑的几个环节3.1 数据处理你以为简单其实最耗时间在AI工程项目里数据处理往往占了整个项目60%以上的工作量但很多人低估了它的复杂度。我从零构建的第一个项目光数据清洗和格式转换就花了两周。这里面的坑太多了我挑几个最典型的说。第一个坑是编码问题。中文文本里混着各种全角半角字符、特殊符号、不可见字符如果不做统一处理后面embedding出来的向量质量会很差。我的做法是写一个专门的清洗管道把文本统一转成UTF-8去掉控制字符把全角标点转成半角连续空白符合并成一个空格。这个管道看起来简单但能帮你省掉后面很多莫名其妙的bug。第二个坑是数据去重。很多人觉得去重就是完全匹配但实际上文本数据里存在大量近似重复的内容。比如同一篇新闻被不同网站转载只改了几个字。这种数据如果不处理会导致检索结果高度冗余用户体验很差。我一般会用MinHash或者SimHash做近似去重阈值设在0.85左右实测下来效果比较平衡。第三个坑是数据分块。做RAG的时候文档不能整篇塞进去必须切成小块。切块策略直接影响检索效果。我试过固定长度切分、按句子切分、按段落切分最后发现最稳的是递归切分先按段落切如果某段还是太长再按句子切还长就按固定长度切。块大小我一般设在256到512个token之间重叠部分设50个token左右。这个参数不是固定的要根据你的文档类型和embedding模型来调。3.2 向量化与索引精度和速度的平衡艺术向量化是从零构建AI工程能力里最核心的环节之一。很多人直接调一个embedding接口就完事了但如果你要深入理解你需要知道几件事。首先是embedding模型的维度选择。维度越高表达能力越强但存储和计算成本也越高。我实测下来对于大多数中文场景768维是一个比较平衡的选择。如果你对精度要求极高可以用1024维甚至更高但检索速度会明显下降。这里有一个经验公式在你的数据量小于100万条的时候维度的影响其实没有索引结构的影响大。所以与其纠结维度不如先把索引调好。索引结构的选择更关键。faiss提供了多种索引类型我常用的有IndexFlatL2、IndexIVFFlat和IndexHNSWFlat。IndexFlatL2是暴力检索精度最高但速度最慢适合数据量小或者对精度要求极高的场景。IndexIVFFlat是倒排索引加聚类速度很快但会损失一些精度适合大规模数据。IndexHNSWFlat是基于图的索引在精度和速度之间取得了很好的平衡是我最常用的选择。调IndexIVFFlat的时候nlist参数很关键。它决定了聚类的数量一般设为sqrt(N)N是你的数据量。比如你有100万条数据nlist就设1000左右。nprobe参数决定检索时访问多少个聚类设得越大精度越高但速度越慢。我一般从10开始调根据实际效果往上加。3.3 推理服务化从脚本到线上服务的距离从零构建的另一个关键环节是服务化。你在本地跑通的脚本和线上能扛住并发请求的服务中间隔着巨大的鸿沟。我踩过的最大的坑是显存管理。本地测试的时候batch size设成32跑得好好的线上并发一上来显存直接爆掉。后来我学乖了服务化的时候一定要做动态batch根据当前显存占用和请求队列长度动态调整batch size。还有一个坑是模型加载。如果你每次请求都重新加载模型那延迟会高到无法接受。正确的做法是服务启动时加载一次模型常驻显存后续请求复用。但这里要注意如果你的服务需要支持多个模型显存可能不够用这时候就需要做模型的热切换或者按需加载。我一般会用一个模型池来管理设置最大常驻模型数超过就按LRU策略淘汰。接口设计也有讲究。我见过有人把整个推理流程做成一个同步接口请求进来就阻塞等结果。这在低并发场景下没问题但并发一高就完蛋。我的做法是把推理请求做成异步任务接口收到请求后返回一个task_id客户端拿着task_id去轮询结果。这样服务端可以用队列来平滑请求峰值不会因为突发流量而崩溃。4. 实操过程手把手搭建一个最小可用的AI工程系统4.1 环境准备与依赖安装我先说环境。Python版本我建议用3.10或3.11这两个版本在AI生态里兼容性最好。3.12有些库还没跟上3.9又有点老。虚拟环境用conda或者venv都行我个人习惯用conda因为它在管理CUDA相关的依赖时更省心。依赖安装这块我列一个最小集合。PyTorch按官网的指令装注意选对CUDA版本。faiss用faiss-cpu或者faiss-gpu取决于你有没有显卡。transformers和sentence-transformers用来做文本向量化。fastapi和uvicorn用来做服务化。numpy和pandas做数据处理。scikit-learn做辅助计算。conda create -n ai-scratch python3.11 conda activate ai-scratch pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu118 pip install faiss-gpu sentence-transformers fastapi uvicorn numpy pandas scikit-learn pyyaml pydantic装完之后验证一下。跑一个简单的脚本加载一个预训练的embedding模型把一句话转成向量看看维度对不对。这一步能帮你提前发现环境问题别等到写了几百行代码才发现CUDA不可用。4.2 数据管道的搭建数据管道我分成三个步骤加载、清洗、分块。加载这块根据你的数据源来可能是CSV、JSON、数据库或者网页。我一般会写一个统一的loader接口不同数据源实现不同的loader上层调用的时候不关心具体来源。清洗我前面说了主要是编码统一、特殊字符处理、去重。这里我补充一个细节去重的时候不要只对原文去重还要对分块后的结果去重。因为有些文档虽然原文不同但分块后可能产生相同的块。我一般会在分块之后再做一次精确去重用hash来做速度很快。分块我用的是递归切分代码逻辑大概是这样先定义一个分隔符列表从大到小排列比如[\n\n, \n, 。, , , , ]。然后递归地对文本进行切分如果切出来的块还是超过最大长度就用下一个分隔符继续切。这个逻辑用LangChain的RecursiveCharacterTextSplitter可以直接实现但如果你想从零构建自己写一个也不难大概几十行代码。def recursive_split(text, separators, max_len, overlap): if len(text) max_len: return [text] for sep in separators: if sep in text: parts text.split(sep) chunks [] current for part in parts: if len(current) len(part) len(sep) max_len: current part sep else: if current: chunks.append(current) current part sep if current: chunks.append(current) # 对超长的chunk继续递归 result [] for chunk in chunks: if len(chunk) max_len: result.extend(recursive_split(chunk, separators[1:], max_len, overlap)) else: result.append(chunk) return result return [text[i:imax_len] for i in range(0, len(text), max_len - overlap)]这个函数我简化了一下实际用的时候还要处理overlap的逻辑就是在相邻块之间保留一部分重叠内容避免关键信息被切断。overlap的大小一般是max_len的10%到20%。4.3 向量化与索引构建向量化我用的是sentence-transformers里的模型中文场景我推荐用BAAI/bge-large-zh或者moka-ai/m3e-large。这两个模型在中文语义相似度任务上表现都不错。加载模型的时候注意设成eval模式并且用torch.no_grad()包住推理过程不然显存会莫名其妙地涨。from sentence_transformers import SentenceTransformer import torch model SentenceTransformer(BAAI/bge-large-zh) model.eval() def embed_texts(texts, batch_size32): with torch.no_grad(): embeddings model.encode(texts, batch_sizebatch_size, show_progress_barTrue, normalize_embeddingsTrue) return embeddings注意normalize_embeddingsTrue这个参数。它会把向量归一化到单位长度这样后面用内积计算相似度就等价于余弦相似度。这个细节很多人忽略导致检索结果不稳定。索引构建我用faiss的IndexHNSWFlat。HNSW的参数有两个关键M和efConstruction。M控制每个节点的连接数一般设16到64之间越大精度越高但内存占用也越大。efConstruction控制构建时的搜索深度一般设100到200。我实测下来M32、efConstruction200是一个比较通用的配置。import faiss import numpy as np dim 1024 # bge-large-zh的维度 index faiss.IndexHNSWFlat(dim, 32) index.hnsw.efConstruction 200 embeddings embed_texts(chunks) index.add(embeddings.astype(np.float32)) faiss.write_index(index, my_index.faiss)检索的时候efSearch参数控制搜索深度设得越大精度越高但速度越慢。我一般从64开始调根据实际效果调整。4.4 服务化与接口设计服务化我用FastAPI因为它异步支持好写起来也简洁。核心接口有两个一个是索引构建接口一个是检索接口。索引构建一般离线做检索接口是在线的。from fastapi import FastAPI from pydantic import BaseModel import faiss import numpy as np app FastAPI() index faiss.read_index(my_index.faiss) model SentenceTransformer(BAAI/bge-large-zh) class SearchRequest(BaseModel): query: str top_k: int 5 class SearchResult(BaseModel): text: str score: float app.post(/search) async def search(req: SearchRequest): query_vec model.encode([req.query], normalize_embeddingsTrue) scores, indices index.search(query_vec.astype(np.float32), req.top_k) results [] for score, idx in zip(scores[0], indices[0]): results.append(SearchResult(textchunks[idx], scorefloat(score))) return {results: results}这里我简化了实际项目中还要考虑错误处理、日志记录、性能监控这些。但核心逻辑就是这么简单。启动服务用uvicorn注意设workers参数一般设为CPU核数加一。注意服务化的时候一定要做输入校验和限流。我见过有人直接把用户输入拼到查询里结果被注入了超长文本直接把服务打挂。用pydantic做输入校验限制query的最大长度再加一个简单的令牌桶限流能避免大部分问题。5. 常见问题与排查技巧实录5.1 检索结果不相关怎么办这是最常见的问题。排查思路我一般分三步走。第一步检查embedding质量。把query和几个已知相关的文档分别embedding算一下余弦相似度看看相关文档的分数是不是明显高于不相关的。如果分数都差不多说明embedding模型不适合你的场景需要换模型或者做微调。第二步检查分块策略。如果块切得太碎语义信息不完整检索效果肯定差。如果块切得太大噪声太多也会影响效果。我一般会可视化几个检索结果看看返回的块是不是包含了完整的信息。第三步检查索引参数。如果是用IVF索引nprobe设得太小会导致漏检。如果是HNSWefSearch设得太小也会漏检。把这两个参数调大试试如果效果明显提升说明是索引参数的问题。5.2 显存不够用怎么优化显存问题我遇到过太多次了。优化手段有几个方向。第一减小batch size。这是最直接的但会影响吞吐量。第二用混合精度。PyTorch的amp自动混合精度能省不少显存而且速度还有提升。第三用梯度检查点。这个主要针对训练场景推理场景一般用不到。第四用模型量化。把FP16转成INT8显存直接减半但精度会掉一些需要评估是否可接受。我一般会先看显存占用曲线确定是模型本身占得多还是中间激活占得多。如果是模型本身那就只能量化或者换小模型。如果是中间激活那就减小batch size或者用梯度检查点。5.3 服务响应慢怎么排查响应慢的原因很多我一般按这个顺序排查。先看是不是模型推理慢。用一个简单的请求测一下纯推理时间如果推理本身就慢那就要优化模型或者换更小的模型。再看是不是检索慢。faiss的检索一般很快但如果索引没加载到内存里每次都要从磁盘读那就会很慢。确保索引常驻内存。然后看是不是网络或者序列化的问题。返回的结果如果很大序列化和传输也会花时间。我一般会限制返回的字段只返回必要的信息。最后看是不是并发处理的问题。如果是同步接口并发请求会排队响应时间自然就上去了。改成异步或者加worker能缓解。5.4 常见问题速查表问题现象可能原因排查方法解决方案检索结果不相关embedding模型不匹配计算相关文档的相似度分数换模型或微调检索结果不相关分块策略不合理查看返回块的内容完整性调整块大小和重叠检索结果不相关索引参数过小调大nprobe或efSearch重新调参显存溢出batch size过大查看显存占用曲线减小batch size显存溢出模型精度过高检查模型dtype使用混合精度或量化服务响应慢索引未常驻内存检查索引加载方式启动时加载到内存服务响应慢同步阻塞查看请求处理日志改为异步处理服务崩溃输入未校验检查请求日志加输入校验和限流5.5 几个我踩过的坑和对应的经验第一个坑是faiss索引的保存和加载。faiss.write_index保存的索引文件在不同版本的faiss之间可能不兼容。我有一次升级faiss版本后旧索引直接加载失败。后来我养成了习惯保存索引的同时也保存原始向量万一索引坏了还能重建。第二个坑是embedding模型的tokenizer。有些模型对特殊字符的处理不一样比如有的会把换行符当成普通字符有的会当成特殊token。如果你在分块的时候保留了换行符embedding出来的结果可能和预期不一样。我的做法是在embedding之前把文本里的换行符统一替换成空格避免这个问题。第三个坑是并发下的模型推理。PyTorch模型在推理时不是线程安全的如果你用多线程同时调用同一个模型实例可能会得到错误的结果或者直接崩溃。正确的做法是用锁保护或者每个线程用独立的模型实例。我一般用后者虽然显存占用多一点但省心。第四个坑是索引的增量更新。faiss的索引不支持直接删除或更新向量只能重建。如果你的数据经常变动每次重建索引成本很高。我的做法是用两个索引一个主索引一个增量索引查询的时候同时查两个然后合并结果。增量索引积累到一定量之后再合并到主索引里。6. 从零构建之后你真正获得的是什么把上面这一套走下来你得到的不只是一个能跑的AI系统而是一种可以迁移到任何AI项目里的能力。你知道数据该怎么处理知道模型该怎么选知道索引该怎么调知道服务该怎么部署。这些经验不是看几篇教程就能获得的必须自己动手踩一遍坑才能内化。我在带团队的时候发现从零构建过至少一个完整项目的工程师和只调过API的工程师在解决问题时的表现差距非常大。前者遇到问题会系统性地排查从数据到模型到服务一层层往下查。后者往往只会重启服务或者换一个API试试。这个差距在项目顺利的时候看不出来一旦出了线上问题就是天壤之别。如果你现在还在犹豫要不要花时间从零构建一个项目我的建议是不要犹豫。选一个你熟悉的业务场景哪怕就是一个简单的文档问答按照上面的流程完整走一遍。你会遇到很多问题但每解决一个问题你对AI工程的理解就深一层。这个过程没有捷径但走完之后你会发现之前那些看起来很神秘的AI系统其实也就是那么回事。