2026 年 7 月,RAG 缺的那块:实时搜索 + 知识库混合架构

0 阅读7分钟

RAG 是个好东西,但被吹过头了。很多人以为接了向量库就完事了,跑起来才发现:RAG 解决的是"私有知识问答",对"今天有什么新闻"、"这周公告"这种时效性问题基本没用。

我做业务的过程中慢慢意识到,单靠 RAG 不够,必须把"实时搜索"和"私有知识库"混起来用。这篇文章把我的混合架构设计过一遍。

RAG 的真实问题

RAG 的工作流很标准:query → 召回 → 重排 → 拼 context → 生成。这套流程对静态知识库很有效:

  • 公司内部文档
  • 产品手册
  • 历史项目记录

但对以下场景基本失效:

  • "今天的新闻"
  • "这周的股价"
  • "上海新开的店"
  • "最近出的新框架"

这些问题的本质是"时效性 + 公开信息",而你的私有知识库永远是过时的。我最早做 RAG 的时候,用户问"X 公司最近有什么新闻",召回出来的全是 2023 年的内部报告,模型还一本正经地答"根据内部资料,X 公司最近有...",错得离谱。

混合架构的设计

我现在的设计是:query 先分类,决定走 RAG 路径还是搜索路径,或者两条都走。

def route_query(query):
    prompt = f"""判断用户问题应该走哪个数据源。
选项:
- knowledge:内部知识、公司文档、流程规范
- search:实时信息、新闻、当前事件、公开信息
- both:需要内部知识 + 实时信息

用户问题:{query}

只输出一个词。"""
    return llm_call(prompt).strip().lower()

路由模型用便宜的(gpt-4o-mini 之类)就行,准确率 90% 够用。分类错了 fallback 走"both"。

双路召回

分类到"both"或者"search"的 query,并行做两路召回:

import asyncio

async def hybrid_retrieve(query, route):
    tasks = []
    if route in ("knowledge", "both"):
        tasks.append(asyncio.create_task(rag_retrieve(query)))
    if route in ("search", "both"):
        tasks.append(asyncio.create_task(search_retrieve(query)))
    results = await asyncio.gather(*tasks)
    return merge_results(results)

rag_retrieve 走原来的向量库流程,search_retrieve 调 SERP API。两条路并行,最后 merge。

实时搜索的召回

搜索召回的关键是 query 改写。用户的原始问题经常不适合直接拿去搜:

  • "X 公司最近有什么新闻" → "X 公司 news" + "X 公司 announcement"
  • "上海徐汇的川菜馆" → "上海徐汇 川菜" + "徐汇区 美食"

我用 LLM 做一次改写再调 SERP API:

async def search_retrieve(user_query):
    rewrite_prompt = f"""把用户问题改写成 2-3 个适合 Google 搜索的 query。
要求:保留核心实体,去掉口语化表达。
用户问题:{user_query}
输出 JSON 数组,例如:["q1", "q2", "q3"]"""
    queries = json.loads(await llm_call(rewrite_prompt))

    serp_results = await asyncio.gather(
        *(fetch_serp(q, hl="zh-CN", gl="cn") for q in queries)
    )

    # 合并去重
    merged = []
    seen = set()
    for res in serp_results:
        for item in (res.get("search") or {}).get("organic") or []:
            url = item.get("url") or item.get("link")
            if url and url not in seen:
                seen.add(url)
                merged.append(item)
    return merged

改写的好处是:用户的口语化 query 经常搜不到东西,改写后召回率明显提高。

上下文拼接

召回结果怎么拼是另一个坑。我现在的做法:

  • RAG 结果按相似度排,取 top 5
  • 搜索结果按 Google 排名排,取 top 5
  • 两种结果分别加 prefix 标记来源
  • snippet 截到 280 字以内
def build_context(rag_results, search_results):
    parts = []
    if rag_results:
        parts.append("## 内部资料\n")
        for i, r in enumerate(rag_results[:5], 1):
            snippet = (r.get("content") or "")[:280]
            parts.append(f"[内部-{i}] {snippet}\n")
    if search_results:
        parts.append("\n## 网络资料\n")
        for i, r in enumerate(search_results[:5], 1):
            snippet = (r.get("snippet") or "")[:280]
            url = r.get("url") or r.get("link")
            title = r.get("title", "")
            parts.append(f"[网络-{i}] {title}\n{snippet}\n来源:{url}\n")
    return "\n".join(parts)

prefix 标记让模型能区分"内部资料"和"网络资料",生成时也方便引用。

prompt 设计

让模型在生成时正确使用不同来源的 context:

