SSD Nodes Learn 🎉 VPS $5.50/月起
指南 Matt Connor作者: Matt Connor

如何在 VPS 上自托管 RAG 流水线:从分块到向量检索

本指南教你在单台 VPS 上构建完整的 RAG 流水线。通过 PostgreSQL 和 pgvector 实现本地向量存储,利用 Ollama 部署嵌入模型,并提供具体的 SQL 架构与 HNSW 索引配置建议,助你有效降低重复性嵌入任务的托管成本。

自托管 RAG 流水线的架构

RAG(检索增强生成)流水线包含五个阶段:文档分块、向量化分块、存储向量、检索与问题最相关的分块,以及将这些分块发送给语言模型以生成答案。在您已租用的 VPS 上,前四个阶段均可在本地运行。PostgreSQL 配合 pgvector 扩展用于存储向量,而 Ollama 部署的小型嵌入模型负责将文本转化为向量。仅最后一个阶段需要调用外部资源。

这种拆分正是本指南的核心逻辑。分块是纯粹的 CPU 任务。嵌入模型仅有 1.37 亿参数,占用几百 MB 内存。存储部分基于 Postgres 表,在写入第一行数据前即可通过算术计算出所需空间。对于包含数十万个分块的语料库,上述所有任务均可在普通 VPS 上运行。生成阶段则不同,因为它对每个问题都会产生持续的成本。

RAG 流水线中哪些环节会产生费用

DigitalOcean 的 端到端 RAG 教程 采用了托管向量数据库和托管嵌入模型,其成本章节仅给出了定性建议:缓存重复查询、减少检索到的分块数量、在生成前进行重排序。这些建议是正确的。但该教程忽略了一个能改变成本计算方式的选项:在您已经付费的服务器上运行嵌入模型。

请按 Token 数量而非美元金额来计算,因为 Token 计数不会随价格表变动而失效。假设语料库包含 100,000 个分块,每个分块 400 个 Token;针对该语料库提出 10,000 个问题;每个答案检索 8 个分块;问题与指令块占用 100 个 Token;答案占用 400 个 Token。

ChartToken load for a 100,000 chunk corpus and 10,000 questions
The data behind this chart
[
  {
    "label": "Embed the corpus (once)",
    "tokens_millions": 40,
    "tokens_per_question": "4,000"
  },
  {
    "label": "Embed each question",
    "tokens_millions": 0.2,
    "tokens_per_question": "20"
  },
  {
    "label": "Generation input",
    "tokens_millions": 33,
    "tokens_per_question": "3,300"
  },
  {
    "label": "Generation output",
    "tokens_millions": 4,
    "tokens_per_question": "400"
  }
]

对整个语料库进行嵌入处理需要 40 百万个 Token,且仅需执行一次。分摊到 10,000 个问题中,每个问题分摊 4,000 个 Token。如果提问十万次,该数值会降至 400。生成环节的成本则永远不会下降。在您回答的每一个问题中,输入端固定消耗 3,300 个 Token,输出端固定消耗 400 个 Token。

因此,资金应投入到重复发生的环节。请自行托管嵌入步骤,因为只需支付一次成本,且 VPS 本身就在运行。请购买生成步骤的服务,因为在该环节,更优质的模型才具有真正的价值。缓存之所以重要也是出于同样的原因:缓存命中可以跳过唯一一个成本无法摊薄的环节。KV 缓存与提示词缓存的区别 决定了您可以复用其中的哪一部分。RAG 提示词通常包含一段稳定的指令块,后接一段动态的分块内容,这种结构最能从缓存中获益。

分块:为何固定大小加重叠是默认的最佳实践

分块是检索的基本单位,其大小决定了后续所有环节的效果。分块必须足够小,以确保其嵌入(embedding)仅指向单一主题;因为嵌入在向量空间中是一个点,如果一个分块涵盖了四个主题,它会落在这些主题的中间,导致与任何一个主题的关联度都不高。同时,分块必须足够大,以便能够独立回答问题,因为语言模型只能看到该分块,而无法感知其周围的文档内容。

建议从 300 个单词、50 个单词的重叠量开始。英文平均约为每个单词 1.3 个 token,因此 300 个单词大约是 400 个 token。设置重叠是为了防止位于边界处的句子被强行拆分,导致拆分后的两部分都无法完整回答问题。

