【从 0 到 1 动手造 Agent】(组件篇)06、向量库:让 Agent 按「意思」找东西

上一章的记忆系统有一个致命的软肋:它只认字,不认意思。

客人说「我不吃折耳根」,你存的是「我不吃鱼腥草」------同一个东西,一个字都对不上。

这一章,我们让机器学会按「意思」找东西。


一、先讲一个储藏室的故事

接着上一章的厨房。

冰箱收拾利索了,大厨能记住「客人不吃香菜」了。但这家店还有一间储藏室------里面堆着十万份食材、配方和历史记录。

客人推门进来,说:「今天想吃点提鲜的。」

大厨走进储藏室,傻眼了。因为货架上贴的标签是「干贝」「火腿」「菌菇」「味精」------没有一样东西叫「提鲜的」。

按名字找,永远找不到。

客人又说:「上次那道又酸又辣的菜,再来一份。」

大厨翻遍货架,找出了「酸菜」「辣椒」「醋」「花椒」------但「又酸又辣」这个感觉,不在任何一个标签上。

这就是关键词检索的死穴:它匹配的是字,可人说的是意思。

一个真正的储藏室,应该能这样工作:我描述一种「味道」,你把最接近的几样东西挑出来。

这就是向量库要做的事。


二、先把一件事说清楚:向量库 ≠ Embedding 模型

在动手之前,必须先破除一个常见的混淆。

「向量库」这个词,其实包含了两件完全不同的事:

flowchart LR subgraph A[1 把文字变成向量] A1[输入:一段文字] A2[输出:一串数字坐标] end subgraph B[2 拿到向量之后怎么用] B1[存起来] B2[找最像的几条] B3[在百万条里毫秒级找到] end A --> B A1 -.->|这件事由 embedding 模型负责<br/>- API 调用或本地模型| A2 B1 -.->|这才是向量库的本体| B3 style A fill:#F1F3F4,stroke:#9AA0A6,color:#1A1A1A style B fill:#E8F0FE,stroke:#4285F4,color:#1A1A1A
  • 第一件事 (文字 → 坐标)今天已经彻底商品化了。你有三种选择:调 API、跑本地模型、或者用最原始的统计方法自己造。它不难,也不需要你懂。
  • 第二件事 (存、找、快)才是向量库的本体,也是这一章的主攻方向。

为什么要这么分?因为很多教程把这两件事搅在一起讲,结果是读者学完了也不知道哪部分是自己该写的,哪部分是调个接口就行的

这一章的代码会用一个零依赖的字面量向量化器 顶替第一件事,好让整个向量库能跑起来。它当然比不上真实 embedding,但它在演示里的失败,恰好能让你看清真实 embedding 的价值在哪里。


三、Embedding 到底是什么?一个被神化了的词

3.1 词向量的秘密:意思相近的词,出现在相似的上下文里

先看一个事实,它会颠覆你对「AI 语义理解」的想象:

词向量不需要神经网络,用最原始的统计方法就能造出来。

原理只有一句话:

意思相近的词,出现在相似的上下文里。

「番茄」和「西红柿」,这两个词一个字都不重合。但它们出现在什么样的句子里?

  • 番茄炒蛋 、番茄沙拉 、番茄鸡蛋汤
  • 西红柿炒蛋 、西红柿沙拉 、西红柿鸡蛋汤

上下文几乎一样。

那么,只要统计「每个词都和谁一起出现」,就能得到一个向量;上下文相似的词,向量自然就靠得近。

这就是 word2vec 的本质------它做的也是这件事,只是用了更聪明的方式去压缩和降维。

3.2 用 20 行代码把它算出来

