agent学习Day23——文件上传与 Embedding 语义检索

这一篇把两个核心能力串成一条线------文件上传 (把文档安全接进后端)和 Embedding + 余弦相似度(让机器"看懂"语义)。两者看着不相关,其实都在为后面的向量检索和 RAG 打地基:上传是为了拿到原始文本,Embedding 是把文本变成计算机能计算的向量。


一、文件上传:让后端安全地把文档接进来

为什么需要文件上传

RAG 的第一个动作是"喂文档"。用户手里的 JD、简历、知识库,绝大多数都是文件,后端得有一个入口把它们接收进来。FastAPI 的 UploadFile 就是干这个的------它把 HTTP 的 multipart/form-data 请求直接封装成一个异步文件对象,你不用自己去解析底层的字节流。

UploadFile 的工作机制

关键点:UploadFile异步 的,file.read() 必须 await。FastAPI 默认把小文件放内存、大文件落临时磁盘,你拿到的 file 对象已经帮你把"内存还是磁盘""怎么流式读"这些脏活处理好了。

真正需要关心的:分层校验

文件上传最大的风险,从来不是"怎么收",而是"怎么安全地收"。一个来路不明的上传请求,可能带毒、可能超大把内存撑爆、也可能用特殊文件名写穿你的目录。所以必须在"收下"之前逐层把关。

一个稳健的上传处理,至少需要这几道关:

python 复制代码
# app/api/routes/files.py
@router.post("/upload")
async def upload(file: UploadFile = File(...)):
    # 1. 校验文件名存在(filename 可能为 None)
    if not file.filename:
        raise HTTPException(status_code=400, detail="缺少文件名")

    # 2. 校验扩展名(不读内容就能判断,省 IO)
    ext = os.path.splitext(file.filename)[1].lower()
    if ext not in ALLOWED_EXTENSIONS:
        raise HTTPException(status_code=400, detail="只支持 .txt 和 .md")

    # 3. 读内容
    content_bytes = await file.read()

    # 4. 校验空文件
    if not content_bytes:
        raise HTTPException(status_code=400, detail="文件不能为空")

    # 5. 校验大小
    if len(content_bytes) > MAX_FILE_SIZE:
        raise HTTPException(status_code=400, detail="文件太大")

    # 6. 校验可解码为 UTF-8 文本
    try:
        content_bytes.decode("utf-8")
    except UnicodeDecodeError:
        raise HTTPException(
            status_code=400, detail="无法解码,请确认是 UTF-8 文本"
        )

每一步对应一个真实威胁:

  • 文件名存在 :客户端可以不传文件名,直接拿 None 当文件名落盘会崩。
  • 扩展名合法 :限定只收 .txt / .md 这类纯文本,从源头挡掉可执行文件。注意扩展名只是"标"不是"质",后面还要配合内容校验。
  • 非空 / 大小:防撑爆存储和内存。
  • UTF-8 可解码:RAG 后面要拿文本切块、转向量,二进制乱码进来整个链路都会炸,所以必须在入口就确认它是真文本。

校验顺序有讲究:先校验便宜、能秒判的条件(扩展名、空、大小),最后才做"读内容 + 解码"这种费 IO 的操作。错误前置,省资源。

安全存储:文件名绝对不能用用户给的

用户传的 filename 不能直接拿来存盘------../../etc/passwd 这种相对路径能写穿你的目录,覆盖系统文件。解法是:落盘名由服务端随机生成,原始名只回显给用户、绝不用于路径。

python 复制代码
# app/api/routes/files.py
safe_name = f"{uuid.uuid4().hex}{ext}"          # 随机名,杜绝路径穿越
upload_dir = os.path.join("data", "uploads")
os.makedirs(upload_dir, exist_ok=True)
save_path = os.path.join(upload_dir, safe_name)
with open(save_path, "wb") as f:
    f.write(content_bytes)
return {"file_id": safe_name, "filename": file.filename, "size": len(content_bytes)}

踩坑记录