如果文档具有结构,应优先按结构进行拆分。先按标题拆分,再按段落拆分,仅在某个部分仍然过长时才应用固定大小规则。如果分块从句中开始,模型在最终回答时会引用这些不完整的片段,导致阅读体验不佳。

在能够衡量效果之前,不要盲目调整分块策略。固定大小加重叠的方法具有确定性且重新运行的成本较低,这使其成为一个可以被超越的基准。应先完善评分查询逻辑,再逐步进行单变量调整。

在同一台服务器上进行嵌入及其内存与延迟成本

curl -fsSL https://ollama.com/install.sh | sh
ollama pull nomic-embed-text

nomic-embed-text 拥有 1.37 亿个参数,截至 2026 年 8 月,下载大小为 274 MB。在围绕它设计数据表之前,请先检查其返回结果。

curl -s http://127.0.0.1:11434/api/embed \
  -d '{"model": "nomic-embed-text", "input": "search_document: hello"}' |
  python3 -c 'import json,sys; print(len(json.load(sys.stdin)["embeddings"][0]))'

该命令会输出 768。你的列类型必须与该数值完全匹配。

该模型有两个设置容易导致用户出错。

任务前缀是强制性的。 Nomic 的模型卡片指出,输入“必须包含任务指令前缀”。文档嵌入时需在前面加上 search_document: ,问题嵌入时需加上 search_query: 。如果遗漏这些前缀,程序不会报错:你依然能得到向量,但检索质量会下降,且没有任何日志会说明原因。

长输入会被静默截断。 /api/embed 端点包含一个 truncate 字段,其默认值为 true,而 Ollama 封装的模型声明支持 2K 上下文。超过该长度的文本块会在限制处被截断并进行嵌入,导致其尾部无法被搜索。在测试期间,请发送 "truncate": false,这样超大的文本块会触发报错,而不是被静默处理。

请批量处理请求,并让模型驻留内存。

curl -s http://127.0.0.1:11434/api/embed -d '{
  "model": "nomic-embed-text",
  "input": ["search_document: first chunk", "search_document: second chunk"],
  "keep_alive": "30m"
}' > /dev/null

input 接受列表输入,发送包含 32 个文本块的单个请求优于发送 32 个请求,因为 HTTP 往返和模型查找只需执行一次,而非 32 次。keep_alive 控制模型在请求后驻留内存的时长,默认值为 5 分钟。当模型过期后,下一次请求将再次产生加载耗时。

请在你的服务器上测量两个关键指标。它们取决于你的 vCPU 数量,因此任何公开数据都无法直接套用。

ollama ps
time curl -s http://127.0.0.1:11434/api/embed \
  -d '{"model":"nomic-embed-text","input":"search_document: ... one real chunk ..."}' > /dev/null

ollama ps 会打印已加载模型的驻留内存大小,这是 keep_alive 保持模型驻留期间所占用的 RAM。将 time 的输出除以批处理大小,即可得到每个文本块的处理秒数。将其乘以文本块总数,即可得出一次性索引成本。在仅使用 CPU 的方案中,处理 100,000 个文本块的语料库可能需要数小时而非数分钟。这没问题,因为索引过程只需执行一次,且可以在夜间通过 nice -n 19 运行。如果数小时的耗时无法接受,真正的问题在于租用 GPU 是否划算,这属于 与 API Token 成本的盈亏平衡计算,而非个人偏好问题。

如果服务器已经在运行聊天模型,嵌入模型将作为第二个驻留模型,内存占用会叠加。在 VPS 上运行 Ollama 涵盖了生成侧的规模评估,而 自托管模型在并发用户下的表现 涵盖了多用户同时请求时的情况。嵌入模型足够小,可以与上述任何模型共存。

索引脚本,从头至尾

在 Ubuntu 24.04 上,在虚拟环境之外直接运行 pip install 会因 error: externally-managed-environment 而停止,因为系统 Python 归 apt 管理。

python3 -m venv ~/rag
~/rag/bin/pip install "psycopg[binary]" pgvector
import json, urllib.request
import psycopg
from pgvector.psycopg import register_vector
from pgvector import Vector

OLLAMA = "http://127.0.0.1:11434/api/embed"
MODEL = "nomic-embed-text"