python 复制代码
def build_word_vectors(corpus: list[str], window: int = 2) -> dict[str, list[float]]:
    """用「共现统计」造词向量 ------ 词向量最原始、也最本质的形态。"""
    docs = [d.split() for d in corpus]

    co: dict[str, Counter] = defaultdict(Counter)
    vocab: Counter = Counter()
    for tokens in docs:
        vocab.update(tokens)
        for i, tok in enumerate(tokens):
            lo = max(0, i - window)
            hi = min(len(tokens), i + window + 1)
            for j in range(lo, hi):
                if i != j:
                    co[tok][tokens[j]] += 1            # ★ 统计「谁和谁一起出现」

    words = sorted(vocab)
    row_sum = {w: sum(co[w].values()) for w in words}
    col_sum: Counter = Counter()
    for w in words:
        for k, v in co[w].items():
            col_sum[k] += v
    total = sum(row_sum.values()) or 1

    vectors: dict[str, list[float]] = {}
    for w in words:
        row = []
        for c in words:
            # PPMI:正的点互信息。负数一律压成 0,噪音太大。
            pmi = math.log(
                (co[w][c] / total)
                / ((row_sum[w] / total) * (col_sum[c] / total) + 1e-12)
                + 1e-12
            )
            row.append(max(0.0, pmi))
        vectors[w] = normalize(row)
    return vectors

喂给它 8 句刻意构造的句子,看看算出来什么:

text 复制代码
═══ 演示一 · 词向量:意思相近的词,出现在相似的上下文里 ═══

  cos(番茄  , 西红柿   ) = +0.644
  cos(炒蛋  , 沙拉    ) = +0.351
  cos(番茄  , 数据库   ) = +0.000
  cos(索引  , 检索    ) = +0.308
  cos(索引  , 番茄    ) = +0.000

  ↑ 「番茄」和「西红柿」一个字都不重合,向量却明显靠近 ------
    因为它们总出现在同样的上下文里(炒蛋、沙拉、鸡蛋汤)。
    而「番茄」和「数据库」从不共现,相似度直接是 0。
    ★ 这就是词向量的全部秘密:意思相近的词,出现在相似的上下文里。

「番茄」和「西红柿」相似度 0.644,「番茄」和「数据库」相似度 0.000。

一个只用了 8 句话、纯统计、没有一行神经网络的程序,居然真的把「语义」算出来了。

3.3 所以,真实的 embedding 贵在哪?

真实的 embedding 模型(比如各家 API 提供的那些,或者本地能跑的开源模型)做了同一件事,只是:

维度 我们这 20 行 真实 embedding 模型
语料规模 8 句话 万亿级 token
上下文窗口 前后 2 个词 整段文本
维度 词表大小 几百到几千维(压缩过)
能处理的单位 整句、整段
「折耳根」=「鱼腥草」 ❌ 算不出来 ✅ 大概率能

从「统计共现」到「理解语义」,中间隔的是数据量,不是魔法。

这个概念一旦立住,后面的一切就都好理解了------embedding 就是坐标,向量库就是一套在坐标空间里找邻居的索引结构。

而找邻居这件事,跟文字已经没关系了。 它就是纯粹的几何问题。


四、余弦相似度:两个坐标有多像?

有了坐标,怎么衡量「像不像」?

答案是看两个向量的夹角

flowchart LR O([原点]) --> A[向量 A<br/>- 番茄] O --> B[向量 B<br/>- 西红柿] O --> C[向量 C<br/>- 数据库] A -.->|夹角很小<br/>-> 很相似| B A -.->|夹角接近 90度<br/>-> 不相关| C style A fill:#FFF4E5,stroke:#FB8C00,color:#1A1A1A style B fill:#FFF4E5,stroke:#FB8C00,color:#1A1A1A style C fill:#E8F0FE,stroke:#4285F4,color:#1A1A1A

为什么用夹角而不是距离?因为夹角只看方向,不看长度。

一篇 10000 字的长文和一句 10 个字的短句,讲的是同一件事。它们的向量长度 差很远,但方向应该一致。用夹角衡量,长短就不影响了。

python 复制代码
def normalize(vec: list[float]) -> list[float]:
    """把向量拉成单位长度 ------ 之后算余弦就只要点积。"""
    norm = math.sqrt(sum(v * v for v in vec))
    return vec if norm == 0 else [v / norm for v in vec]


def cosine(a: list[float], b: list[float]) -> float:
    """余弦相似度。因为都归一化过了,点积就是余弦。"""
    return sum(x * y for x, y in zip(a, b))

▍一个工程上的小技巧:入库前先把所有向量归一化。

归一化之后,向量长度恒为 1,两个单位向量的点积就等于它们的余弦。于是查询时只需要一次乘加,省掉了开根号和除法。

