AI 工具

用 Exa 搜索 API 生成带引用简报:去重来源并压缩上下文

Exa 是给程序调用的搜索 API,返回网页的相关摘录(highlights)或正文。 这篇做 exabrief.py:用官方 SDK 搜索一个主题,去重后生成带编号引用的简报(brief.md),以及一份限定长度、可以直接交给大模型的上下文(context.txt)。

BINBHO · 图文教程

Exa 是给程序调用的搜索 API,返回网页的相关摘录(highlights)或正文。 这篇做 exa_brief.py:用官方 SDK 搜索一个主题,去重后生成带编号引用的简报(brief.md),以及一份限定长度、可以直接交给大模型的上下文(context.txt)。

先看清成品

  • 真实接口 401,SDK 抛出 ValueError;脚本识别后给出“去控制台创建 key”的提示。
  • 替身上跑通:4 条结果去重为 3 条(utm 参数、www.、末尾斜杠的同一篇文章合并);没有发布日期的结果显示“日期未知”。
  • 发现并修复:引用链接中带着 ?utm_source=newsletter。pytest 6 项通过。

合规提醒:Exa 返回的是第三方网页内容,转载或商用前要遵守原网站的版权和使用条款;不要用 Exa 采集个人信息。本文只使用 Exa 官方 API 和官方 Python SDK(exa-py)。API key 在 Exa 控制台(dashboard.exa.ai)创建,放在环境变量 EXA_API_KEY 中。调用费用以 Exa 官方价格页为准(本文不列价格)。

运行说明:box 上没有 Exa API key。我用无效 key 真实调用了 POST https://api.exa.ai/search,得到 HTTP 401 {"error":"Invalid API key. …","tag":"INVALID_API_KEY"};通过官方 SDK(exa-py 2.26.0)调用时,SDK 抛出的是 ValueError(2026-10-21 20:06,UTC+8)。其余响应来自我写的本地替身:两条结果照抄官方快速入门页面的示例响应,另外的结果和网页正文是我编的(example.com)。SDK 通过官方的 base_url 参数指向替身。所有脚本和测试都在 box 上真实运行过。

做完之后你会得到什么?

成果 说明
exa_brief.py 搜索并生成简报和上下文
fake_exa.py 本地替身(部分结果照抄官方示例)
demo_local.py 用替身演示
test_brief.py 6 项测试

需要准备哪些环境?

项目 说明
Python 3.9 以上,pip install exa-py pytest(本文为 exa-py 2.26.0)
Exa API key 在 Exa 控制台创建,export EXA_API_KEY=…

关键事实

事实 来源
官方推荐默认使用 contents: {"highlights": true};需要完整上下文时用 text,两者只选一个(同时请求会返回并计费两份) 官方 Search 快速入门
type 可选 auto(默认)、lite、deep-lite、deep;numResults 默认 10,最多 100;Search 不支持分页 同上
includeDomains 是硬性限制;来源偏好写在查询里 同上
认证:Authorization: Bearer 或 x-api-key;官方 SDK 从 EXA_API_KEY 读取 同上
Exa(api_key, base_url, user_agent, timeout) 构造参数 exa-py 2.26.0 源码签名(实测)

文件结构

74-exa-A-src/
├── exa_brief.py
├── fake_exa.py
├── demo_local.py
└── test_brief.py

第 1 步:主脚本

"""用 Exa 官方 Python SDK(exa-py)搜索一个主题,生成带编号引用的简报(brief.md),以及一份限定长度的上下文(context.txt),可交给大模型使用。
官方建议:内容默认用 highlights;只有需要完整上下文时才用 text,且两者只选一个(同时请求会返回并计费两份)。
key 放在环境变量 EXA_API_KEY。"""
import argparse, os, re, sys
from urllib.parse import urlsplit, urlunsplit, parse_qsl, urlencode
from exa_py import Exa

def norm_url(u):
    """去掉 utm_* 参数、www. 前缀和末尾斜杠,用于去重。"""
    p = urlsplit(u); q = urlencode([(k, v) for k, v in parse_qsl(p.query) if not k.lower().startswith("utm_")])
    return urlunsplit((p.scheme.lower(), p.netloc.lower().removeprefix("www."), p.path.rstrip("/") or "/", q, ""))

def strip_tracking(u):
    """引用时只去掉 utm_* 跟踪参数,其余保持原样(演示输出里第一次就把 ?utm_source=newsletter 带进了引用)。"""
    p = urlsplit(u); q = urlencode([(k, v) for k, v in parse_qsl(p.query) if not k.lower().startswith("utm_")])
    return urlunsplit((p.scheme, p.netloc, p.path, q, p.fragment))

