
简介这是一本面向生成式AI开发者的技术专著系统讲解从模型选型、提示工程、微调到部署优化的完整落地路径并重点剖析RAG与Agent协同的上下文感知推理架构以突破大语言模型的知识截止与幻觉局限。书中结合Amazon Bedrock托管服务通过LangChain、ReAct等框架展示文本、图像、音频等多模态数据的融合应用以及如何与外部API和私有数据源进行动态交互适合希望构建企业级AI应用的工程师、机器学习从业者与数据科学家研读。资源包为1个PDF文件大小84.56MB内容为英文原版书籍章节结构完整包含大量案例代码、架构图解与实操说明便于按目录系统查阅。目前已有126人学习浏览可作为生成式AI项目生命周期设计的重要参考帮助读者在实际业务中完成从用例定义到量化部署的闭环。1. 上下文感知的AI应用把它拆成一套能落地的RAG工程上下文感知的AI应用说白了就是让模型在回答之前先搞清楚它该看哪些资料、资料之间的上下文关系是什么而不是靠死记硬背的参数知识硬答。我第一次把一个内部知识库问答系统上线时用户问“离职赔偿怎么算”模型给我答成“请咨询HR”检索回来的文档明明是对的生成阶段却完全没用上。问题就出在上下文感知这个环节没做扎实。这份资源讲的是生成式AI、RAG、多模态在真实业务场景里怎么组合从文档清洗、分块、向量化到检索、重排、上下文管理整条链路都有可复现的做法。适合正在做知识库问答、给AI加业务上下文、被答非所问折磨的开发者。它不是理论讲义是一套能照着抄的工程笔记。2. 拆RAG链路文档清洗、分块、向量化与检索的工程选型2.1 为什么是RAG先把“知道什么”和“能查到什么”分开生成式AI有个天生的短板它的知识停留在训练时点业务文档、内部制度、线上手册它都没见过。硬让它回答就是幻觉的来源。RAG的做法很直接——把外部文档切成块、做成索引查询时先检索出相关片段再塞进提示词让模型基于这些片段作答。这个思路把“模型知道什么”和“系统能查到什么”拆成了两条独立的流水线。数据侧是离线流程文档清洗 → 分块 → 向量化 → 建索引。查询侧是在线流程问题 → 查询改写 → 检索 → 重排 → 拼装上下文 → 生成。大部分翻车场景都发生在数据侧的前两步和查询侧的检索一步而不是生成侧。所以拿到这份资源我建议你先别急着调提示词把重心放在数据流水线上。离线这条链路里每个环节的产出都是下一个环节的输入任何一步的脏数据都会被放大。比如PDF转出来的文本带着目录页码和页眉直接分块后向量索引里全是噪声表格被OCR打平成一行文字检索回来就变成一串错位的数字。这些不是模型问题是工程问题。2.2 清洗与分块chunk_size、overlap与切分顺序的工程参数清洗这一步看着不起眼实际是上下文感知的第一道关卡。常见做法是先做版面分析把页眉页脚、目录页、图表标题识别出来并剔除或单独标记。PDF里最常见的坑是文本提取后每行末尾带着连字符断词还有双栏文档被按阅读顺序硬拼成单栏。我一般会用pdfplumber配合正则做一轮预处理效果比直接调API强很多。清洗干净后进入分块。分块策略直接决定检索单元的质量我常用的三种策略对比策略适用场景优点缺点固定长度切分结构化程度低的文本实现简单、长度可控容易切断完整语义按标题层级切分文档带清晰标题结构上下文完整、利于定位块大小波动大语义切分段落边界明确的文本贴合语义单元计算开销大、不稳定最常用的是递归字符分块——先按段落分段落太长再按句子分句子还长再按字符数截断同时用overlap保留上下文衔接。示例代码from langchain_text_splitters import RecursiveCharacterTextSplitter text open(employee_handbook.md, encodingutf-8).read() splitter RecursiveCharacterTextSplitter( chunk_size500, # 单块最大字符数 chunk_overlap80, # 相邻块重叠字符数保留承接信息 separators[\n\n, \n, 。, , , , ], keep_separatorTrue, # 保留分隔符避免句子开头被截断 ) chunks splitter.split_text(text) for i, chunk in enumerate(chunks[:3]): print(f--- chunk {i} ---) print(chunk[:200])chunk_size和chunk_overlap是最敏感的两个参数。chunk_size取300到800之间比较常见太大块内噪声多检索召回一堆无关内容太小语义被切碎检索到的片段“看起来相关、读起来没头没尾”。overlap一般取chunk_size的10%到20%作用是当某句话恰好落在切分边界时下一块还保留着前半句信息。代码里keep_separatorTrue是我个人的偏好目的是让句子尽量完整不让标点符号被拦腰切断。2.3 向量化与Faiss索引Embedding选型与top_k的第一道关卡分块完成后的下一步是向量化。中文场景我一般优先选针对中文优化的开源Embedding模型比如bge-m3这一类的多语言模型它在中文语义匹配上的表现比直接用通用英文模型好不少。选Embedding模型时有个经验别只看榜单分数拿你自己业务里的100个问题去跑一遍召回看top_k里到底有几个是真正有用的。向量索引的选型是精度和速度的权衡。小规模知识库几万块以内直接用Faiss的IndexFlatIP做暴力检索就行速度完全够超过十万块我才会换HNSW这类近似最近邻索引。HNSW的M参数控制图连接数M越大召回越准、内存和查询耗时也越高常见取32到64。构建和检索示例import faiss import numpy as np from sentence_transformers import SentenceTransformer model SentenceTransformer(BAAI/bge-m3) chunk_texts [c for c in chunks] # 批量向量化normalize_embeddingsTrue 让向量归一化后续用内积等价于余弦相似度 embeddings model.encode(chunk_texts, normalize_embeddingsTrue) embeddings np.array(embeddings).astype(float32) dim embeddings.shape[1] index faiss.IndexFlatIP(dim) index.add(embeddings) # 查询阶段top_k 先给大一点如 20留给后续重排 query 离职经济补偿金怎么计算 query_vec model.encode([query], normalize_embeddingsTrue) scores, indices index.search(np.array(query_vec), k20) for rank, (score, idx) in enumerate(zip(scores[0], indices[0])): print(f#{rank1} score{score:.4f}) print(chunk_texts[idx][:120]) print(---)这里有个容易忽略的参数是top_k。很多人一上来就设top_k3结果重排环节根本没有候选集可选。我一般会让粗排先召回20到50条再用重排模型精排到5到10条宁可在粗排阶段多召回一些也不能让真正相关的文档在第一道关卡就被淘汰。另外业务里一定要加metadata过滤——比如限定“只看2024年以后的制度”否则检索器会把过期政策当成依据。3. 把上下文做厚多模态分块与结构化索引的落地做法3.1 多模态不是“图文混排”表格、版式与扫描件的上下文含义很多业务文档的上下文藏在版式里而不是文字本身。一张员工薪资结构表列名是“岗位津贴”“绩效基数”“扣款项”行是不同职级这种上下文关系一旦被打平成纯文本就全丢了。我在一个项目里见过最典型的翻车PDF里的表格被OCR提取成一行行的碎片文字检索“绩效基数怎么算”模型把表头和数据错位拼在一起给出一串完全错误的数字。多模态在RAG语境下不是指“图片文字”混排这么简单而是要区分三类内容各自的处理方式第一类是表格必须保留行列结构第二类是流程图和组织架构图这些是图片但承载的是关系信息最好转成结构化描述或按图处理第三类是纯装饰性图片直接忽略不要浪费向量空间。这三类混在一份文档里不能一刀切都走OCR。我在拿到一份PDF后会先跑一遍版面分析把页面切分成标题、段落、表格、图片四类区域然后分路处理段落走文本分块表格转成Markdown表格图片走视觉模型生成描述最后各自向量化存入索引。这个流程比“整页转文字”慢30%左右但检索质量是数量级的提升。3.2 表格与图片的索引化转Markdown、VLM描述与向量融合表格在RAG里的最佳归宿是转成Markdown或HTML这两种格式既保留了行列结构又能被LLM直接理解同时词汇表高度结构化检索时表头和表内容能同时命中。常用做法是用pdfplumber提取表格坐标再转成Markdown遇到扫描件就得先用OCR识别单元格再重组。示例import pdfplumber def extract_table_to_markdown(pdf_path, page_num): tables_md [] with pdfplumber.open(pdf_path) as pdf: page pdf.pages[page_num] tables page.extract_tables() for table in tables: rows [] for row in table: # 清掉单元格里的换行符避免Markdown结构被破坏 cleaned [str(cell).replace(\n, ) if cell else for cell in row] rows.append(cleaned) headers rows[0] md_lines [| | .join(headers) |, | | .join([---] * len(headers)) |] for r in rows[1:]: md_lines.append(| | .join(r) |) tables_md.append(\n.join(md_lines)) return tables_md # 每个表格块独立成一个chunk配合前后段落标题一起入库 tables extract_table_to_markdown(salary_structure.pdf, 3) print(tables[0])这段代码的逻辑是打开PDF指定页用extract_tables拿到完整表格结构逐行清洗换行符再把二维数组拼成Markdown表格。参数只有pdf_path和page_num实际项目里我会根据版面分析结果自动定位表格所在页面并把表格前后最近的标题文本拼进同一个chunk这样检索“绩效基数是基本工资的多少倍”时表头和上下文能同时命中。图片相对麻烦一些。我的做法是先用视觉语言模型VLM给图片生成一段描述再把描述文本向量化而不是直接用图片向量。原因有两个一是绝大多数检索场景的query是文本形态图文向量对齐训练的成本很高二是描述文本能保留“这张图在文档中处于什么位置、说明什么关系”的上下文信息。VLM产出的描述要写成陈述句比如“这是一张2024年度各部门人员编制对比柱状图研发部编制120人产品部45人”这种描述检索时比“图1”有用得多。3.3 父子分块与元数据库让检索结果“带着上下文”分块再精细检索到的也只是一个局部片段。用户问“这个政策什么时候开始生效”命中的块可能只写了执行细则生效日期在它的父段落里。这就是经典的上下文缺失问题。我强烈建议做父子分块小粒度块负责被检索检索命中后把对应的父块比如一整节作为上下文喂给模型。实现方式是在索引里维护两层数据子块用于向量检索父块用于生成阶段的上下文。子块和父块的映射关系在入库时建立检索后通过映射关系取回父块。实例代码如下docs [ {parent_id: sec-3, parent_text: 第三章 薪酬与绩效\n3.1 绩效基数\n绩效基数为岗位基本工资的80%……, child_id: chunk-17, child_text: 绩效基数为岗位基本工资的80%按月发放}, {parent_id: sec-5, parent_text: 第五章 离职补偿……, child_id: chunk-22, child_text: 离职补偿按N1标准执行……}, ] # 建立 child_id - parent_text 的映射表 child_to_parent {d[child_id]: {parent_id: d[parent_id], parent_text: d[parent_text]} for d in docs} def retrieve_with_parent(child_text_id, child_to_parentchild_to_parent): matched child_to_parent.get(child_text_id) if not matched: return None return { parent_id: matched[parent_id], parent_text: matched[parent_text], child_text: next(d[child_text] for d in docs if d[child_id] child_text_id), } ctx retrieve_with_parent(chunk-17) print(ctx[parent_text])这里的关键设计是把子块的“检索性”和父块的“完整性”分开。子块小而准向量检索时不会因为混入太多无关内容而拉低相似度父块大而全喂给生成模型时能提供完整上下文。另一个作用是控制提示词的token长度——子块命中后只取对应父块而不是把整个文档都塞进去。实际落地时parent和child的映射关系可以存SQLite或MongoDB不需要放进Faiss里。4. 检索质量上不去混合召回、查询改写与重排的组合拳4.1 查询改写用户的问法不是检索的好问法用户的提问方式和文档里的表达经常对不上。用户说“离职赔偿金”文档里写的是“经济补偿金”用户接着问“那N1呢”这其实是个指代问题必须结合上一轮“离职赔偿”才能理解。直接拿“那N1呢”去做向量检索结果必然是答非所问。查询改写就是在这个环节解决上下文衔接的问题。常见做法有两种。轻量级的做法是规则改写维护一个业务同义词词典和指代词表命中就替换或补全。重量级的做法是用LLM对query做一次重写把多轮对话补全成独立问题再提取关键词。我通常两者结合——先用规则处理高频业务词再用LLM处理指代消解。LLM改写示例def rewrite_query(raw_query, chat_history, domain_terms): # domain_terms 是业务同义词/专用词表用于术语归一化 prompt ( 你是检索助手。把用户问题改写成适合向量检索的独立问题。\n 要求1. 结合历史对话补全指代2. 用词尽量贴近文档用语3. 只输出改写后的问题。\n f业务术语表{domain_terms}\n历史对话{chat_history}\n原始问题{raw_query}\n改写结果 ) rewritten llm_call(prompt, max_tokens64, temperature0) return rewritten.strip()改写时的关键参数是temperature必须设成0或接近0。改写任务是确定性任务不需要创造性温度高了会引入无关词直接拉低检索精确率。domain_terms这个参数容易被忽视我见过不少项目把改写完全交给LLM结果模型把“AI”全称扩写、缩写改写检索反而变差。把业务词表写进提示词能让改写结果向文档用语收敛。4.2 混合召回BM25与向量互补向量检索擅长语义匹配对同义改写很鲁棒但对专有名词和精确ID不敏感。用户搜“BG-2024-071号文件”Embedding可能把重点放在“文件”上而BM25能精确命中“BG-2024-071”。反过来用户搜“工资构成有哪些”BM25可能匹配不到“薪酬结构”向量却能对上。混合召回就是把两者结合这也是RAG工程里的标准配置。实现上一般用BM25跑关键词召回Faiss跑向量召回再把两路结果用RRFReciprocal Rank Fusion融合排序。RRF的思路简单有效不比较绝对分数——两个模型的分数尺度完全不同不能直接相加——而是按排名位置给分。示例from rank_bm25 import BM25Okapi import jieba # 1. BM25 召回对所有chunk做分词建立索引 tokenized_corpus [list(jieba.cut(c)) for c in chunk_texts] bm25 BM25Okapi(tokenized_corpus) # 2. 向量召回用Faiss或Milvus取top_k # query_vec, index, embeddings 此前已构建好 vec_scores, vec_indices index.search(query_vec, k20) vec_hits list(vec_indices[0]) # 3. BM25 召回 bm25_scores bm25.get_scores(list(jieba.cut(query))) bm25_hits sorted(range(len(bm25_scores)), keylambda i: bm25_scores[i], reverseTrue)[:20] # 4. RRF 融合对两路结果按排名倒数加权 def rrf_fuse(hit_lists, k60): scores {} for hits in hit_lists: for rank, doc_id in enumerate(hits): scores[doc_id] scores.get(doc_id, 0) 1.0 / (k rank 1) return sorted(scores.items(), keylambda x: x[1], reverseTrue) fused rrf_fuse([vec_hits, bm25_hits]) for doc_id, score in fused[:10]: print(f{doc_id}\tscore{score:.6f}\t{chunk_texts[doc_id][:60]})RRF里的常数k取60是论文和工程实践里都推荐的默认值它控制排名权重的衰减速度k越小排名靠前的结果优势越大。两路召回的top_k建议保持一致如果向量召回质量明显好于BM25可以融合时给向量路的结果加权rank_bm25也支持自定义权重但一般先用等权跑基线再根据实测调整。4.3 重排精排向生成模型只喂“打得准的”粗召回的目标是“别漏掉”精排的目标是“别错喂”。向量检索的top_k结果里前几名可能都是字面相似但语义不相关的片段直接拼进提示词模型会被噪声带偏。重排阶段我一般会用cross-encoder风格的排序模型把query和每个候选片段拼接后打分比双塔式的向量相似度要准得多。工程上的参数链条是向量检索top_k20到50 → 重排模型对全部候选打分 → 取重排后前top_n5到10条作为生成上下文。重排模型可以用bge-reranker-v2-m3这类开源模型示例from FlagEmbedding import FlagReranker reranker FlagReranker(BAAI/bge-reranker-v2-m3, use_fp16True) query 绩效基数按什么比例计算 candidates [chunk_texts[i] for i in vec_hits[:20]] pairs [[query, doc] for doc in candidates] scores reranker.compute_score(pairs, normalizeTrue) # 按重排分数降序取前5条 ranked sorted(zip(candidates, scores), keylambda x: x[1], reverseTrue) top_n ranked[:5] for doc, score in top_n: print(frerank score{score:.4f}) print(doc[:150]) print(---)normalizeTrue会把分数归一化到0到1方便设定阈值。实际项目里我会加一个score_threshold过滤重排分数低于某个值常见阈值0.3到0.5之间视模型和数据分布而定的片段直接丢弃宁可少喂也不能乱喂。这个阈值就是“上下文质量”的底线它决定了生成模型看到的上下文里噪声比例有多高。重排是RAG链路中性价比最高的一环加了之后回答准确率至少能上一个台阶代价只是多一次模型推理。5. 避坑上下文感知RAG的五个高频坑与排查路径5.1 检索命中了答案还是错的现象向量检索返回的片段看起来都相关top 3里甚至有原文原句但生成模型给出的答案和原文矛盾。原因分块切断了关键信息片段里只有结论没有条件限定。比如文档写“经审批通过的项目可申请额外预算”如果分块把“经审批通过”和“项目可申请额外预算”切到两个块里模型就会把适用范围扩大。解决改父子分块保证检索命中后拿回完整父块作为上下文同时在提示词里强制要求“只能依据上下文内容作答上下文没有的信息不要补充”。我在排查这一步时会把检索到的片段原文和生成答案逐句对照——这一步很关键它能把问题准确定位到检索环节还是生成环节。5.2 表格被OCR打平数字错位现象PDF里的薪资表经OCR提取后变成“岗位 津贴 绩效 8000 12000 15%”这种平铺文本检索回来数字对不上列名。原因OCR按文字顺序输出表格的行列结构在输出时已经丢失Embedding模型也拿不到列与列的对应关系。解决表格必须用表格解析流程处理——用pdfplumber提取坐标结构或转成Markdown表格、HTML table而不要走纯OCR文本流。如果手头只有扫描件也要先用版面分析模型把表格区域框出来再做结构化识别而不是全页OCR后直接分块。5.3 向量检索对同义词和否定词不敏感现象用户问“不包含绩效工资”向量检索返回的都是包含“绩效工资”的补贴说明文档因为“不包含”这个否定词在语义向量里权重很低。原因Embedding模型对否定语义的区分能力有限“含绩效”和“不含绩效”在向量空间里距离很近。解决两招并用——第一混合召回里提高BM25关键词权重让“不包含”“除外”“不含”这类词也能命中对应文档第二查询改写时把否定句改写成正面表述或显式标出排除项比如“排除绩效工资的补贴项目”。重排阶段也要注意cross-encoder对否定的判断力强于双塔能筛掉不少这类错误。5.4 父块太大把上下文窗口撑爆现象为了保留完整上下文把父块设成整个章节结果一个章节几千字提示词放不下放多了超出上下文窗口放少了又回到“上下文不完整”的老问题。原因父块粒度和上下文窗口容量之间的矛盾没处理好所有章节一样处理没有按实际内容调节。解决按标题层级动态切分父块——二级标题下的内容作为默认父块超过字数上限就下钻到三级标题。另外一个实用技巧是“摘要化父块”对过长的父块生成一段200到300字的摘要检索命中时优先用摘要详细数据需要时再追溯全文。这个做法能省一半token同时保留上下文主干。5.5 长对话上下文越滚越脏回答开始飘现象会话前几轮回答正常聊到第15轮回答开始出现幻觉甚至引用前面几轮里用户随口说的错误信息。原因多轮对话的上下文被原样拼进提示词前面的错误信息、无关闲聊和高相似度噪声都进了生成阶段模型分不清哪些是可靠的资料上下文哪些是聊天记录。解决对对话历史做压缩而不是全量拼接。我常用的策略是滑动窗口只保留最近3轮到5轮同时对更早的对话做摘要摘要只保留事实性结论丢掉情绪和无关内容。还有一招是“上下文分级”——业务文档检索结果放在提示词里标记为“资料”对话历史标记为“对话记录”并明确提示模型优先依据“资料”。从那以后我每次调完检索链路都强制在评估集上跑一遍回归再谈上线。希望帮到你。6. 把“上下文质量”变成可度量指标离线评估与回归基线上下文感知做得好不好不能靠人工点几个问题就说“还行”。感知是主观的但检索质量可以量化。我在项目稳定后做的第一件事就是建一套离线评估集和回归脚本让每次改分块参数、换Embedding模型都有数据支撑。评估集的结构是三列问题、参考文档ID人工标注这道题应该引用哪些文档、参考答案。规模不用大50到100条覆盖主要业务场景就够了但必须包含三类困难样本指代不清的追问、表格型问题、否定型问题——就是第5章踩过的坑。评估脚本的核心是计算命中率和排位质量import json eval_set json.load(open(eval_set.json, encodingutf-8)) # 每条格式: {question: ..., gold_doc_ids: [sec-3, sec-5], answer_ref: ...} def evaluate_retrieval(retrieve_func, eval_set, k5): hit_count 0 reciprocal_rank_sum 0.0 for item in eval_set: retrieved retrieve_func(item[question])[:k] # 返回 [{doc_id, score}, ...] retrieved_ids [r[doc_id] for r in retrieved] gold set(item[gold_doc_ids]) if gold set(retrieved_ids): hit_count 1 # MRR第一个命中位置选倒数 rr 1.0 / (min(i 1 for i, rid in enumerate(retrieved_ids) if rid in gold)) reciprocal_rank_sum rr recall_at_k hit_count / len(eval_set) mrr reciprocal_rank_sum / len(eval_set) return {Recall5: round(recall_at_k, 4), MRR: round(mrr, 4)} # 用法把线上检索函数传进来跑出指标 metrics evaluate_retrieval(my_retrieve_func, eval_set, k5) print(metrics)这段代码里Recall5衡量的是前5条结果里有没有出现参考文档。MRR衡量的是第一个命中的参考文档排在第几位。这两个指标能直接暴露问题——Recall低说明粗排阶段漏了MRR低说明相关文档排太后了。有了这两个数字优化就能从“凭感觉”变成“看指标”。比如我调chunk_size每个候选值跑一遍评估选Recall5最高的那个而不是靠人工翻看十来个query的结果。除了检索指标还要对最终答案做质量分评估。我的做法是让一个大模型当裁判给答案按“是否忠于上下文、是否完整、是否多余”三个维度打分。这部分可以脚本化用LLM对每一条评估样本的生成结果打分生成一份质量报告。注意在提示词里给裁判明确打分标准否则它只会给“看起来不错”这样的模糊反馈。我刚跑这套评估时发现重排模型换了一个版本后MRR涨了3个点但Recall5掉了5个点——多召回的一些正确文档没进前5。这种曲折在调参时经常发生所以从那以后每次改分块、换Embedding、调重排阈值我都强制在评估集上跑一遍完整回归拿到三项指标趋势再定方案。评估集成了这套系统里最值钱的数据资产希望这套方法能帮你把上下文感知从玄学变成可度量的工程问题。本文还有配套的精品资源点击获取