AI驱动的智能搜索引擎开发完全教程

教程简介

全面讲解AI搜索引擎的核心技术,涵盖语义搜索与向量检索、查询理解与意图识别、多源数据融合、搜索结果重排、Perplexity风格AI搜索实现、混合搜索策略、搜索Agent设计等核心内容,帮助开发者从零构建生产级AI搜索引擎。

AI驱动的智能搜索引擎开发完全教程

从零到一构建生产级AI搜索引擎:语义理解、混合检索、智能排序与Perplexity风格搜索的完整实现


目录

  1. 教程简介
  2. 搜索引擎架构设计
  3. 语义搜索与向量检索
  4. 查询理解与意图识别
  5. 多源数据融合
  6. 搜索结果排序与重排(Reranking)
  7. 实时索引与增量更新
  8. 混合搜索策略:BM25+向量
  9. Perplexity风格AI搜索实现
  10. 搜索Agent设计
  11. 搜索质量评估
  12. 最佳实践与生产部署
  13. 总结

教程简介

传统搜索引擎依赖关键词匹配(如BM25算法),在处理语义相似但词汇不同的查询时表现欠佳。随着大语言模型(LLM)和向量检索技术的成熟,AI搜索引擎正在彻底改变信息检索的方式。

本教程将带你从零构建一个完整的AI搜索引擎系统,涵盖:

  • 语义搜索:利用Embedding模型将文本转化为向量,实现语义级别的检索
  • 混合检索:结合传统BM25与向量检索的优势,提升召回质量
  • 查询理解:通过NLU技术理解用户真实意图,支持多轮对话式搜索
  • 智能排序:使用Reranking模型对搜索结果进行精排
  • AI生成式回答:参考Perplexity的设计理念,基于检索结果生成带引用的综合回答
  • 搜索Agent:设计能够自主规划搜索策略的智能Agent

技术栈概览

组件 技术选型
Embedding模型 text-embedding-3-small / BGE / GTE
向量数据库 Milvus / Qdrant / Chroma
全文检索 Elasticsearch / Meilisearch
Reranking Cohere Rerank / BGE-Reranker / Cross-Encoder
LLM OpenAI GPT-4 / Claude / 开源LLM
Agent框架 LangChain / LlamaIndex / 自研

搜索引擎架构设计

整体架构

一个生产级AI搜索引擎通常包含以下核心层次:

┌─────────────────────────────────────────────────────┐
│                   用户交互层                          │
│         Web UI / API / 对话式界面                     │
├─────────────────────────────────────────────────────┤
│                   查询处理层                          │
│    查询理解 → 意图识别 → 查询改写 → 查询扩展          │
├─────────────────────────────────────────────────────┤
│                   检索层                              │
│    BM25全文检索 ←→ 向量语义检索 ←→ 知识图谱检索       │
├─────────────────────────────────────────────────────┤
│                   排序与融合层                        │
│    结果融合 → Reranking → 去重 → 多样性控制           │
├─────────────────────────────────────────────────────┤
│                   生成与呈现层                        │
│    LLM总结生成 → 引用标注 → 摘要展示                  │
├─────────────────────────────────────────────────────┤
│                   数据与索引层                        │
│    文档解析 → Chunking → Embedding → 索引构建         │
└─────────────────────────────────────────────────────┘

核心模块设计

"""
AI搜索引擎核心架构 - 主入口模块
"""
from dataclasses import dataclass, field
from typing import List, Optional, Dict, Any
from enum import Enum
import asyncio


class SearchMode(Enum):
    """搜索模式枚举"""
    KEYWORD = "keyword"          # 纯关键词搜索
    SEMANTIC = "semantic"        # 纯语义搜索
    HYBRID = "hybrid"            # 混合搜索
    AI_ANSWER = "ai_answer"      # AI生成式回答


@dataclass
class SearchQuery:
    """搜索查询对象"""
    text: str
    mode: SearchMode = SearchMode.HYBRID
    top_k: int = 10
    filters: Dict[str, Any] = field(default_factory=dict)
    session_id: Optional[str] = None
    context: List[str] = field(default_factory=list)  # 多轮对话上下文


@dataclass
class SearchDocument:
    """搜索文档对象"""
    id: str
    title: str
    content: str
    url: Optional[str] = None
    source: Optional[str] = None
    metadata: Dict[str, Any] = field(default_factory=dict)
    score: float = 0.0


@dataclass
class SearchResponse:
    """搜索响应对象"""
    query: str
    rewritten_query: Optional[str] = None
    intent: Optional[str] = None
    documents: List[SearchDocument] = field(default_factory=list)
    ai_answer: Optional[str] = None
    citations: List[Dict[str, str]] = field(default_factory=list)
    search_time_ms: float = 0.0


class AISearchEngine:
    """AI搜索引擎主类"""

    def __init__(self, config: Dict[str, Any]):
        self.config = config
        self.query_processor = QueryProcessor(config)
        self.retriever = HybridRetriever(config)
        self.reranker = Reranker(config)
        self.answer_generator = AnswerGenerator(config)
        self.session_manager = SessionManager()

    async def search(self, query: SearchQuery) -> SearchResponse:
        """执行搜索的主流程"""
        import time
        start = time.time()

        # 1. 查询处理:理解意图、改写查询
        processed = await self.query_processor.process(query)

        # 2. 混合检索
        documents = await self.retriever.retrieve(
            query=processed.rewritten_query or query.text,
            mode=query.mode,
            top_k=query.top_k * 3,  # 多召回,后续重排
            filters=query.filters
        )

        # 3. 重排序
        reranked = await self.reranker.rerank(
            query=query.text,
            documents=documents,
            top_k=query.top_k
        )

        # 4. 生成式回答(如果是AI模式)
        ai_answer = None
        citations = []
        if query.mode == SearchMode.AI_ANSWER:
            ai_answer, citations = await self.answer_generator.generate(
                query=query.text,
                context_docs=reranked
            )

        elapsed_ms = (time.time() - start) * 1000

        return SearchResponse(
            query=query.text,
            rewritten_query=processed.rewritten_query,
            intent=processed.intent,
            documents=reranked,
            ai_answer=ai_answer,
            citations=citations,
            search_time_ms=elapsed_ms
        )

语义搜索与向量检索

Embedding模型选型

语义搜索的核心是将文本转化为高维向量,使语义相似的文本在向量空间中距离更近。选择合适的Embedding模型至关重要。

主流Embedding模型对比

模型 维度 特点 适用场景
text-embedding-3-small 1536 OpenAI出品,性价比高 通用场景
text-embedding-3-large 3072 高精度,成本较高 高精度需求
BGE-large-zh 1024 中文优化,开源 中文场景
GTE-large-zh 1024 阿里出品,中文强 中文企业场景
E5-mistral-7b 4096 基于LLM的Embedding 超高精度需求

向量检索实现

"""
语义搜索与向量检索模块
"""
import numpy as np
from typing import List, Tuple, Optional
import hashlib


class EmbeddingService:
    """Embedding服务封装"""

    def __init__(self, model_name: str = "text-embedding-3-small",
                 api_key: Optional[str] = None):
        self.model_name = model_name
        self.api_key = api_key
        self._cache = {}  # 简单缓存

    async def embed_text(self, text: str) -> List[float]:
        """单条文本Embedding"""
        cache_key = hashlib.md5(text.encode()).hexdigest()
        if cache_key in self._cache:
            return self._cache[cache_key]

        if "text-embedding" in self.model_name:
            embedding = await self._openai_embed(text)
        else:
            embedding = await self._local_embed(text)

        self._cache[cache_key] = embedding
        return embedding

    async def embed_batch(self, texts: List[str]) -> List[List[float]]:
        """批量Embedding"""
        if "text-embedding" in self.model_name:
            return await self._openai_embed_batch(texts)
        else:
            return [await self._local_embed(t) for t in texts]

    async def _openai_embed(self, text: str) -> List[float]:
        """调用OpenAI Embedding API"""
        from openai import AsyncOpenAI
        client = AsyncOpenAI(api_key=self.api_key)
        response = await client.embeddings.create(
            model=self.model_name,
            input=text
        )
        return response.data[0].embedding

    async def _openai_embed_batch(self, texts: List[str]) -> List[List[float]]:
        """批量调用OpenAI Embedding API(支持分批)"""
        from openai import AsyncOpenAI
        client = AsyncOpenAI(api_key=self.api_key)
        all_embeddings = []
        batch_size = 100  # OpenAI限制每次最多处理的文本数

        for i in range(0, len(texts), batch_size):
            batch = texts[i:i + batch_size]
            response = await client.embeddings.create(
                model=self.model_name,
                input=batch
            )
            all_embeddings.extend([d.embedding for d in response.data])

        return all_embeddings

    async def _local_embed(self, text: str) -> List[float]:
        """本地模型Embedding(以sentence-transformers为例)"""
        from sentence_transformers import SentenceTransformer
        if not hasattr(self, '_local_model'):
            self._local_model = SentenceTransformer(self.model_name)
        embedding = self._local_model.encode(text, normalize_embeddings=True)
        return embedding.tolist()