def client():
    return Exa(api_key=os.environ.get("EXA_API_KEY"), base_url=os.environ.get("EXA_BASE_URL", "https://api.exa.ai"), user_agent="binbho-tutorial/1.0")

def search(query, n=10, domains=None, since=None, exa=None):
    exa = exa or client()
    try:
        r = exa.search(query, type="auto", num_results=n, include_domains=domains or None, start_published_date=since, contents={"highlights": True})
    except ValueError as e:                     # SDK 把 HTTP 错误包装成 ValueError(实测 401 就是这样)
        if "401" in str(e): raise SystemExit("Exa 401:API key 无效。在 https://dashboard.exa.ai/api-keys 创建 key,放进 EXA_API_KEY") from None
        raise
    seen, out = set(), []
    for x in r.results:
        k = norm_url(x.url)
        if k not in seen: seen.add(k); out.append(x)
    return out

def brief(query, results, per_source=600, budget=4000):
    md = [f"# {query}", "", "> 由 Exa 搜索结果自动整理,摘录未经核实;引用前请打开原文。", ""]
    ctx, used = [], 0
    for i, x in enumerate(results, 1):
        date = (x.published_date or "")[:10] or "日期未知"
        hl = re.sub(r"\s+", " ", " … ".join(x.highlights or [])).strip()
        if len(hl) > per_source: hl = hl[:per_source].rstrip() + "…"
        url = strip_tracking(x.url)
        md += [f"## [{i}] {x.title}", f"- 来源:{url}", f"- 发布:{date}", "", f"> {hl}", ""]
        chunk = f"[{i}] {x.title} ({url}, {date})\n{hl}\n"
        if used + len(chunk) > budget: md.append(f"<!-- 上下文已满:[{i}] 及之后的来源没有放进 context.txt -->"); break
        ctx.append(chunk); used += len(chunk)
    return "\n".join(md) + "\n", "\n".join(ctx)

def main(argv=None):
    ap = argparse.ArgumentParser(); ap.add_argument("query"); ap.add_argument("-n", type=int, default=10); ap.add_argument("--domain", action="append")
    ap.add_argument("--since", help="只要这个日期之后发布的,例如 2026-01-01"); ap.add_argument("--budget", type=int, default=4000)
    a = ap.parse_args(argv)
    res = search(a.query, a.n, a.domain, a.since)
    md, ctx = brief(a.query, res, budget=a.budget)
    open("brief.md", "w", encoding="utf-8").write(md); open("context.txt", "w", encoding="utf-8").write(ctx)
    print(f"{len(res)} 个来源(已去重)→ brief.md;context.txt {len(ctx)} 字符(上限 {a.budget})")
    return 0

if __name__ == "__main__":
    sys.exit(main())

第 2 步:本地替身

"""本地替身(我写的,不是 Exa 官方的)。/search 的结果照抄 Exa 官方 Search 快速入门页面中的示例响应(两条:arXiv 和 Thoughtworks),
另加两条我编的结果(example.com)用来测试去重和截断;401 的响应体是对真实接口的实测结果。"""
import json, threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

OFFICIAL = [
    {"id": "https://arxiv.org/html/2608.21702", "title": "From Association to Causation: Improving Retrieval Precision ofRetrieval-Augmented Generation via Causal Relations and an Attention Mechanism",
     "url": "https://arxiv.org/html/2608.21702", "highlights": ["Retrieval-Augmented Generation (RAG) grounds LLM generation on retrieved documents, but the standard terminal retrieval stage—dense-vector similarity, optionally followed by reranking—often returns documents that merely share keywords with the query without containing the needed information...\n..."]},
    {"id": "https://www.thoughtworks.com/en-us/insights/blog/generative-ai/four-retrieval-techniques-improve-rag", "title": "Four retrieval techniques to improve RAG you need to know",
     "url": "https://www.thoughtworks.com/en-us/insights/blog/generative-ai/four-retrieval-techniques-improve-rag", "publishedDate": "2025-04-14T00:00:00.000Z",
     "highlights": ["It's not surprising, then, that we've seen a range of different approaches emerge that attempt to address RAG's limitations over the last year or so.\n..."]},
]
MINE = [
    {"id": "https://example.com/rag-guide?utm_source=newsletter", "title": "示例:RAG 检索调优指南", "url": "https://example.com/rag-guide?utm_source=newsletter",
     "publishedDate": "2026-10-19T08:00:00.000Z", "highlights": ["混合检索(BM25 + 向量)在长尾查询上更稳定。" * 30]},
    {"id": "https://www.example.com/rag-guide/", "title": "示例:RAG 检索调优指南", "url": "https://www.example.com/rag-guide/",
     "publishedDate": "2026-10-19T08:00:00.000Z", "highlights": ["同一篇文章的另一个网址。"]},
]
PAGES = {"https://example.com/benchmark": "In our benchmark, hybrid retrieval improved recall@10 from 0.61 to 0.74 on the long-tail query set. Latency rose by 12 ms.",
         "https://example.com/blocked": None}

