1.2
Part I / Build · Week 2

用真实的检索栈替换玩具搜索。

三层架构,按顺序构建:词法基线、稠密语义、重排序。最后加上一个落地验证器。到最后,每一条结论都可追溯、可验证。

STEP 1

对语料库切块。不要过度设计。

在做任何比子串匹配更聪明的事之前,我们需要把文档拆分成可检索的单元。这就是"切块"(chunking)。很容易想得太复杂——语义切块、延迟切块、递归 AST 拆分——但现在,先做最简单的事。只有当评估(eval)结果表明有必要时,我们才去改进。

经验法则

  • 目标大小:每块约 500 个令牌(token)(大约 2000 个英文字符)。
  • 重叠:相邻块之间约 50 个令牌(token)的重叠。用于捕获答案跨越块边界的情况。
  • 按自然边界切分:优先在段落处断开,其次是句子。不要在句子中间截断。
  • 保留元数据:每块都知道自己的 doc_id 和文档内的 chunk_idx。我们需要这些来反向引用。

注意:以下代码与提供商无关,切块只是 Python。

# retrieval/chunk.py
import tiktoken  # works for both providers as a counter
from dataclasses import dataclass

enc = tiktoken.get_encoding("cl100k_base")

def n_tokens(s: str) -> int:
    return len(enc.encode(s))

@dataclass
class Chunk:
    chunk_id: str   # f"{doc_id}::{idx}"
    doc_id: str
    idx: int
    text: str
    n_tok: int

def chunk_doc(doc_id: str, text: str,
              target: int = 500,
              overlap: int = 50) -> list[Chunk]:
    paragraphs = [p.strip() for p in text.split("\n\n") if p.strip()]
    chunks, buf, buf_tok = [], [], 0

    def flush():
        nonlocal buf, buf_tok
        if not buf: return
        joined = "\n\n".join(buf)
        chunks.append(Chunk(
            chunk_id=f"{doc_id}::{len(chunks)}",
            doc_id=doc_id,
            idx=len(chunks),
            text=joined,
            n_tok=n_tokens(joined),
        ))
        # Keep last paragraph for overlap
        buf = [buf[-1]] if overlap and n_tokens(buf[-1]) <= overlap else []
        buf_tok = n_tokens(buf[0]) if buf else 0

    for p in paragraphs:
        t = n_tokens(p)
        if buf_tok + t > target and buf:
            flush()
        buf.append(p)
        buf_tok += t
    flush()
    return chunks

运行并检查结果

>>> from retrieval.chunk import chunk_doc
>>> from pathlib import Path
>>> text = Path("corpus/pgbouncer-modes.md").read_text()
>>> chunks = chunk_doc("pgbouncer-modes", text)
>>> for c in chunks[:3]:
...     print(c.chunk_id, c.n_tok, c.text[:50])
pgbouncer-modes::0 487 # PgBouncer Pool Modes\n\nPgBouncer supports
pgbouncer-modes::1 503 ## Session Pooling\n\nIn session pooling mode,
pgbouncer-modes::2 491 ## Transaction Pooling\n\nThis is the most c

三个块,令牌数都接近 500,每块保留了章节标题。形状不错。

上下文切块技巧

在嵌入切块之前,在每块前面添加一句摘要,描述该块在其父文档中的内容。这能显著提升检索效果——Anthropic 的检索增强(retrieval-augmented)上下文检索研究报告称可提升约 35%。

成本很小:在索引阶段每块调用一次廉价模型,只需一次。用 Haiku 或 GPT-4o-mini,并批量处理。

# retrieval/contextualize.py
from anthropic import Anthropic
client = Anthropic()

CTX_PROMPT = """Document title: {doc_id}
Full document:
<document>{full_text}</document>

Here is a chunk from the document:
<chunk>{chunk_text}</chunk>

In one sentence, situate this chunk in the document
(what section, what topic, what role). No preamble.
Output just the sentence."""

def contextualize(chunk_text, doc_id, full_text):
    response = client.messages.create(
        model="claude-haiku-4-5",
        max_tokens=100,
        messages=[{"role": "user",
                   "content": CTX_PROMPT.format(
                       doc_id=doc_id,
                       full_text=full_text,
                       chunk_text=chunk_text)}],
    )
    return response.content[0].text.strip()
# retrieval/contextualize.py
from openai import OpenAI
client = OpenAI()