class VectorStore:
    """向量存储抽象层 - 支持多种后端"""

    def __init__(self, backend: str = "milvus", **kwargs):
        self.backend = backend
        self.config = kwargs

    async def add_documents(self, ids: List[str],
                           vectors: List[List[float]],
                           metadatas: List[dict]):
        """添加文档到向量库"""
        if self.backend == "milvus":
            await self._milvus_insert(ids, vectors, metadatas)
        elif self.backend == "qdrant":
            await self._qdrant_insert(ids, vectors, metadatas)
        elif self.backend == "chroma":
            await self._chroma_insert(ids, vectors, metadatas)

    async def search(self, query_vector: List[float],
                    top_k: int = 10,
                    filters: Optional[dict] = None) -> List[Tuple[str, float, dict]]:
        """向量相似度搜索,返回 (id, score, metadata)"""
        if self.backend == "milvus":
            return await self._milvus_search(query_vector, top_k, filters)
        elif self.backend == "qdrant":
            return await self._qdrant_search(query_vector, top_k, filters)
        elif self.backend == "chroma":
            return await self._chroma_search(query_vector, top_k, filters)

    async def _milvus_insert(self, ids, vectors, metadatas):
        """Milvus插入实现"""
        from pymilvus import Collection
        collection = Collection(self.config.get("collection_name", "documents"))
        entities = [
            ids,
            vectors,
            [m.get("title", "") for m in metadatas],
            [m.get("content", "") for m in metadatas],
        ]
        collection.insert(entities)
        collection.flush()

    async def _milvus_search(self, query_vector, top_k, filters):
        """Milvus搜索实现"""
        from pymilvus import Collection
        collection = Collection(self.config.get("collection_name", "documents"))
        collection.load()

        search_params = {
            "metric_type": "COSINE",
            "params": {"nprobe": 16}
        }

        results = collection.search(
            data=[query_vector],
            anns_field="embedding",
            param=search_params,
            limit=top_k,
            output_fields=["title", "content", "source"]
        )

        return [
            (hit.id, hit.score, hit.entity)
            for hit in results[0]
        ]

    async def _qdrant_insert(self, ids, vectors, metadatas):
        """Qdrant插入实现"""
        from qdrant_client import QdrantClient
        from qdrant_client.models import PointStruct
        client = QdrantClient(**self.config.get("connection", {}))
        points = [
            PointStruct(id=i, vector=v, payload=m)
            for i, v, m in zip(ids, vectors, metadatas)
        ]
        client.upsert(
            collection_name=self.config.get("collection_name", "documents"),
            points=points
        )

    async def _qdrant_search(self, query_vector, top_k, filters):
        """Qdrant搜索实现"""
        from qdrant_client import QdrantClient
        from qdrant_client.models import Filter, FieldCondition, MatchValue
        client = QdrantClient(**self.config.get("connection", {}))

        query_filter = None
        if filters:
            conditions = [
                FieldCondition(key=k, match=MatchValue(value=v))
                for k, v in filters.items()
            ]
            query_filter = Filter(must=conditions)

        results = client.search(
            collection_name=self.config.get("collection_name", "documents"),
            query_vector=query_vector,
            limit=top_k,
            query_filter=query_filter
        )
        return [(r.id, r.score, r.payload) for r in results]

    async def _chroma_insert(self, ids, vectors, metadatas):
        """Chroma插入实现"""
        import chromadb
        client = chromadb.Client()
        collection = client.get_or_create_collection(
            name=self.config.get("collection_name", "documents")
        )
        collection.add(
            ids=ids,
            embeddings=vectors,
            metadatas=metadatas,
            documents=[m.get("content", "") for m in metadatas]
        )

    async def _chroma_search(self, query_vector, top_k, filters):
        """Chroma搜索实现"""
        import chromadb
        client = chromadb.Client()
        collection = client.get_or_create_collection(
            name=self.config.get("collection_name", "documents")
        )
        results = collection.query(
            query_embeddings=[query_vector],
            n_results=top_k,
            where=filters
        )
        return list(zip(
            results["ids"][0],
            results["distances"][0],
            results["metadatas"][0]
        ))

余弦相似度与近似最近邻(ANN)

向量检索的核心算法是近似最近邻搜索(ANN),常用算法包括:

  • HNSW(Hierarchical Navigable Small World):基于图结构的ANN算法,查询速度快,精度高
  • IVF(Inverted File Index):基于聚类的索引方式,适合大规模数据
  • PQ(Product Quantization):向量压缩技术,节省存储空间
"""
HNSW索引构建示例(使用hnswlib)
"""
import hnswlib
import numpy as np


def build_hnsw_index(vectors: np.ndarray, dim: int,
                     ef_construction: int = 200,
                     m: int = 16) -> hnswlib.Index:
    """
    构建HNSW索引

    参数:
        vectors: 形状为 (n, dim) 的向量矩阵
        dim: 向量维度
        ef_construction: 构建时的搜索宽度,越大越精确但越慢
        m: 每个节点的最大连接数
    """
    index = hnswlib.Index(space='cosine', dim=dim)

    # 初始化索引
    index.init_index(
        max_elements=len(vectors),
        ef_construction=ef_construction,
        m=m
    )

    # 添加向量
    labels = np.arange(len(vectors))
    index.add_items(vectors, labels)

    # 设置查询时的搜索精度
    index.set_ef(50)

    return index


def search_hnsw(index: hnswlib.Index, query_vector: np.ndarray,
                top_k: int = 10) -> tuple:
    """使用HNSW索引进行搜索"""
    labels, distances = index.knn_query(query_vector, k=top_k)
    return labels[0], distances[0]

查询理解与意图识别

意图识别分类

用户搜索意图通常分为以下几类:

  1. 信息查询(Informational):用户想要了解某个知识或信息
  2. 导航查询(Navigational):用户想要到达某个特定网站或页面
  3. 交易查询(Transactional):用户想要完成某个操作或购买
  4. 本地查询(Local):用户寻找本地服务或位置信息
"""
查询理解与意图识别模块
"""
from typing import List, Dict, Optional, Tuple
from dataclasses import dataclass
from enum import Enum


class QueryIntent(Enum):
    INFORMATIONAL = "informational"
    NAVIGATIONAL = "navigational"
    TRANSACTIONAL = "transactional"
    LOCAL = "local"
    CONVERSATIONAL = "conversational"


@dataclass
class ProcessedQuery:
    """处理后的查询"""
    original: str
    rewritten_query: Optional[str] = None
    intent: QueryIntent = QueryIntent.INFORMATIONAL
    entities: List[Dict[str, str]] = None
    keywords: List[str] = None
    language: str = "zh"
    confidence: float = 0.0

    def __post_init__(self):
        if self.entities is None:
            self.entities = []
        if self.keywords is None:
            self.keywords = []