def embed(texts, prefix="search_document: "):
    payload = {"model": MODEL,
               "input": [prefix + t for t in texts],
               "truncate": False,
               "keep_alive": "30m"}
    req = urllib.request.Request(OLLAMA, data=json.dumps(payload).encode(),
                                 headers={"Content-Type": "application/json"})
    with urllib.request.urlopen(req) as resp:
        return json.load(resp)["embeddings"]

def split(text, size=300, overlap=50):
    words = text.split()
    step = size - overlap
    return [" ".join(words[i:i + size]) for i in range(0, len(words), step)]

with psycopg.connect("dbname=rag user=rag") as conn:
    register_vector(conn)
    for doc_id, text in documents():          # your loader
        pieces = split(text)
        for start in range(0, len(pieces), 32):
            batch = pieces[start:start + 32]
            vectors = embed(batch)
            with conn.cursor() as cur:
                cur.executemany(
                    "INSERT INTO chunks (doc_id, seq, body, embedding)"
                    " VALUES (%s, %s, %s, %s)",
                    [(doc_id, start + i, body, Vector(vec))
                     for i, (body, vec) in enumerate(zip(batch, vectors))])
        conn.commit()

documents() 由你编写:无论你是遍历文件还是数据库行,只需生成文档 ID 及其文本即可。其余部分均为流水线处理。

存储:pgvector 模式及其容量增长

Ubuntu 24.04 提供的 postgresql-16-pgvector 版本为 0.6.0,该版本早于 halfvec 类型。请使用 PostgreSQL 官方仓库获取当前构建版本。

sudo apt update && sudo apt install -y postgresql-common
sudo /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh
sudo apt install -y postgresql-17 postgresql-17-pgvector

软件包名称中的数字必须与服务器的 PostgreSQL 主版本号匹配。随后创建角色、数据库及扩展。

sudo -u postgres createuser --pwprompt rag
sudo -u postgres createdb --owner rag rag
sudo -u postgres psql -d rag -c 'CREATE EXTENSION vector;'
CREATE TABLE chunks (
  id        bigserial PRIMARY KEY,
  doc_id    text NOT NULL,
  seq       int  NOT NULL,
  body      text NOT NULL,
  embedding vector(768) NOT NULL,
  fts       tsvector GENERATED ALWAYS AS (to_tsvector('english', body)) STORED
);

CREATE INDEX chunks_fts ON chunks USING gin (fts);

vector(768) 必须与模型的输出维度匹配。若向该列插入 1024 维向量,Postgres 会拒绝并报错 expected 768 dimensions, not 1024,这是整个流程中最明确的错误提示。生成的 fts 列无需额外维护成本,且便于后续进行关键词搜索。

存储空间计算遵循算术逻辑。pgvector 文档指出 vector 占用 4 * dimensions + 8 字节,halfvec 占用 2 * dimensions + 8。下方的维度计数为各模型发布的输出大小。

ChartVector column size per 100,000 chunks, by embedding dimension
The data behind this chart
[
  {
    "label": "384 (all-minilm)",
    "bytes_per_vector": "1,544",
    "vector_mib_per_100k": 147,
    "halfvec_mib_per_100k": 74
  },
  {
    "label": "768 (nomic-embed-text)",
    "bytes_per_vector": "3,080",
    "vector_mib_per_100k": 294,
    "halfvec_mib_per_100k": 147
  },
  {
    "label": "1024 (mxbai-embed-large)",
    "bytes_per_vector": "4,104",
    "vector_mib_per_100k": 391,
    "halfvec_mib_per_100k": 196
  },
  {
    "label": "1536 (hosted API model)",
    "bytes_per_vector": "6,152",
    "vector_mib_per_100k": 587,
    "halfvec_mib_per_100k": 294
  }
]

在 768 维度下,每个向量占用 3,080 字节,因此 10 万个数据块占用 294 MiB 向量数据。若使用 1536 维的托管模型嵌入相同语料库,则需要 587 MiB,其索引大小也会按比例增长。半精度浮点数可将两者减半:halfvec(768) 存储该语料库仅需 147 MiB。至于这是否会影响召回率,可通过下方的评分查询一次性得出结论。

上述数据仅涵盖向量列本身。文本、行开销和索引均需额外计算,因此请务必测量实际表的大小。

SELECT pg_size_pretty(pg_total_relation_size('chunks')) AS total,
       pg_size_pretty(pg_relation_size('chunks'))       AS heap,
       count(*) AS n_rows