百万级库上,这个优化能省掉可观的算力。这就是「工程」和「原理」的差别------原理一样,落地时处处是细节。


五、真正的难题:怎么找得够快?

5.1 暴力检索:准,但慢得没法用

最直接的做法是:把库里每一个向量都算一遍相似度,然后排序。

python 复制代码
class BruteForce:
    def search(self, query, vectors, k=3):
        scored = [(i, cosine(query, v)) for i, v in enumerate(vectors)]
        scored.sort(key=lambda pair: -pair[1])
        return scored[:k]

这个做法叫暴力检索 ,它的优点是绝对准确------你找到的一定是最近的 k 个。

但它有个致命的问题:复杂度是 O(n)。

  • 1 万条向量:还行;
  • 100 万条向量:每次查询要算 100 万次点积,几百毫秒;
  • 1000 万条:几秒钟。Agent 卡在那儿等检索结果,体验直接崩了。

而真实的 RAG 场景,知识库动辄百万级 chunk。暴力检索根本撑不住。

5.2 三条加速路线

工业界有三条思路,通常组合使用:

flowchart TB P[怎么在百万条里<br/>毫秒级找到最近的几条?] P --> A[1 少算一点<br/>IVF 和 HNSW] P --> B[2 算得快一点<br/>PQ 量化] P --> C[3 先用便宜的筛一遍<br/>两阶段检索] A --> A1[先粗筛出候选集<br/>只对候选集精算] B --> B1[把向量压缩<br/>用更少的字节表示] C --> C1[先用关键词召回<br/>再用向量精排] style P fill:#E8F0FE,stroke:#4285F4,color:#1A1A1A style A fill:#FFF4E5,stroke:#FB8C00,color:#1A1A1A style B fill:#FFF4E5,stroke:#FB8C00,color:#1A1A1A style C fill:#FFF4E5,stroke:#FB8C00,color:#1A1A1A

这一章我们手写第一条路线里最经典的 IVF 索引------因为它最好理解,也最能体现「近似最近邻」的精髓。

5.3 IVF:先分桶,再找邻居

IVF(倒排文件索引)的思路,用储藏室打比方就是:

不要每次都在十万个货架里翻。先按大类分成 100 个区,每个区选一个「区代表」。

客人来了,先看哪个区的代表最对味,只去那两三个区翻。

对应到算法上:

  1. 建索引:用 k-means 把所有向量聚成 N 个簇,每个簇算一个「中心点」;
  2. 查询 :先找和查询向量最接近的几个中心点,只在这些簇里暴力扫