class QueryProcessor:
    """查询处理器"""

    def __init__(self, config: dict):
        self.config = config
        self.llm_client = self._init_llm(config)

    def _init_llm(self, config):
        """初始化LLM客户端"""
        from openai import AsyncOpenAI
        return AsyncOpenAI(api_key=config.get("api_key"))

    async def process(self, query) -> ProcessedQuery:
        """
        完整的查询处理流程:
        1. 语言检测
        2. 查询纠错
        3. 意图识别
        4. 实体抽取
        5. 查询改写
        """
        text = query.text

        # 并行执行多个处理步骤
        import asyncio
        intent_task = asyncio.create_task(self._classify_intent(text))
        entities_task = asyncio.create_task(self._extract_entities(text))
        rewrite_task = asyncio.create_task(self._rewrite_query(text, query.context))

        intent, intent_conf = await intent_task
        entities = await entities_task
        rewritten = await rewrite_task

        # 提取关键词
        keywords = self._extract_keywords(text)

        return ProcessedQuery(
            original=text,
            rewritten_query=rewritten if rewritten != text else None,
            intent=intent,
            entities=entities,
            keywords=keywords,
            confidence=intent_conf
        )

    async def _classify_intent(self, query: str) -> Tuple[QueryIntent, float]:
        """使用LLM进行意图分类"""
        prompt = f"""请分析以下搜索查询的用户意图,返回JSON格式:
查询: "{query}"

分类选项:
- informational: 用户想了解知识或信息
- navigational: 用户想找到特定网站或页面
- transactional: 用户想完成操作或购买
- local: 用户寻找本地服务
- conversational: 对话式查询,需要上下文

返回格式: {{"intent": "分类", "confidence": 0.0-1.0, "reason": "原因"}}"""

        response = await self.llm_client.chat.completions.create(
            model="gpt-4o-mini",
            messages=[{"role": "user", "content": prompt}],
            temperature=0,
            response_format={"type": "json_object"}
        )

        import json
        result = json.loads(response.choices[0].message.content)
        intent_map = {e.value: e for e in QueryIntent}
        intent = intent_map.get(result["intent"], QueryIntent.INFORMATIONAL)
        return intent, result.get("confidence", 0.5)

    async def _extract_entities(self, query: str) -> List[Dict[str, str]]:
        """实体抽取"""
        prompt = f"""从以下查询中提取实体,返回JSON数组:
查询: "{query}"

实体类型: 人名(PER)、地名(LOC)、组织(ORG)、产品(PROD)、时间(TIME)、技术(TECH)

返回格式: [{{"text": "实体文本", "type": "实体类型"}}]"""

        response = await self.llm_client.chat.completions.create(
            model="gpt-4o-mini",
            messages=[{"role": "user", "content": prompt}],
            temperature=0,
            response_format={"type": "json_object"}
        )

        import json
        result = json.loads(response.choices[0].message.content)
        return result.get("entities", [])

    async def _rewrite_query(self, query: str,
                             context: List[str] = None) -> str:
        """
        查询改写:
        - 多轮对话上下文融合
        - 同义词扩展
        - 查询补全
        """
        context_str = ""
        if context:
            context_str = "\n对话历史:\n" + "\n".join(
                f"- {c}" for c in context[-5:]  # 最近5轮
            )

        prompt = f"""请改写以下搜索查询,使其更适合搜索引擎检索。
要求:
1. 如果有对话历史,融合上下文信息
2. 补充必要的同义词或相关词
3. 保持查询的核心语义不变
4. 如果查询已经很清晰,直接返回原文

{context_str}
当前查询: "{query}"

只返回改写后的查询文本,不要解释。"""

        response = await self.llm_client.chat.completions.create(
            model="gpt-4o-mini",
            messages=[{"role": "user", "content": prompt}],
            temperature=0,
            max_tokens=200
        )

        return response.choices[0].message.content.strip().strip('"')

    def _extract_keywords(self, query: str) -> List[str]:
        """基于规则的关键词提取(可替换为更高级的方法)"""
        # 简单实现:按空格和标点分词
        import re
        # 移除常见停用词
        stopwords = {"的", "了", "是", "在", "我", "有", "和", "就",
                     "不", "人", "都", "一", "一个", "上", "也", "很",
                     "到", "说", "要", "去", "你", "会", "着", "没有",
                     "看", "好", "自己", "这", "他", "吗", "什么", "怎么",
                     "the", "a", "an", "is", "are", "was", "were", "in",
                     "on", "at", "to", "for", "of", "with", "by"}

        tokens = re.findall(r'[\w\u4e00-\u9fff]+', query.lower())
        keywords = [t for t in tokens if t not in stopwords and len(t) > 1]
        return keywords

多源数据融合

数据源接入

AI搜索引擎通常需要融合多种数据源,包括网页、文档、数据库、API等。

"""
多源数据融合模块
"""
from typing import List, Dict, Any, AsyncIterator
from dataclasses import dataclass
from abc import ABC, abstractmethod
import asyncio


@dataclass
class RawDocument:
    """原始文档"""
    content: str
    title: str = ""
    url: str = ""
    source_type: str = ""
    metadata: Dict[str, Any] = None

    def __post_init__(self):
        if self.metadata is None:
            self.metadata = {}


class DataSource(ABC):
    """数据源抽象基类"""

    @abstractmethod
    async def fetch(self, query: str, limit: int = 10) -> List[RawDocument]:
        """根据查询获取数据"""
        pass

    @abstractmethod
    async def index_all(self) -> AsyncIterator[RawDocument]:
        """全量索引,返回文档流"""
        pass


class WebDataSource(DataSource):
    """网页数据源"""

    def __init__(self, api_key: str, search_engine_id: str):
        self.api_key = api_key
        self.search_engine_id = search_engine_id

    async def fetch(self, query: str, limit: int = 10) -> List[RawDocument]:
        """通过Google Custom Search API获取网页"""
        import aiohttp
        url = "https://www.googleapis.com/customsearch/v1"
        params = {
            "key": self.api_key,
            "cx": self.search_engine_id,
            "q": query,
            "num": min(limit, 10)
        }

        async with aiohttp.ClientSession() as session:
            async with session.get(url, params=params) as resp:
                data = await resp.json()

        documents = []
        for item in data.get("items", []):
            doc = RawDocument(
                content=item.get("snippet", ""),
                title=item.get("title", ""),
                url=item.get("link", ""),
                source_type="web",
                metadata={
                    "displayLink": item.get("displayLink", ""),
                    "pageMap": item.get("pagemap", {})
                }
            )
            documents.append(doc)

        return documents

    async def index_all(self) -> AsyncIterator[RawDocument]:
        """网页数据源通常不支持全量索引"""
        return
        yield  # Make it a generator


class DatabaseDataSource(DataSource):
    """数据库数据源"""

    def __init__(self, connection_string: str, table: str,
                 content_columns: List[str],
                 title_column: str = "title"):
        self.connection_string = connection_string
        self.table = table
        self.content_columns = content_columns
        self.title_column = title_column

    async def fetch(self, query: str, limit: int = 10) -> List[RawDocument]:
        """数据库全文搜索"""
        import asyncpg
        conn = await asyncpg.connect(self.connection_string)

        # 构建全文搜索查询
        search_expr = " || ' ' || ".join(
            f"COALESCE({col}, '')" for col in self.content_columns
        )
        sql = f"""
            SELECT {self.title_column}, {', '.join(self.content_columns)},
                   ts_rank(to_tsvector('simple', {search_expr}),
                           plainto_tsquery('simple', $1)) as rank
            FROM {self.table}
            WHERE to_tsvector('simple', {search_expr}) @@
                  plainto_tsquery('simple', $1)
            ORDER BY rank DESC
            LIMIT $2
        """
        rows = await conn.fetch(sql, query, limit)
        await conn.close()

        return [
            RawDocument(
                content=" ".join(str(row[col]) for col in self.content_columns),
                title=str(row[self.title_column]),
                source_type="database",
                metadata={"rank": row["rank"]}
            )
            for row in rows
        ]

    async def index_all(self) -> AsyncIterator[RawDocument]:
        """全量导出数据库内容"""
        import asyncpg
        conn = await asyncpg.connect(self.connection_string)

        sql = f"SELECT {self.title_column}, {', '.join(self.content_columns)} FROM {self.table}"
        rows = await conn.fetch(sql)
        await conn.close()

        for row in rows:
            yield RawDocument(
                content=" ".join(str(row[col]) for col in self.content_columns),
                title=str(row[self.title_column]),
                source_type="database"
            )


