还在为大模型“胡言乱语”头疼不用微调不用烧显卡一个被无数大厂验证过的技术方案——RAG就能让大模型瞬间学会你公司的私有知识。今天我们用最通俗的语言把这项技术的每一个细节都掰开揉碎讲清楚。01 大模型的两个“致命伤”先问你一个问题如果让你在完全不查资料的情况下回答“2024年诺贝尔物理学奖颁给了谁”你能答上来吗大概率不能——因为你的“知识”停留在几年前这就是知识滞后。大模型也一样。它虽然在海量数据上训练了很久但训练一次动辄几个月花费几千万甚至上亿美金。训练完成之后它的知识就“定格”了。你问它2024年发生的事情它要么说不知道要么就按照以前的规律瞎编。另一个问题是知识缺失。你家公司内部的规章制度、产品文档、客户案例大模型根本没见过。你问它“我们公司年假怎么休”它只能跟你道歉说“我无法回答”。最要命的是第三个问题幻觉。幻觉就是大模型“一本正经地胡说八道”。比如你问“哈利波特第一次骑自行车是什么时候”它可能很自信地回答“1997年在女贞路4号。”——听起来像模像样但全是编的。书里根本没这回事。为什么会这样因为大模型本质是一个“文字接龙高手”它不知道对错只知道“根据前面的文字下一个字最可能是什么”。这就好比一个学霸虽然能背很多书但如果你问他书本上没有的内容他就会开始“自由发挥”。有一个真实案例某律师用大模型准备庭审材料大模型“引用”了好几个判例还附上了详细的案号、法官名字。结果对方律师一查——这些判例全是编的这位律师因此被罚款。这不是段子是真事。那么有没有办法让大模型既能发挥它的语言天赋又能保证回答有据可查有。这个办法就是 RAG。02 RAG 是什么开卷考试了解一下RAG 的全称是Retrieval-Augmented Generation中文叫“检索增强生成”。名字很唬人但本质就四个字开卷考试。想一想开卷考试和闭卷考试有什么区别闭卷考试全靠记忆。大模型现在就是闭卷模式。开卷考试允许你翻书查资料。RAG 就是给大模型配一本“参考书”。具体流程是这样你问一个问题“我们公司年假怎么休”RAG 系统先去你公司的“知识库”比如员工手册、公司制度文档里把相关的内容搜出来。然后把搜到的内容连同你的问题一起交给大模型。大模型看着这些“参考资料”来组织答案。这样一来大模型的回答就有了事实依据不再是空口白话。而且你随时可以更新知识库里的文档大模型的知识也就跟着更新了不需要重新训练。RAG 的另一个优点是保护隐私。你的私有数据不需要喂给大模型训练只用在检索那一刻临时调用。数据始终在你自己的数据库里。典型的RAG有两个主要流程索引从数据源提取数据构建索引。检索生成接受用户查询并从索引中检索相关数据然后将其传递给模型。。索引阶段从各种数据源加载数据将文档切分为小块对文本块进行嵌入存储嵌入向量。检索生成阶段根据用户输入使用检索器从存储中检索相关文本块大模型使用包含问题和检索结果的提示生成回答。接下来我们就按这个流程用代码一步步搭建一个 RAG 系统。代码注释会非常详细哪怕你第一次接触也能看懂。03 第一步加载文档——让程序“读”懂你的文件你的知识可能散落在各种格式的文件里Word 文档、PDF、Excel、网页、Markdown……第一步要做的就是把它们统统读进来变成程序能处理的数据。LangChain 是目前最流行的 RAG 框架它提供了非常多的“文档加载器”。你可以把它想象成各种格式的“文件打开器”。3.1 加载纯文本文件TXT# 安装 LangChain 社区扩展包 # 在命令行执行pip install langchain_community from langchain_community.document_loaders import TextLoader # 创建一个加载器告诉它文件在哪用什么编码中文用 utf-8 loader TextLoader( file_path公司员工手册.txt, # 你的文件路径 encodingutf-8 # 中文编码 ) # 执行加载得到文档对象列表 # 每个文档对象包含两部分page_content文字内容和 metadata元数据比如文件名 documents loader.load() # 打印看看结果 print(f加载了 {len(documents)} 个文档) print(f第一个文档的内容前200字{documents[0].page_content[:200]}) print(f元数据{documents[0].metadata})注load() 方法会一次性把整个文件读到内存。如果文件非常大比如几百MB建议用 lazy_load()它会像“逐行读取”一样一次只加载一部分节省内存。3.2 加载 CSV 表格CSV 是表格文件每一行是一条记录。加载时你可以指定哪一列作为“正文内容”哪几列作为“元数据”。from langchain_community.document_loaders.csv_loader import CSVLoader # 最简单的用法把每一行都转成一个文档 loader CSVLoader(file_path客户反馈.csv) docs loader.load() # 这样每一行会变成page_content 包含整行的内容metadata 里只有 source 文件名 # 进阶用法精确控制哪些列放哪里 loader CSVLoader( file_path客户反馈.csv, metadata_columns[客户ID, 反馈时间], # 这些列作为元数据用来过滤、追溯 content_columns[反馈内容] # 这一列作为正文用来检索 ) docs loader.load()3.3 加载 JSON 数据JSON 是很常见的数据格式但它的结构可能很复杂嵌套对象、数组。LangChain 用 jq 语法来精确提取你想要的部分。# pip install jq from langchain_community.document_loaders import JSONLoader # 假设你的 JSON 长这样 # { # articles: [ # {title: RAG入门, content: RAG是一种..., author: 张三}, # {title: 向量数据库, content: Milvus是..., author: 李四} # ] # } # 提取 articles 数组中的每一项把整项作为文档内容 loader JSONLoader( file_path知识库.json, jq_schema.articles[], # jq 语法取出 articles 数组的每个元素 text_contentFalse # 保持原始 JSON 结构不强制转成字符串 ) docs loader.load() # 更精细只提取 content 字段作为正文把 title 和 author 合并作为元数据 loader JSONLoader( file_path知识库.json, jq_schema .articles[] | { content: .content, metadata: {title: .title, author: .author} } , text_contentTrue # 把构造好的对象转成 JSON 字符串作为 page_content )jq 小贴士. 代表当前节点[] 表示遍历数组| 是管道把左边的结果传给右边{key: value} 是构造新对象。学习这几个符号就能应付大部分场景。3.4 加载网页有时候你需要爬取某个网页的内容作为知识来源。LangChain 集成了 BeautifulSoup 来解析 HTML。# pip install beautifulsoup4 import bs4 from langchain_community.document_loaders import WebBaseLoader # 加载百度百科的一个页面并且只提取正文区域classJ-lemma-content loader WebBaseLoader( web_paths[https://baike.baidu.com/item/RAG/12345], # 可以传多个网址 bs_kwargs{ parse_only: bs4.SoupStrainer(class_J-lemma-content) # 只抓这个 class 里的内容 } ) docs loader.load()3.5 加载 PDF——最难啃的骨头PDF 看起来简单实际上格式非常复杂。有的 PDF 是文字版可以直接选中文字有的是扫描版图片需要 OCR 识别还有的混合表格、图片、双栏排版。如果你的 PDF 比较简单纯文字没有复杂表格可以用最简单的 PyPDFLoaderfrom langchain_community.document_loaders import PyPDFLoader loader PyPDFLoader(产品说明书.pdf) docs loader.load()如果 PDF 有扫描图片或复杂排版建议用 UnstructuredPDFLoader它支持 OCR 识别图片中的文字还能识别表格结构。# pip install unstructured[local-inference] # 还需要安装 PopplerPDF渲染和 TesseractOCR具体安装方法网上搜一下 from langchain_community.document_loaders import UnstructuredPDFLoader loader UnstructuredPDFLoader( file_path扫描版合同.pdf, modeelements, # elements 模式会按标题、段落、表格等元素切分 strategyhi_res, # hi_res 高精度解析最慢但最准 infer_table_structureTrue, # 尝试解析表格结果放在 metadata 的 text_as_html 里 languages[chi_sim] # OCR 用简体中文 ) docs loader.load()实战经验如果你的项目对文档解析质量要求很高比如法律合同、学术论文可以考虑付费服务如 MinerU、Mathpix 等它们对公式、表格、多栏排版的识别效果远超开源方案。很多大厂的 RAG 项目一半以上的工作量都花在了文档解析这一步。04 第二步文档切分——把大块肉切成适口小块文档加载完之后你可能得到一个很长的文档比如一本员工手册几十页。你不能直接把整个手册喂给大模型因为大模型能接收的文本长度有限比如 4k、8k、128k tokens。超出的部分会被截断。即使模型能接收很长的文本无关信息太多也会干扰它。就像让你在整本百科全书里找一个知识点还不如只给你相关的那一页。所以我们需要把长文档切成一个个小“块”Chunk每个块大概几百到上千字。4.1 用什么策略切最简单的办法是按固定字符数切比如每 500 字切一块。但这会把一个句子拦腰切断。比如“我爱吃苹果”在“我爱”后面切了那么“吃苹果”就失去了主语。更好的办法是按标点符号切优先在句号、问号、感叹号处切如果没有再在逗号、空格处切。这样能保证每个块都是完整的句子或段落。LangChain 提供的 RecursiveCharacterTextSplitter 就是这种“递归切分器”。它有一个分隔符列表按优先级从高到低尝试切分。# pip install langchain-text-splitters from langchain_text_splitters import RecursiveCharacterTextSplitter # 先加载一个长文档 from langchain_community.document_loaders import TextLoader loader TextLoader(员工手册.txt, encodingutf-8) doc loader.load() # 创建切分器 splitter RecursiveCharacterTextSplitter( separators[\n\n, \n, 。, , , , , , ], # 解释先找两个换行段落分隔没有就找一个换行没有就找句号依次类推 # 最后一个空字符串 表示如果所有分隔符都找不到就按字符硬切 chunk_size500, # 每个块最多 500 个字符 chunk_overlap50, # 相邻块重叠 50 个字符防止边界信息丢失 add_start_indexTrue # 记录每个块在原文档中的起始位置 ) chunks splitter.split_documents(doc) print(f原文档切成了 {len(chunks)} 个块) print(f第一个块的内容{chunks[0].page_content})4.2 两个关键参数chunk_size 和 chunk_overlapchunk_size块大小决定了每个块包含多少信息。太小比如 200 字每个块信息太少可能找不到足够的上下文。太大比如 2000 字块内杂音多而且浪费 Token大模型按 Token 收费的。经验值对于中文500~1000 字符是比较好的范围。chunk_overlap重叠大小相邻块之间重复的部分。为什么需要重叠比如一个关键句子正好在切分边界上如果没有重叠它可能被切成两半分别属于两个块检索时哪一半都不完整。有了重叠这个句子会完整地出现在两个块里。一般取 chunk_size 的 10%~20%。你可以这样理解切分就像把一长串香肠切成小段每段长度 chunk_size但是切的时候刀口稍微退回去一点chunk_overlap这样相邻两段会有一部分重合保证接头处的信息不丢失。05 第三步向量嵌入——给文字“贴坐标”计算机不认识文字但认识数字。嵌入模型的作用就是把一段文字转换成一串数字向量这串数字代表了文字的“语义”。想象一下你把所有文档块放在一个巨大的多维空间里意思相近的块会靠得很近。比如“苹果手机”和“iPhone”这两个词的向量距离很近而“苹果手机”和“香蕉”的距离很远。5.1 选择一个好用的中文嵌入模型目前国产的 BAAI北京智源研究院推出的 BGE 系列模型对中文的支持非常好而且是开源的。模型名向量维度速度效果推荐场景bge-small-zh512快良好资源有限、快速验证bge-base-zh768中等很好大多数场景推荐bge-large-zh1024慢最好追求极致准确率bge-m31024慢最好多语言文档含多种语言向量维度可以理解为坐标的“维度”。三维空间能区分前后左右上下维度越高能表达的语义细节越丰富但计算量也越大。5.2 用代码生成向量# pip install sentence-transformers langchain_huggingface from langchain_huggingface import HuggingFaceEmbeddings # 加载模型第一次运行会自动下载大概 400MB # 如果你已经下载到本地可以用 os.path.expanduser(~/models/bge-base-zh-v1.5) embed_model HuggingFaceEmbeddings( model_nameBAAI/bge-base-zh-v1.5 # 也可以换成其他模型名 ) # 单个文本嵌入用于用户的问题 question 年假有多少天 question_vector embed_model.embed_query(question) print(f问题向量长度{len(question_vector)}) # 输出 768 print(f向量前5个值{question_vector[:5]}) # 一堆小数 # 批量文本嵌入用于文档块 doc_texts [员工每年享有5天带薪年假, 产假为98天, 婚假3天] doc_vectors embed_model.embed_documents(doc_texts) print(f文档块数量{len(doc_vectors)}每个向量长度{len(doc_vectors[0])})注意embed_query 和 embed_documents 虽然底层都是生成向量但有些模型会对查询和文档做不同的处理比如添加特殊前缀所以不要混用。用户的问题永远用 embed_query文档块永远用 embed_documents。06 第四步向量数据库——给向量们安个家生成了向量之后需要把它们存起来并且能够快速查找。这就是向量数据库干的事。你可以把向量数据库想象成一个超级智能的“图书馆”你给它一段文字它立刻告诉你图书馆里哪几本书的内容和这段文字最像。6.1 选择哪个向量数据库Chroma轻量级纯 Python几行代码就能跑起来适合学习和原型验证。FAISSFacebook 出品检索速度极快但不提供持久化存储重启就丢适合研究用途。Milvus功能最强大支持分布式、标量过滤、高并发适合生产环境。我们这里用Milvus Lite版本它可以直接在本地文件上运行不需要搭建服务器非常适合学习。6.2 安装和初始化pip install pymilvus[milvus-lite]from pymilvus import MilvusClient # 创建一个客户端数据会保存在当前目录下的 milvus_demo.db 文件里 client MilvusClient(uri./milvus_demo.db)6.3 设计数据表结构Schema在 Milvus 里表叫 Collection。我们需要告诉它每个字段叫什么、什么类型。from pymilvus import DataType # 定义表结构 def create_schema(): schema MilvusClient.create_schema( auto_idTrue, # 自动生成主键ID不需要自己提供 enable_dynamic_fieldTrue # 允许动态添加未定义的字段 ) # 添加字段id主键整数类型 schema.add_field(field_nameid, datatypeDataType.INT64, is_primaryTrue) # 添加字段vector向量768维浮点数 schema.add_field(field_namevector, datatypeDataType.FLOAT_VECTOR, dim768) # 添加字段text原文内容最长1024个字符 schema.add_field(field_nametext, datatypeDataType.VARCHAR, max_length1024) # 添加字段metadata元数据JSON格式可以放任意结构 schema.add_field(field_namemetadata, datatypeDataType.JSON) return schema # 创建索引索引能加速检索 def create_index(): index_params MilvusClient.prepare_index_params() index_params.add_index( field_namevector, # 对向量字段建立索引 index_typeAUTOINDEX, # 自动选择最合适的索引类型 metric_typeL2 # 相似度计算方式L2欧氏距离值越小越相似 ) return index_paramsmetric_type 的三种选择L2欧氏距离两点之间的直线距离越小越相似。最直观。IP内积向量点积越大越相似需要先对向量做归一化。COSINE余弦相似度关注方向而非长度1表示完全相同。文本相似度通常用这个。为了简单我们先用 L2。6.4 创建表并插入数据# 如果表已经存在先删除方便重复运行 collection_name demo_collection if client.has_collection(collection_name): client.drop_collection(collection_name) # 创建表 client.create_collection( collection_namecollection_name, schemacreate_schema(), index_paramscreate_index() ) # 假设我们已经有了切分好的 chunks 和对应的向量 # chunks: List[Document]每个 Document 有 page_content 和 metadata # embeddings: List[List[float]]每个向量与 chunks 一一对应 # 把数据组装成 Milvus 需要的格式 data_to_insert [] for chunk, vector in zip(chunks, embeddings): data_to_insert.append({ vector: vector, # 向量 text: chunk.page_content, # 原文 metadata: chunk.metadata # 元数据如文件名、页码等 }) # 插入数据 insert_result client.insert( collection_namecollection_name, datadata_to_insert ) print(f插入了 {insert_result[insert_count]} 条数据)6.5 检索根据问题找最相似的块当用户问一个问题时我们先把问题转成向量然后去向量数据库里找最相似的 k 个文档块。def search_similar(query_text, embed_model, client, top_k3): # 1. 把问题转成向量 query_vector embed_model.embed_query(query_text) # 2. 在数据库里搜索 results client.search( collection_namedemo_collection, data[query_vector], # 要搜索的向量 anns_fieldvector, # 在哪个向量字段里搜索 search_params{metric_type: L2}, # 使用欧氏距离 output_fields[text, metadata], # 要返回哪些字段 limittop_k # 返回最相似的前 top_k 个 ) # results 的结构[[{id, distance, entity}, ...]] # 提取出原文 contexts [hit[entity][text] for hit in results[0]] return contexts # 测试 query 公司年假怎么计算 contexts search_similar(query, embed_model, client) for i, ctx in enumerate(contexts): print(f结果{i1}:\n{ctx}\n)6.6 关于“近似最近邻”ANN的通俗解释你可能会好奇数据库里可能有几十万条向量怎么能在几毫秒内找到最相似的如果老老实实把所有向量都算一遍这叫 KNN精确最近邻数据量一大就慢死了。聪明的方法是近似最近邻ANN事先给向量建一个“索引”就像书的目录。查找时不需要翻遍整本书而是先看目录找到大概的章节再去那几页里仔细找。Milvus 默认用的 HNSW 算法你可以这样理解想象你要在一个大城市里找一个人。如果你从大街上一个人一个人地问累死。更好的办法先找这个人可能在哪个区上层索引再去那个区里找街道中层最后到街道上找门牌号底层。HNSW 就是建立了一个多层地图上层“跳”得快下层找得准。这个索引只需要建一次插入数据时之后每次检索都享受加速。07 第五步生成——让大模型看着参考资料写答案最后一步把检索到的相关文档块连同用户的问题一起发给大模型让它生成答案。7.1 构建提示词Prompt提示词就是你对大模型说的话。一个好的提示词要包含三部分角色设定你是一个专业的HR助手...参考资料以下是从员工手册中检索到的内容...用户问题请根据参考资料回答...from langchain_core.prompts import ChatPromptTemplate template ChatPromptTemplate.from_messages([ (system, 你是一个专业的HR助手。请根据下面的【参考资料】回答用户的问题。 如果参考资料里没有相关信息请如实说‘资料中没有提到’不要编造答案。 \n\n【参考资料】\n{context} ), (human, {question}) ])7.2 调用大模型你可以使用 OpenAI、国产模型如智谱、通义千问或者本地部署的模型。这里以 OpenAI 兼容接口为例# pip install langchain-openai from langchain_openai import ChatOpenAI llm ChatOpenAI( modelgpt-3.5-turbo, # 或 gpt-4 temperature0, # 温度越低回答越确定不随意发挥 api_key你的API密钥, # 也可以从环境变量读取 base_urlhttps://api.openai.com/v1 # 如果是其他厂商换成对应地址 )7.3 把检索和生成串起来LangChain 提供了 RunnablePassthrough 和 RunnableLambda 来构建处理链。from langchain_core.runnables import RunnablePassthrough, RunnableLambda from langchain_core.output_parsers import StrOutputParser # 定义一个函数检索上下文 def retrieve_context(question): return search_similar(question, embed_model, client, top_k3) # 构建链 rag_chain ( { question: RunnablePassthrough(), # 原样传递问题 context: RunnableLambda(retrieve_context) # 调用检索函数 } | template # 填入提示词模板 | llm # 调用大模型 | StrOutputParser() # 提取输出文本 ) # 执行 question 新员工的年假是怎么计算的 answer rag_chain.invoke(question) print(answer)7.4 流式输出像 ChatGPT 那样一个字一个字出来如果你想实现打字机效果可以用 stream 方法for chunk in rag_chain.stream(试用期员工有年假吗): print(chunk, end, flushTrue)08 总结RAG 的优缺点和适用场景优点知识实时更新只需要往数据库里加新文档不需要重新训练模型。可溯源答案有据可查可以告诉用户“这是根据第X页的文档得出的”。降低幻觉大模型有参考资料胡说八道的概率大大降低。保护隐私私有数据留存在你自己的数据库里不用交给模型厂商。缺点响应延迟每次问答都要做检索生成两步比直接调用大模型慢一些。消耗更多 Token把检索到的上下文也塞进提示词里Token 用量会增加。检索效果决定上限如果没检索到相关的资料大模型再强也没用。什么时候该用 RAG✅适合 RAG知识频繁更新公司制度、产品文档、新闻资讯领域知识不在大模型训练数据中企业内部资料、私有数据库需要答案有据可查法律、医疗、金融不想承担微调模型的高昂成本❌不适合 RAG任务本身不需要外部知识比如写一首诗、做数学计算实时性要求极高毫秒级响应文档量极小且基本不变那直接提示词里写死就行了最后的话RAG 并不是什么高深莫测的技术它只是巧妙地利用了“检索”这个传统技术给大模型装上了一双可以翻书的“手”。很多人一开始觉得大模型很神奇但当你理解了它的局限再配合 RAG你会发现一个可控、可靠、可更新的 AI 系统并不需要几十亿的投入只需要你按照这篇文章的步骤一步步搭建起来。如果你的团队也想落地一个智能问答机器人建议从最简单的 Chroma 本地小模型开始跑通整个流程。等有了实际需求和数据量再平滑迁移到 Milvus 商业大模型。一步一个脚印你会发现 RAG 远比想象中简单。