这一篇把两个核心能力串成一条线------文件上传 (把文档安全接进后端)和 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。