class DataFusionPipeline:
    """数据融合管线"""

    def __init__(self, sources: List[DataSource]):
        self.sources = sources

    async def search_all(self, query: str,
                         limit_per_source: int = 10) -> List[RawDocument]:
        """并行搜索所有数据源"""
        tasks = [
            source.fetch(query, limit_per_source)
            for source in self.sources
        ]
        results = await asyncio.gather(*tasks, return_exceptions=True)

        all_docs = []
        for result in results:
            if isinstance(result, list):
                all_docs.extend(result)
            elif isinstance(result, Exception):
                print(f"数据源查询失败: {result}")

        return all_docs

    def deduplicate(self, documents: List[RawDocument]) -> List[RawDocument]:
        """基于内容相似度去重"""
        seen = set()
        unique = []
        for doc in documents:
            # 使用内容哈希作为去重键
            content_hash = hash(doc.content[:200])
            if content_hash not in seen:
                seen.add(content_hash)
                unique.append(doc)
        return unique

    def normalize(self, documents: List[RawDocument]) -> List[RawDocument]:
        """统一不同数据源的文档格式"""
        for doc in documents:
            # 清理HTML标签
            if "<" in doc.content and ">" in doc.content:
                import re
                doc.content = re.sub(r'<[^>]+>', '', doc.content)
            # 标准化空白字符
            doc.content = " ".join(doc.content.split())
        return documents

搜索结果排序与重排(Reranking)

排序架构

搜索排序通常分为两个阶段:

  1. 粗排(First-Stage Retrieval):从海量文档中快速召回候选集
  2. 精排(Reranking):使用更精确的模型对候选集进行重排序
"""
搜索结果排序与重排模块
"""
from typing import List, Dict, Tuple, Optional
import numpy as np


class BM25Scorer:
    """BM25评分器"""

    def __init__(self, k1: float = 1.5, b: float = 0.75):
        self.k1 = k1
        self.b = b
        self.doc_freqs = {}  # 词 -> 出现该词的文档数
        self.doc_lens = []    # 每篇文档的长度
        self.avg_dl = 0       # 平均文档长度
        self.n_docs = 0       # 文档总数
        self.index = {}       # 倒排索引: term -> [(doc_id, term_freq)]

    def build_index(self, documents: List[str]):
        """构建BM25倒排索引"""
        self.n_docs = len(documents)
        self.doc_lens = []
        self.index = {}
        self.doc_freqs = {}

        for doc_id, doc in enumerate(documents):
            terms = self._tokenize(doc)
            self.doc_lens.append(len(terms))
            term_counts = {}
            for term in terms:
                term_counts[term] = term_counts.get(term, 0) + 1

            for term, count in term_counts.items():
                if term not in self.index:
                    self.index[term] = []
                self.index[term].append((doc_id, count))
                self.doc_freqs[term] = self.doc_freqs.get(term, 0) + 1

        self.avg_dl = sum(self.doc_lens) / self.n_docs if self.n_docs > 0 else 0

    def score(self, query: str, top_k: int = 10) -> List[Tuple[int, float]]:
        """计算BM25分数"""
        query_terms = self._tokenize(query)
        scores = np.zeros(self.n_docs)

        for term in query_terms:
            if term not in self.index:
                continue

            df = self.doc_freqs[term]
            idf = np.log((self.n_docs - df + 0.5) / (df + 0.5) + 1)

            for doc_id, tf in self.index[term]:
                dl = self.doc_lens[doc_id]
                tf_norm = (tf * (self.k1 + 1)) / (
                    tf + self.k1 * (1 - self.b + self.b * dl / self.avg_dl)
                )
                scores[doc_id] += idf * tf_norm

        # 返回top_k
        top_indices = np.argsort(scores)[::-1][:top_k]
        return [(int(idx), float(scores[idx])) for idx in top_indices if scores[idx] > 0]

    def _tokenize(self, text: str) -> List[str]:
        """中文分词(简化实现,生产环境建议使用jieba)"""
        try:
            import jieba
            return list(jieba.cut(text))
        except ImportError:
            # 回退到简单字符级分词
            import re
            tokens = re.findall(r'[\u4e00-\u9fff]+|[a-zA-Z]+|\d+', text.lower())
            return tokens


class CrossEncoderReranker:
    """基于Cross-Encoder的重排序器"""

    def __init__(self, model_name: str = "BAAI/bge-reranker-v2-m3"):
        self.model_name = model_name
        self.model = None

    def _load_model(self):
        """延迟加载模型"""
        if self.model is None:
            from sentence_transformers import CrossEncoder
            self.model = CrossEncoder(self.model_name, max_length=512)

    async def rerank(self, query: str, documents: List,
                    top_k: int = 10) -> List:
        """对文档进行重排序"""
        self._load_model()

        if not documents:
            return []

        # 构建 query-document 对
        pairs = [(query, doc.content[:512]) for doc in documents]

        # 计算相关性分数
        scores = self.model.predict(pairs)

        # 按分数排序
        scored_docs = list(zip(documents, scores))
        scored_docs.sort(key=lambda x: x[1], reverse=True)

        # 返回top_k并更新分数
        result = []
        for doc, score in scored_docs[:top_k]:
            doc.score = float(score)
            result.append(doc)

        return result


class CohereReranker:
    """使用Cohere Rerank API的重排序器"""

    def __init__(self, api_key: str, model: str = "rerank-multilingual-v3.0"):
        self.api_key = api_key
        self.model = model

    async def rerank(self, query: str, documents: List,
                    top_k: int = 10) -> List:
        """调用Cohere Rerank API"""
        import cohere
        co = cohere.Client(self.api_key)

        doc_texts = [doc.content[:4000] for doc in documents]  # Cohere限制

        response = co.rerank(
            query=query,
            documents=doc_texts,
            top_n=top_k,
            model=self.model
        )

        result = []
        for item in response.results:
            doc = documents[item.index]
            doc.score = item.relevance_score
            result.append(doc)

        return result


class HybridRanker:
    """混合排序器:融合多种排序信号"""

    def __init__(self, weights: Dict[str, float] = None):
        self.weights = weights or {
            "bm25": 0.3,
            "vector": 0.4,
            "rerank": 0.3
        }

    def fuse_scores(self, score_maps: Dict[str, Dict[int, float]]) -> List[Tuple[int, float]]:
        """
        融合多种排序信号

        参数:
            score_maps: {"bm25": {doc_id: score}, "vector": {doc_id: score}, ...}
        """
        all_doc_ids = set()
        for scores in score_maps.values():
            all_doc_ids.update(scores.keys())

        fused_scores = {}
        for doc_id in all_doc_ids:
            score = 0.0
            for method, weight in self.weights.items():
                if method in score_maps and doc_id in score_maps[method]:
                    # Min-Max归一化
                    method_scores = list(score_maps[method].values())
                    if method_scores:
                        min_s = min(method_scores)
                        max_s = max(method_scores)
                        raw = score_maps[method][doc_id]
                        normalized = (raw - min_s) / (max_s - min_s + 1e-10)
                        score += weight * normalized
            fused_scores[doc_id] = score

        ranked = sorted(fused_scores.items(), key=lambda x: x[1], reverse=True)
        return ranked

    @staticmethod
    def reciprocal_rank_fusion(
        rankings: List[List[int]], k: int = 60
    ) -> List[Tuple[int, float]]:
        """
        Reciprocal Rank Fusion (RRF)算法

        参数:
            rankings: 多个排序列表,每个列表是按相关性排序的文档ID
            k: 平滑参数,通常取60
        """
        scores = {}
        for ranking in rankings:
            for rank, doc_id in enumerate(ranking):
                scores[doc_id] = scores.get(doc_id, 0) + 1 / (k + rank + 1)

        return sorted(scores.items(), key=lambda x: x[1], reverse=True)

实时索引与增量更新

索引管理

生产环境中,搜索引擎需要支持文档的实时增删改和索引的增量更新。

"""
实时索引与增量更新模块
"""
from typing import List, Dict, Optional
from datetime import datetime
import asyncio
import logging

logger = logging.getLogger(__name__)


