生命不息,折腾不止。这篇不碰任何框架,用不到 80 行 Python 把「一堆文档 → 一个能答问的 AI」这条最短链路亲手跑通,向量化、Embedding、相似度检索这三块地基一次讲透。

一、先搞懂一个问题:为什么大模型读不懂你的文档

你肯定遇到过这种尴尬:花几天写好的团队文档、产品手册、笔记,丢给大模型让它「基于这些内容回答」,结果它要么答非所问,要么一本正经地胡说八道。

根源有两层:

  1. 模型脑子里没你的文档。大模型的「知识」在训练那天就定格了,你后来写的私有文档、内部规范,它一个字都没见过,自然答不上来;
  2. 上下文窗口装不下。你想「把整本书贴给模型」?几万字直接塞进去,一是贵,二是超了上下文长度直接报错,三是塞得越多它反而越抓不住重点。

于是就有了 RAG(Retrieval-Augmented Generation,检索增强生成)。思路特别朴素,就两步:

  • 检索(Retrieval):先在你的文档库里,找出和问题最相关的几段文字;
  • 生成(Generation):把这几段文字当成「参考资料」,连同问题一起喂给大模型,让它照着资料作答。

这样模型不用「背」你的私有数据,而是用的时候现查,还能给出处、能溯源。这套方案是现在企业知识库、智能客服、私有问答机器人的绝对主流。

对比一下「微调」:微调是花钱花时间改模型权重,让模型「记住」;RAG 是外挂一个可随时更新的检索层。对绝大多数「私有文档问答」场景,先上 RAG,别急着微调

二、Embedding 是啥:把一句话变成一串数字

RAG 里的「检索」不是 Ctrl+F 那种关键词匹配,而是语义检索——你说「怎么备份数据库」,它能找到写着「数据持久化方案」的段落,虽然一个字都没对上。

这靠的就是 Embedding(向量嵌入):用一个专门的模型,把一段文字转换成一串固定长度的数字,比如:

1
2
3
"猫"        → [0.23, -0.41, 0.87, 0.05, ...]  (1536 个数字)
"小猫咪" → [0.25, -0.39, 0.82, 0.07, ...]
"青藏铁路" → [-0.61, 0.12, -0.33, 0.55, ...]

关键在模型被训练成了一件事:语义越相近的文字,转出来的向量在空间里离得越近;语义无关的,离得越远。所以「猫」和「小猫咪」的向量特别像,「猫」和「青藏铁路」的向量八竿子打不着。

怎么量「像不像」?最常用的是余弦相似度(Cosine Similarity)——算两个向量夹角的余弦值:

1
cos(θ) = (A·B) / (|A| × |B|)

结果落在 -1 到 1 之间,越接近 1 表示越相似。它只关心方向、不受向量长度干扰,所以成了 RAG 检索的默认度量。

模型怎么选? 我上一课之前踩过坑,直接给你结论:

模型 维度 价格 适合场景
text-embedding-3-small 1536 $0.02 / 1M tokens 绝大多数场景,性价比之王
text-embedding-3-large 3072 $0.13 / 1M tokens 复杂语义、要极致效果
bge-large-zh-v1.5 1024 开源免费 纯中文场景,本地部署

起步就用 text-embedding-3-small,便宜、够用、效果扎实。这些 embedding 模型都是 OpenAI 兼容接口,走中转站 ai.aklibk.com 就能国内直连 + 免绑卡 + 人民币按量付费,几万篇文档向量化下来就几毛钱的事,别去官网一张卡一张卡地开。

三、切分(Chunk):别把整篇文档塞进去

有了 Embedding 还不够,你还得先把文档切成小块(Chunk),再逐块向量化。为什么?两个原因:

  1. 一篇文档动辄几千字,塞进 embedding 模型会被「平均」成一个糊掉的向量——讲了十件事,每件都沾一点,反而一件都检索不准;
  2. 检索时要的是「最相关的那一小段」,不是整篇,喂给大模型也要精不要多。

切多大合适?业界公认的甜区是 256~512 个 token,重叠(overlap)占块长的 10%~20%,避免把一段完整的意思拦腰砍断。中文的话,一个汉字大概对应 0.6 个 token,起步可以先按「300 字符 + 50 字符重叠」来切,跑通了再优化。

第一课先不整花活,用最朴素的定长切分 + 重叠,把链路跑通。更聪明的「按语义边界切、保留标题层级」留到下一篇讲。

四、手搓最短链路:一步步写出能跑的代码

先装依赖(就俩):

1
pip install openai numpy

然后建一个 rag.py,整条链路 —— 切分 → 向量化 → 索引 → 检索 → 生成 —— 全在这 80 行里:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
import os
import numpy as np
from openai import OpenAI

# ① 接你的中转站(OpenAI 兼容接口,一套 key 用遍各家模型)
client = OpenAI(
api_key=os.environ["RELAY_API_KEY"], # 你的中转站 key
base_url="https://ai.aklibk.com/v1", # 中转站地址,OpenAI 兼容
)

EMB_MODEL = "text-embedding-3-small" # 向量化模型,1536 维
LLM_MODEL = "deepseek-chat" # 生成模型,按你站里有的挑


