Skip to content

AI 搜索引擎开发实战

从零构建类 Perplexity 的 AI 搜索引擎——联网搜索 + 网页内容提取 + RAG 增强 + 大模型答案生成 + 引用溯源,用 Python 实现一个能"上网查资料再回答"的智能搜索助手。


1. AI 搜索引擎核心原理:从传统搜索到 AI 搜索

1.1 传统搜索 vs AI 搜索:返回链接 vs 直接给答案

传统搜索 vs AI 搜索:

  传统搜索(Google)
  ═══════════════════════════════════════
  用户提问 → 返回 10 个链接
  用户需要自己点开、阅读、总结
  优点:信息全、来源广
  缺点:需要人工筛选和总结

  AI 搜索(Perplexity)
  ═══════════════════════════════════════
  用户提问 → 联网搜索 → 阅读网页 → 生成答案
  直接给出带引用的结构化回答
  优点:省时、直接、有出处
  缺点:可能遗漏信息、依赖 LLM 质量

1.2 Perplexity 架构拆解:它是怎么做的

Perplexity 的核心架构:

  ┌──────────┐     ┌──────────┐     ┌──────────┐
  │ 1. 查询改写│───→│ 2. 联网搜索│───→│ 3. 网页抓取│
  │ 优化搜索词 │     │ 多个搜索源 │     │ 提取正文   │
  └──────────┘     └──────────┘     └──────────┘

  ┌──────────┐     ┌──────────┐     ┌──────────┐
  │ 6. 引用标注│←───│ 5. 答案生成│←───│ 4. RAG 组装│
  │ [1][2][3] │     │ 流式输出   │     │ Context   │
  └──────────┘     └──────────┘     └──────────┘

1.3 核心流程:查询 → 搜索 → 提取 → 生成 → 引用

步骤做什么用什么
查询改写将用户问题转为搜索友好的关键词LLM
联网搜索调用搜索 API 拿到 URL 和摘要Tavily / Bing API
网页抓取抓取 URL 内容,提取正文httpx + Trafilatura
RAG 组装将多个网页内容塞进 Context分块 + 截断
答案生成基于搜索结果生成回答GPT-4o / Claude
引用标注每句话标注来源 [1][2]Prompt 指令

1.4 技术栈选型:Python + FastAPI + LLM

推荐技术栈:

  后端框架:  FastAPI(异步、SSE 支持)
  搜索 API:  Tavily(为 AI 应用设计的搜索 API)
  网页抓取:  httpx + Trafilatura
  大模型:    OpenAI GPT-4o / Claude 3.5 Sonnet
  流式输出:  Server-Sent Events (SSE)
  缓存:      Redis(搜索结果缓存)

第 1 章核心知识回顾:

概念一句话解释
AI 搜索联网搜索 + LLM 生成 = 直接给答案
6 步流程改写→搜索→抓取→组装→生成→引用
Tavily专为 AI 应用设计的搜索 API

2. 联网搜索:接入搜索引擎 API

2.1 搜索 API 选型:Google / Bing / Tavily / SerpAPI

API价格特点推荐度
Tavily免费 1000 次/月专为 AI 设计,直接返回干净内容⭐⭐⭐⭐⭐
Bing Search$3/1000 次微软官方,结果全面⭐⭐⭐⭐
SerpAPI$50/月起支持多种搜索引擎⭐⭐⭐
Google CSE免费 100 次/天免费额度少⭐⭐

2.2 Tavily 实战:最适合 AI 应用的搜索 API

python
from tavily import TavilyClient
import os

client = TavilyClient(api_key=os.getenv("TAVILY_API_KEY"))

# 基础搜索
results = client.search(
    query="Python asyncio 最佳实践 2024",
    search_depth="advanced",       # basic 或 advanced(更深度)
    max_results=5,
    include_raw_content=True,      # 包含网页原始内容
)

for r in results["results"]:
    print(f"📄 {r['title']}")
    print(f"   URL: {r['url']}")
    print(f"   摘要: {r['content'][:100]}...")
    print(f"   相关度: {r['score']}")