class IndexManager:
    """索引管理器"""

    def __init__(self, vector_store, embedding_service, config: dict):
        self.vector_store = vector_store
        self.embedding_service = embedding_service
        self.config = config
        self._pending_ops = []  # 待处理的索引操作
        self._batch_size = config.get("batch_size", 100)
        self._flush_interval = config.get("flush_interval_seconds", 5)

    async def add_document(self, doc_id: str, title: str,
                          content: str, metadata: dict = None):
        """添加单个文档到索引"""
        self._pending_ops.append({
            "op": "add",
            "id": doc_id,
            "title": title,
            "content": content,
            "metadata": metadata or {},
            "timestamp": datetime.utcnow().isoformat()
        })

        if len(self._pending_ops) >= self._batch_size:
            await self.flush()

    async def update_document(self, doc_id: str, title: str = None,
                             content: str = None, metadata: dict = None):
        """更新文档"""
        self._pending_ops.append({
            "op": "update",
            "id": doc_id,
            "title": title,
            "content": content,
            "metadata": metadata,
            "timestamp": datetime.utcnow().isoformat()
        })

        if len(self._pending_ops) >= self._batch_size:
            await self.flush()

    async def delete_document(self, doc_id: str):
        """删除文档"""
        self._pending_ops.append({
            "op": "delete",
            "id": doc_id,
            "timestamp": datetime.utcnow().isoformat()
        })

    async def flush(self):
        """批量处理待处理的索引操作"""
        if not self._pending_ops:
            return

        ops = self._pending_ops.copy()
        self._pending_ops.clear()

        add_ops = [op for op in ops if op["op"] == "add"]
        update_ops = [op for op in ops if op["op"] == "update"]
        delete_ops = [op for op in ops if op["op"] == "delete"]

        # 批量添加
        if add_ops:
            await self._batch_add(add_ops)

        # 批量更新(删除旧的 + 添加新的)
        if update_ops:
            await self._batch_update(update_ops)

        # 批量删除
        if delete_ops:
            await self._batch_delete(delete_ops)

        logger.info(f"索引刷新完成: 添加{len(add_ops)}, "
                    f"更新{len(update_ops)}, 删除{len(delete_ops)}")

    async def _batch_add(self, ops: List[dict]):
        """批量添加文档"""
        texts = [op["content"] for op in ops]
        ids = [op["id"] for op in ops]

        # 批量生成Embedding
        embeddings = await self.embedding_service.embed_batch(texts)

        metadatas = [
            {
                "title": op["title"],
                "content": op["content"],
                **op["metadata"],
                "indexed_at": op["timestamp"]
            }
            for op in ops
        ]

        await self.vector_store.add_documents(ids, embeddings, metadatas)

    async def _batch_update(self, ops: List[dict]):
        """批量更新文档"""
        # 先删除旧文档
        ids = [op["id"] for op in ops]
        await self.vector_store.delete(ids)

        # 重新添加
        add_ops = [
            {
                "id": op["id"],
                "title": op.get("title", ""),
                "content": op.get("content", ""),
                "metadata": op.get("metadata", {}),
                "timestamp": op["timestamp"]
            }
            for op in ops if op.get("content")
        ]
        if add_ops:
            await self._batch_add(add_ops)

    async def _batch_delete(self, ops: List[dict]):
        """批量删除文档"""
        ids = [op["id"] for op in ops]
        await self.vector_store.delete(ids)


class IncrementalIndexer:
    """增量索引器 - 定期检查数据源变化并更新索引"""

    def __init__(self, data_source, index_manager: IndexManager,
                 check_interval: int = 300):
        self.data_source = data_source
        self.index_manager = index_manager
        self.check_interval = check_interval
        self._running = False

    async def start(self):
        """启动增量索引任务"""
        self._running = True
        logger.info(f"增量索引器启动,检查间隔: {self.check_interval}秒")

        while self._running:
            try:
                await self._check_and_update()
            except Exception as e:
                logger.error(f"增量索引检查失败: {e}")
            await asyncio.sleep(self.check_interval)

    async def stop(self):
        """停止增量索引任务"""
        self._running = False

    async def _check_and_update(self):
        """检查数据源变化并更新索引"""
        # 获取自上次检查以来变化的文档
        changed_docs = await self.data_source.get_changed_documents()

        for doc in changed_docs:
            if doc.get("deleted"):
                await self.index_manager.delete_document(doc["id"])
            else:
                await self.index_manager.add_document(
                    doc_id=doc["id"],
                    title=doc["title"],
                    content=doc["content"],
                    metadata=doc.get("metadata", {})
                )

        # 批量刷新
        await self.index_manager.flush()
        logger.info(f"增量更新完成: {len(changed_docs)} 个文档变化")

混合搜索策略:BM25+向量

混合检索实现

混合搜索结合了BM25的精确匹配能力和向量检索的语义理解能力,是当前最主流的搜索策略。

"""
混合搜索策略实现
"""
from typing import List, Dict, Any, Optional
import asyncio


class HybridRetriever:
    """混合检索器"""

    def __init__(self, config: dict):
        self.config = config
        self.bm25_scorer = BM25Scorer(
            k1=config.get("bm25_k1", 1.5),
            b=config.get("bm25_b", 0.75)
        )
        self.vector_store = VectorStore(
            backend=config.get("vector_backend", "milvus"),
            **config.get("vector_config", {})
        )
        self.embedding_service = EmbeddingService(
            model_name=config.get("embedding_model", "text-embedding-3-small"),
            api_key=config.get("api_key")
        )
        self.ranker = HybridRanker(weights=config.get("fusion_weights", {
            "bm25": 0.3,
            "vector": 0.4,
            "rerank": 0.3
        }))

    async def retrieve(self, query: str, mode: str = "hybrid",
                      top_k: int = 30,
                      filters: Optional[dict] = None) -> List:
        """
        混合检索

        参数:
            query: 查询文本
            mode: 检索模式 (keyword/semantic/hybrid)
            top_k: 返回数量
            filters: 过滤条件
        """
        if mode == "keyword":
            return await self._keyword_search(query, top_k)
        elif mode == "semantic":
            return await self._semantic_search(query, top_k, filters)
        else:
            return await self._hybrid_search(query, top_k, filters)

    async def _keyword_search(self, query: str, top_k: int) -> List:
        """BM25关键词搜索"""
        results = self.bm25_scorer.score(query, top_k)
        return [SearchDocument(id=str(doc_id), title="", content="",
                               score=score) for doc_id, score in results]

    async def _semantic_search(self, query: str, top_k: int,
                               filters: Optional[dict]) -> List:
        """向量语义搜索"""
        query_embedding = await self.embedding_service.embed_text(query)
        results = await self.vector_store.search(
            query_vector=query_embedding,
            top_k=top_k,
            filters=filters
        )

        documents = []
        for doc_id, score, metadata in results:
            doc = SearchDocument(
                id=str(doc_id),
                title=metadata.get("title", ""),
                content=metadata.get("content", ""),
                url=metadata.get("url", ""),
                source=metadata.get("source", ""),
                score=score
            )
            documents.append(doc)

        return documents

    async def _hybrid_search(self, query: str, top_k: int,
                             filters: Optional[dict]) -> List:
        """混合搜索:并行执行BM25和向量搜索"""
        # 并行执行两种搜索
        keyword_task = asyncio.create_task(self._keyword_search(query, top_k))
        semantic_task = asyncio.create_task(
            self._semantic_search(query, top_k, filters)
        )

        keyword_results, semantic_results = await asyncio.gather(
            keyword_task, semantic_task
        )

        # 使用RRF融合排序
        keyword_ranking = [int(doc.id) for doc in keyword_results]
        semantic_ranking = [int(doc.id) for doc in semantic_results]

        fused = HybridRanker.reciprocal_rank_fusion(
            [keyword_ranking, semantic_ranking], k=60
        )

        # 构建文档映射
        doc_map = {}
        for doc in keyword_results + semantic_results:
            doc_map[doc.id] = doc

        # 按融合分数排序
        result = []
        for doc_id, score in fused[:top_k]:
            if str(doc_id) in doc_map:
                doc = doc_map[str(doc_id)]
                doc.score = score
                result.append(doc)

        return result

    def index_document(self, doc_id: str, title: str, content: str,
                      metadata: dict = None):
        """将文档添加到BM25索引"""
        # 这里简化处理,实际应该维护一个完整的文档存储
        if not hasattr(self, '_documents'):
            self._documents = {}
        self._documents[doc_id] = {"title": title, "content": content,
                                    "metadata": metadata or {}}

        # 重建BM25索引
        docs = [f"{d['title']} {d['content']}" for d in self._documents.values()]
        self.bm25_scorer.build_index(docs)

Perplexity风格AI搜索实现

设计理念

Perplexity AI的核心创新在于将搜索引擎与大语言模型结合,不仅返回搜索结果,还生成带有引用来源的综合回答。本节我们将实现类似的功能。

"""
Perplexity风格AI搜索实现
"""
from typing import List, Dict, Tuple, Optional
from dataclasses import dataclass
import json


@dataclass
class Citation:
    """引用来源"""
    index: int
    title: str
    url: str
    snippet: str