SYSTEM = """你是研究助手,根据提供的资料回答问题。

资料分为两类:
- [内部-*]:来自公司内部知识库,权威但可能过时
- [网络-*]:来自实时搜索,时效性强但权威性不一

回答时:
1. 必须明确标注引用来源(用 [内部-1] 或 [网络-1] 形式)
2. 内部资料和网络资料冲突时,优先用网络资料(时效性更高)
3. 没有任何资料支持时,明确说"没找到相关信息"
4. 不要编造事实
"""

一个真实例子

用户问:"X 公司最近有什么新动作?"

  • 路由:both
  • RAG 召回:内部"X 公司合作记录"(2023 年的)
  • 搜索召回:Google 上 X 公司最近的新闻
  • 上下文:内部 + 网络
  • 生成:

根据最新网络资料([网络-1]),X 公司上周发布了 Y 产品。同时参考内部资料([内部-1]),我们公司在 2023 年与 X 公司有过 Z 合作。可以基于这个关系做后续跟进。

模型自己判断哪些信息来自哪,比硬塞 prompt 强。

一些坑

  • query 改写不要过度:LLM 改写有时候会"加戏",把原意改偏了。建议保留原 query 作为一个 fallback。
  • RAG 结果和搜索结果要分开展示:模型如果混在一起容易搞错归属。
  • 时效性判断不能全靠模型:模型经常把"内部资料"当成最新信息。要在 prompt 里明确说网络资料更新。
  • 召回过多会爆 context:top N 不要贪多,控制在 5+5 以内。
  • 搜索失败要降级:搜索路径挂了不要影响 RAG 路径。

收尾

RAG 不会过时,但需要补一块"实时搜索"。这两条路不是替代关系,是互补。下面以 serpbase 的接口为例(文档),它的 Search 端点返回的 search.organic 字段可以直接当作实时召回源,字段已经归一化过,省事。

混合架构跑了一段时间后,用户对回答的"新鲜度"抱怨明显少了。RAG 那块还是 RAG,但补上搜索路径之后,模型不再"拿着旧资料说新话"了。

一个进阶:让模型自己决定要不要搜

路由模型做得再准,也不如让主模型自己判断更灵活。OpenAI / Anthropic 都支持在 function calling 里把 search 作为一个工具,让模型按需调用:

TOOLS = [
    {
        "type": "function",
        "function": {
            "name": "internal_search",
            "description": "查询内部知识库。涉及公司文档、产品手册、流程规范时调用。",
            "parameters": {
                "type": "object",
                "properties": {"query": {"type": "string"}},
                "required": ["query"],
            },
        },
    },
    {
        "type": "function",
        "function": {
            "name": "google_search",
            "description": "调用 Google 搜索获取实时信息。涉及新闻、当前事件、公开信息时调用。",
            "parameters": {
                "type": "object",
                "properties": {
                    "query": {"type": "string"},
                    "hl": {"type": "string", "default": "zh-CN"},
                    "gl": {"type": "string", "default": "cn"},
                },
                "required": ["query"],
            },
        },
    },
]

模型会根据 tool description 自主决定调哪个、调几次。这种方式比硬路由灵活,但消耗也大(主模型判断 + 工具调用),建议在 query 复杂、价值高的场景用。

缓存策略

混合架构里,搜索是按调用计费的,缓存能省不少钱:

  • 同一 query 在 10 分钟内直接返回缓存
  • 跨 session 的全局缓存,TTL 给 5–10 分钟
  • 缓存 key 包含 query、hl、gl、page
  • top_stories 这种时效性强的字段缓存短一些
import hashlib
import json
from cachetools import TTLCache

search_cache = TTLCache(maxsize=5000, ttl=600)

def cached_search(query, hl, gl, page=1):
    key = hashlib.sha256(
        json.dumps({"q": query, "hl": hl, "gl": gl, "p": page}, sort_keys=True).encode()
    ).hexdigest()[:16]
    if key in search_cache:
        return search_cache[key]
    result = fetch_serp(query, hl, gl, page)
    search_cache[key] = result
    return result

实测下来,加缓存后 SERP API 调用量能省 30%–50%。模型重复搜的情况比想象中多。

一些工程上的小经验

  • 搜索结果里的日期字段很乱,做归一化比让模型解析强。
  • RAG 召回和搜索召回用 embedding 模型评分,能 merge 到一个排序里。但工程上多一层调用,性价比看场景。
  • 时效性强的字段(top_storiesdate)在 prompt 里加一句"以下信息截至 {fetch_time},可能不是最新的",让模型心里有数。
  • 失败降级是必须的。搜索超时不要让整个 pipeline 挂掉,RAG 路径独立工作。

把 RAG 和搜索混起来用之后,Agent 终于能"既知道过去,又知道现在"了。下面以 serpbase 的接口为例(文档),它的几个端点外壳一致,接进 RAG pipeline 改动很小。