Agent每日一技第二篇:让检索当“工具”,而不是把长文硬塞进提示里

5 阅读7分钟

第一篇我们把 HTTP API 变成可调用工具,Agent 能摸到外部世界。今天把“事实稳定性”这条短板补上:构建一个最小可用的向量检索 + 轻量 rerank,把文档知识作为独立工具暴露给模型,需要时取片段、控制体积、带着评分回传观测,而不是把整篇长文塞进上下文里赌运气。

# 一个零依赖的小型向量检索器:哈希嵌入 + 余弦相似 + 关键词加权的轻量 rerank
import re, math, numpy as np
from dataclasses import dataclass
from typing import List, Dict, Any, Tuple

def _tokens(s: str) -> list[str]:
    return [t for t in re.findall(r"[A-Za-z0-9\u4e00-\u9fa5]+", s.lower()) if t]

def _hash_vec(tokens: list[str], dim: int = 512) -> np.ndarray:
    v = np.zeros(dim, dtype=np.float32)
    for t in tokens:
        h = (hash(t) % dim + dim) % dim
        v[h] += 1.0
    n = np.linalg.norm(v) or 1.0
    return v / n

@dataclass
class Chunk:
    doc_id: str
    idx: int
    text: str
    vec: np.ndarray
    toks: list[str]

class MiniVectorStore:
    def __init__(self, dim: int = 512):
        self.dim = dim
        self.chunks: list[Chunk] = []
        self.idf: Dict[str, float] = {}

    def add_text(self, doc_id: str, text: str, *, max_chars=500, overlap=80):
        pieces, i = [], 0
        while i < len(text):
            pieces.append(text[i:i+max_chars])
            i += max(1, max_chars - overlap)
        for k, p in enumerate(pieces):
            toks = _tokens(p)
            vec = _hash_vec(toks, self.dim)
            self.chunks.append(Chunk(doc_id, k, p, vec, toks))
        self._recompute_idf()

    def _recompute_idf(self):
        df: Dict[str, int] = {}
        for c in self.chunks:
            for t in set(c.toks):
                df[t] = df.get(t, 0) + 1
        N = max(1, len(self.chunks))
        self.idf = {t: math.log((N + 1) / (df_t + 0.5)) + 1.0 for t, df_t in df.items()}

    def _lex_score(self, query_toks: list[str], c: Chunk) -> float:
        score = 0.0
        for t in query_toks:
            if t in c.toks:
                score += self.idf.get(t, 0.5)
        return score

    def search(self, query: str, top_k: int = 5) -> list[dict]:
        q_toks = _tokens(query)
        q_vec = _hash_vec(q_toks, self.dim)
        mat = np.stack([c.vec for c in self.chunks]) if self.chunks else np.zeros((0, self.dim), np.float32)
        sims = (mat @ q_vec).tolist() if len(self.chunks) else []
        cand_idx = np.argsort(sims)[-max(top_k*8, top_k):][::-1] if sims else []
        cands = [(self.chunks[i], float(sims[i])) for i in cand_idx]
        reranked = sorted(cands, key=lambda x: (x[1], self._lex_score(q_toks, x[0])), reverse=True)[:top_k]
        results = []
        for c, s in reranked:
            results.append({
                "doc_id": c.doc_id,
                "chunk_id": c.idx,
                "score": round(s, 4),
                "text": c.text if len(c.text) <= 600 else c.text[:580] + "…",
            })
        return results

这段代码做了三件朴素但关键的事。第一,分块而不是整篇塞输入,字符上限与重叠让召回既稳又不过度碎片化。第二,特征哈希把词袋压成固定维度向量,没有任何外部依赖,演示了“只要有一个 embed(),检索就能工作”的结构;生产里你把 _hash_vec 换成你的真实嵌入服务即可,接口形态不变。第三,先用向量相似做粗排,再用关键词的 IDF 叠加做 rerank,弱化“随机命中的相关噪声片段”,让返回的片段更贴题。

# 把检索暴露为 Agent 工具:kb_search
from typing import Callable

TOOLS: Dict[str, Callable[..., Any]] = {}

def tool(fn):
    TOOLS[fn.__name__] = fn
    return fn

VS = MiniVectorStore()

@tool
def kb_search(query: str, top_k: int = 5) -> list[dict]:
    """检索已索引知识片段并返回高分片段,含 doc_id、chunk_id、score、text。参数: query:string 必填; top_k:integer"""
    return VS.search(query, top_k=top_k)

def index_demo_docs():
    VS.add_text("faq", "退款周期通常在三个工作日内完成,法定节假日顺延。若超过五个工作日未到账,请联系人工客服核对收款账户信息。")
    VS.add_text("guide", "创建项目后可在设置中启用Webhook,支持事件:构建完成、部署成功、部署失败。Secret 用于签名验证。")
    VS.add_text("policy", "我们在欧洲地区默认启用数据最小化策略,仅持久化作业日志的摘要,不存储完整原始内容。")