class AnswerGenerator:
    """AI回答生成器"""

    def __init__(self, config: dict):
        self.config = config
        from openai import AsyncOpenAI
        self.client = AsyncOpenAI(api_key=config.get("api_key"))
        self.model = config.get("answer_model", "gpt-4o")

    async def generate(self, query: str,
                      context_docs: List) -> Tuple[str, List[Citation]]:
        """
        基于检索文档生成回答

        参数:
            query: 用户查询
            context_docs: 检索到的文档列表

        返回:
            (answer, citations): 回答文本和引用列表
        """
        if not context_docs:
            return "抱歉,未找到相关信息来回答您的问题。", []

        # 构建引用上下文
        citations = []
        context_parts = []

        for i, doc in enumerate(context_docs[:8], 1):  # 最多使用8个文档
            citation = Citation(
                index=i,
                title=doc.title or f"来源 {i}",
                url=doc.url or "",
                snippet=doc.content[:300]
            )
            citations.append(citation)
            context_parts.append(
                f"[{i}] {citation.title}\n{doc.content[:1000]}"
            )

        context_text = "\n\n".join(context_parts)

        # 生成回答
        system_prompt = """你是一个专业的AI搜索助手。请基于提供的参考资料回答用户的问题。

要求:
1. 回答必须基于提供的参考资料,不要编造信息
2. 使用引用标注,格式为 [1] [2] 等,对应参考资料的编号
3. 回答应该全面、准确、有条理
4. 如果参考资料不足以完整回答问题,明确指出信息不足的部分
5. 保持客观中立的语调
6. 使用Markdown格式化回答"""

        user_prompt = f"""参考资料:
{context_text}

用户问题: {query}

请基于以上参考资料回答问题,并在相关内容后标注引用来源。"""

        response = await self.client.chat.completions.create(
            model=self.model,
            messages=[
                {"role": "system", "content": system_prompt},
                {"role": "user", "content": user_prompt}
            ],
            temperature=0.3,
            max_tokens=2000,
            stream=False
        )

        answer = response.choices[0].message.content
        return answer, citations

    async def generate_stream(self, query: str,
                             context_docs: List):
        """
        流式生成回答(适用于实时展示)

        返回: 异步生成器,逐token输出
        """
        citations = []
        context_parts = []

        for i, doc in enumerate(context_docs[:8], 1):
            citation = Citation(
                index=i,
                title=doc.title or f"来源 {i}",
                url=doc.url or "",
                snippet=doc.content[:300]
            )
            citations.append(citation)
            context_parts.append(f"[{i}] {citation.title}\n{doc.content[:1000]}")

        context_text = "\n\n".join(context_parts)

        system_prompt = "你是一个专业的AI搜索助手。基于参考资料回答问题,使用[1][2]等标注引用。"
        user_prompt = f"参考资料:\n{context_text}\n\n问题: {query}"

        stream = await self.client.chat.completions.create(
            model=self.model,
            messages=[
                {"role": "system", "content": system_prompt},
                {"role": "user", "content": user_prompt}
            ],
            temperature=0.3,
            max_tokens=2000,
            stream=True
        )

        async for chunk in stream:
            if chunk.choices[0].delta.content:
                yield chunk.choices[0].delta.content

        # 最后yield引用信息
        yield "\n\n---CITATIONS---\n"
        yield json.dumps([
            {"index": c.index, "title": c.title, "url": c.url}
            for c in citations
        ], ensure_ascii=False)


class FollowUpGenerator:
    """后续问题建议生成器"""

    def __init__(self, client):
        self.client = client

    async def suggest_followups(self, query: str, answer: str,
                                citations: List[Citation]) -> List[str]:
        """基于当前问答生成后续问题建议"""
        prompt = f"""基于以下问答对,生成3个用户可能感兴趣的后续问题。
要求: 问题应该自然、具体、有探索性。

原始问题: {query}
回答摘要: {answer[:500]}

返回JSON格式: {{"questions": ["问题1", "问题2", "问题3"]}}"""

        response = await self.client.chat.completions.create(
            model="gpt-4o-mini",
            messages=[{"role": "user", "content": prompt}],
            temperature=0.7,
            response_format={"type": "json_object"}
        )

        result = json.loads(response.choices[0].message.content)
        return result.get("questions", [])[:3]

搜索Agent设计

Agent架构

搜索Agent能够自主规划搜索策略,处理复杂查询,并进行多步推理。

"""
搜索Agent设计
"""
from typing import List, Dict, Any, Optional, Callable
from dataclasses import dataclass, field
from enum import Enum
import json


class AgentAction(Enum):
    SEARCH = "search"
    SUMMARIZE = "summarize"
    COMPARE = "compare"
    ASK_CLARIFICATION = "ask_clarification"
    FINAL_ANSWER = "final_answer"


@dataclass
class AgentStep:
    """Agent执行步骤"""
    action: AgentAction
    thought: str
    parameters: Dict[str, Any] = field(default_factory=dict)
    result: Any = None


@dataclass
class AgentPlan:
    """Agent执行计划"""
    goal: str
    steps: List[AgentStep] = field(default_factory=list)
    current_step: int = 0


class SearchAgent:
    """搜索Agent - 能够自主规划和执行多步搜索"""

    def __init__(self, search_engine, llm_client, config: dict = None):
        self.search_engine = search_engine
        self.llm_client = llm_client
        self.config = config or {}
        self.max_steps = self.config.get("max_steps", 5)
        self.tools = self._build_tools()

    def _build_tools(self) -> Dict[str, Callable]:
        """构建Agent可用工具"""
        return {
            "search": self._tool_search,
            "search_with_filter": self._tool_search_filtered,
            "summarize": self._tool_summarize,
            "extract_info": self._tool_extract_info,
        }

    async def run(self, query: str, session_id: str = None) -> Dict[str, Any]:
        """
        运行搜索Agent

        Agent会自主规划搜索策略,执行多步操作,最终给出综合回答
        """
        steps = []
        context = []
        final_answer = None

        for step_num in range(self.max_steps):
            # 1. 规划下一步动作
            plan = await self._plan_next_step(query, context, steps)

            if plan["action"] == "final_answer":
                final_answer = plan.get("answer", "")
                break

            # 2. 执行动作
            step = AgentStep(
                action=AgentAction(plan["action"]),
                thought=plan.get("thought", ""),
                parameters=plan.get("parameters", {})
            )

            # 调用对应工具
            tool_name = plan["action"]
            if tool_name in self.tools:
                step.result = await self.tools[tool_name](**plan.get("parameters", {}))

            steps.append(step)
            context.append({
                "step": step_num + 1,
                "action": plan["action"],
                "thought": plan.get("thought", ""),
                "result_summary": self._summarize_result(step.result)
            })

        # 3. 如果没有生成最终回答,基于所有步骤的结果生成
        if not final_answer:
            final_answer = await self._generate_final_answer(query, steps)

        return {
            "query": query,
            "answer": final_answer,
            "steps": [
                {
                    "action": s.action.value,
                    "thought": s.thought,
                    "result": self._summarize_result(s.result)
                }
                for s in steps
            ],
            "total_steps": len(steps)
        }

    async def _plan_next_step(self, query: str, context: List,
                              steps: List[AgentStep]) -> Dict:
        """规划下一步动作"""
        context_str = json.dumps(context, ensure_ascii=False, indent=2)

        prompt = f"""你是一个搜索Agent,负责规划搜索策略来回答用户的问题。

用户问题: {query}

已执行的步骤:
{context_str if context else "无"}

可用工具:
1. search - 搜索相关信息。参数: {{"query": "搜索词"}}
2. search_with_filter - 带过滤条件的搜索。参数: {{"query": "搜索词", "filters": {{"source": "xxx"}}}}
3. summarize - 总结已有信息。参数: {{"text": "要总结的文本"}}
4. extract_info - 从文本中提取特定信息。参数: {{"text": "文本", "info_type": "要提取的信息类型"}}
5. final_answer - 生成最终回答。参数: {{"answer": "最终回答"}}

请决定下一步动作。返回JSON格式:
{{"action": "工具名", "thought": "思考过程", "parameters": {{参数}}}}"""

        response = await self.llm_client.chat.completions.create(
            model=self.config.get("agent_model", "gpt-4o"),
            messages=[{"role": "user", "content": prompt}],
            temperature=0,
            response_format={"type": "json_object"}
        )

        return json.loads(response.choices[0].message.content)

    async def _tool_search(self, query: str) -> List[Dict]:
        """搜索工具"""
        from dataclasses import asdict
        result = await self.search_engine.search(
            SearchQuery(text=query, mode=SearchMode.HYBRID, top_k=5)
        )
        return [
            {
                "title": doc.title,
                "content": doc.content[:500],
                "url": doc.url,
                "score": doc.score
            }
            for doc in result.documents
        ]

    async def _tool_search_filtered(self, query: str,
                                    filters: dict = None) -> List[Dict]:
        """带过滤的搜索工具"""
        result = await self.search_engine.search(
            SearchQuery(text=query, mode=SearchMode.HYBRID,
                       top_k=5, filters=filters or {})
        )
        return [
            {
                "title": doc.title,
                "content": doc.content[:500],
                "url": doc.url,
                "score": doc.score
            }
            for doc in result.documents
        ]

    async def _tool_summarize(self, text: str) -> str:
        """总结工具"""
        response = await self.llm_client.chat.completions.create(
            model="gpt-4o-mini",
            messages=[{
                "role": "user",
                "content": f"请总结以下内容的关键信息:\n\n{text[:3000]}"
            }],
            temperature=0,
            max_tokens=500
        )
        return response.choices[0].message.content

    async def _tool_extract_info(self, text: str, info_type: str) -> str:
        """信息提取工具"""
        response = await self.llm_client.chat.completions.create(
            model="gpt-4o-mini",
            messages=[{
                "role": "user",
                "content": f"从以下文本中提取{info_type}:\n\n{text[:3000]}"
            }],
            temperature=0,
            max_tokens=500
        )
        return response.choices[0].message.content

    async def _generate_final_answer(self, query: str,
                                     steps: List[AgentStep]) -> str:
        """基于所有步骤的结果生成最终回答"""
        results_text = "\n\n".join([
            f"步骤{i+1} ({s.action.value}):\n{self._summarize_result(s.result)}"
            for i, s in enumerate(steps)
            if s.result
        ])

        response = await self.llm_client.chat.completions.create(
            model=self.config.get("answer_model", "gpt-4o"),
            messages=[
                {
                    "role": "system",
                    "content": "你是一个专业的AI搜索助手,基于搜索结果给出准确、全面的回答。"
                },
                {
                    "role": "user",
                    "content": f"用户问题: {query}\n\n搜索结果:\n{results_text}\n\n请综合以上信息回答用户的问题。"
                }
            ],
            temperature=0.3
        )

        return response.choices[0].message.content

    def _summarize_result(self, result: Any) -> str:
        """将结果转为简短摘要"""
        if result is None:
            return "无结果"
        if isinstance(result, str):
            return result[:300]
        if isinstance(result, list):
            return json.dumps(result[:3], ensure_ascii=False)[:500]
        return str(result)[:300]