python
# 不用 SDK,直接用 httpx
import httpx

async def tavily_search(query: str, max_results: int = 5) -> list[dict]:
    async with httpx.AsyncClient() as client:
        resp = await client.post(
            "https://api.tavily.com/search",
            json={
                "api_key": os.getenv("TAVILY_API_KEY"),
                "query": query,
                "search_depth": "advanced",
                "max_results": max_results,
                "include_raw_content": True,
            },
            timeout=30,
        )
        data = resp.json()
        return data.get("results", [])

2.3 查询改写:让搜索结果更精准

python
async def rewrite_query(user_question: str, chat_history: list = None) -> str:
    """用 LLM 将用户问题改写为搜索引擎友好的查询"""
    
    prompt = f"""将用户问题改写为适合搜索引擎的查询关键词。

规则:
- 提取核心关键词,去掉口语化表达
- 如果有时间要求,加上年份
- 输出纯关键词,不要完整句子

用户问题:{user_question}
搜索关键词:"""
    
    response = await client.chat.completions.create(
        model="gpt-4o-mini",       # 用小模型就够了
        messages=[{"role": "user", "content": prompt}],
        max_tokens=50,
    )
    return response.choices[0].message.content.strip()

# 示例
# "Python 的异步编程怎么学比较好?" → "Python asyncio 异步编程 教程 2024"
# "怎么用 Docker 部署 FastAPI?"   → "Docker FastAPI 部署 docker-compose"

2.4 搜索结果去重与排序

python
from urllib.parse import urlparse

def deduplicate_results(results: list[dict]) -> list[dict]:
    """去重:同一域名只保留得分最高的结果"""
    seen_domains = {}
    for r in sorted(results, key=lambda x: x.get("score", 0), reverse=True):
        domain = urlparse(r["url"]).netloc
        if domain not in seen_domains:
            seen_domains[domain] = r
    return list(seen_domains.values())

def filter_results(results: list[dict], min_score: float = 0.5) -> list[dict]:
    """过滤低质量结果"""
    return [r for r in results if r.get("score", 0) >= min_score]

💡 查询改写是 AI 搜索质量的关键——直接拿用户的口语化问题去搜,结果往往不好。用小模型(GPT-4o-mini)做改写,成本极低但效果显著。

第 2 章核心知识回顾:

概念一句话解释
TavilyAI 搜索首选,直接返回干净内容
查询改写口语问题→搜索关键词,用 GPT-4o-mini
去重同域名保留最高分结果

3. 网页内容提取:从 URL 到干净文本

3.1 网页抓取:httpx + 异步并发

python
import httpx

async def fetch_page(url: str, timeout: int = 15) -> str | None:
    """抓取单个网页"""
    headers = {
        "User-Agent": "Mozilla/5.0 (compatible; AISearchBot/1.0)",
        "Accept": "text/html,application/xhtml+xml",
    }
    try:
        async with httpx.AsyncClient(follow_redirects=True) as client:
            resp = await client.get(url, headers=headers, timeout=timeout)
            resp.raise_for_status()
            return resp.text
    except Exception as e:
        print(f"抓取失败 {url}: {e}")
        return None

3.2 HTML 转干净文本:Trafilatura 一键提取正文

python
import trafilatura

def extract_content(html: str, url: str = None) -> dict | None:
    """从 HTML 中提取正文、标题"""
    text = trafilatura.extract(
        html,
        include_comments=False,
        include_tables=True,
        favor_precision=True,         # 宁可少提取,也别提取到广告
    )
    if not text or len(text) < 100:   # 太短的内容没价值
        return None
    
    metadata = trafilatura.extract_metadata(html)
    return {
        "content": text,
        "title": metadata.title if metadata else "",
        "url": url,
    }

3.3 内容清洗:去噪声保留核心信息

python
import re