FROM chunks;

如果您希望使用带有 API 和用户账户管理的同类扩展,自托管 Supabase 栈 是已预装 pgvector 的 Postgres 环境,本指南中的所有查询均可直接运行。

索引:关键的 HNSW 设置

当数据量在几千行以下时,请跳过索引。精确搜索会读取每一行,在此规模下速度足够快,且召回率完美。当顺序扫描不再满足性能要求时再添加索引,并理解其权衡:近似索引返回的是近似的邻居。

SET maintenance_work_mem = '2GB';
SET max_parallel_maintenance_workers = 3;
CREATE INDEX chunks_embedding ON chunks
  USING hnsw (embedding vector_cosine_ops) WITH (m = 16, ef_construction = 64);

m = 16ef_construction = 64 是 pgvector 的默认值。调高它们可以提高召回率,但会增加构建时间和索引大小。除非确定你的模型输出的是单位长度向量,否则请配合 <=> 运算符使用 vector_cosine_ops,因为余弦距离会忽略向量长度,而内积则不会。

监控构建过程。当图结构超过 maintenance_work_mem 时,pgvector 会给出提示:

NOTICE:  hnsw graph no longer fits into maintenance_work_mem after 100000 tuples
DETAIL:  Building will take significantly more time.

这不是错误,构建仍会完成,但会进入速度慢得多的处理路径。在构建索引的会话中调高 maintenance_work_mem,不要修改服务器的全局默认值,因为该设置是针对单次维护操作的,过高的全局值会导致服务器内存耗尽。在第二个会话中跟踪长时间的构建过程。

SELECT phase, round(100.0 * blocks_done / nullif(blocks_total, 0), 1) AS "%"
FROM pg_stat_progress_create_index;

然后将完成后的索引大小与服务器内存进行对比。

SELECT pg_size_pretty(pg_relation_size('chunks_embedding'));
SHOW shared_buffers;

HNSW 搜索通过遍历图结构进行,因此它会访问索引中分散的页面,而不是读取连续范围。如果索引无法放入内存,每次查询都会触发磁盘读取,用户感知到的就是查询延迟的“长尾效应”。这是服务器容量规划的唯一准则:索引加上实际服务的行数据应能放入内存。free -m 和上述大小是需要对比的两个数值。

在查询时,hnsw.ef_search 是召回率的调节旋钮,默认值为 40。

BEGIN;
SET LOCAL hnsw.ef_search = 100;
SELECT id, body FROM chunks ORDER BY embedding <=> $1 LIMIT 8;
COMMIT;

更高的值会搜索图中更多的节点,找到更好的邻居,但会增加延迟。这是一个会话级设置,因此可以在不影响索引的情况下为单次查询调高该值。

如果查询完全没有使用索引,执行计划会显示出来。

EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM chunks ORDER BY embedding <=> $1 LIMIT 8;

此处出现顺序扫描通常与存储方式有关。一个 768 维的向量占用 3,080 字节,这超过了 Postgres 的行内存储限制,因此该值会被移至 TOAST 表(用于存储超大值的行外存储区)。pgvector 的官方说明指出,查询规划器在成本估算中不会计算行外存储,这可能导致顺序扫描看起来比实际成本更低。ALTER TABLE chunks ALTER COLUMN embedding SET STORAGE PLAIN; 可以将向量保留在行内。该设置仅对修改后写入的行生效,因此现有行需要进行表重写。

检索:单次查询,双重信号

向量搜索用于查找与问题语义相同的内容。它在处理精确字符串(如零件编号、错误代码或姓氏)时表现较弱。关键词搜索则恰好相反,且 Postgres 已具备此功能。与其运行第二个系统,不如将两者合并在一次查询中。

倒数排名融合(Reciprocal rank fusion)是最简单的组合方法。每个结果根据其在各列表中的 1 / (60 + rank) 进行计算,并将两个分数相加。由于它读取的是排名位置而非距离,因此无需进行分数归一化。

WITH semantic AS (
  SELECT id, row_number() OVER (ORDER BY distance) AS rank
  FROM (SELECT id, embedding <=> $1 AS distance
        FROM chunks ORDER BY embedding <=> $1 LIMIT 40) s
),
keyword AS (
  SELECT id, row_number() OVER (ORDER BY score DESC) AS rank
  FROM (SELECT c.id, ts_rank_cd(c.fts, q) AS score
        FROM chunks c, websearch_to_tsquery('english', $2) q
        WHERE c.fts @@ q
        ORDER BY score DESC LIMIT 40) k
)
SELECT c.id, c.body,
       coalesce(1.0 / (60 + s.rank), 0) + coalesce(1.0 / (60 + k.rank), 0) AS rrf