CTX_PROMPT = """Document title: {doc_id}
Full document:
<document>{full_text}</document>

Here is a chunk from the document:
<chunk>{chunk_text}</chunk>

In one sentence, situate this chunk in the document
(what section, what topic, what role). No preamble.
Output just the sentence."""

def contextualize(chunk_text, doc_id, full_text):
    response = client.responses.create(
        model="gpt-5-mini",
        input=CTX_PROMPT.format(
            doc_id=doc_id,
            full_text=full_text,
            chunk_text=chunk_text),
    )
    return response.output_text.strip()

输出长什么样?以 PgBouncer 事务池化章节的某个块为例:

"This chunk explains transaction pooling in PgBouncer,
describing how server connections are released after
each transaction commits — a constraint that breaks
features requiring session state."

这一句话现在会随每个块在检索(retrieval)全程中携带。当用户问"为什么我的预备语句在 PgBouncer 中会失败?"时,嵌入中包含了"约束会破坏需要会话状态的特性"——即使该块里没有"预备"这个确切词,语义上也与问题接近。

另外两种值得了解的方案

上下文切块技巧并不是赋予块文档级上下文的唯一办法。还有两种主流方案,用不同的取舍解决同一问题:

  • 父文档检索(parent-document retrieval)。用小块来建索引和搜索(检索精准),但当某个小块命中时,喂给 LLM 的是它所在的更大的父级章节,而不是小块本身。简单,无需额外模型调用——当你的块小到无法单独推理时很合适。
  • 延迟切块(late chunking)。先在令牌(token)级别嵌入整篇文档,之后再把令牌嵌入池化成块向量。每个块向量从周围令牌中继承了文档级的上下文——完全不需要逐块调用 LLM。索引阶段比上下文检索更便宜,后者每块要花一次小模型调用。

粗略法则:上下文检索在密集技术文档上最强(它写出一条真正、贴合查询形态的摘要);当索引阶段成本或语料规模要紧时,延迟切块以低得多的代价拿到大部分收益;父文档最简单,在块单独太小无法作答时表现突出。在第 4 阶段用评估来选——别想当然。

Question
为什么是约 500 个令牌(token)?为什么不是 1000 或 2000?

两种压力互相拉扯。更大的块保留了更多上下文——当模型实际阅读时很有用。更小的块检索更精准——一个 2000 令牌的块可能因为某段恰好相似就成为最佳匹配,而其余 90% 完全无关。

500 令牌是技术文档的甜蜜点。对代码库,可以更小(250),因为信号密度更高。对文学或叙事类文本,可以更大(800–1200),以保持叙述流畅。

别凭感觉猜——在第 4 阶段通过评估来量化。试试 300、500、1000,选在评估集上 recall@5 最高的那个。

Question
上下文切块实际上要花多少钱?

索引阶段每块调用一次小模型。对一个产生约 3000 块的 200 篇文档的语料库:总 API 费用大约 1–3 美元,并行化后约 10 分钟完成。这是索引阶段的成本。查询时的检索速度与没有上下文切块时相同。

投资回报率相当可观。如果你的评估显示 recall@5 从 0.62 提升到 0.84(这是技术文档上下文检索的典型提升幅度),那你用 2 美元的索引成本,换来了一个在查询时明显减少追加搜索的智能体——永久节省模型令牌。

STEP 2

构建混合搜索:BM25 + 稠密 + RRF。

现在我们用真正理解查询意图的东西替换玩具版的 search_docs。三个组件,分别构建,然后融合。

BM25 — 词法基线

BM25 是一种 1990 年代的统计关键词匹配算法,在技术文档上,它往往单独击败花哨的基于嵌入的搜索。请重读这句话。人们习惯性地直接用嵌入而跳过 BM25——这是个错误。BM25 是你的底线:对于用户使用了文档里相同词汇的查询,它绝不应该输。

pip install bm25s
# retrieval/bm25_index.py
import bm25s

class BM25Index:
    def __init__(self, chunks):
        self.chunks = chunks
        corpus = [c.text for c in chunks]
        self.retriever = bm25s.BM25()
        self.retriever.index(bm25s.tokenize(corpus, stopwords="en"))

    def search(self, query: str, top_k: int = 50) -> list[str]:
        q_tok = bm25s.tokenize(query, stopwords="en")
        results, scores = self.retriever.retrieve(q_tok, k=top_k)
        return [self.chunks[i].chunk_id for i in results[0]]