def clean_content(text: str, max_length: int = 3000) -> str:
    """清洗提取的文本"""
    # 去掉连续空行
    text = re.sub(r'\n{3,}', '\n\n', text)
    # 去掉多余空格
    text = re.sub(r' {2,}', ' ', text)
    # 去掉常见噪声
    noise_patterns = [
        r'Cookie.*?Policy',
        r'Subscribe.*?newsletter',
        r'Advertisement',
    ]
    for pattern in noise_patterns:
        text = re.sub(pattern, '', text, flags=re.IGNORECASE)
    # 截断过长内容
    if len(text) > max_length:
        text = text[:max_length] + "..."
    return text.strip()

3.4 并发抓取:同时处理 10 个网页

python
import asyncio

async def fetch_and_extract_all(urls: list[str], max_concurrent: int = 10) -> list[dict]:
    """并发抓取多个网页并提取内容"""
    semaphore = asyncio.Semaphore(max_concurrent)
    
    async def process_one(url: str) -> dict | None:
        async with semaphore:
            html = await fetch_page(url)
            if not html:
                return None
            result = extract_content(html, url)
            if result:
                result["content"] = clean_content(result["content"])
            return result
    
    tasks = [process_one(url) for url in urls]
    results = await asyncio.gather(*tasks)
    return [r for r in results if r is not None]

# 使用
urls = [r["url"] for r in search_results]
pages = await fetch_and_extract_all(urls)
# 10 个网页并发抓取,通常 2-3 秒完成

💡 Tavily 的 include_raw_content=True 已经帮你做了抓取和提取——如果用 Tavily,可以跳过手动抓取。但了解手动实现的方法,在用 Bing API 等不提供内容的搜索 API 时很重要。

第 3 章核心知识回顾:

概念一句话解释
Trafilatura从 HTML 提取正文,自动去广告导航
并发抓取Semaphore 限流 + asyncio.gather 并行
内容截断每个网页最多 3000 字,避免 Context 爆炸

4. RAG 增强:将搜索结果喂给大模型

4.1 搜索结果分块与截断策略

python
def prepare_sources(pages: list[dict], max_total: int = 12000) -> list[dict]:
    """准备搜索结果作为 Context,控制总长度"""
    sources = []
    total_length = 0
    
    for i, page in enumerate(pages):
        content = page["content"]
        remaining = max_total - total_length
        
        if remaining <= 0:
            break
        
        # 截断单个来源
        if len(content) > remaining:
            content = content[:remaining] + "..."
        
        sources.append({
            "index": i + 1,
            "title": page["title"],
            "url": page["url"],
            "content": content,
        })
        total_length += len(content)
    
    return sources

4.2 Context 组装:把多个网页塞进 Prompt

python
def build_context(sources: list[dict]) -> str:
    """将搜索结果组装为 Context 文本"""
    context_parts = []
    for s in sources:
        context_parts.append(
            f"[来源 {s['index']}] {s['title']}\n"
            f"URL: {s['url']}\n"
            f"内容:\n{s['content']}\n"
        )
    return "\n---\n".join(context_parts)

4.3 相关性排序:优先使用最相关的内容

python
async def rerank_sources(query: str, sources: list[dict]) -> list[dict]:
    """用 LLM 对搜索结果进行相关性重排"""
    
    prompt = f"""对以下搜索结果按与问题的相关性打分(1-10 分)。
    
问题:{query}

搜索结果:
{chr(10).join(f"[{s['index']}] {s['title']}: {s['content'][:200]}" for s in sources)}

按 JSON 格式输出:[&#123;&#123;"index": 1, "score": 8&#125;&#125;, ...]"""
    
    response = await client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[{"role": "user", "content": prompt}],
        response_format={"type": "json_object"},
    )
    
    scores = json.loads(response.choices[0].message.content)
    # 按分数重新排序
    score_map = {s["index"]: s["score"] for s in scores}
    return sorted(sources, key=lambda s: score_map.get(s["index"], 0), reverse=True)

4.4 Prompt 设计:基于证据回答 + 引用标注

