尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

从零构建AI工程链路:RAG知识库问答实战

发布时间:2026/9/29 18:54:24

资讯中心
01
ARTICLE

从零构建AI工程链路:RAG知识库问答实战

从零构建AI工程链路:RAG知识库问答实战
“ai-engineering-from-scratch” 这个项目名听起来像是一个开源仓库但它其实是过去大半年我给自己布置的一整套“拆解任务”不借助任何一站式编排框架从 API 请求、文档分块、向量检索到生成调用一层层亲手搭建一个能回答我自己知识库问题的 AI 应用。如果你已经写了好几年业务代码、最近想往 ai-engineering 方向转或者你试过 LangChain 的 demo 但总觉得底层全是黑盒那这篇博文应该能直接帮你省下几个周末的试错时间。我会把整个项目的设计思路、核心概念、可运行的代码以及踩过的坑全部摊开来讲内容偏实操读完你就可以照着搭一个属于自己的最小可用 AI 工程链路。1. 为什么我决定从零开始做 AI 工程而不是直接套框架1.1 背景从业务开发转向 AI 工程的第一道坎我先说背景。我之前主要写后端服务对机器学习只懂个大概。2023 年底开始接触大模型应用时第一反应和大多数人一样找一个封装最全的框架把 PDF 丢进去跑出一个问答机器人。demo 确实几分钟就能跑通但等到想优化回答质量、排查“为什么这个问题答偏了”的时候问题就来了——框架帮我把文档切块、向量化、检索、拼提示词全做了中间任何环节出错我看到的只是一句莫名其妙的回答根本不知道是哪一步出了问题。那段时间我意识到一件事如果我想在 AI 工程这条路上走远就不能把所有环节都交给黑盒。我需要亲手把每个环节拆开看清楚一块文本是怎么变成向量的向量又是怎么被检索出来的检索结果又是怎么被塞进提示词里的。于是就有了“ai-engineering-from-scratch”这个项目从零开始不依赖高级编排框架只靠最基础的 HTTP 请求、Python 库和数据结构构建一条完整的 AI 工程链路。1.2 从零开始带来的三个实际好处很多人会问现在框架这么成熟为什么还要自己造轮子我的回答是造轮子的目的不是生产轮子而是为了拥有修车能力。第一排查问题快。当系统返回一个错误答案时我能直接定位到是检索没召回相关内容还是提示词写得太模糊又或者是文本分块把关键信息切断了。框架把这一切都封装掉了反而让问题定位变得困难。第二定制能力强。我的知识库里有大量半结构化文本比如项目周报、技术方案、产品 PRD。这些文档的标题层级、段落边界都有业务含义直接套用通用分块策略会丢失结构信息。从零开始之后我可以针对这些文档自定义分块规则比如按 Markdown 标题切分、按代码块边界切分效果立刻好了一个档次。第三知识复用性高。这个项目让我真正理解了 embedding、向量检索、上下文窗口这些概念而不是停留在“会用某个工具”的层面。后面不管换成什么模型、什么向量库我都能快速迁移。底层能力是自己的上层工具随时可以换。1.3 这个项目到底做了什么“ai-engineering-from-scratch”不是一个很大的项目但它覆盖了一条完整的生产链路文档加载从本地目录读取 Markdown、TXT、PDF 格式的内容文本分块基于段落和标题层级把长文档切成适合嵌入的片段向量化用文本嵌入模型把片段变成高维向量向量存储与检索实现了一个轻量级向量存储支持余弦相似度检索生成回答把检索到的片段拼进提示词调用大模型生成最终答案评估与日志记录每次检索的命中内容、token 消耗、延迟用来评估系统质量。如果你也正处于“会调用 API、但不懂工程链路”的阶段这个项目应该能给你一个非常清晰的地图每个环节解决什么问题、输入输出是什么、有哪些关键参数要调。2. 动手前必须要吃透的 4 个核心概念2.1 Token大模型世界的“最小计价单位”很多新手在做 AI 工程时第一个卡住的概念就是 token。大模型并不是按字处理文本的而是把文本切分成 token——一个 token 可能是半个词、一个词或者一个标点。举例来说英文“unbelievable”可能被切成“un”、“believ”、“able”三个 token而中文通常一个字可能对应一个或多个 token。这个概念的直接影响是“成本和长度”。你调用模型时提示词里的每个 token 都要付费模型的输出也不是无限长的而是受最大 token 数限制。我在项目里专门写了一个辅助函数用来统计每次请求的输入 token 和输出 token并把它们记录到日志里。没有这一步你根本不知道自己的应用为什么账单涨得那么快。提示不同模型的 tokenizer 不一样同样的文本在不同模型下 token 数可能差异明显。做成本估算时最好用目标模型自己的 tokenizer而不是靠感觉估计。对工程来说token 还决定了文本分块的粒度。如果你把 2000 字的段落直接塞给 embedding 模型它不一定能很好地捕捉语义而且检索效果也会下降。后面我会详细讲分块参数怎么设置。2.2 上下文窗口给模型“多少张纸”写答案上下文窗口是模型一次能看到的 token 总量你可以把它理解成模型面前的“桌面”大小。桌面越大能摊开的资料越多回答时就有更多依据。但这个桌面是有代价的输入越长单次请求费用越高响应延迟也越长。在 RAG 系统里上下文窗口直接决定了你能往提示词里塞多少检索结果。我在实践中发现很多初学者的错误是“贪多”把 top_k 设置为 10一下塞进 6000 token 的文本。结果模型把重点信息淹没在无关内容里回答质量反而下降。检索不是越多越好而是要精准。我的经验是先给模型 3 到 5 个相关片段每个片段控制在 300 到 500 token 左右效果通常最好。上下文窗口还决定了一个工程策略长文档不能直接丢给模型生成摘要或回答必须先切片把相关片段找出来再交给模型。这就是 RAG 存在的根本原因——不是模型记不住而是我们付不起“把所有内容都塞进提示词”的成本甚至根本塞不进去。2.3 嵌入向量把语义变成坐标嵌入是 AI 工程里最核心但也最抽象的概念。简单说文本嵌入模型会把一段文字变成一个几百维甚至上千维的浮点数组这个数组里每个维度的数值代表文本在某个潜在语义维度上的“得分”。语义相近的文本在向量空间里的距离也相近。我用一个生活化类比来帮助理解假设我们把所有动物按“体型”和“是否宠物”两个维度排列成坐标狗在靠近中体型、是宠物的位置猫也在附近而鲸鱼则离得很远。嵌入模型做的事情本质上类似只不过维度更多、语义更丰富。这个特性的工程价值在于我们不再需要用“关键词是否匹配”来检索文档而是可以用“语义是否相近”来检索。比如搜索“怎么修复登录报错”即使文档里写的是“登录接口返回 500”两者关键词完全不同但语义相近向量检索也能把它们匹配上。在我的项目里我选择了开源的sentence-transformers模型来做本地嵌入这样无需外网调用也可以离线运行。这一点对原型阶段特别友好。当然如果你有更强的硬件或者需要更高的精度也可以换成 OpenAI 的text-embedding-3-small或者智谱的 embedding 模型方法都一样。2.4 RAG先翻笔记再回答问题的工程套路RAG 的全称是 Retrieval-Augmented Generation检索增强生成。它的思路很朴素模型不知道你私有的文档内容但你可以先把相关资料“查”出来拼成一个带上下文的提示词再让模型基于上下文回答。我习惯把 RAG 分成四步索引Indexing把离线文档分块、嵌入、存入向量库检索Retrieval把用户问题也嵌入成向量在向量库里找最相近的片段增强Augmentation把检索到的片段拼进提示词模板生成Generation调用大模型生成最终答案。这四步里面检索环节最容易被低估。很多人以为只要把文档丢进向量库就万事大吉结果回答永远不准确。原因多半是分块太粗暴、嵌入模型选得不对、或 top_k 参数设置不合理。我在项目里把检索结果单独打印到一个日志文件里每一步都看得到模型回答得不好到底是没检索到还是检索到了但没用好。这个习惯帮我在项目后期节省了大量调参时间。3. 实操从空目录到一个可用的知识库问答助手3.1 准备环境依赖和项目结构这个项目我会用 Python 来实现核心依赖很少sentence-transformers负责文本转向量采用本地模型方便离线调试numpy负责向量相似度计算requests负责调用大模型 APIpypdf负责解析 PDF 文档如果你不需要 PDF可以跳过。我只用了这四个库。没有 LangChain没有 Chroma没有 FAISS。这样做的目的就是让你看清每一步发生了什么。建议的项目结构如下ai-engineering-from-scratch/ ├── data/ # 原始文档目录 ├── output/ # 生成的向量索引和日志 ├── src/ │ ├── chunker.py # 文本分块 │ ├── embedder.py # 文本嵌入 │ ├── vector_store.py # 向量存储与检索 │ ├── generator.py # 大模型调用 │ └── pipeline.py # 串联完整流程 └── main.py # 命令行入口3.2 文本分块第一个影响质量的关键环节文本分块的质量决定了检索的上限。如果一块里有太多无关内容嵌入向量会被稀释如果一块太短又可能丢失完整的上下文。我踩过最深的坑就是把 Markdown 文档按固定字符长度硬切结果常常把一句话切成两半或者把一个表格拆得七零八落。我来分享一下我在项目里使用的分层分块策略# src/chunker.py def split_by_headers(text: str) - list[str]: 按标题层级切分 Markdown 文档 lines text.splitlines() chunks [] current_chunk [] for line in lines: if line.startswith(#): if current_chunk: chunks.append(\n.join(current_chunk)) current_chunk [] current_chunk.append(line) if current_chunk: chunks.append(\n.join(current_chunk)) return chunks def split_long_chunk(chunk: str, max_tokens800, overlap100) - list[str]: 对过长的段落进行二次切分同时保留部分重叠 words chunk.split() if len(words) max_tokens: return [chunk] sub_chunks [] start 0 while start max_tokens len(words): end start max_tokens sub_chunks.append( .join(words[start:end])) start end - overlap if start len(words): sub_chunks.append( .join(words[start:])) return sub_chunks你可能会问为什么要保留重叠overlap因为文本在切分边界处很容易丢失半个语义单元。比如一句“这个功能的限制是每天只能调用 100 次”如果“每天只能调用”在上一块“100 次”在下一块两块都语义不完整。overlap 会让边界部分的上下文被重复保留减少信息丢失。提示对中文文本按字符切分往往比按空格切分更合适。你可以把上面的示例改成按字符或者按句子边界切分。关键在于切分一定要尊重文本本身的语义边界。我的经验是先用标题层级切分把每个段落作为基本单位如果段落还太长再按句子或固定 token 数二次切分。对 95% 的文档类知识库这个策略都够用了。3.3 嵌入与向量存储从文本到坐标再存起来分块之后下一步就是把每块文本变成向量。我选择用sentence-transformers加载本地模型这样完全离线运行不需要为嵌入请求付费也方便批量测试。# src/embedder.py from sentence_transformers import SentenceTransformer model SentenceTransformer(sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2) def embed_texts(texts: list[str]) - list[list[float]]: embeddings model.encode( texts, normalize_embeddingsTrue, # 归一化后点积等价于余弦相似度 show_progress_barTrue ) return embeddings.tolist()normalize_embeddingsTrue是个容易被忽略但很重要的参数。归一化之后所有向量都是单位向量两个向量的点积就等于余弦相似度数值范围固定在 -1 到 1 之间。这样做的好处有两个一是检索排序更稳定二是可以直接用向量点积做相似度计算性能更好。接下来我们实现一个“从零开始”的向量存储。不使用任何外部向量数据库因为我希望你能看到检索的本质# src/vector_store.py import numpy as np class SimpleVectorStore: def __init__(self): self.texts [] self.vectors np.array([]).reshape(0, 0) def add(self, text: str, vector: list[float]): self.texts.append(text) v np.array(vector).reshape(1, -1) if self.vectors.size 0: self.vectors v else: self.vectors np.vstack([self.vectors, v]) def search(self, query_vector: list[float], top_k: int 3) - list[tuple[str, float]]: q np.array(query_vector).reshape(1, -1) scores np.dot(self.vectors, q.T).flatten() top_indices np.argsort(scores)[::-1][:top_k] return [(self.texts[i], float(scores[i])) for i in top_indices]这段代码的核心是np.dot(self.vectors, q.T)它一次性计算了所有向量与查询向量的点积再按相似度从高到低排序取出前top_k个结果。做到这一步你已经拥有一个“本地向量数据库”的核心能力了。后续如果想扩展可以换成 FAISS 或 Chroma接口基本差不远。3.4 检索把用户问题变成查询向量检索阶段其实很简单把用户问题用同一个嵌入模型转成向量然后调用向量存储的search方法。from src.embedder import embed_texts from src.vector_store import SimpleVectorStore def retrieve(store: SimpleVectorStore, question: str, top_k: int 3): query_embedding embed_texts([question])[0] results store.search(query_embedding, top_ktop_k) return results这里有一个非常容易出现的问题提问方式和文档表达方式存在差异。比如用户问“系统为什么这么卡”而文档里写的是“高并发场景下响应时间显著上升”。虽然语义相近但如果嵌入模型能力不足可能匹配不上。我的经验是优先选择支持多语言的嵌入模型把检索到的结果打印出来人工判断“检索质量”必要时可以在检索前对问题进行轻微改写比如把口语化问题转换成更正式的表述。我把这步单独做成一个模块不只是为了代码整洁更是为了能在日志里清晰看到每一步的输入和输出。你可以在后面的日志追踪里回看某次回答对应的检索片段快速定位是检索还是生成出了问题。3.5 生成用提示词把检索结果变成回答检索到相关资料后把它们拼进提示词再调用大模型生成回答。提示词的设计在 AI 工程里非常重要它直接决定了模型能否正确使用你提供的资料。下面是我在项目里使用的一个标准提示模板# src/generator.py import requests import json SYSTEM_PROMPT 你是一个严谨的知识库助手。请严格依据提供的资料回答问题。 要求 1. 只能使用资料中出现的信息不能编造。 2. 如果资料中没有足够信息直接回答“根据现有资料无法回答”。 3. 回答时先给出结论再用资料内容简要支撑。 4. 使用中文回答。 def build_prompt(question: str, contexts: list[str]) - str: context_block \n\n.join([ f[资料 {i1}]\n{text} for i, text in enumerate(contexts) ]) return f{context_block} 问题{question} def generate_answer(question: str, contexts: list[str], api_key: str, model: str gpt-4o-mini): prompt build_prompt(question, contexts) resp requests.post( https://api.openai.com/v1/chat/completions, headers{Authorization: fBearer {api_key}}, json{ model: model, messages: [ {role: system, content: SYSTEM_PROMPT}, {role: user, content: prompt} ], temperature: 0.2, max_tokens: 512 }, timeout60 ) resp.raise_for_status() data resp.json() return data[choices][0][message][content]你可能注意到我把temperature设置成 0.2。这是因为知识库问答场景更看重准确和稳定而不是创造性。如果temperature太高模型可能会尽情发挥、编造内容。如果做创意写作可以调高做 RAG 问答就老老实实把温度控制在低区间0 到 0.3 都比较合适。3.6 完整流程串起来跑一遍现在把分块、嵌入、存储、检索、生成连起来# main.py import os from src.chunker import split_by_headers, split_long_chunk from src.embedder import embed_texts from src.vector_store import SimpleVectorStore def load_documents(folderdata): docs [] for filename in os.listdir(folder): path os.path.join(folder, filename) if not filename.endswith(.md): continue with open(path, r, encodingutf-8) as f: docs.append(f.read()) return docs def build_index(docs): store SimpleVectorStore() for doc in docs: chunks split_by_headers(doc) for chunk in chunks: for sub_chunk in split_long_chunk(chunk): if len(sub_chunk.strip()) 20: continue vector embed_texts([sub_chunk])[0] store.add(sub_chunk, vector) return store store build_index(load_documents()) question 这个知识库问答系统的分块策略是什么 results retrieve(store, question, top_k3) for text, score in results: print(f[相似度 {score:.4f}] {text[:100]}...\n) answer generate_answer(question, [text for text, _ in results], api_keyos.environ[OPENAI_API_KEY]) print(回答, answer)当我第一次把这条链路完整跑通时那种“所有环节都尽在掌握”的感觉是直接套用框架完全无法获得的。就像你组装了一台电脑每个零件都认识出了问题能自己拆下来换。这种理解深度会在后续调优中持续受益。4. 工程化升级不能“能跑”还得“好跑”4.1 建立评估集用数据判断回答质量等你把系统跑通之后最需要面对的一个问题就是“怎么知道它变好了”没有评估指标你永远只能靠感觉调参。我在项目里建立了一个非常轻量的评估集准备 30 到 50 条与知识库相关的“黄金问题”每条问题匹配一个标准答案。然后写一个批量脚本让系统依次回答这些问题再用固定规则打分# 简单评估人工看一遍打 0 或 1 def evaluate(question, answer, reference): if not answer: return 0 if 无法回答 in answer and reference ! : return 0 # 最简单的方式人工打分或让另一个 LLM 打分 return 1 if judge_llm(question, answer, reference) 通过 else 0你完全可以用一个更强的 LLM 当裁判给每次回答打“通过/不通过”或 1 到 5 分。这套评估体系虽然粗糙但非常有价值当你改了分块策略或换了一个嵌入模型跑同一批问题对比平均分就能立刻知道改动是正向还是负向。没有评估集的所谓“感觉更准了”在工程上毫无意义。4.2 成本与延迟监控别在月底收到账单才惊讶AI 应用的运行成本主要来自两个方面嵌入成本和生成成本。嵌入通常是一次性成本构建索引时付费但如果文档频繁更新就会持续产生费用。生成成本则是每次调用都会产生。我写了一个简单的成本估算函数def estimate_cost(input_tokens, output_tokens, modelgpt-4o-mini): input_rate 0.15e-6 # 每 token 价格以美元为单位 output_rate 0.6e-6 cost input_tokens * input_rate output_tokens * output_rate return cost对个人项目而言这些成本可能微不足道但当你把它部署成服务、供团队使用时流量放大后成本会很可观。建议每次请求都记录模型、输入 token 数、输出 token 数、延迟写入 local log 或 JSONL 文件。我现在养成了习惯任何 AI 功能上线前必须先估算单次调用的平均成本再乘以预期调用量确认预算边界。延迟方面检索本地向量几乎不耗时个人知识库级别通常几十毫秒瓶颈基本都在大模型生成上。如果端到端延迟太高优先考虑换更快的模型、减小max_tokens、或者减少检索片段数量。4.3 可观测性把每次“思考过程”留下来AI 应用与传统软件最大的不同是答案大概率是对的但偶尔会错得很离谱而且每次错误的原因可能都不一样。如果没有日志你根本无从排查。我在项目里设计了结构化日志记录以下字段question用户问题contexts检索到的片段完整内容或摘要retrieval_scores每个片段的相似度分数answer最终回答model使用的模型名称input_tokens/output_tokenslatency_ms端到端耗时timestamp时间戳。每次请求结束把这组数据追加到一个 JSONL 文件里。看起来很简单但它就是现代 AI 应用的可观测性基础。等你在生产环境遇到问题只需打开日志就能回放当时的完整上下文找出失败原因。我强烈建议从项目最开始就记录日志不要等出问题再补。因为你后补的日志永远缺了最有价值的那批失败样本。5. 血泪避坑手册我从失败里总结出的几条硬经验5.1 分块策略不对检索质量直接崩这是我遇到的第一个大坑。最初我用固定 500 字符切分文档以为很合理结果发现很多问题在检索阶段就“歪了”。举个例子我知识库里有一份关于“API 限流策略”的文档限流阈值和规则分别写在不同段落。固定切分把阈值部分单独成块检索“限流阈值是多少”时召回结果里的片段可能只提到“限流分为两种策略”完全没包含具体数字。解决方式就是我前面讲到的优先按标题、段落进行语义切分再对超长段落做二次切分。切分必须尊重文档结构而不是机械地数长度。之后我还加入了 overlap边界处的信息丢失问题基本被解决。5.2 top_k 不是越大越好很多教程会让你把 top_k 设为 4 或 5但实际效果取决于你的文档质量和分块方式。我曾经把 top_k 调到 10以为上下文越多模型答得越准结果反而频繁串答模型把不同片段里相互矛盾的信息混在一起回答变得四不像。我的经验是先用 top_k3 跑一批问题观察检索片段的相似度分数。如果前三名的相似度都在 0.7 以上说明检索质量不错如果第三名已经低于 0.5那多出来的片段大概率是噪声调高 top_k 只会帮倒忙。5.3 幻觉问题提示词里必须明确的边界RAG 系统最常见的失败模式是“一本正经地胡说八道”。模型拿到相关但不完全充分的资料时会脑补缺失信息。比如知识库里只写了系统支持“邮箱登录”模型却自信地告诉你“还支持手机号登录”。解决幻觉我靠三招提示词强调约束明确要求“只能使用资料中出现的信息”并规定“资料不足时直接回答不知道”降温度temperature 设置为 0.2 以下建立拒答机制在一轮对话中如果检索到的片段相似度最高都低于某个阈值比如 0.5直接返回“资料库中没有相关内容”不调用生成模型。第三招最容易被忽略但它能大幅提升系统的可信度。一个敢说“不知道”的 AI 助手比一个总是硬答的助手更可靠。5.4 API 限流和超时必须做重试和降级当系统进入准生产环境后你一定会遇到 API 限流429 错误和超时504 错误。我在第一次给团队演示时就撞上了限流场面极其尴尬。之后我加了一个很基础的重试机制import time def request_with_retry(func, max_retries3, base_delay1.0): for attempt in range(max_retries): try: return func() except Exception as e: if attempt max_retries - 1: raise e delay base_delay * (2 ** attempt) time.sleep(delay)指数退避重试是处理限流的标准做法第一次失败等 1 秒第二次等 2 秒第三次等 4 秒。同时建议在日志里记录重试次数因为如果重试频繁出现说明你的调用频率已经接近 API 配额上限需要做限流降级或更换模型供应商。5.5 嵌入模型选择不要盲目追“最强”嵌入模型的选择对检索效果影响很大但不必一开始就选最强最贵的。我的建议是如果知识库以中文为主优先选择多语言嵌入模型先用手头的文档跑一个小样本对比几个模型的“命中率”再决定用哪个本地嵌入模型的好处是零调用成本可以随意实验外部 API 嵌入模型虽然精度可能更高但每次都付费调试成本不低。不要因为某个模型在榜单上分数高就无脑选用。真正的标准只有一个在你自己的知识库样本上检索命中率和回答质量是否达标。我自己最后选型时用了三个模型的对比结果表模型名、检索命中率、平均端到端延迟、单次成本。跑完数据再选而不是凭感觉。项目后续还能怎么扩展我现在已经把这个项目扩展了不少比如加了文档增量更新、检索结果去重、回答引用来源标注等功能。和最初那个“最小可用链路”相比代码量翻了几倍但核心骨架依然是分块、嵌入、检索、生成这四步。每次加新功能之前我都会先问自己它是让检索更准了还是让生成更稳了还是让运维更省心了如果三者都不沾就暂时不做。从零开始做 AI 工程最大的收获不是代码本身而是建立了一种“拆解能力”任何一个看似复杂的 AI 应用都能被我拆成可理解、可调试、可优化的环节。你把这个能力建立起来之后再去看那些花哨的框架和工具就不会再觉得神秘只会觉得“哦原来它是在管这一层”。如果你也准备启动一个类似的 from-scratch 项目我的建议很简单找一个你自己真正关心的知识库用周末两天时间先把最小链路跑通然后再一个环节一个环节地打磨。别等什么都准备好了再动手因为 AI 工程里最耗时的部分永远是那些你动手之后才会发现的真实问题。
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

◈

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

◐

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

▲

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。