python 复制代码
class IVFIndex:
    """倒排文件索引:先聚类分桶,查询时只扫最近的几个桶。

    用一点点准确率,换几十倍的速度。这就是「近似最近邻」的全部思路。
    """

    def __init__(self, n_clusters: int = 4, n_probe: int = 2, iters: int = 15):
        self.n_clusters = n_clusters
        self.n_probe = n_probe          # 查询时扫几个桶
        self.iters = iters
        self.centroids: list[list[float]] = []
        self.buckets: list[list[int]] = []

    def build(self, vectors: list[list[float]]) -> None:
        if not vectors:
            return
        n = min(self.n_clusters, len(vectors))

        # ── 初始化:均匀挑 n 个点当初始质心(确定性,方便复现)──
        step = max(1, len(vectors) // n)
        self.centroids = [list(vectors[i * step]) for i in range(n)]

        # ── 手写 k-means ──
        for _ in range(self.iters):
            buckets: list[list[int]] = [[] for _ in self.centroids]
            for idx, vec in enumerate(vectors):
                best = max(
                    range(len(self.centroids)),
                    key=lambda c: cosine(vec, self.centroids[c]),
                )
                buckets[best].append(idx)          # ★ 每个向量归到最近的簇

            moved = False
            for c, members in enumerate(buckets):
                if not members:
                    continue
                dim = len(vectors[0])
                merged = [
                    sum(vectors[m][d] for m in members) / len(members)
                    for d in range(dim)
                ]
                new_centroid = normalize(merged)   # ★ 把簇心挪到成员的均值处
                if cosine(new_centroid, self.centroids[c]) < 0.999999:
                    moved = True
                self.centroids[c] = new_centroid
            if not moved:                          # 簇心不再移动 → 收敛
                break

        self.buckets = buckets

    def search(self, query, vectors, k: int = 3):
        # ① 先找最近的 n_probe 个质心
        nearest = sorted(
            range(len(self.centroids)),
            key=lambda c: -cosine(query, self.centroids[c]),
        )[: self.n_probe]

        # ② 只在这些桶里暴力扫
        candidates = [i for c in nearest for i in self.buckets[c]]
        scored = [(i, cosine(query, vectors[i])) for i in candidates]
        scored.sort(key=lambda pair: -pair[1])
        return scored[:k], len(candidates)

这段代码只做了两件事:把相近的向量放到同一个桶里,查询时只翻几个桶。

效果如何?用 200 条分属 8 个主题的文档实测:

text 复制代码
═══ 演示四 · 索引:用一点点准确率换速度 ═══

  向量总数:200,聚成 8 个桶、每次查 2 个桶
  暴力检索:扫描  200 个向量,耗时 3.82 ms
  IVF 检索:扫描   50 个向量,耗时 1.31 ms

  → 扫描量降到 25%,速度提升 2.9 倍
  → 结果是否一致:是

  ★ 这就是「近似最近邻」:少扫 3/4 的向量,结果依然对。
    真实场景的 HNSW 索引在百万级数据上,能省掉 99% 以上的扫描。

扫描量降到 25%,速度快了近 3 倍,而结果一模一样。

注意这里的结果「碰巧」完全正确------因为我们的测试数据聚类结构非常干净。真实数据上,IVF 会有一定概率漏掉真正的最近邻。

这就是「近似」二字的代价:拿一点点召回率,换几十倍的速度。 在 RAG 场景里,这个交换几乎永远是划算的------因为后面还有模型兜底。

5.4 HNSW 是什么?

工业界目前最主流的索引是 HNSW(分层可导航小世界图)。它的思路完全不同,但同样优美:

想象你要在一个陌生城市里找一个地址。

你不会一条街一条街地扫。你会先看全国地图(粗粒度),定位到大概的省;再看市区地图,定位到大概的区;最后才看街道图,找到具体的门牌号。

HNSW 就是把向量组织成多层图:上层稀疏、跨度大(全国地图),下层密集、跨度小(街道图)。查询时从最上层开始,一层层往下逼近。

它的优点是又快又准 ,缺点是建索引慢、内存占用大

索引 建库速度 查询速度 召回率 内存
暴力 不用建 最慢 100% 最小
IVF 高(可调)
HNSW 最快 很高
IVF + PQ 中等 极小

生产环境的选型经验:数据量 < 10 万,直接暴力检索就够;10 万 ~ 1000 万,IVF 或 HNSW;上亿,IVF + PQ。


六、比索引更重要的事:切块

讲完了索引,必须回头讲一件更容易被忽略、却更影响效果的事。

6.1 切菜的大小,决定了这锅菜好不好炒

向量库通常不存整篇文档,而是存切好的小块(chunk)。原因很简单:

  • 一整篇文章的向量,是「平均」出来的------什么都像,又什么都不像
  • 小块才可能有明确的语义焦点,检索时才分得清。

但切多大?这是一个没有标准答案、却处处是坑的问题:

切法 后果
切太小(50 字) 每块信息不全,模型拿到手也不知道上下文
切太大(2000 字) 语义被稀释,检索精度暴跌
硬切、不重叠 一句话被拦腰截断,两半都读不懂

6.2 重叠区:防止句子被腰斩

我们的实现加了最简单也最有效的一招------相邻块之间留一段重叠

python 复制代码
def chunk(text: str, size: int = 100, overlap: int = 20) -> list[str]:
    """把长文本切成小块。

    overlap 是「重叠区」------上一块的尾巴会出现在下一块的开头。
    为什么需要它?因为一句话可能正好被切断,重叠能保证它至少完整地
    出现在某一块里。
    """
    text = text.strip()
    if len(text) <= size:
        return [text] if text else []

    pieces, start = [], 0
    while start < len(text):
        end = start + size
        pieces.append(text[start:end])
        if end >= len(text):
            break
        start = end - overlap          # ★ 回退 overlap 个字符
    return pieces

跑起来看:

text 复制代码
═══ 演示二 · 切块:重叠区保证句子不被拦腰截断 ═══

  第 1 块 │ 后厨的流程是这样的。首先由配菜工位负责清洗和切配,把食材处理成可以直接下锅的状态
  第 2 块 │ 成可以直接下锅的状态。然后交给炒锅工位,由大厨掌勺完成烹饪。最后是装盘工位,负责
  第 3 块 │ 最后是装盘工位,负责摆盘和出餐。整个流程中,传菜单是唯一的共享状态。

看第 1 块和第 2 块的衔接处------「成可以直接下锅的状态」这半句,完整地出现在了第 2 块的开头。

如果没有重叠,「把食材处理成可以直接下锅的状态」这句会被切成「把食材处理成可以直」和「接下锅的状态」,两块都读不通,检索出来也没法用。

切块是 RAG 里最不性感、却最影响效果的一步。 索引决定了「找得快不快」,切块决定了「找得准不准」------而后者往往更重要。


七、混合检索:向量不是万能的

7.1 向量检索的两个软肋

向量检索擅长「意思相近」,但在两种情况下会翻车:

  1. 专有名词、编号、代码 。你搜「错误码 E1024」,向量检索可能给你返回一堆「错误码 E2048」「错误码 E1024 的变体」------它分不清这几个数字的差别
  2. 长尾词。语料里从没出现过的词,embedding 模型没见过,向量质量很差。

而这两类,恰恰是关键词检索(BM25)最擅长的

7.2 两路合并

所以工业界的标准做法是混合检索

flowchart LR Q[查询] --> V[向量路<br/>按意思找] Q --> B[关键词路<br/>BM25 按字找] V --> M[按权重合并分数] B --> M M --> R[最终 Top-K] style Q fill:#E8F0FE,stroke:#4285F4,color:#1A1A1A style V fill:#FFF4E5,stroke:#FB8C00,color:#1A1A1A style B fill:#FFF4E5,stroke:#FB8C00,color:#1A1A1A style R fill:#E6F4EA,stroke:#34A853,color:#1A1A1A

BM25 是经典的关键词打分算法,核心思想是:一个词在这篇文档里出现得越多、在整个库里出现得越少,它就越重要。

python 复制代码
class BM25:
    """经典关键词检索。补向量检索的短板:专有名词、编号、精确匹配。"""

    def __init__(self, docs: list[str], k1: float = 1.5, b: float = 0.75):
        self.k1, self.b = k1, b
        self.docs_tokens = [ngrams(d, ns=(1, 2)) for d in docs]
        self.doc_len = [len(t) for t in self.docs_tokens]
        self.avg_len = (sum(self.doc_len) / len(self.doc_len)) if self.doc_len else 1
        self.n = len(docs)

        self.tf: list[Counter] = [Counter(t) for t in self.docs_tokens]
        df: Counter = Counter()
        for counter in self.tf:
            df.update(counter.keys())
        # ★ IDF:越罕见的词,权重越高
        self.idf = {
            term: math.log(1 + (self.n - freq + 0.5) / (freq + 0.5))
            for term, freq in df.items()
        }

    def scores(self, query: str) -> list[float]:
        q_terms = ngrams(query, ns=(1, 2))
        out = []
        for i in range(self.n):
            score = 0.0
            for term in q_terms:
                f = self.tf[i].get(term, 0)
                if f == 0:
                    continue
                # ★ 词频饱和:同一个词出现 10 次和 100 次,不该差 10 倍
                denom = f + self.k1 * (1 - self.b + self.b * self.doc_len[i] / self.avg_len)
                score += self.idf.get(term, 0.0) * f * (self.k1 + 1) / denom
            out.append(score)
        return out

合并分数就一行:

text 复制代码
最终分数 = alpha × 向量分数 + (1 - alpha) × 关键词分数

alpha 一般在 0.5 ~ 0.7 之间------向量路稍占主导,关键词路负责兜底。

7.3 组装成向量库

python 复制代码
class VectorStore:
    def __init__(self, embedder=None, use_index: bool = True, n_clusters: int = 4):
        self.embedder = embedder or HashingEmbedder()
        self.use_index = use_index
        self.n_clusters = n_clusters
        self.chunks: list[str] = []
        self.vectors: list[list[float]] = []
        self.index: IVFIndex | None = None
        self.bm25: BM25 | None = None
        self.last_scanned = 0

    def add_text(self, text: str, size: int = 100, overlap: int = 20) -> int:
        """把一篇长文切块、向量化、入库。返回切了多少块。"""
        pieces = chunk(text, size=size, overlap=overlap)
        for piece in pieces:
            self.chunks.append(piece)
            self.vectors.append(self.embedder.embed(piece))
        self._rebuild()
        return len(pieces)

    def search(self, query: str, k: int = 3, alpha: float = 0.5):
        """混合检索:向量分数 × alpha + 关键词分数 × (1-alpha)。"""
        if not self.chunks:
            return []

        q_vec = self.embedder.embed(query)

        # ── 向量路 ──
        if self.index is not None:
            vec_hits, self.last_scanned = self.index.search(
                q_vec, self.vectors, k=len(self.chunks)
            )
        else:
            vec_hits = BruteForce().search(q_vec, self.vectors, k=len(self.chunks))
            self.last_scanned = len(self.vectors)
        vec_scores = dict(vec_hits)

        # ── 关键词路 ──
        raw_bm25 = self.bm25.scores(query) if self.bm25 else [0.0] * len(self.chunks)
        top_bm25 = max(raw_bm25) or 1.0
        bm_scores = {i: s / top_bm25 for i, s in enumerate(raw_bm25)}

        # ── 合并 ──
        merged = []
        for i in range(len(self.chunks)):
            s = alpha * vec_scores.get(i, 0.0) + (1 - alpha) * bm_scores.get(i, 0.0)
            merged.append((i, s))
        merged.sort(key=lambda pair: -pair[1])

        return [(self.chunks[i], round(s, 4)) for i, s in merged[:k]]

八、跑起来看看

往库里放四篇文档,然后问它三个问题:

text 复制代码
═══ 演示三 · 混合检索:向量 + 关键词 ═══

  📥 后厨流程:切成 3 块
  📥 记忆系统:切成 2 块
  📥 沙盒隔离:切成 2 块
  📥 向量检索:切成 2 块

  库中共 9 块向量,维度 512

  问:怎么保证搞砸了不影响主机?
     [0.614] 换掉,宿主机毫发无损。长连接保证状态可以继承。...
     [0.601] Agent 要伸进真实世界,需要隔离。每条命令都在独立的沙盒里执行,搞砸了就整间换掉...

  问:记忆什么时候该忘记?
     [0.732] 记忆的难点不是存,而是三个决策:什么时候写、捞哪条出来、什么时候忘。检索时用相关性、...
     [0.063] 后厨的流程是这样的。首先由配菜工位负责清洗和切配,把食材处理成可以直接下锅的状态。然...

  问:怎么加快检索速度?
     [0.609]  HNSW 这类近似最近邻索引,用一点点准确率换几十倍的速度。...
     [0.570] 向量库把文本变成坐标,意思相近的文本坐标也相近。为了加速检索,通常用 IVF 或 H...

三个问题全部命中了正确的文档。

特别注意第一个问题 ------「怎么保证搞砸了 不影响主机 」,这两个词在原文档里出现的是「宿主机」和「整间换掉」。字面上并不完全对应,但混合检索依然把它找出来了。

也特别注意第二个问题的第二条结果 ------分数只有 0.063,却依然被返回了。

这是一个必须提醒的坑:向量检索永远会给你返回 Top-K,哪怕它其实一个都不相关。

所以真实系统里必须设一个最低分阈值------低于这个分数就返回「没找到」,而不是硬塞几条不相关的内容给模型。

让模型读到「我没找到」,远比读到「一堆看似相关其实是噪音」的内容要好。

8.1 诚实地说说这个实现的短板

必须坦白:这一章的向量化器是字面量哈希,不是真正的语义 embedding。它带来两个明确的失败:

场景 我们这个实现 真实 embedding
查「折耳根」,文档里写的是「鱼腥草」 ❌ 完全找不到 ✅ 能找到
查「怎么让系统更稳」,文档里写「提升稳定性」 ❌ 字面对不上 ✅ 能找到
查「E1024 错误码」 比真实 embedding 还准 ⚠️ 可能混淆相近编号

看第三行 ------这就是为什么混合检索是标配:关键词路补的,恰好是向量路的短板;反过来也一样。

真实项目里,把 HashingEmbedder 换成一次 embedding API 调用即可,向量库的其余部分一个字都不用改。

这正是第二节那个「两件事要分开」的用意:embedding 是可以替换的零件,向量库才是你要造的机器。


九、小结

9.1 这一章我们造了什么

零件 作用 关键那一行
切块 把长文切成可检索的小块 start = end - overlap
向量化 文字 → 坐标 crc32(gram) % dim
余弦相似度 衡量两个坐标有多像 归一化后,点积即余弦
暴力检索 准,但 O(n) 逐个比
IVF 索引 快,用一点准确率换 先找簇心,只扫几个桶
BM25 关键词路的兜底 IDF × 词频饱和
混合检索 两路加权合并 α×向量 + (1-α)×关键词

9.2 一句话总结这一章

向量库的本质,是「把找相似」变成「找最近的邻居」。

一旦文字变成了坐标,剩下的就全是几何问题------而几何问题,是可以用朴素的索引结构、在毫秒之间解决的。

9.3 生产环境怎么做

真实项目里,这一章的每个零件都有成熟的替代品:

这一章 生产环境 什么时候该换
字面量哈希向量 embedding API / 本地模型 立刻。这是差距最大的一环
手写 IVF FAISS / Milvus / Qdrant / pgvector 数据量 > 10 万
手写 BM25 Elasticsearch / 数据库全文索引 需要复杂过滤和聚合
内存存储 向量数据库 + 元数据过滤 需要持久化和多租户
固定切块 语义切块(按段落 / 按标题) 文档结构清晰时

但请先跑通这一章的版本,再去换。 因为一旦用上现成的库,你就很难再体会到「为什么需要索引」「为什么要混合检索」------你会以为这些是理所当然的。

9.4 下一章

现在,Agent 有了记忆(第五章),记忆有了语义检索能力(这一章)。

但还有一个问题没解决:它依然只有一只「会查资料」的手,没有一只「会干活」的手。

下一章,我们讲工具------模型的手究竟该长什么样:工具的描述怎么写模型才不选错、参数怎么校验、执行失败了怎么把错误变成有效信号、多个工具同时调用怎么不出乱子。

本章核心结论 :向量库要拆成两件事看------「把文字变成向量」是可以替换的零件,「在百万坐标里毫秒级找邻居」才是向量库的本体。 而后者,全部是朴素的几何与索引结构,没有一丝魔法。

完整可运行代码code/06_vector/mini_vectordb.py

相关推荐
蜡台1 小时前
AI Agent:大模型基础、底层架构与核心工作原理(深度技术拆解)
ai·agent
Raistwen1 小时前
Agent架构师从0出发(第9篇):从RAG到Agent——检索增强与行动决策的融合与边界
agent
IamZJT_1 小时前
Agent 系统工程 03|工具执行成功但回执丢了,重试还是不重试?
人工智能
IamZJT_1 小时前
拆开 DeepSeek Harness 03|模型已经回答完了,Agent 为什么还没结束?
人工智能
花间相见1 小时前
【AI应用开发|Agent记忆】—— Codex 与 Claude Code 持久化记忆对比:两条不用向量库的路线
后端·agent
用户3134672143541 小时前
Agent 开发学习笔记(六):LCEL:把组件串成链
langchain·agent
AI创界者1 小时前
【Python进阶】重构经典设计模式:单例、工厂、观察者在现代 Python (3.10+) 中的最佳演进
人工智能·aigc
Rocky Ding*1 小时前
【三年面试五年模拟】阿里巴巴-千问技术部算法一面全解析
论文阅读·人工智能·深度学习·机器学习·aigc·ai-native·ai agent
蜗牛互联网1 小时前
Claude放宽生命科学限制:代价是验证、分级和30天留存
java·人工智能·后端