python
SEARCH_ANSWER_PROMPT = """你是一个 AI 搜索助手。基于提供的搜索结果回答用户问题。

规则:
1. 只使用搜索结果中的信息回答,不要编造
2. 每个关键论述后用 [1][2] 标注来源编号
3. 如果搜索结果无法回答,明确说"搜索结果中未找到相关信息"
4. 用中文回答,条理清晰
5. 如果有多个来源给出不同信息,都列出来并说明差异

搜索结果:
{context}

用户问题:{question}"""

💡 Prompt 中的引用规则是 AI 搜索的灵魂——没有引用标注,AI 搜索就退化成了"联网聊天",用户无法验证信息的真实性。

第 4 章核心知识回顾:

概念一句话解释
长度控制总 Context 控制在 12000 字以内
Rerank用小模型对搜索结果重排,最相关的放前面
引用标注[1][2] 让每句话都有出处

5. 答案生成与引用溯源

5.1 流式答案生成:边搜边答的体验

python
from openai import AsyncOpenAI

client = AsyncOpenAI()

async def generate_answer_stream(question: str, sources: list[dict]):
    """流式生成答案"""
    context = build_context(sources)
    prompt = SEARCH_ANSWER_PROMPT.format(context=context, question=question)
    
    stream = await client.chat.completions.create(
        model="gpt-4o",
        messages=[{"role": "user", "content": prompt}],
        stream=True,
        temperature=0.1,           # 低温度,更忠实于搜索结果
    )
    
    async for chunk in stream:
        delta = chunk.choices[0].delta
        if delta.content:
            yield delta.content

5.2 内联引用标注:让每句话都有出处

python
import re

def parse_citations(answer: str, sources: list[dict]) -> dict:
    """解析答案中的引用标注"""
    # 提取所有引用编号 [1], [2], [1][3]
    citation_pattern = r'\[(\d+)\]'
    cited_indices = set(int(m) for m in re.findall(citation_pattern, answer))
    
    # 构建引用列表
    citations = []
    for idx in sorted(cited_indices):
        source = next((s for s in sources if s["index"] == idx), None)
        if source:
            citations.append({
                "index": idx,
                "title": source["title"],
                "url": source["url"],
                "snippet": source["content"][:150] + "...",
            })
    
    return {"answer": answer, "citations": citations}

5.3 引用来源卡片:展示标题、URL 和摘要

python
def format_sources_display(citations: list[dict]) -> str:
    """格式化引用来源展示"""
    lines = ["\n---\n📚 **参考来源:**\n"]
    for c in citations:
        lines.append(f"[{c['index']}] [{c['title']}]({c['url']})")
        lines.append(f"   {c['snippet']}\n")
    return "\n".join(lines)

5.4 答案质量控制:防幻觉、防过时信息

策略实现方式
低温度temperature=0.1 减少创造性
强制引用Prompt 中要求每个论述标注来源
无答案检测搜索结果无关时明确告知用户
时效标注显示来源的发布日期
多源交叉多个来源说同一件事 → 可信度高

💡 AI 搜索的质量核心是"忠实于搜索结果"——temperature=0.1 + 强制引用规则 + 无答案检测,三者缺一不可。

第 5 章核心知识回顾:

概念一句话解释
流式生成边搜边答,用户体验好
引用解析正则提取 [1][2],关联到来源
防幻觉低温度 + 强制引用 + 无答案检测

6. 追问与多轮对话

6.1 追问检测:需要重新搜索吗?

python
async def need_new_search(question: str, history: list[dict], sources: list[dict]) -> bool:
    """判断追问是否需要重新搜索"""
    prompt = f"""根据对话历史和已有搜索结果,判断用户的新问题是否需要重新搜索。

已有搜索结果的主题:{', '.join(s['title'] for s in sources[:3])}

对话历史:
{chr(10).join(f"{m['role']}: {m['content'][:100]}" for m in history[-4:])}

用户新问题:{question}

回答 YES(需要重新搜索)或 NO(已有信息足够回答)。"""
    
    response = await client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[{"role": "user", "content": prompt}],
        max_tokens=5,
    )
    return "YES" in response.choices[0].message.content.upper()

6.2 对话历史管理:多轮搜索的上下文