FROM (SELECT id FROM semantic UNION SELECT id FROM keyword) u
JOIN chunks c ON c.id = u.id
LEFT JOIN semantic s ON s.id = u.id
LEFT JOIN keyword  k ON k.id = u.id
ORDER BY rrf DESC
LIMIT 8;

$1 是来自同一模型的查询嵌入向量,通过 search_query: 前缀构建。$2 是作为文本的查询内容。两者均由您的应用程序绑定。websearch_to_tsquery 可以接收真实的用户提问,且不会因标点符号而报错,而 to_tsquery 则无法做到这一点。还需要注意一点:在 HNSW 扫描之上添加 WHERE 过滤器可能会返回比请求数量更少的行,因为系统会先搜索索引,然后再执行过滤。SET hnsw.iterative_scan = relaxed_order; 可以让 pgvector 持续扫描,直到获取足够的行数。

如何判断检索效果的好坏?

这是几乎所有 RAG 指南都会跳过的步骤,但它却是唯一能让你判断其他选择是否有效的依据。这不需要评估框架,只需要 30 个问题以及每个问题对应的正确数据块 ID。

请手动编写这些问题。选取用户针对该语料库提出的真实问题,执行检索,阅读返回结果,并记录下本应被命中的数据块 ID。30 个问题无法解决微小的差异,但足以捕捉到关键差异,因为这些差异通常非常显著。

CREATE TABLE gold (
  id        bigserial PRIMARY KEY,
  question  text   NOT NULL,
  chunk_id  bigint NOT NULL REFERENCES chunks(id),
  embedding vector(768) NOT NULL
);

使用 search_query: 前缀对每个问题进行向量化并存储,然后通过一次查询对整个集合进行评分。

WITH hits AS (
  SELECT g.id,
         min(r.rank) FILTER (WHERE r.id = g.chunk_id) AS hit_rank
  FROM gold g
  CROSS JOIN LATERAL (
    SELECT top.id, row_number() OVER (ORDER BY top.distance) AS rank
    FROM (SELECT c.id, c.embedding <=> g.embedding AS distance
          FROM chunks c
          ORDER BY c.embedding <=> g.embedding
          LIMIT 10) top
  ) r
  GROUP BY g.id
)
SELECT count(*)         AS questions,
       count(hit_rank)  AS found_in_top_10,
       round(avg(coalesce(1.0 / hit_rank, 0)), 3) AS mrr
FROM hits;

found_in_top_10 除以 questions 即为 Top-10 召回率:即答案出现在你发送给模型的窗口内的频率。MRR(平均倒数排名)计算的是正确数据块位置的倒数之和,未命中则计为 0,因此它更倾向于将正确答案排在第一位,而不是第八位。当你更改数据块大小、更换向量化模型或添加关键词搜索时,这两个数值都会发生变化,现在你可以直观地看到它们的变化方向。

请将 Top-10 召回率置于首位,因为生成器无法使用它从未接收过的数据块。当 Top-10 召回率达到 0.9 但答案仍然错误时,问题出在提示词或模型本身,而非检索环节。这一区分能为你节省数天的盲目排查时间。

请单独检查索引。近似搜索会牺牲召回率,而 pgvector 可以让你看到损失程度:使用精确搜索运行相同的查询并对比 ID。

BEGIN;
SET LOCAL enable_indexscan = off; -- use exact search
SELECT id FROM chunks ORDER BY embedding <=> $1 LIMIT 10;
COMMIT;

如果十个 ID 中有九个相同,说明 ef_search 设置合理。如果只有四个相同,则需要调高该值。

重排序与生成:API 的核心价值所在

重排序模型(Reranker)属于另一类模型。它将问题与单个数据块(chunk)合并读取并进行评分,这种方式优于独立计算两个嵌入向量(embedding)的对比。由于重排序模型运行速度较慢,无法在整个语料库上执行,因此它被放置在检索流程的后端。它仅处理检索阶段返回的 40 个候选结果,而非数据库中全部 100,000 个数据块。托管式重排序 API 按每个问题处理 40 个短文本对计费,并在昂贵的生成阶段前剔除最明显的误报。