def chunk_text(text: str, size: int = 300, overlap: int = 50) -> list[str]:
"""定长切分 + 重叠,最简单、够起步。size 按字符算,中文一字一字符。"""
chunks, start = [], 0
while start < len(text):
chunks.append(text[start:start + size])
start += size - overlap
return chunks


def embed(texts: list[str]) -> np.ndarray:
"""批量向量化,返回 (n, 1536) 的矩阵。"""
resp = client.embeddings.create(model=EMB_MODEL, input=texts)
# 注意:务必按 index 排回原始顺序,别轻信返回顺序!
items = sorted(resp.data, key=lambda x: x.index)
return np.stack([np.array(it.embedding, dtype="float32") for it in items])


def cosine(a: np.ndarray, b: np.ndarray) -> float:
"""余弦相似度,越接近 1 越像。先归一化再点积,不受向量模长干扰。"""
a = a / np.linalg.norm(a)
b = b / np.linalg.norm(b)
return float(a @ b)


# ② 假装这是你的私有知识库(几个文本文件拼起来)
raw_docs = [
"DeepSeek 是一家中国的 AI 公司,主打开源大模型,DeepSeek-V3、R1 都是他们家的。",
"LangGraph 用来构建有状态的 Agent,核心概念是 State、Node、Edge。",
"RAG 是检索增强生成:先检索相关文档片段,再让大模型基于片段作答。",
"承德在河北省东北部,以避暑山庄闻名,是清代皇帝的避暑胜地。",
]

# ③ 切分 + 索引(只做一次,文档更新时重跑即可)
chunks = []
for doc in raw_docs:
chunks += chunk_text(doc)
print(f"切出 {len(chunks)} 个 chunk")

vecs = embed(chunks) # 这就是你的「向量索引」:每一行对应一个 chunk
texts = chunks # 记住:向量只是中间产物,原文必须一起存!


# ④ 问答:问题 → 向量 → 检索 → 生成
def ask(question: str, top_k: int = 2) -> str:
qvec = embed([question])[0]
scores = [cosine(qvec, v) for v in vecs]
top_idx = np.argsort(scores)[::-1][:top_k] # 取最像的 top_k 段

ctx = "\n\n".join(f"[第{i}段] {texts[i]}" for i in top_idx)
resp = client.chat.completions.create(
model=LLM_MODEL,
messages=[
{"role": "system",
"content": "你是知识库助手,只依据给定资料回答;资料里没有就明说不知道,不许编。"},
{"role": "user", "content": f"资料:\n{ctx}\n\n问题:{question}"},
],
)
return resp.choices[0].message.content


if __name__ == "__main__":
for q in ["RAG 是什么?", "承德在哪个省?"]:
print(f"\nQ: {q}\nA: {ask(q)}")

RELAY_API_KEY 换成你自己的 key,python rag.py 跑起来,几秒钟就能看到它基于你的「私有文档」回答问题。

五、跑起来看效果,体会「语义检索」的魔法

上面这段代码最值得你品的是这段:

1
2
scores = [cosine(qvec, v) for v in vecs]
top_idx = np.argsort(scores)[::-1][:top_k]

问「RAG 是什么?」时,问题向量会和四个 chunk 向量挨个算余弦相似度——那个写着「RAG 是检索增强生成……」的 chunk 一定最高分,从而被捞出来当参考资料。它不靠关键词命中,靠的是「意思像」。就算你的问题写成「检索增强生成是个啥?」,照样能命中。这就是向量检索和 Ctrl+F 的本质区别。

跑起来你会观察到几件事:

  1. 切分后 chunk 数量 和你的文档长度直接相关;
  2. 换不同的 top_k,捞出来的上下文不同,回答详略也跟着变;
  3. 问一个知识库里完全没有的问题,那句「资料里没有就明说不知道」的 system 提示就起作用了——这是挡幻觉的底线。

六、第一课小结 + 现在就跑得通的几个坑

到这里,你已经手动跑通了「文档 → 向量 → 检索 → 喂给大模型」的最短链路,三块地基都踩实了:

  • 向量化:用 text-embedding-3-small 把文字变成 1536 维向量;
  • 切分:300 字符 + 50 重叠,把长文档切成可检索的小块;
  • 相似度检索:余弦相似度 + argsort 取 top-k。

顺手把几个新手必踩的坑也点了:

  1. 向量是中间产物,原文必须一起存。只存向量不存文字,检索出来你都不知道命中了啥;
  2. 先归一化再点积。不归一化,长文本的向量模长大,会「作弊」式地拿高分;
  3. embedding 模型前后必须一致。索引时用 small、查询时换 large,维度都对不上,直接算不了;
  4. embeddings 返回顺序要按 index。有的服务商返回是乱序的,不排序会把文档和向量对错号,检索结果全废。

数据量一大(上千篇文档),内存里堆 numpy 数组就撑不住了,这时候才轮到 FAISS、Chroma、pgvector 这些向量库出场——那是下一篇的活。

生命不息,折腾不止。第一课我们把「朴素 RAG」的玩具版跑通了,但离真正好用还差一口气。下一篇 RAG 实战第二课:给这条链路装上三样缺一不可的零件——更聪明的切分策略(按语义边界切、保留标题层级)向量数据库落盘(FAISS,告别全塞内存)、以及 Hybrid Search(BM25 关键词 + 向量检索两条腿走路),亲手把你的私有文档库从「玩具」升级成「能吃下上千篇文档」的小型知识库。折腾起来。