python
class SearchSession:
    """管理一次搜索会话的多轮对话"""
    
    def __init__(self):
        self.history: list[dict] = []
        self.all_sources: list[dict] = []  # 累积所有搜索结果
    
    async def ask(self, question: str) -> str:
        # 判断是否需要新搜索
        if not self.all_sources or await need_new_search(question, self.history, self.all_sources):
            # 执行新搜索
            query = await rewrite_query(question, self.history)
            results = await tavily_search(query)
            new_sources = prepare_sources(results)
            self.all_sources.extend(new_sources)
        
        # 用所有累积的搜索结果生成答案
        answer = await generate_answer(question, self.all_sources, self.history)
        
        self.history.append({"role": "user", "content": question})
        self.history.append({"role": "assistant", "content": answer})
        
        return answer

6.3 相关问题推荐:自动生成追问建议

python
async def suggest_followups(question: str, answer: str) -> list[str]:
    """基于问答生成追问建议"""
    prompt = f"""基于以下问答,生成 3 个用户可能想继续追问的问题。

问题:{question}
回答:{answer[:500]}

要求:
- 问题要有价值,不是简单重复
- 从不同角度提问
- 用中文

输出格式(每行一个):"""
    
    response = await client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[{"role": "user", "content": prompt}],
        max_tokens=200,
    )
    
    lines = response.choices[0].message.content.strip().split("\n")
    return [line.strip().lstrip("0123456789.-) ") for line in lines if line.strip()][:3]

6.4 搜索结果缓存:避免重复搜索

python
import hashlib
import json
from functools import lru_cache

# 简单内存缓存
_search_cache: dict[str, list[dict]] = {}

async def cached_search(query: str, max_results: int = 5) -> list[dict]:
    cache_key = hashlib.md5(query.encode()).hexdigest()
    
    if cache_key in _search_cache:
        return _search_cache[cache_key]
    
    results = await tavily_search(query, max_results)
    _search_cache[cache_key] = results
    return results

💡 追问不一定需要重新搜索——"解释一下第三点"这种追问用已有结果就能回答。用小模型判断是否需要新搜索,节省 API 调用。

第 6 章核心知识回顾:

概念一句话解释
追问检测小模型判断 YES/NO,决定是否重新搜索
累积来源多轮搜索结果合并,信息越来越丰富
推荐追问自动生成 3 个有价值的追问建议

7. 完整实现:FastAPI + 前端 Demo

7.1 后端 API 设计:SSE 流式搜索接口

python
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
import json

app = FastAPI()

class SearchRequest(BaseModel):
    query: str
    session_id: str | None = None

@app.post("/api/search")
async def search(req: SearchRequest):
    """SSE 流式搜索接口"""
    async def event_stream():
        # 1. 查询改写
        yield f"data: {json.dumps({'type': 'status', 'message': '正在分析问题...'})}\n\n"
        search_query = await rewrite_query(req.query)
        
        # 2. 联网搜索
        yield f"data: {json.dumps({'type': 'status', 'message': '正在搜索...'})}\n\n"
        results = await tavily_search(search_query)
        
        # 3. 发送搜索结果来源
        sources = prepare_sources(results)
        yield f"data: {json.dumps({'type': 'sources', 'data': sources})}\n\n"
        
        # 4. 流式生成答案
        yield f"data: {json.dumps({'type': 'status', 'message': '正在生成答案...'})}\n\n"
        async for chunk in generate_answer_stream(req.query, sources):
            yield f"data: {json.dumps({'type': 'answer', 'content': chunk})}\n\n"
        
        # 5. 推荐追问
        # (收集完整答案后生成)
        yield f"data: {json.dumps({'type': 'done'})}\n\n"
    
    return StreamingResponse(event_stream(), media_type="text/event-stream")

7.2 完整流水线:从用户提问到最终答案