搜索质量评估

评估指标体系

搜索质量评估是持续优化搜索引擎的关键。主要评估指标包括:

"""
搜索质量评估模块
"""
from typing import List, Dict, Set, Tuple
import numpy as np
from dataclasses import dataclass


@dataclass
class EvaluationResult:
    """评估结果"""
    precision_at_k: float
    recall_at_k: float
    mrr: float  # Mean Reciprocal Rank
    ndcg: float  # Normalized Discounted Cumulative Gain
    map_score: float  # Mean Average Precision
    hit_rate: float


class SearchEvaluator:
    """搜索质量评估器"""

    def __init__(self):
        pass

    def evaluate(self, predictions: List[List[str]],
                ground_truth: List[Set[str]],
                k: int = 10) -> EvaluationResult:
        """
        综合评估搜索质量

        参数:
            predictions: 每个查询的预测结果列表 (按相关性排序的文档ID)
            ground_truth: 每个查询的相关文档集合
            k: 评估的top-k
        """
        precisions = []
        recalls = []
        mrrs = []
        ndcgs = []
        aps = []
        hits = []

        for pred, gt in zip(predictions, ground_truth):
            pred_k = pred[:k]
            precisions.append(self._precision_at_k(pred_k, gt))
            recalls.append(self._recall_at_k(pred_k, gt))
            mrrs.append(self._mrr(pred, gt))
            ndcgs.append(self._ndcg(pred_k, gt, k))
            aps.append(self._average_precision(pred, gt))
            hits.append(1.0 if any(p in gt for p in pred_k) else 0.0)

        return EvaluationResult(
            precision_at_k=np.mean(precisions),
            recall_at_k=np.mean(recalls),
            mrr=np.mean(mrrs),
            ndcg=np.mean(ndcgs),
            map_score=np.mean(aps),
            hit_rate=np.mean(hits)
        )

    def _precision_at_k(self, predicted: List[str],
                       relevant: Set[str]) -> float:
        """P@K: top-K结果中相关文档的比例"""
        if not predicted:
            return 0.0
        hits = sum(1 for p in predicted if p in relevant)
        return hits / len(predicted)

    def _recall_at_k(self, predicted: List[str],
                    relevant: Set[str]) -> float:
        """R@K: 相关文档中被检索到的比例"""
        if not relevant:
            return 0.0
        hits = sum(1 for p in predicted if p in relevant)
        return hits / len(relevant)

    def _mrr(self, predicted: List[str], relevant: Set[str]) -> float:
        """MRR: 第一个相关结果的排名倒数"""
        for i, p in enumerate(predicted):
            if p in relevant:
                return 1.0 / (i + 1)
        return 0.0

    def _ndcg(self, predicted: List[str], relevant: Set[str],
             k: int) -> float:
        """NDCG@K: 归一化折损累积增益"""
        dcg = 0.0
        for i, p in enumerate(predicted[:k]):
            rel = 1.0 if p in relevant else 0.0
            dcg += rel / np.log2(i + 2)  # i+2 因为log2(1)=0

        # 理想排序的DCG
        ideal_rels = sorted(
            [1.0 if p in relevant else 0.0 for p in predicted[:k]],
            reverse=True
        )
        idcg = sum(
            rel / np.log2(i + 2)
            for i, rel in enumerate(ideal_rels)
        )

        return dcg / idcg if idcg > 0 else 0.0

    def _average_precision(self, predicted: List[str],
                          relevant: Set[str]) -> float:
        """AP: 平均精度"""
        if not relevant:
            return 0.0

        hits = 0
        sum_precision = 0.0
        for i, p in enumerate(predicted):
            if p in relevant:
                hits += 1
                sum_precision += hits / (i + 1)

        return sum_precision / len(relevant)


class ABTestEvaluator:
    """A/B测试评估器"""

    def __init__(self):
        self.results_a = []
        self.results_b = []

    def record_interaction(self, group: str, query: str,
                          clicked_rank: int = None,
                          dwell_time: float = None,
                          satisfied: bool = False):
        """记录用户交互"""
        record = {
            "query": query,
            "clicked_rank": clicked_rank,
            "dwell_time": dwell_time,
            "satisfied": satisfied
        }
        if group == "a":
            self.results_a.append(record)
        else:
            self.results_b.append(record)

    def analyze(self) -> Dict:
        """分析A/B测试结果"""
        def compute_metrics(results):
            if not results:
                return {}
            clicks = [r["clicked_rank"] for r in results if r["clicked_rank"]]
            return {
                "total_queries": len(results),
                "click_through_rate": len(clicks) / len(results),
                "average_click_position": np.mean(clicks) if clicks else 0,
                "satisfaction_rate": sum(
                    1 for r in results if r["satisfied"]
                ) / len(results),
                "average_dwell_time": np.mean(
                    [r["dwell_time"] for r in results if r["dwell_time"]]
                ) if any(r["dwell_time"] for r in results) else 0
            }

        metrics_a = compute_metrics(self.results_a)
        metrics_b = compute_metrics(self.results_b)

        return {
            "group_a": metrics_a,
            "group_b": metrics_b,
            "comparison": {
                "ctr_lift": (
                    (metrics_b.get("click_through_rate", 0) -
                     metrics_a.get("click_through_rate", 0))
                    / max(metrics_a.get("click_through_rate", 1e-10), 1e-10)
                ),
                "satisfaction_lift": (
                    (metrics_b.get("satisfaction_rate", 0) -
                     metrics_a.get("satisfaction_rate", 0))
                    / max(metrics_a.get("satisfaction_rate", 1e-10), 1e-10)
                )
            }
        }


