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

15 阅读19分钟

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

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

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


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

接着上一章的厨房。

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

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

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

按名字找,永远找不到。

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

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

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

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

这就是向量库要做的事。


二、先把一件事说清楚:向量库 ≠ 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 行代码把它算出来

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 句刻意构造的句子,看看算出来什么:

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

  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 个字的短句,讲的是同一件事。它们的向量长度差很远,但方向应该一致。用夹角衡量,长短就不影响了。

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 暴力检索:准,但慢得没法用

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

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. 查询:先找和查询向量最接近的几个中心点,只在这些簇里暴力扫
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 个主题的文档实测:

═══ 演示四 · 索引:用一点点准确率换速度 ═══

  向量总数: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 重叠区:防止句子被腰斩

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

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

跑起来看:

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

  第 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 是经典的关键词打分算法,核心思想是:一个词在这篇文档里出现得越多、在整个库里出现得越少,它就越重要。

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

合并分数就一行:

最终分数 = alpha × 向量分数 + (1 - alpha) × 关键词分数

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

7.3 组装成向量库

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]]

八、跑起来看看

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

═══ 演示三 · 混合检索:向量 + 关键词 ═══

  📥 后厨流程:切成 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 / 本地模型立刻。这是差距最大的一环
手写 IVFFAISS / Milvus / Qdrant / pgvector数据量 > 10 万
手写 BM25Elasticsearch / 数据库全文索引需要复杂过滤和聚合
内存存储向量数据库 + 元数据过滤需要持久化和多租户
固定切块语义切块(按段落 / 按标题)文档结构清晰时

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

9.4 下一章

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

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

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

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

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