稠密嵌入 — 语义层

稠密检索将块和查询都嵌入同一个向量空间,然后找出最接近查询的块。能捕获 BM25 漏掉的同义转述("how to vacuum a table" 匹配 "running VACUUM on a relation")。

对于嵌入模型,两家提供商都有不错的选择。我们将使用 Voyage AI(Anthropic 推荐的嵌入合作伙伴)和 OpenAI 的 text-embedding-3-large。向量数据库两者相同——我们在本地使用 ChromaDB。

pip install chromadb voyageai
# retrieval/dense_index.py — Voyage embeddings
import chromadb, voyageai
voyage = voyageai.Client()
chroma = chromadb.PersistentClient(path=".chroma")

def embed(texts, input_type="document"):
    r = voyage.embed(texts, model="voyage-3",
                     input_type=input_type)
    return r.embeddings

class DenseIndex:
    def __init__(self, chunks, name="corpus"):
        self.col = chroma.get_or_create_collection(name)
        if self.col.count() == 0:
            embeddings = embed([c.text for c in chunks])
            self.col.add(
                ids=[c.chunk_id for c in chunks],
                embeddings=embeddings,
                documents=[c.text for c in chunks],
            )

    def search(self, query, top_k=50):
        q_emb = embed([query], input_type="query")[0]
        r = self.col.query(query_embeddings=[q_emb],
                           n_results=top_k)
        return r["ids"][0]
# retrieval/dense_index.py — OpenAI embeddings
import chromadb
from openai import OpenAI
oai = OpenAI()
chroma = chromadb.PersistentClient(path=".chroma")

def embed(texts):
    r = oai.embeddings.create(
        model="text-embedding-3-large",
        input=texts,
    )
    return [e.embedding for e in r.data]

class DenseIndex:
    def __init__(self, chunks, name="corpus"):
        self.col = chroma.get_or_create_collection(name)
        if self.col.count() == 0:
            embeddings = embed([c.text for c in chunks])
            self.col.add(
                ids=[c.chunk_id for c in chunks],
                embeddings=embeddings,
                documents=[c.text for c in chunks],
            )

    def search(self, query, top_k=50):
        q_emb = embed([query])[0]
        r = self.col.query(query_embeddings=[q_emb],
                           n_results=top_k)
        return r["ids"][0]

互惠排名融合 — 合并两路结果

BM25 和稠密检索都返回按排名排列的块 ID 列表。RRF 用一个简单公式将它们合并为单一排名:对每个结果,在所有排名中按 1 / (k + rank) 计分,累加得分,降序排列。k=60 是标准默认值。

# retrieval/hybrid.py
def rrf(rankings: list[list[str]], k: int = 60) -> list[str]:
    scores: dict[str, float] = {}
    for ranking in rankings:
        for rank, cid in enumerate(ranking):
            scores[cid] = scores.get(cid, 0.0) + 1.0 / (k + rank + 1)
    return sorted(scores, key=scores.get, reverse=True)

class HybridSearch:
    def __init__(self, bm25: BM25Index, dense: DenseIndex):
        self.bm25 = bm25
        self.dense = dense

    def search(self, query: str, top_k: int = 20) -> list[str]:
        bm = self.bm25.search(query, top_k=50)
        ds = self.dense.search(query, top_k=50)
        return rrf([bm, ds])[:top_k]

快速对比:改进了什么?

用同一个查询——"when to use VACUUM versus VACUUM FULL"——分别跑各种检索方法:

SUBSTRING (Phase 1):  found 0 docs ─ phrase doesn't appear literally

BM25 alone:
  1. routine-vacuuming::3     0.71   (contains "VACUUM FULL")
  2. sql-vacuum::0            0.65
  3. runtime-config-vacuum::1 0.41

DENSE alone:
  1. routine-vacuuming::3     0.89   (matches semantically)
  2. routine-vacuuming::4     0.86
  3. sql-vacuum::2            0.81

HYBRID (RRF):
  1. routine-vacuuming::3     0.0328
  2. routine-vacuuming::4     0.0163
  3. sql-vacuum::0            0.0162
  4. sql-vacuum::2            0.0161
值得关注的现象

BM25 将 routine-vacuuming::3 排在第 1,因为"VACUUM FULL"在文本中字面出现。稠密检索也将其排第一,但原因不同——语义上与问题相似。