现象 真相
启动 uvicorn 报缺依赖 python-multipart 未装。FastAPI 是按需检查 multipart,只有用到 UploadFile/File(...) 才触发这个依赖,平时不报错
pytest 报 No module named 'app' 测试进程找不到包根,需要在项目配置里把项目根目录加进 pythonpath(如 ["."]
evil.exe 改名 .txt 绕过扩展名校验 扩展名只是"标",不是"质"。完整防御是 decode 校验 → magic number → 可打印字符比例三层

小结:文件上传的本质是"异步收字节 + 分层校验 + 安全存储"。校验顺序按成本排,文件名用随机化杜绝路径穿越。要记住------上传这件事,安全性和功能性一样重要。


二、Embedding 与余弦相似度:让机器"看懂"语义

为什么需要 Embedding

计算机比对文字,最初只能比字面------"便宜"和"廉价"字面零重合,意思却一样;"编程"和"写代码"字面不同,意思重叠。字面匹配一碰到同义词、近义词、不同表达方式就直接失效。

Embedding 的解法:把文字丢进模型,吐出一串数字(高维向量)。这串数字就是文字在"语义空间"里的坐标。语义相近 → 坐标相近,而距离是计算机能算的。你不用懂模型怎么训练,只需要:传文字进去,拿一串数字出来。

选用硅基流动的 BAAI/bge-large-zh-v1.5,输出 1024 维向量,免费且中文效果好。维度越高一般能承载越细的语义区分,但计算和存储成本也更高,1024 维在中文场景是效果和开销的平衡点。

余弦相似度:为什么不用欧氏距离

两个向量都拿到后,怎么比"多相似"?最直觉的欧氏距离(两点直线距离)有个坑:它把"长度"和"方向"混在一起了。

例子:[0.3, 0.4][0.6, 0.8] 方向完全一样(后者是前者的 2 倍),语义一模一样,但欧氏距离算出 0.5------把"长度不同"误判成了"不相似"。

语义相似度只该看方向,不该看长度。 方向用夹角描述,夹角用余弦算------这就是"余弦相似度"名字的由来。

公式:

复制代码
cos(A, B) = (A·B) / (|A| × |B|)
  • 分子 A·B:点积,对应分量相乘再相加
  • 分母 |A| × |B|:两个模长相乘(不是相加),模长 = 各分量平方和开根号
  • 值域 -1, 1:1 = 完全同方向(语义一致),0 = 无关,-1 = 反方向(NLP 里少见,因为向量分量通常为正)

用余弦而不是欧氏距离,本质就是把向量长度归一化,只保留方向信息

需要做的:调用 Embedding API

要跑通 Embedding,你需要三样东西:一个模型服务、一组访问凭证、明确用哪个模型

我们用硅基流动(兼容 OpenAI 接口),需要准备:

bash 复制代码
# .env
SILICONFLOW_API_KEY=sk-xxxxxxxx
SILICONFLOW_BASE_URL=https://api.siliconflow.cn/v1
EMBEDDING_MODEL=BAAI/bge-large-zh-v1.5

在配置里声明这三个字段(和 LLM 的 DEEPSEEK_* 写法一致):

python 复制代码
# app/core/config.py
# --- Embedding(硅基流动)---
SILICONFLOW_API_KEY: str
SILICONFLOW_BASE_URL: str
EMBEDDING_MODEL: str = "BAAI/bge-large-zh-v1.5"

核心是一个 EmbeddingService------它和 LLM 服务是同一套思路(都是用 openai 库 + 异步客户端),只是换配置来源、换调用方法:

python 复制代码
# app/services/embedding_service.py
from openai import AsyncOpenAI
from app.core.config import Settings


class EmbeddingService:
    def __init__(self, settings: Settings):
        self.settings = settings
        self.client = AsyncOpenAI(
            api_key=settings.SILICONFLOW_API_KEY,
            base_url=settings.SILICONFLOW_BASE_URL,
        )

    async def embed(self, text: str) -> list[float]:
        response = await self.client.embeddings.create(
            model=self.settings.EMBEDDING_MODEL,
            input=text,
        )
        return response.data[0].embedding

和 LLM 调用对照着记,一次就懂:

LLM 对话 Embedding 向量
方法 chat.completions.create embeddings.create
传参 messages=[...] input="一段文字"
取结果 response.choices[0].message.content response.data[0].embedding

自己实现余弦相似度

把公式翻译成代码,关键在 zip 配对对应分量、** 0.5 开根号:

python 复制代码
# scripts/test_similarity.py
def cosine_similarity(vec1: list[float], vec2: list[float]) -> float:
    dot_product = sum(a * b for a, b in zip(vec1, vec2))   # 点积
    norm_a = sum(a ** 2 for a in vec1) ** 0.5             # 模长 |A|
    norm_b = sum(b ** 2 for b in vec2) ** 0.5             # 模长 |B|
    return dot_product / (norm_a * norm_b)                # 分母:乘,不是加

验证这条路走得通

把三句话转成向量再互比,看数值是否符合直觉:

python 复制代码
# scripts/test_similarity.py
vec1 = await service.embed("Python 是一门编程语言")
vec2 = await service.embed("Java 是一门编程语言")
vec3 = await service.embed("今天天气很好")
print(cosine_similarity(vec1, vec2))   # 0.7951 → 语义近
print(cosine_similarity(vec1, vec3))   # 0.3175 → 语义远

前两句都讲"编程语言",cos 高;第三句讲天气,cos 低。说明 Embedding + 余弦相似度这条路确实能把"语义远近"算出来。

一个实用概念:阈值

实际做匹配时,不能只给个相似度数字,得划一条线:"cos > 0.7 算相关,否则不相关"。这个 0.7 叫阈值(threshold),是搜索和推荐系统的核心旋钮------调高了召回少但更准,调低了召回多但更杂。阈值没有标准答案,要按你的数据和场景反复试。

小结:Embedding 把"语义"变成"坐标",余弦相似度用"夹角"衡量语义远近。API 调用和 LLM 同宗(都是 openai 库),只是方法名换 embeddings.create、入参换 input、出参取 data[0].embedding


三、两个能力如何拼成 RAG 链路

文件上传和 Embedding 不是孤立的知识点,它们共同拼出 RAG 的前半段:

上传文件 → 读文本 → 切块 → Embedding 转向量 → 存向量库 → 用户提问时把问题也转向量 → 余弦相似度找最相关的块 → 喂给 LLM 生成答案

前半段(上传 + Embedding)解决的是"知识怎么进来、怎么变成可检索的向量";后半段(向量库 + 查询 + LLM 生成)解决的是"问题来了怎么找到对的上下文、怎么组织成答案"。这一篇把前两块的底座打牢,后面就是顺着这条链往下接的事。

全文小结:文件上传 = 异步收字节 + 分层校验 + 安全存储,重点在"安全地把文档接进来";Embedding + 余弦相似度 = 文字变向量 + 夹角算相似,重点在"让语义可被计算"。两者共同支撑后续向量检索与 RAG。

相关推荐
小白说大模型1 小时前
【MYSQL】MYSQL学习的一大重点:索引(下)- B+树
b树·学习·mysql
leisoo80971 小时前
筹码分布数据分析实战:用Python构建主力建仓成本分析系统
python·数据挖掘·数据分析
淼澄研学1 小时前
PyTorch 2.0 核心机制解析与5个实操方法
人工智能·pytorch·python
却道天凉_好个秋1 小时前
音视频学习(一百零一):IR-Cut
学习·音视频·ir-cut
森诺Alyson1 小时前
前沿技术借鉴研讨-2026.8.6(影响中国育龄妇女生育偏好的因素/分孕周血压与不良妊娠结局的风险)
论文阅读·经验分享·学习·论文笔记
若无情我 纸小铭1 小时前
Nginx学习笔记(二) Nginx--connection&request
笔记·学习·nginx
rogabet-note1 小时前
cef3和tkinter网页浏览器
python
半兽先生1 小时前
MinerU + LibreOffice 混合架构:搞定 .doc/.ppt 旧格式文档解析与切片
人工智能·python·机器学习·ai·架构
小趴蔡ha1 小时前
03 NumPy 入门:机器学习中的数组和矩阵
python·线性代数·机器学习·numpy