class RelevanceLabeler:
    """相关性标注工具(用于构建评估数据集)"""

    def __init__(self):
        self.annotations = []

    def add_annotation(self, query: str, doc_id: str,
                      relevance: int, annotator: str = ""):
        """
        添加相关性标注

        relevance: 0-3的评分
            0: 完全不相关
            1: 略微相关
            2: 部分相关
            3: 高度相关
        """
        self.annotations.append({
            "query": query,
            "doc_id": doc_id,
            "relevance": relevance,
            "annotator": annotator
        })

    def get_ground_truth(self, query: str,
                        threshold: int = 1) -> Set[str]:
        """获取某个查询的相关文档集合"""
        return {
            a["doc_id"]
            for a in self.annotations
            if a["query"] == query and a["relevance"] >= threshold
        }

    def export_dataset(self) -> List[Dict]:
        """导出评估数据集"""
        return self.annotations

最佳实践与生产部署

性能优化建议

  1. 缓存策略:对热门查询结果进行缓存,减少重复计算
  2. 异步并发:充分利用异步IO,提高并发处理能力
  3. 批量处理:Embedding和Reranking操作尽量批量执行
  4. 索引分片:大规模数据集采用分布式索引
  5. 降级策略:当某个检索通道不可用时,自动降级到其他通道
"""
生产部署配置示例
"""
import os


PRODUCTION_CONFIG = {
    # Embedding配置
    "embedding": {
        "model": "text-embedding-3-small",
        "api_key": os.getenv("OPENAI_API_KEY"),
        "batch_size": 100,
        "cache_ttl": 3600,  # 缓存1小时
    },

    # 向量数据库配置
    "vector_db": {
        "backend": "milvus",
        "host": os.getenv("MILVUS_HOST", "localhost"),
        "port": int(os.getenv("MILVUS_PORT", "19530")),
        "collection_name": "search_documents",
        "index_type": "HNSW",
        "metric_type": "COSINE",
        "index_params": {
            "M": 16,
            "efConstruction": 256
        },
        "search_params": {
            "ef": 128
        }
    },

    # BM25配置
    "bm25": {
        "k1": 1.2,
        "b": 0.75,
        "backend": "elasticsearch",
        "es_url": os.getenv("ES_URL", "http://localhost:9200"),
        "index_name": "search_docs"
    },

    # Reranking配置
    "reranker": {
        "model": "BAAI/bge-reranker-v2-m3",
        "top_k": 10,
        "batch_size": 32
    },

    # LLM配置
    "llm": {
        "model": "gpt-4o",
        "temperature": 0.3,
        "max_tokens": 2000
    },

    # 搜索Agent配置
    "agent": {
        "max_steps": 5,
        "agent_model": "gpt-4o",
        "answer_model": "gpt-4o"
    },

    # 性能配置
    "performance": {
        "search_timeout_ms": 3000,
        "answer_timeout_ms": 15000,
        "cache_enabled": True,
        "cache_size": 10000,
        "max_concurrent_searches": 100
    }
}

部署架构

                    ┌──────────────┐
                    │  Load Balancer│
                    └──────┬───────┘
                           │
              ┌────────────┼────────────┐
              │            │            │
        ┌─────┴─────┐ ┌───┴────┐ ┌────┴─────┐
        │ Search API │ │Search  │ │ Search   │
        │ Server 1   │ │Server 2│ │ Server 3 │
        └─────┬─────┘ └───┬────┘ └────┬─────┘
              │            │            │
        ┌─────┴────────────┴────────────┴─────┐
        │          Service Layer              │
        │  ┌─────┐ ┌──────┐ ┌───────┐        │
        │  │Query│ │Retri-│ │Rerank │        │
        │  │Proc │ │eval  │ │Service│        │
        │  └─────┘ └──────┘ └───────┘        │
        │  ┌─────┐ ┌──────┐ ┌───────┐        │
        │  │LLM  │ │Agent │ │Answer │        │
        │  │Pool │ │Engine│ │Gen    │        │
        │  └─────┘ └──────┘ └───────┘        │
        └──────────────┬─────────────────────┘
                       │
        ┌──────────────┼─────────────────────┐
        │         Data Layer                  │
        │  ┌──────┐ ┌──────┐ ┌──────────┐   │
        │  │Milvus│ │ES    │ │Redis     │   │
        │  │Cluster│ │Cluster│ │Cache     │   │
        │  └──────┘ └──────┘ └──────────┘   │
        └────────────────────────────────────┘

监控与告警

"""
搜索引擎监控模块
"""
import time
from typing import Dict, List
from dataclasses import dataclass, field
from collections import defaultdict


@dataclass
class SearchMetrics:
    """搜索指标"""
    total_queries: int = 0
    total_latency_ms: float = 0
    cache_hits: int = 0
    cache_misses: int = 0
    errors: int = 0
    latency_buckets: Dict[str, int] = field(
        default_factory=lambda: {
            "<100ms": 0, "100-500ms": 0,
            "500ms-1s": 0, "1-3s": 0, ">3s": 0
        }
    )


class SearchMonitor:
    """搜索引擎监控器"""

    def __init__(self):
        self.metrics = SearchMetrics()
        self.query_logs: List[Dict] = []
        self.slow_queries: List[Dict] = []

    def record_search(self, query: str, latency_ms: float,
                     results_count: int, from_cache: bool = False,
                     error: str = None):
        """记录一次搜索"""
        self.metrics.total_queries += 1
        self.metrics.total_latency_ms += latency_ms

        if from_cache:
            self.metrics.cache_hits += 1
        else:
            self.metrics.cache_misses += 1

        if error:
            self.metrics.errors += 1

        # 延迟分布
        if latency_ms < 100:
            self.metrics.latency_buckets["<100ms"] += 1
        elif latency_ms < 500:
            self.metrics.latency_buckets["100-500ms"] += 1
        elif latency_ms < 1000:
            self.metrics.latency_buckets["500ms-1s"] += 1
        elif latency_ms < 3000:
            self.metrics.latency_buckets["1-3s"] += 1
        else:
            self.metrics.latency_buckets[">3s"] += 1

        # 记录慢查询
        if latency_ms > 1000:
            self.slow_queries.append({
                "query": query,
                "latency_ms": latency_ms,
                "results_count": results_count,
                "error": error
            })

    def get_summary(self) -> Dict:
        """获取监控摘要"""
        total = self.metrics.total_queries
        if total == 0:
            return {"message": "暂无查询数据"}

        return {
            "total_queries": total,
            "average_latency_ms": self.metrics.total_latency_ms / total,
            "cache_hit_rate": self.metrics.cache_hits / total,
            "error_rate": self.metrics.errors / total,
            "latency_distribution": self.metrics.latency_buckets,
            "slow_queries_count": len(self.slow_queries),
            "recent_slow_queries": self.slow_queries[-5:]
        }

总结

本教程全面讲解了AI驱动的智能搜索引擎的开发,涵盖了从架构设计到生产部署的完整链路。以下是核心要点回顾:

关键技术总结

技术模块 核心要点
语义搜索 Embedding模型选型、向量检索(ANN)、索引构建
查询理解 意图识别、实体抽取、查询改写与扩展
混合检索 BM25+向量检索、RRF融合排序
Reranking Cross-Encoder精排、多信号融合
AI回答 引用生成、流式输出、后续问题建议
搜索Agent 自主规划、多步推理、工具调用
质量评估 P@K、NDCG、MRR、A/B测试

进阶方向

  1. 多模态搜索:支持图片、视频等非文本内容的搜索
  2. 个性化搜索:基于用户画像和历史行为进行个性化排序
  3. 知识图谱增强:利用知识图谱提升实体理解和关系推理
  4. 实时学习:基于用户反馈持续优化搜索质量
  5. 跨语言搜索:支持多语言查询和文档的统一检索

推荐学习资源

  • 向量数据库实践:Milvus、Qdrant、Weaviate官方文档
  • Embedding模型:Sentence-Transformers、MTEB排行榜
  • 搜索框架:LlamaIndex、Haystack、LangChain
  • 学术论文:DPR、ColBERT、SPLADE、HyDE

本教程由AI搜索引擎技术实践编写,代码示例仅供学习参考,实际生产环境需根据具体需求进行调整和优化。

内容声明

本文内容为AI技术学习教程,仅供学习参考。如涉及技术问题,欢迎通过 xurj005@163.com 与我们交流。

目录