class State:
    def __init__(self): self.bodies = []; self.results = OFFICIAL + MINE

def make(st):
    class H(BaseHTTPRequestHandler):
        def log_message(self, *a): pass
        def send(self, code, obj):
            b = json.dumps(obj).encode(); self.send_response(code); self.send_header("Content-Type", "application/json")
            self.send_header("Content-Length", str(len(b))); self.end_headers(); self.wfile.write(b)
        def do_POST(self):
            body = json.loads(self.rfile.read(int(self.headers["Content-Length"]))); st.bodies.append((self.path, body))
            if self.headers.get("x-api-key") != "good":
                return self.send(401, {"requestId": "f5c0d981250e865f0008dba10a9dee5f", "error": "Invalid API key. Provide a valid key using 'Authorization: Bearer <key>' or 'x-api-key: <key>'. Create a key at https://dashboard.exa.ai/api-keys", "tag": "INVALID_API_KEY"})
            if self.path == "/search":
                since = body.get("startPublishedDate")
                res = [r for r in st.results if not since or (r.get("publishedDate") or "") >= since]
                return self.send(200, {"requestId": "demo", "results": res[: body.get("numResults", 10)], "searchTime": 1.0})
            if self.path == "/contents":
                res, stat = [], []
                for u in body["urls"]:
                    t = PAGES.get(u)
                    if t is None: stat.append({"id": u, "status": "error", "error": {"tag": "CRAWL_NOT_FOUND", "httpStatusCode": 404}})
                    else: res.append({"id": u, "url": u, "title": u, "text": t}); stat.append({"id": u, "status": "success"})
                return self.send(200, {"requestId": "demo", "results": res, "statuses": stat})
            self.send(404, {"error": "not found"})
    srv = ThreadingHTTPServer(("127.0.0.1", 0), H); threading.Thread(target=srv.serve_forever, daemon=True).start()
    return srv, f"http://127.0.0.1:{srv.server_address[1]}"

第 3 步:演示脚本

"""用本地替身演示(不连接 Exa,不花费额度):python demo_local.py "查询" [其他参数]"""
import os, sys, fake_exa as F
srv, url = F.make(F.State()); os.environ["EXA_BASE_URL"] = url; os.environ.setdefault("EXA_API_KEY", "good")
import exa_brief as B
try: code = B.main(sys.argv[1:])
finally: srv.shutdown()
sys.exit(code)

第 4 步:测试

import pytest, fake_exa as F, exa_brief as B

@pytest.fixture
def exa(monkeypatch, tmp_path):
    st = F.State(); srv, url = F.make(st)
    monkeypatch.setenv("EXA_BASE_URL", url); monkeypatch.setenv("EXA_API_KEY", "good"); monkeypatch.chdir(tmp_path)
    yield st; srv.shutdown()

def test_request_shape(exa):
    B.search("recent techniques for improving retrieval in RAG systems", n=5, domains=["arxiv.org"], since="2026-01-01")
    path, body = exa.bodies[0]
    assert path == "/search" and body == {"query": "recent techniques for improving retrieval in RAG systems", "type": "auto", "numResults": 5,
                                          "includeDomains": ["arxiv.org"], "startPublishedDate": "2026-01-01", "contents": {"highlights": True}}

def test_dedupe_and_brief(exa):
    res = B.search("rag")
    assert len(res) == 3                                                       # utm 参数和 www./末尾斜杠版本合并
    md, ctx = B.brief("rag", res)
    assert md.count("## [") == 3 and "发布:日期未知" in md and "发布:2025-04-14" in md
    assert "[3] 示例:RAG 检索调优指南" in ctx

def test_budget(exa):
    md, ctx = B.brief("rag", B.search("rag"), budget=700)
    assert len(ctx) <= 700 and "[1]" in ctx and "[3]" not in ctx and "上下文已满" in md

def test_bad_key(exa, monkeypatch):
    monkeypatch.setenv("EXA_API_KEY", "bad")
    with pytest.raises(SystemExit, match="401"): B.search("x")

