内容参考于:图灵AI大模型全栈
摘要索引
原理是先把文档进行分片,然后把分片的内容使用大模型进行生成摘要,然后放到向量数据库中,然后比如分片之后有100个,就创建100个唯一的id(跟身份证一样,由数字、字母组成),然后分别对100个摘要数据和100个分片之后的原文数据进行绑定,也就是说一个id绑定一个摘要一个原文,摘要放到向量数据库中用来做问题的搜索,就是说输入问题后先通过向量搜索摘要,找到摘要后,通过id去获取对于的原文,然后把原文带着问题去问大模型获取答案
摘要索引使用的不多,如果数据没有问题(非常准确),经过摘要后(经过大模型摘要后),会出现问题,大模型会出现幻觉会把无问题的数据摘要的有问题,摘要索引适合于PDF里面的表格、图片
代码,运行时先连接VPN,它要下载 spaCy 模型,如果不连接VPN会很慢
base_llm.py文件的内容
pythonfrom langchain_openai import ChatOpenAI from dotenv import load_dotenv import os from langchain_huggingface import HuggingFaceEmbeddings model_path = r"E:\AiModel\Local_model\BAAI\bge-large-zh-v1___5" # 替换为实际路径 embeddings_model = HuggingFaceEmbeddings(model_name=model_path) load_dotenv() api_key = os.getenv('huoshan') llm = ChatOpenAI( api_key=os.getenv("DASHSCOPE_API_KEY"), base_url="https://dashscope.aliyuncs.com/compatible-mode/v1", model="qwen-plus", )
python#!/usr/bin/env python # -*- coding: UTF-8 -*- # 上面两行是Python文件标准头 # 第1行:告诉Linux/Mac系统用python解释器执行这个文件(Windows系统会忽略) # 第2行:声明文件编码为UTF-8,避免代码里的中文出现乱码 # -------------------------- 模块导入部分 -------------------------- # 从自定义模块base_llm中导入两个提前初始化好的模型实例 from base_llm import llm, embeddings_model # - llm:大语言模型实例(比如DeepSeek、GPT这类对话模型),后续用来生成摘要、回答问题 # - embeddings_model:嵌入模型实例,作用是把文本转换成一串数字(向量),实现「语义相似度搜索」 from langchain_core.stores import InMemoryByteStore # 导入「内存字节存储」工具类 # 作用:在电脑内存里以「键值对」的形式存数据(key是文档ID,value是原始文档内容) # 为什么需要它:多向量检索需要两个仓库------向量库存「摘要向量」做搜索,字节库存「原始全文」做回答支撑 from langchain_chroma import Chroma # 导入Chroma向量数据库封装 # Chroma是轻量级本地向量数据库,开箱即用,不需要单独安装服务 # 作用:存储文本对应的向量,用户提问时把问题也转成向量,快速找到语义最相似的文本 from langchain_community.document_loaders import UnstructuredWordDocumentLoader, WebBaseLoader # 导入两个文档加载器(LangChain社区提供的通用工具) # - UnstructuredWordDocumentLoader:专门读取本地Word文档(.docx),把文件里的文字转成标准格式 # - WebBaseLoader:专门爬取网页正文内容,把网页里的文字提取出来转成标准格式 # 为什么需要加载器:不同格式的数据源(文件、网页)读取方式不同,用加载器可以统一成后续能处理的格式 from langchain_text_splitters import RecursiveCharacterTextSplitter # 导入「递归字符文本分割器」 # 作用:把长篇大文档切成一小块一小块(叫chunk),避免文档太长超过模型的输入长度限制,也能提升检索准确度 # 为什么叫「递归字符」:它会优先按段落→句子→单词的顺序拆分,尽量不把一句话切断,比硬切更合理 # MultiVectorRetriever 多用检索器 from langchain_classic.retrievers import MultiVectorRetriever # 导入「多向量检索器」------这是本代码的核心组件 # 核心设计思路:用「短摘要」做向量检索(语义更集中、召回更准),检索到后再返回「完整原文」 # 解决的问题:直接把长文档切块检索,经常出现单块语义不全、答非所问的情况 # 工作流程:用户提问 → 向量库搜最相似的摘要 → 通过摘要绑定的ID → 去字节库取出完整原始文档 import uuid # 导入Python内置的uuid模块 # 作用:生成全局唯一的随机字符串ID,给每个文档分配专属编号,用来绑定「摘要」和「原文」 from langchain_core.documents import Document # 导入LangChain的标准文档类 # 这是LangChain里所有文本的统一格式,包含两个核心属性: # - page_content:文本的具体内容(字符串) # - metadata:文本的附加信息(字典),比如来源、ID、作者等 # 为什么统一用它:加载器、分割器、向量库都认这个格式,各个组件可以无缝衔接 from langchain_core.output_parsers import StrOutputParser # 导入「字符串输出解析器」 # 作用:把大模型返回的「消息对象」转换成纯文本字符串 # 为什么需要:大模型原生返回的是带角色、带格式的对象,我们最终需要的只是文字内容 from langchain_core.prompts import ChatPromptTemplate # 导入「聊天提示词模板」 # 作用:定义给大模型的提问模板,用占位符(比如{doc})填充动态内容,批量生成标准化的提问 from langchain_core.runnables import RunnableMap # 导入「可运行映射」 # 作用:把多个处理步骤组合成字典,并行处理输入,给后续步骤同时提供多个变量 # 什么时候用:比如问答时需要同时把「检索到的文档」和「用户问题」传给提示词,就用它来组装 # -------------------------- 1. 加载文档 -------------------------- # 定义要爬取的网页地址 url = "https://news.pku.edu.cn/mtbdnew/15ac0b3e79244efa88b03a570cbcbcaa.htm" # 入参来源:手动指定的合法网页URL,后续传给网页加载器 # 初始化文档加载器列表(可以同时加载多个不同来源的文档) loaders = [ UnstructuredWordDocumentLoader("人事管理流程.docx"), WebBaseLoader(url) ] # 列表里放了两个加载器实例,分别对应本地Word文件和在线网页 # 入参说明: # UnstructuredWordDocumentLoader的入参:本地Word文件的路径(字符串) # → 入参来源:本地存在的docx文件,建议和代码放同一目录,也可以写绝对路径 # WebBaseLoader的入参:网页URL字符串 # → 入参来源:上面定义的url变量 # 为什么用列表:统一管理多个数据源,后面循环批量加载,代码更简洁 docs = [] # 初始化空列表,用来存放所有加载完成的Document对象 # 后续每个加载器读取到的文档都会追加到这个列表里 for loader in loaders: docs.extend(loader.load()) # 循环遍历每个加载器,执行「读取文档」操作,把结果全部加到docs列表 # 调用的函数:loader.load() # 为什么要调用:加载器只是定义了「要加载谁」,调用load()才会真正去读文件/爬网页 # 入参:无参数,加载目标在创建加载器的时候就已经传进去了 # 返回值:Document对象的列表(一个文件/网页可能返回1个或多个Document) # 逻辑说明:逐个加载所有数据源,把所有文档汇总到一个列表,后续统一处理 # -------------------------- 2. 文档切块 -------------------------- # 分割器 text_split = RecursiveCharacterTextSplitter(chunk_size=1024, chunk_overlap=100) # 创建文本分割器实例 # 为什么要切块:原始文档太长(比如整篇新闻、整份制度文件),直接向量化会丢失细节,也可能超过模型的输入长度 # 入参说明: # chunk_size=1024:每个文本块的最大字符数,这里设为1024个字符 # → 入参来源:手动配置,一般500-2000都常用,根据模型上下文、检索精度调整 # chunk_overlap=100:相邻两个文本块的重叠字符数,这里是100个字符 # → 入参来源:手动配置,作用是避免切块把一句话切断,保证上下文语义连贯 # 其他常用可传入参: # separators:指定分割的优先级,默认是["\n\n", "\n", " ", ""](先按段落切,再按行切...) # length_function:计算长度的函数,默认按字符数统计 docs = text_split.split_documents(docs) # 调用分割器的批量切块方法,把所有长文档切成小块 # 为什么调用:把长文档列表转换成小块文档列表,后续才能生成摘要、存入向量库 # 入参:docs ------ 前面加载好的长文档列表 # 入参来源:上一步循环加载得到的文档集合 # 返回值:切割后的小块Document列表,每个小块都保留了原文档的元数据 # 逻辑:遍历每个长文档,按照chunk_size和chunk_overlap的规则切成多个小文档,覆盖原docs变量 # -------------------------- 3. 批量生成文档摘要 -------------------------- # 创建摘要生成链 chain = ( {"doc": lambda x: x.page_content} | ChatPromptTemplate.from_template("总结下面的文档:\n\n{doc}") | llm | StrOutputParser() ) # 用LangChain的链式语法(LCEL)搭建一条「摘要生成流水线」 # 功能:输入一个Document对象 → 提取文本 → 生成提示词 → 调用大模型 → 输出摘要字符串 # 为什么用链式写法:代码简洁、支持批量调用、各个步骤可复用 # 逐段拆解: # 1. {"doc": lambda x: x.page_content} # → 输入处理:接收Document对象x,提取它的文本内容,赋值给变量doc # → 入参x:Document对象,后续批量调用时来自docs列表的每个元素 # 2. | ChatPromptTemplate.from_template("总结下面的文档:\n\n{doc}") # → 管道符| 表示把上一步的输出传给下一步 # → from_template:根据字符串创建提示词模板,{doc}是占位符,会被上一步的文本替换 # → 入参:手写的提示词模板字符串 # 3. | llm # → 把组装好的提示词传给大语言模型,生成摘要回答 # 4. | StrOutputParser() # → 把大模型返回的消息对象转成纯字符串,得到最终的摘要文本 summ = chain.batch(docs, {'max_concurrency': 5}) # 批量执行摘要链,给所有文档块一次性生成摘要 # 函数:chain.batch() ------ 批量执行链 # 为什么调用:文档块数量多,逐个调用太慢,用批量并行处理提升效率 # 入参说明: # 第1个参数:docs ------ 切割后的小块文档列表 # → 入参来源:上一步分割得到的所有文档块 # 第2个参数:配置字典 {'max_concurrency': 5} # → max_concurrency:最大并发数,同时最多发5个请求给大模型 # → 入参来源:手动配置,根据模型API的限流规则调整,避免并发太高被拒绝 # 返回值:summ是字符串列表,每个元素是对应文档块的摘要,顺序和docs一一对应 # -------------------------- 4. 初始化存储与检索器 -------------------------- # 存摘要之后转换成emb的内容 vector = Chroma(collection_name='summ', embedding_function=embeddings_model) # 创建Chroma向量数据库实例 # 作用:存储摘要文本对应的向量,后续用户提问时做相似度搜索 # 入参说明: # collection_name='summ':集合名称(相当于数据库里的表名),这里命名为summ(摘要缩写) # → 入参来源:手动命名,用来区分不同的向量集合 # embedding_function=embeddings_model:嵌入模型实例,负责把文本转成向量 # → 入参来源:最开头从base_llm导入的嵌入模型 # 其他常用可传入参: # persist_directory:本地持久化文件夹路径,不传则只存在内存里,程序关闭数据就消失 # 逻辑:初始化一个专门存摘要向量的库,自动用指定模型做向量化 # 存原文档 store = InMemoryByteStore() # 创建内存字节存储实例 # 作用:存储原始的完整文档块,用ID做key # 为什么用它:多向量检索器需要两个存储,向量库存摘要做搜索,字节库存原文做回答 # 入参:无必传参数,默认创建一个空的内存键值对存储 # 特点:程序退出数据就丢失,适合演示;生产环境一般换成Redis、本地文件等持久化存储 id_key = 'doc_id' # 定义元数据里的键名字符串,值为'doc_id' # 作用:作为元数据的字段名,用来存放文档的唯一ID,实现摘要和原文的绑定 # 来源:手动定义的字符串,后续会在Document的metadata里用这个key存ID值 # 创建多用检索器 retriever = MultiVectorRetriever( vectorstore=vector, byte_store=store, id_key=id_key ) # 创建多向量检索器实例------这是整个代码的核心 # 为什么调用:实现「摘要检索 + 原文返回」的两级检索,提升召回准确度 # 入参说明: # vectorstore=vector:向量数据库实例,存摘要向量 # → 入参来源:上面创建的vector变量 # byte_store=store:字节存储实例,存原始文档 # → 入参来源:上面创建的store变量 # id_key=id_key:元数据中存储文档ID的键名 # → 入参来源:上面定义的id_key变量 # 其他常用可传入参: # search_kwargs={"k": 3}:检索时返回最相似的3个结果 # search_type="mmr":用最大边际相关性检索,避免结果重复 # 工作原理: # 存数据:摘要带doc_id存入向量库,原文用doc_id当key存入字节库 # 取数据:用户提问→搜向量库得到相似摘要→从摘要元数据拿doc_id→去字节库取对应原文 # -------------------------- 5. 存入数据(摘要+原文绑定) -------------------------- # docs 长度100 生成100个随机数 doc_ids = [str(uuid.uuid4()) for i in docs] # 用列表推导式,给每个文档块生成一个全局唯一的ID字符串 # 为什么生成ID:给每个文档块分配专属编号,让摘要和原文能一一对应 # 函数:uuid.uuid4() # 入参:无,生成随机的UUID # 返回值:UUID对象,转成字符串后是36位的唯一随机串 # 逻辑:docs有多少个元素,就生成多少个ID,顺序和docs一一对应,第i个ID对应第i个文档 # 创建摘要文档列表(包含元数据) metadata可以用来关联原始文档和摘要文档会自动找到匹配关系 summary_docs = [ Document(page_content=s, metadata={id_key: doc_ids[i]}) for i, s in enumerate(summ) ] # 把摘要字符串包装成标准的Document对象,同时带上对应的文档ID元数据 # 为什么这么做:向量库只能存Document对象,而且必须在元数据里带上doc_id才能关联原文 # 列表推导式逻辑: # enumerate(summ):遍历摘要列表,同时拿到索引i和摘要内容s # page_content=s:文档内容就是摘要文本 # metadata={id_key: doc_ids[i]}:元数据里存入对应的文档ID # 结果:summary_docs是Document列表,每个摘要都绑定了对应原文的ID # 把压缩之后的内容添加到向量数据库 retriever.vectorstore.add_documents(summary_docs) # 把所有摘要文档存入向量数据库 # 为什么调用:只有把摘要存进向量库,后续才能做相似度检索 # 入参:summary_docs ------ 带元数据的摘要Document列表 # 入参来源:上一步生成的summary_docs # 内部执行逻辑: # 1. 提取每个摘要Document的文本内容 # 2. 用嵌入模型把文本转换成向量 # 3. 把向量、文本、元数据一起存入Chroma的summ集合 # 4. 每个摘要向量都绑定了对应的doc_id # 源文档加载到内容 retriever.docstore.mset(list(zip(doc_ids, docs))) # 批量把原始文档存入字节存储 # 为什么调用:把原文和ID绑定存入,后续检索到摘要ID后,才能取出对应的原始文档 # 函数:mset() ------ 批量设置键值对(multi set) # 入参:列表,每个元素是(键, 值)的元组,格式类似 [(id1, doc1), (id2, doc2), ...] # 入参来源: # - doc_ids:文档ID列表 # - docs:原始文档块列表 # - zip(doc_ids, docs):把两个列表按顺序配对成元组 # - list(...):转成列表格式,符合mset的入参要求 # 逻辑:key是文档ID,value是对应的原始Document对象,批量存入内存字节存储 # 到此,摘要和原文就通过doc_id一一对应起来了 # -------------------------- 【注释掉的完整RAG问答链】 -------------------------- # 下面这段是完整的「检索+回答」RAG流程,当前代码只演示检索过程,所以被注释掉了 # prompt = ChatPromptTemplate.from_template("根据下面的文档回答问题:\n\n{doc}\n\n问题: {question}") # 定义问答提示词模板 # 作用:给大模型的指令模板,有两个占位符:{doc}放检索到的参考文档,{question}放用户问题 # 为什么被注释:属于完整问答功能,当前只演示检索步骤,暂时注释 # # 生成问题回答链 # chain = RunnableMap({ # "doc": lambda x: retriever.invoke(x["question"]), # "question": lambda x: x["question"] # }) | prompt | llm | StrOutputParser() # 搭建完整的RAG问答链 # 为什么用RunnableMap:需要同时生成「doc」和「question」两个变量,传给后面的提示词模板 # 内部逻辑: # - "doc"分支:接收用户问题,调用检索器获取相关的原始文档 # - "question"分支:把用户问题原样传递 # 两个分支并行执行完,一起传给提示词模板,再调用大模型生成回答 # 为什么被注释:问答功能整体暂不运行,所以注释 # # # 生成问题回答 # query = "病假的请假流程?" # 定义测试用的用户问题 # 为什么被注释:对应问答链的测试,链被注释了,这里也一起注释 # answer = chain.invoke({"question": query}) # 调用问答链,传入问题得到回答 # 入参:字典,key为question,value为用户问题 # 返回值:大模型生成的回答字符串 # 为什么被注释:问答链整体注释,调用也一起注释 # print("-------------回答--------------") # print(answer) # 打印回答结果 # 为什么被注释:同上 # retrieved_docs = retriever.invoke(query) # 直接调用检索器,检索和问题相关的原始文档 # 入参:用户问题字符串 # 返回值:检索到的原始Document列表 # 为什么被注释:属于另一种检索演示方式,后面用了更底层的分步演示,这里注释 # print("-------------检索到的文档--------------") # print(retrieved_docs) # 打印检索到的原始文档 # 为什么被注释:同上 # -------------------------- 6. 演示分步检索 -------------------------- sub_docs = retriever.vectorstore.similarity_search("病假的请假流程?") # 第一步:直接调用向量库的相似度搜索,找到和问题最相似的摘要文档 # 为什么调用:演示多向量检索的「第一阶段------摘要召回」,看搜出来的摘要内容 # 函数:similarity_search() 相似度搜索 # 入参:查询字符串,这里是测试问题"病假的请假流程?" # 入参来源:手动输入的测试问题 # 可选入参: # k=4:默认返回前4个最相似的结果,可以手动指定数量 # filter:按元数据过滤结果 # 返回值:sub_docs是Document列表,每个元素是相似的摘要文档,元数据里带doc_id # 逻辑:把查询文本转成向量,和向量库里所有摘要向量计算余弦相似度,返回最相似的结果 print(sub_docs) # 打印所有检索到的摘要文档列表,查看完整召回结果 print("-------------检索到的文档--------------") print(sub_docs[0]) # 打印第一个(相似度最高的)摘要文档,查看最相关的摘要内容和它的元数据 summ_id = sub_docs[0].metadata[id_key] # 从相似度最高的摘要元数据里,取出对应的文档ID # 作用:拿到这个摘要绑定的原文ID,下一步去字节存储里取原始文档 # 来源:sub_docs[0].metadata字典中,key为id_key(也就是'doc_id')对应的值 orig_doc = retriever.docstore.mget([summ_id]) # 第二步:根据文档ID,从字节存储里取出原始文档 # 为什么调用:演示多向量检索的「第二阶段------取原文」,拿到完整的原始文档块 # 函数:mget() ------ 批量获取(multi get) # 入参:ID列表,这里是[summ_id],只查询一个ID # 入参来源:上一步从摘要元数据里提取的summ_id # 返回值:orig_doc是列表,每个元素是对应ID的原始Document对象 # 逻辑:根据doc_id去内存字节存储里查找对应的原始文档块,返回结果 print("-------------原始文档--------------") print(orig_doc) # 打印获取到的原始文档,查看完整的原文内容