两者都答对并不能说明这是测试混合价值的好用例。但换一个转述查询,比如"how do I reclaim disk space after deleting rows"——稠密检索会找到 VACUUM FULL 的文档(正确答案),而 BM25 可能完全漏掉,因为"reclaim"和"disk space"并不出现在那些文档里。

混合搜索两种情况都能覆盖。代价是两次索引查询而不是一次——可忽略不计。

Question
我真的需要同时用 BM25 和稠密检索吗?稠密嵌入不能搞定一切吗?

不行。稠密嵌入有一个特定的失效模式:精确词项敏感度。如果用户输入"PostgreSQL"而你的文档写的是"Postgres",BM25 会漏掉(不同词项),稠密检索通常能捕获(嵌入相近)。不错。但如果用户输入"voyage-3"——特定的嵌入模型名称——稠密检索可能会返回泛泛讨论"嵌入模型"的块,而 BM25 能精准命中这个确切的提及。

生产级检索几乎总是同时需要两者。经验法则:用户知道正确词汇时用 BM25;用户用自己的话描述时用稠密检索;不确定哪种时用混合。

Question
RRF 中为什么用 k=60

这是原始 RRF 论文(Cormack et al., 2009)中的值。直觉上:k 控制排名位置的影响程度。k 小,头部结果主导;k 大,权重更均匀。60 作为默认值已经用了十五年,因为它在很多数据集上表现良好——而不是因为它对某个具体数据集是最优的。

可以调,但别在评估结果告诉你有必要之前动它。

STEP 3

在前 20 个结果上加重排序器。

混合搜索让你获得不错的召回率——正确的块通常在前 20 内。但它可能在第 8 位,而你只有时间给智能体看 5 个。重排序器通过使用更贵但更精准的模型对前 20 重新打分来解决这个问题。

架构分两阶段:混合搜索快速宽泛地运行(从整个语料库取前 20 个候选),然后重排序器慢速精准地运行(对这 20 个重新排序,得出最优的前 5 个)。这是一种标准模式,叫做"先检索再重排"。

重排序器与嵌入的区别

嵌入模型分别将查询和块编码为向量,再比较——速度快但失去了两者之间的交叉注意力。重排序器("交叉编码器")同时编码两者并给出一个分数——每对比较更慢,但精度高得多,因为它能看到每个块如何匹配查询的每个部分。

重排序器有三种选项——选一个:

  • Cohere Rerank — 托管 API,20 个块约 50ms。生产环境最流行的选择。
  • Voyage rerank-2 — 托管 API,质量和价格类似。
  • BGE-reranker-v2 — 开放权重模型,本地运行。配置慢一些;推理时免费。
pip install cohere
# retrieval/rerank.py — Cohere
import cohere
co = cohere.Client()

def rerank(query: str, chunks: list[Chunk],
           top_k: int = 5) -> list[Chunk]:
    if not chunks: return []
    response = co.rerank(
        model="rerank-english-v3.0",
        query=query,
        documents=[c.text for c in chunks],
        top_n=top_k,
    )
    return [chunks[r.index] for r in response.results]

整合在一起

完整的检索(retrieval)管道,封装在智能体调用的单一函数后面:

# retrieval/__init__.py
from retrieval.bm25_index import BM25Index
from retrieval.dense_index import DenseIndex
from retrieval.hybrid import HybridSearch
from retrieval.rerank import rerank
from retrieval.chunk import chunk_doc

# Built once at import time
ALL_CHUNKS = load_or_build_chunks()
BY_ID = {c.chunk_id: c for c in ALL_CHUNKS}
hybrid = HybridSearch(BM25Index(ALL_CHUNKS), DenseIndex(ALL_CHUNKS))

def retrieve(query: str, top_k: int = 5) -> list[Chunk]:
    candidate_ids = hybrid.search(query, top_k=20)
    candidates = [BY_ID[cid] for cid in candidate_ids]
    return rerank(query, candidates, top_k=top_k)

将其重新接入智能体

替换 agent/tools.py 中的玩具版 search_docs

from retrieval import retrieve

def search_docs(query: str) -> list[dict]:
    chunks = retrieve(query, top_k=5)
    return [
        {
            "chunk_id": c.chunk_id,
            "doc_id": c.doc_id,
            "snippet": c.text[:300],
        }
        for c in chunks
    ]