def test_norm_url():
    assert B.norm_url("https://www.Example.com/a/?utm_source=x&id=3") == "https://example.com/a?id=3"

def test_citation_without_utm(exa):
    md, ctx = B.brief("rag", B.search("rag"))
    assert "utm_source" not in md + ctx and "- 来源:https://example.com/rag-guide\n" in md

第 5 步:运行

$ python demo_local.py "recent techniques for improving retrieval in RAG systems" --budget 1500
3 个来源(已去重)→ brief.md;context.txt 1484 字符(上限 1500)
$ head brief.md
# recent techniques for improving retrieval in RAG systems

> 由 Exa 搜索结果自动整理,摘录未经核实;引用前请打开原文。

## [1] From Association to Causation: Improving Retrieval Precision ofRetrieval-Augmented Generation via Causal Relations and an Attention Mechanism
- 来源:https://arxiv.org/html/2608.21702
- 发布:日期未知

> Retrieval-Augmented Generation (RAG) grounds LLM generation on retrieved documents, but the standard terminal retrieval stage—dense-vector similarity, optionally followed by reranking—often returns documents that merely share keywords with the query without containing the needed information... ...

## [2] Four retrieval techniques to improve RAG you need to know
- 来源:https://www.thoughtworks.com/en-us/insights/blog/generative-ai/four-retrieval-techniques-improve-rag
- 发布:2025-04-14

> It's not surprising, then, that we've seen a range of different approaches emerge that attempt to address RAG's limitations over the last year or so. ...

## [3] 示例:RAG 检索调优指南
- 来源:https://example.com/rag-guide
- 发布:2026-10-19

> 混合检索(BM25 + 向量)在长尾查询上更稳定。混合检索(BM25 + 向量)在长尾查询上更稳定。混合检索(BM25 + 向量)在长尾查询上更稳定。混合检索(BM25 + 向量)在长尾查询上更稳定。混合检索(BM25 + 
…
$ EXA_API_KEY=bad python demo_local.py x
Exa 401:API key 无效。在 https://dashboard.exa.ai/api-keys 创建 key,放进 EXA_API_KEY
$ curl -s -X POST https://api.exa.ai/search -H 'Content-Type: application/json' -H 'Authorization: Bearer invalid' -d '{"query":"test","type":"lite"}'     # 真实接口
{"requestId":"f5c0d981250e865f0008dba10a9dee5f","error":"Invalid API key. Provide a valid key using 'Authorization: Bearer <key>' or 'x-api-key: <key>'. Create a key at https://dashboard.exa.ai/api-keys","tag":"INVALID_API_KEY"} (HTTP 401)
$ python -m pytest -q
6 passed

怎么检查结果?

  • 测试会检查 SDK 实际发出的请求体(camelCase:numResults、includeDomains、startPublishedDate)。
  • context.txt 不超过 --budget;放不下的来源在 brief.md 中有注释说明。
  • 简报中的摘录是 Exa 选出的片段,引用前打开原文。

调试示例

1. SDK 的 401 是 ValueError。 用无效 key 通过 exa-py 调用,得到的是:

ValueError Request failed with status code 401: {"requestId":"5af9…","error":"Invalid API key. …","tag":"INVALID_API_KEY"}

不是专门的认证异常类型。脚本捕获 ValueError,看到 401 时给出明确提示,其他错误原样抛出。

2. 官方示例中第一条结果没有 publishedDate。 我照抄官方示例响应做替身时注意到,arXiv 那条结果没有 publishedDate 字段。简报中显示“日期未知”,而不是报错或留空。

3. 引用中带跟踪参数。 第一次运行演示,第 3 个来源的链接是 https://example.com/rag-guide?utm_source=newsletter。去重时已经去掉了 utm 参数,但输出时用的仍是原始网址。修复:输出前去掉 utm_*,其他参数保留。

常见问题

现象 原因 处理
401 key 错误 在 Exa 控制台重新创建
结果太少 includeDomains 太窄,或起始日期太近 放宽筛选
上下文太长 highlights 太多 降低 per_source 或 budget;不要同时请求 text

模板规则

  • 默认用 highlights。
  • 引用时去掉跟踪参数。
  • 摘录要回到原文核对。
  • key 只放在环境变量里。

项目资源

用途 地址
Exa Search 快速入门(官方) https://exa.ai/docs/search/quickstart
Exa Python SDK(官方 GitHub) https://github.com/exa-labs/exa-py
Exa SDK 快速入门(官方) https://exa.ai/docs/sdks/quickstart
Exa 官网 https://exa.ai/

让 Exa 找来源,让引用编号把每句话和来源对上。