index_demo_docs()

这一步把检索真正接进 Agent 的世界。kb_search 的返回永远是结构化的,既带片段文本也带来源标识,你可以在策略层要求模型“必须依据片段作答并在需要时指出来源”,而不必把来源格式硬编码在自然语言提示里。为了能立即跑通,我顺手写了 index_demo_docs() 往知识库塞了三段文本,你在工程里只需把企业文档、FAQ、运行手册等批量喂给 add_text 即可,索引自动更新 IDF。

# 在决策层约束“先检索再作答”的行为契约(展示核心片段,具体串接沿用你的第零篇闭环)
import json, textwrap

def build_system_prompt_with_kb() -> str:
    return textwrap.dedent(f"""
    只输出 JSON。
    如需从知识库获取事实,先输出:
    {{"action":"tool","tool":"kb_search","args":{{"query":"<你的查询>"}}}}
    拿到检索结果后再决定是否给出最终回答:
    {{"action":"final","reply":"<基于片段的回答,必要时引用 doc_id#chunk_id>"}}
    工具列表:
    - kb_search(query:str, top_k:int):检索知识片段
    """)

把“如何使用知识库”的协议写清楚之后,模型会先触发 kb_search,我们执行检索,把若干片段以小而精的结构回填,随后它再收口生成最终回答。这个顺序很关键,很多人把文档大段塞进一条提示里让模型“自己看”,看似省事,其实是把决策权放在了不稳定的地方。把检索做成一次独立的工具调用,你就有了可观测的召回、可控的上下文体积,以及可复现的链路。

# 一个最小的“基于检索作答”演示:不串真实 LLM,仅展示工具回填与收口形态
query = "Webhook 支持哪些事件?"
hits = kb_search(query, top_k=3)
print(json.dumps({"tool":"kb_search","result":hits}, ensure_ascii=False, indent=2))

即便没有连上模型,你也能看到工具返回的片段与评分,真实接入时,这个结构化结果就是你喂给模型的“观测”。建议你在执行层做两件小事提升稳健性。第一,给 kb_search 加一个最小得分阈值,召回全是低分时直接提示“知识库未命中”,让模型走“无法确认”的分支,而不是硬编。第二,把返回片段压到一个严格的字数预算,例如六百到一千字符之间;超过预算就截断到句子边界,这比盲目砍字更不易破坏语义。

# 一个更贴近生产的返回裁剪:句子级压缩与最低得分过滤
def _shrink_sentences(text: str, budget: int = 400) -> str:
    sents = re.split(r"(。|!|?|\.)", text)
    acc, out = 0, []
    for i in range(0, len(sents), 2):
        seg = sents[i] + (sents[i+1] if i+1 < len(sents) else "")
        if acc + len(seg) > budget: break
        out.append(seg); acc += len(seg)
    return "".join(out) if out else text[:budget]

@tool
def kb_search(query: str, top_k: int = 5, min_score: float = 0.1, budget: int = 500) -> list[dict]:
    """检索已索引知识片段并返回高分片段,带来源与裁剪。参数: query 必填; top_k 可选; min_score 可选; budget 可选"""
    raw = VS.search(query, top_k=top_k)
    kept = [r for r in raw if r["score"] >= min_score]
    for r in kept:
        r["text"] = _shrink_sentences(r["text"], budget)
    return kept

这段小改动把“检索是否可靠”变成一个可调参数,而不是祈祷模型自我约束。很多看似“模型幻觉”的问题,本质是召回与裁剪做得不当,模型只好填空;当你把最小阈值与最大预算钉住,幻觉率会直接跳水。顺便一提,min_scorebudget 也完全可以交给策略层去调度,例如第一次问答用高阈值、少片段;若未能回答,再降阈值扩大召回范围,这个“渐进式扩大检索”远比一上来就塞十几个片段要划算。

最后把边界讲透。向量空间用的是哈希“假嵌入”,只是为了去依赖、跑通流程;在你的工程里将 _hash_vec 替换为真实嵌入服务即可,签名保持 embed_many(texts)->ndarray 的形态,剩下的搜索与 rerank 不用改。同时不要把知识库当“万能外脑”,强制要求模型在给出事实类答案时引用 doc_id#chunk_id,并允许它在检索低分或矛盾时说“不知道”。你给了它一个能说“不”的出口,系统才稳。

明天不在检索上继续深挖,我们换个方向,做一次“并发工具与结果合流”的最小实现:当问题既需要查询汇率又要抓天气,如何让 Agent 并行调用多个工具、在时延与预算可控的前提下合并结果、以及在部分失败时优雅降级。