生成阶段是产生持续费用的环节,可通过两个杠杆优化成本。首先,减少发送的数据块数量;通过测试 recall at 10 指标,确定在不丢失答案的前提下能发送的最少数据块数量。其次,保持提示词(prompt)前部的字节完全一致,以命中服务商的提示词缓存(prompt cache),并将检索到的数据块置于该稳定部分之后。同时,按问题缓存已生成的答案,因为成本最低的生成 token 就是你上周已经生成过的那些。

服务器规格评估及扩展瓶颈

此处的所有规格准则均基于实际测量,而非估算。

  • 内存是核心约束:计算方式为 ollama ps 中的常驻模型大小,加上 HNSW 索引大小,再加上 shared_buffers,并需预留空间用于处理连接和页面缓存。
  • 磁盘空间需为 pg_total_relation_size('chunks') 的两倍,因为重建索引时会同时保留新旧两个副本。
  • CPU 决定了重索引所需的时间,计算方式为实测的每个数据块处理秒数乘以数据块总数。
  • 重索引的频率往往高于预期,因为一旦更换嵌入模型,所有已存储的向量数据均会失效。

当出现以下情况时,当前架构将不再适用,且这些瓶颈是可以预见的。当 HNSW 索引大小超过可购买的内存上限时,查询延迟将受限于磁盘寻道速度,任何配置优化都无法解决。当单张表服务于多个租户且每个查询都需要进行租户过滤时,必须对表进行分区,这属于重构工作。当索引写入操作与用户查询争抢同一台服务器的资源时,应先将嵌入处理任务迁移至第二台服务器,再考虑迁移数据库。在上述问题出现之前,在现有 VPS 上运行带有 pgvector 的 Postgres 是生产环境的可行方案,上述指标可用于评估距离性能极限的余量。

FAQ

我可以在一台 VPS 上运行 RAG 流水线吗,还是必须使用向量数据库?

对于包含数十万个分块的语料库,一台 VPS 足以胜任。在 768 维的情况下,100,000 个分块的向量数据约为 294 MiB,加上文本和 HNSW 索引,完全可以放入普通配置的内存中。限制因素是内存而非行数,因为 HNSW 搜索会在索引中进行跳转,一旦索引无法完全载入内存,延迟就会显著增加。通过对比索引上的 pg_relation_sizefree -m,即可确定当前负载情况。

我需要 GPU 来对文档进行向量化吗?

如果只需进行一次向量化,后续仅执行查询,则不需要。像 nomic-embed-text 这样拥有 1.37 亿参数的模型可以在 CPU 上运行,对大型语料库进行完整遍历只需数小时,可以在夜间完成。当文档持续流入,或者需要在同一台机器上运行生成任务时,GPU 的作用才会显现。请使用 /api/embed 在你的服务器上测试一个批次的时间,并乘以分块总数进行估算,因为 vCPU 性能差异巨大,公开的参考数据往往不适用。

为什么我的向量查询使用的是顺序扫描而不是 HNSW 索引?

使用 EXPLAIN (ANALYZE, BUFFERS) 查看执行计划。常见原因是存储方式:pgvector 指出查询优化器在计算成本时不会考虑行外存储(out-of-line storage),这使得顺序扫描看起来比实际成本更低;而 768 维的向量占用 3,080 字节,默认会存储在 TOAST 表中。ALTER TABLE chunks ALTER COLUMN embedding SET STORAGE PLAIN; 可以将新行保留在行内。另外两个原因是:运算符与索引不匹配(使用 vector_cosine_ops 构建的索引仅能被 <=> 使用),以及查询中缺少 ORDER BY ... LIMIT(因为近似索引仅适用于有序的最近邻查询)。

我该如何评估检索效果的好坏?

构建一个包含 30 个问题的黄金数据集,每个问题对应一个包含答案的分块 ID,并将问题向量与它们一并存储。随后测量 Recall@10(正确分块出现在前 10 名的频率)和 MRR(对排名靠前的结果给予更高权重)。这两个指标能告诉你分块大小、向量化模型或排序融合(rank fusion)的调整是否有效。如果没有这些指标,你只能在盲目修改设置的同时,依赖对少量答案的主观印象。