def fetch_doc(chunk_id: str) -> dict:
    # Now we fetch a CHUNK, not a whole document.
    # Chunks are bounded ~500 tokens — safe for context.
    c = BY_ID.get(chunk_id)
    if not c: return {"error": "not found"}
    return {"chunk_id": chunk_id,
            "doc_id": c.doc_id,
            "text": c.text}

注意变化:fetch_doc 现在返回单个块而不是整篇文档。我们选择 500 令牌的块是有原因的——它们足够小,可以舒适地放入上下文而不会占主导地位。智能体可以在多步骤调查中获取跨文档的多个块。

现在用第 1 阶段同样的 VACUUM 查询运行一遍

$ python scripts/run.py "When should I VACUUM versus VACUUM FULL?"
──────────────────── Step 0 ────────────────────
→ search_docs({'query': 'VACUUM versus VACUUM FULL'})
   returned: [
     {chunk_id: 'routine-vacuuming::3', snippet: 'VACUUM
       FULL rewrites the entire table and indexes,
       reclaiming disk space but requiring an ACCESS
       EXCLUSIVE lock. Regular VACUUM cannot...' },
     {chunk_id: 'routine-vacuuming::4', snippet: '...'},
     {chunk_id: 'sql-vacuum::0', snippet: '...'},
     ...
   ]

──────────────────── Step 1 ────────────────────
┌─ thinking ─────────────────────────────────────┐
│ The first snippet has the core distinction.    │
│ I'll fetch the full chunk for detail.          │
└────────────────────────────────────────────────┘
→ fetch_doc({'chunk_id': 'routine-vacuuming::3'})

──────────────────── Step 2 ────────────────────
→ submit_answer({...})

status: answered (3 steps)
同样的问题,截然不同的行为

第 1 阶段:5 步,3 次冗余搜索,侥幸恢复。第 2 阶段:3 步,一次搜索就把正确的块排在第 1 位返回。

智能体并没有变聪明。是检索变聪明了,所以智能体要做的工作少了。这才是正确的结论:大多数"智能体质量"的提升,实际上是检索的提升。

Question
我真的需要重排序器吗?混合搜索已经很不错了。

对于答案明显的干净查询,混合搜索单独使用通常已经够用。重排序器在以下三种情况下物有所值:

  • 长尾查询:正确的块在混合输出中排在第 8–15 位。重排序器将其提升到前 3。
  • 模糊查询:多个块表面上都相关。重排序器更擅长选出真正回答问题的那个,而不是仅仅共享关键词的那些。
  • 多方面查询,比如"X 在条件 Z 下对 Y 的性能影响"——嵌入按整体相似度打分,重排序器能平衡多个方面。

用评估来量化。如果加了重排序器之后 recall@5 没有变化,说明你的查询不需要它——跳过,节省延迟(latency)。

STEP 4

添加落地验证器。

检索给了智能体正确的材料。但智能体仍然可能产生幻觉(hallucination)——声称某件事与引用似乎相关,但实际上并不被引用支持。落地验证器可以捕获这种情况。

验证器是一次额外的模型调用,在 submit_answer 最终确定之运行。它接受答案中的结论和引用的块,返回一个裁决:支持、部分支持或不支持。不受支持的结论要么被重写,要么得到引用纠正,要么触发重试。

验证器提示词(prompt)

两家提供商使用相同的提示词(prompt);只有 API 调用不同。

VERIFIER_PROMPT = """You are a strict fact-checker.

Given an answer and the sources it cited, decide for each
factual claim in the answer whether the sources SUPPORT,
PARTIALLY support, or DO NOT SUPPORT the claim.

Output JSON only:
{
  "claims": [
    {
      "claim": "the exact claim from the answer",
      "verdict": "SUPPORT" | "PARTIAL" | "NOT_SUPPORTED",
      "evidence": "quote from a source, or empty",
      "reasoning": "one sentence"
    }
  ]
}

Be strict. If a claim is more specific than what
sources actually say, mark it PARTIAL. If a claim
isn't mentioned in sources, mark it NOT_SUPPORTED."""
# grounding/verify.py
import json
from anthropic import Anthropic
client = Anthropic()

def verify(answer: str, sources: list[dict]) -> dict:
    sources_block = "\n\n".join(
        f"[{s['chunk_id']}]\n{s['text']}"
        for s in sources
    )
    response = client.messages.create(
        model="claude-haiku-4-5",
        max_tokens=1024,
        system=VERIFIER_PROMPT,
        messages=[{
            "role": "user",
            "content": f"ANSWER:\n{answer}\n\n"
                       f"SOURCES:\n{sources_block}",
        }],
    )
    return json.loads(response.content[0].text)
# grounding/verify.py
import json
from openai import OpenAI
client = OpenAI()

def verify(answer: str, sources: list[dict]) -> dict:
    sources_block = "\n\n".join(
        f"[{s['chunk_id']}]\n{s['text']}"
        for s in sources
    )
    response = client.responses.create(
        model="gpt-5-mini",
        instructions=VERIFIER_PROMPT,
        input=f"ANSWER:\n{answer}\n\n"
              f"SOURCES:\n{sources_block}",
        text={"format": {"type": "json_object"}},
    )
    return json.loads(response.output_text)

验证器返回什么

{
  "claims": [
    {
      "claim": "Transaction pooling rotates server
                connections between transactions",
      "verdict": "SUPPORT",
      "evidence": "transaction pooling releases server
                   connections back to the pool after
                   each transaction commits",
      "reasoning": "Source directly states this."
    },
    {
      "claim": "Prepared statements are scoped to a
                session",
      "verdict": "SUPPORT",
      "evidence": "PREPARE creates a prepared statement
                   for the current session only",
      "reasoning": "Source explicitly states session scope."
    },
    {
      "claim": "This conflict is the most common cause
                of PgBouncer migration failures",
      "verdict": "NOT_SUPPORTED",
      "evidence": "",
      "reasoning": "Sources discuss the conflict but
                    make no claim about migration
                    failures or their frequency."
    }
  ]
}
刚刚发生了什么

智能体的答案有三条结论。其中两条被引用的块直接支持。还有一条——"迁移失败最常见的原因"——是模型自己添加的润色,实际上并不存在于来源中。

没有验证器,那个幻觉就会原样输出。有了验证器,我们可以选择删除不受支持的结论、标记为推测性内容,或者让智能体重新去找来源。

接入代理循环(agent loop)

修改循环,在返回成功答案之前进行验证:

# in agent/loop.py, when submit_answer is called:
if tool_name == "submit_answer":
    sources = [BY_ID[cid] for cid in citations
               if cid in BY_ID]
    verdict = verify(answer, [{
        "chunk_id": s.chunk_id,
        "text": s.text,
    } for s in sources])

    unsupported = [c for c in verdict["claims"]
                   if c["verdict"] == "NOT_SUPPORTED"]

    return {
        "status": "answered",
        "answer": answer,
        "citations": citations,
        "verification": verdict,
        "unsupported_claims": len(unsupported),
    }

目前我们只是把裁决附加到结果上——至于怎么处理(重试、警告、剔除),等第 4 阶段的评估结果出来再决定。

验证器是你的第一个基础原语,而不是一个功能。你将在第 3 阶段(验证子智能体输出)和第 4 阶段(它本质上是逐条结论的评估)复用它。把它做好。

Question
对每个答案都运行验证器,不会让我的 API 成本(cost)翻倍吗?

粗略估算:每次成功回答额外增加一次调用,输入量以(答案 + 引用的块)≈ 2–3k 令牌为上限。使用 Haiku 或 gpt-5-mini,每次查询的费用不到一分钱。

对比验证的代价:用户信任了一个幻觉答案,做了错误的决策,从此不再信任这个智能体。验证器是廉价的保险。

Question
如果验证器本身产生幻觉怎么办?

确实可能发生。验证器也是一个大语言模型(LLM)。但它的任务范围窄得多——这条结论在这些来源里有没有?——而这正是模型出奇可靠的任务,尤其是在提示词中要求引用证据的情况下。

你将在第 4 阶段通过人工标注 30 个(结论、来源、裁决)三元组并检查一致性来评估验证器本身。与人类的一致率低于约 80% 时,验证器就在帮倒忙,你需要调整提示词直到它改善。

End of week 2

交付物

一个答案引用具体块的智能体,配有验证器,能确认每条引用确实支持其对应的结论。横向对比:来自第 1 阶段的同样 10 个问题,行为截然不同。

  • 带上下文单行摘要的切块器
  • BM25 + 稠密 + RRF 混合搜索
  • 在前 20 上运行交叉编码器重排序器 → 取前 5
  • 带逐条结论裁决的落地验证器
  • A/B 追踪(trace)对比:第 1 阶段 vs 第 2 阶段