python
async def search_pipeline(question: str) -> dict:
    """完整的搜索流水线(非流式版本)"""
    
    # Step 1: 查询改写
    search_query = await rewrite_query(question)
    
    # Step 2: 联网搜索
    results = await tavily_search(search_query, max_results=8)
    results = deduplicate_results(results)
    
    # Step 3: 内容提取(如果 Tavily 没返回内容)
    urls_without_content = [r["url"] for r in results if not r.get("raw_content")]
    if urls_without_content:
        pages = await fetch_and_extract_all(urls_without_content)
        # 合并内容...
    
    # Step 4: 准备 Context
    sources = prepare_sources(results)
    
    # Step 5: 生成答案
    answer = await generate_answer(question, sources)
    
    # Step 6: 解析引用
    result = parse_citations(answer, sources)
    
    # Step 7: 推荐追问
    followups = await suggest_followups(question, answer)
    result["followups"] = followups
    
    return result

7.3 前端 Demo:极简搜索界面

html
<!DOCTYPE html>
<html>
<head>
    <title>AI 搜索</title>
    <style>
        body { font-family: system-ui; max-width: 800px; margin: 0 auto; padding: 20px; }
        #answer { white-space: pre-wrap; line-height: 1.8; }
        .source { background: #f5f5f5; padding: 10px; margin: 5px 0; border-radius: 8px; }
    </style>
</head>
<body>
    <h1>🔍 AI 搜索</h1>
    <input id="query" placeholder="输入你的问题..." style="width: 100%; padding: 12px; font-size: 16px;">
    <button onclick="search()">搜索</button>
    <div id="status"></div>
    <div id="answer"></div>
    <div id="sources"></div>

    <script>
    async function search() {
        const query = document.getElementById('query').value;
        const answerDiv = document.getElementById('answer');
        answerDiv.textContent = '';
        
        const resp = await fetch('/api/search', {
            method: 'POST',
            headers: {'Content-Type': 'application/json'},
            body: JSON.stringify({query}),
        });
        
        const reader = resp.body.getReader();
        const decoder = new TextDecoder();
        
        while (true) {
            const {done, value} = await reader.read();
            if (done) break;
            
            const lines = decoder.decode(value).split('\n');
            for (const line of lines) {
                if (!line.startsWith('data: ')) continue;
                const data = JSON.parse(line.slice(6));
                
                if (data.type === 'answer') {
                    answerDiv.textContent += data.content;
                } else if (data.type === 'status') {
                    document.getElementById('status').textContent = data.message;
                }
            }
        }
    }
    </script>
</body>
</html>

7.4 性能优化:并行搜索 + 流式生成

python
import asyncio

async def optimized_search(question: str):
    """优化:搜索和改写并行"""
    # 同时执行查询改写和直接搜索
    rewrite_task = rewrite_query(question)
    direct_task = tavily_search(question, max_results=3)
    
    rewritten_query, direct_results = await asyncio.gather(rewrite_task, direct_task)
    
    # 用改写后的查询再搜一次
    rewrite_results = await tavily_search(rewritten_query, max_results=5)
    
    # 合并去重
    all_results = deduplicate_results(direct_results + rewrite_results)
    return all_results

第 7 章核心知识回顾:

概念一句话解释
SSE 接口/api/search 流式返回状态/来源/答案
事件类型status/sources/answer/done 四类事件
并行优化改写和直接搜索同时进行

8. 进阶:Pro 搜索与深度研究

8.1 Pro 搜索:多次搜索 + 交叉验证

python
async def pro_search(question: str) -> dict:
    """Pro 模式:从多个角度搜索,交叉验证"""
    
    # 1. 生成多个搜索角度
    angles = await generate_search_angles(question)
    # ["Python asyncio 性能优化", "asyncio vs threading 对比", "asyncio 生产最佳实践"]
    
    # 2. 并行搜索所有角度
    all_results = []
    tasks = [tavily_search(angle, max_results=3) for angle in angles]
    results_list = await asyncio.gather(*tasks)
    for results in results_list:
        all_results.extend(results)
    
    # 3. 去重 + Rerank
    all_results = deduplicate_results(all_results)
    sources = prepare_sources(all_results, max_total=20000)
    sources = await rerank_sources(question, sources)
    
    # 4. 生成综合答案
    answer = await generate_answer(question, sources[:8])
    return parse_citations(answer, sources)

8.2 深度研究模式:多步推理生成报告

python
async def deep_research(question: str) -> str:
    """深度研究:多步搜索 + 长报告生成"""
    
    # 1. 生成研究大纲
    outline = await generate_research_outline(question)
    # ["1. 背景介绍", "2. 技术原理", "3. 实践案例", "4. 对比分析"]
    
    # 2. 逐个章节搜索+生成
    report_parts = []
    for section in outline:
        query = f"{question} {section}"
        results = await tavily_search(query, max_results=5)
        sources = prepare_sources(results)
        section_content = await generate_section(section, sources)
        report_parts.append(section_content)
    
    # 3. 整合为完整报告
    report = await synthesize_report(question, report_parts)
    return report

8.3 混合搜索:本地知识库 + 联网搜索

python
async def hybrid_search(question: str, local_db=None) -> dict:
    """混合搜索:优先查本地,不够再联网"""
    
    # 1. 先查本地知识库
    local_results = await local_db.search(question, top_k=3) if local_db else []
    
    # 2. 判断本地结果是否足够
    if local_results and max(r["score"] for r in local_results) > 0.85:
        sources = prepare_sources(local_results)
        answer = await generate_answer(question, sources)
        return {"answer": answer, "source_type": "local"}
    
    # 3. 不够就联网搜索
    web_results = await tavily_search(question)
    all_results = local_results + web_results
    sources = prepare_sources(all_results)
    answer = await generate_answer(question, sources)
    return {"answer": answer, "source_type": "hybrid"}

8.4 搜索质量评测与持续优化

评测维度指标方法
答案准确性事实正确率人工标注 + LLM 评测
引用质量引用覆盖率每个关键论述是否都有引用
搜索相关性NDCG搜索结果与问题的相关度排序
响应速度P95 延迟从提问到第一个字输出的时间
用户满意度点赞/踩用户反馈收集

附录:AI 搜索引擎速查手册

A.1 搜索 API 对比表

API月免费额度返回内容延迟推荐场景
Tavily1000 次摘要+正文~2sAI 应用首选
Bing API1000 次摘要+URL~1s需要自己抓取
SerpAPI100 次搜索结果页~3s多引擎支持
Google CSE100 次/天摘要+URL~1s免费但额度少

A.2 网页内容提取工具对比

工具特点安装
Trafilatura精准提取正文,推荐pip install trafilatura
BeautifulSoup通用 HTML 解析pip install beautifulsoup4
newspaper3k新闻文章提取pip install newspaper3k
readability-lxmlMozilla 的算法移植pip install readability-lxml

A.3 核心 Prompt 模板

python
# 查询改写 Prompt
REWRITE_PROMPT = "将用户问题改写为搜索引擎友好的关键词..."

# 答案生成 Prompt
ANSWER_PROMPT = "基于搜索结果回答,每个论述标注引用 [1][2]..."

# 追问检测 Prompt
FOLLOWUP_PROMPT = "判断追问是否需要重新搜索,回答 YES/NO..."

# 追问推荐 Prompt
SUGGEST_PROMPT = "生成 3 个有价值的追问建议..."

A.4 完整架构图与数据流

完整数据流:

  用户提问

    ├─→ 查询改写(GPT-4o-mini)
    │     │
    │     ├─→ Tavily 搜索 ─→ 搜索结果(URL+摘要+正文)
    │     │                      │
    │     │                      ├─→ 去重 + 过滤
    │     │                      │
    │     │                      ├─→ Rerank(可选)
    │     │                      │
    │     │                      ├─→ Context 组装
    │     │                           │
    │     │                           ├─→ 答案生成(GPT-4o 流式)
    │     │                                 │
    │     │                                 ├─→ 引用解析
    │     │                                 │
    │     │                                 ├─→ 来源卡片
    │     │                                 │
    │     │                                 └─→ 追问推荐

    └─→ SSE 流式返回前端

坚持是一种品格