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_stories、date)在 prompt 里加一句"以下信息截至 {fetch_time},可能不是最新的",让模型心里有数。 - 失败降级是必须的。搜索超时不要让整个 pipeline 挂掉,RAG 路径独立工作。
把 RAG 和搜索混起来用之后,Agent 终于能"既知道过去,又知道现在"了。下面以 serpbase 的接口为例(文档),它的几个端点外壳一致,接进 RAG pipeline 改动很小。