啊,这里也是第一期呢,正式开始了Agent的搭建。第一期Agent非常简陋,调用了DeepSeek的API Key,还有单文档静态RAG,以及一个动态知识库和用streamlit写的图形化页面(呜呜呜呜呜具体的代码我已经删掉了捏,因为我的Agent的进阶是直接在上一版的代码上补充修改的)
怎么说捏,第一期还是非常简单的,设计到的技术栈先来一个一个总结吧
API Key的调用
采用了OpenAI的SDK格式去调用API Key
文本切片
这一点还是相对来说比较关键的,我们不能把一个几百页的PDF去直接扔给大模型,所以我们就需要将文本切片,同时,为了保证部分语义不缺失,避免同一句话被切成了两个chunk,我们会采取重叠的措施。
比如说,我们要背诵一本厚厚的操作系统课本,我们不能把这个课本整个的塞进脑子,你们大模型也是一样,受token限制,所以我们就需要将文本进行切片处理。
但是,切片也有一定的坏处,比如说我现在有一段话,但是因为上下文的限制被分到了两个切片,有的时候会造成大模型无法理解语义,那么这个时候就有了重叠。
何为重叠?就是两个切片,比如一个切片500字,我们会让第二个切片的前50字是第一个切片的最后50字,那么有了重叠,就可以让大模型在一定程度上保证语义的连续
向量化与相似度检索
这是RAG系统最核心的“黑科技”。大模型其实并不认识汉字,它只认识数字。
向量化 (Embedding): 我们把切好的一段段文字,丢给一个专门的模型( bge-small-zh),它会把这段话转化成一串很长的数字(比如几百个坐标点)。这个过程就像是给每段话在“知识地图”上找了一个精准的经纬度坐标。
向量数据库 (Qdrant): 这就是一个专门存这些“经纬度坐标”的仓库。
相似度检索: 当你提问“什么是冯·诺依曼架构?”时,系统先把你这个问题也转成一串数字(坐标),然后在仓库里看哪个文档的坐标离你最近。
为什么要这么麻烦? 传统的搜索(比如按 Ctrl+F)是死板的匹配。比如你搜“番茄”,它可能找不到“西红柿”。但向量化能理解语义——它知道“番茄”和“西红柿”在地图上的位置非常近,所以即使字面上不一样,它也能帮你精准地把答案找出来。
那向量化是怎么工作的呢?
你可以把向量化想象成给文字打标签,只不过这个标签不是一个词,而是成百上千个维度。
-
假设我们只有两个维度:可爱程度和体积大小。
-
小猫的坐标可能是 (非常可爱,体积很小)。
-
老虎的坐标可能是 (不可爱/凶猛,体积很大)。
-
-
语义相关性: 在这个坐标系里,小猫和小狗的距离会非常近,而小猫和挖掘机的距离会非常远。
在实际代码中,我用的 bge-small-zh 模型,它会将一段话转化为 512 个或 768 个数字组成的列表。这些数字共同定义了这句话在知识地图上的位置。
在 LangChain 中,实现向量化(Embedding)的代码其实非常简洁,因为它把复杂的数学运算都封装好了。以下是你项目中核心逻辑的还原:
from langchain_community.embeddings import HuggingFaceBgeEmbeddings
model_name = "BAAI/bge-small-zh-v1.5"
encode_kwargs = {'normalize_embeddings': True} # 归一化,方便计算余弦相似度
embeddings = HuggingFaceBgeEmbeddings(
model_name=model_name,
model_kwargs={'device': 'cuda'},
encode_kwargs=encode_kwargs
)
# 将文字转为向量(示例)
text = "操作系统是管理计算机硬件与软件资源的程序。"
query_result = embeddings.embed_query(text)
# 打印前 5 个数字看看
print(f"向量长度: {len(query_result)}")
print(f"前5位特征值: {query_result[:5]}")当你问 AI 问题时,Qdrant 数据库其实在做一道几何题:
-
把你的问题转成一个向量 。
-
把数据库里成千上万个文档片段的向量 拿出来。
-
计算两个向量之间夹角的余弦值。夹角越小,余弦值越接近 1,说明这两句话聊的是同一个话题。
换句话说,向量化就是把“文字”变成了坐标,而搜索就是看哪个文档离你的问题最近。
Qdrant又是什么呢
传统的MySQL等数据库是通过表格,通过结构化存储数据,是关系型数据库,而Qdrant是向量数据库,他是通过去判断两个元素长的像不像,特征重合的程度去查找数据,它存储和索引的是由深度学习模型生成的高维浮点数数组。
在你的代码里,我们会经常接触到这三个词:
Collection(集合): 相当于一张“表”。
Point(点): 数据库里的最小单位。每个“点”包含两个部分:
-
Vector(向量): 那串 512 位的数字坐标。
-
Payload(载荷): 对应的那段原始文字、文件名、页码等。
Distance Metric(距离算法): 也就是我们刚才提到的“余弦相似度”。你得告诉 Qdrant:“请用夹角的大小来帮我判断它们像不像”。
from langchain_community.vectorstores import Qdrant
from qdrant_client import QdrantClient
# 连接 Qdrant
client = QdrantClient(path="./local_qdrant")
# 将文档存入 Qdrant:切片 -> 向量化 -> 存入 Qdrant
qdrant = Qdrant.from_documents(
docs,
embeddings,
path="./local_qdrant",
collection_name="my_knowledge_base"
)当时选择的是 path="./local_qdrant" 模式。这意味着不需要安装复杂的 Docker 或者开启专门的服务。它就像 SQLite 一样,直接在你的硬盘里生成一个文件夹。只要这个文件夹在,你的“知识库”就是持久化的,下次启动程序,AI 依然记得你之前喂给它的 PDF。
动态检索
MD5哈希算法
如果说没有这个算法,我上传了first.pdf,再一些对话过后,我再次上传一遍first.pdf,系统还是会傻傻去分析这个新文档,并且切片、存入Qdrant,这并不是一个好的情况,所以我们引入了MD5哈希算法
MD5是一种特殊的函数。无论你的文本有多长,它都能吐出一个固定长度的“乱码”字符串。
-
唯一性: 哪怕你只改了文本里的一个标点符号,生成的 MD5 字符串也会翻天覆地。
-
确定性: 同样的文本,无论算多少次,得到的 MD5 永远是一样的。
所以,在上传了一个文本之后,我们不是直接将其操作丢入数据库,而是先通过MD5函数,生成他的身份证号
我们不是直接把文本扔进数据库,而是先算一下它的 MD5:
import hashlib
text = "这是操作系统的一段内容..."
# 生成唯一的 ID
content_id = hashlib.md5(text.encode('utf-8')).hexdigest()在存入 Qdrant 时,把这个 content_id 作为 Point 的 ID,这样下次再存入相同内容时,Qdrant 发现 ID 已存在,就会覆盖而不是新增
Metadata 元数据
它的核心作用:
-
精准删除:你想删掉“操作系统.pdf”的相关内容,但不想动“计算机网络.pdf”。如果没有元数据,数据库里全是零散的句子,你根本分不清谁是谁。
-
过滤检索:你可以命令 AI:“只在‘第一章’的范围内寻找答案”。
高级检索增强
那么这里,我们为了避免文档是A但我们提问是A*从而导致搜不到的情况,我们引入了中转站,当你输入一个问题时,系统不再直接跑去数据库,而是先找大模型:
-
大模型头脑风暴: 我们告诉大模型:“用户问了这个问题,请你从不同角度生成 3 个意思相近的问题。”
原问: “冯·诺依曼架构的特点”
扩展 1: “冯·诺依曼计算机由哪几部分组成?”
扩展 2: “冯·诺依曼架构的核心原理是什么?”
扩展 3: “简述冯·诺依曼体系结构的定义。”
-
多路召回: 系统带着这 4 个问题(原问 + 3 个扩展问)同时去 Qdrant 数据库里搜。
-
结果合并: 把搜回来的所有相关片段放在一起,去重,然后交给大模型总结答案。
from langchain.retrievers.multi_query import MultiQueryRetriever
from langchain_openai import ChatOpenAI
# 设置大模型作为“扩展器”
llm = ChatOpenAI(model="deepseek-chat", ...)
# 初始化多查询检索器
retriever_from_llm = MultiQueryRetriever.from_llm(
retriever=qdrant.as_retriever(), # 基础检索器(去 Qdrant 搜)
llm=llm # 负责生成变体问题的模型
)
# 执行检索
# 此时 unique_docs 里包含了从 4 个不同角度搜回来的所有精华
unique_docs = retriever_from_llm.get_relevant_documents(query="冯·诺依曼架构的特点")乌拉乌拉,第一期就到这里了,总之在搭建的过程中呢也遇到了很多的问题,涉及到许多新奇的知识,还是很有意思的