实战项目三:语义搜索与问答系统

目录


项目概述

在智能客服、知识库问答等场景中,语义搜索与问答系统正在快速取代传统的关键词匹配。通过向量检索技术,系统能够理解“意思相近但用词不同”的查询,显著提升命中率和用户体验。

本教程聚焦企业FAQ问答这个轻量级落地场景,手把手带你实现一个开箱即用的语义问答系统。项目目标明确:

  • ✅ 支持自然语言中文提问
  • ✅ 检索准确率达到 85% 以上
  • ✅ 单次查询响应时间小于 300 毫秒
  • ✅ 无需 GPU,普通 CPU 服务器即可部署

核心架构与技术栈

精简架构

整个系统由四层组成,职责清晰:

┌─────────────┐
│  Streamlit  │ ← 用户交互
└──────┬──────┘

┌──────▼──────┐
│  FastAPI    │ ← API网关、鉴权
└──────┬──────┘

┌──────▼──────────────────────┐
│  检索增强生成(RAG)引擎       │
│  ┌─────────┐  ┌──────────┐  │
│  │检索模块  │→ │ 生成模块  │  │
│  └─────────┘  └──────────┘  │
└──────┬──────────────────────┘

┌──────▼──────────────┐
│  向量化+FAISS索引   │ ← 存储层
└─────────────────────┘
  • 用户交互层:Streamlit 搭建,无需前端经验就能做出美观界面。
  • API 网关层:FastAPI 提供高性能、自动文档化的 RESTful 接口。
  • RAG 引擎层:先检索再生成,用检索到的知识去辅助回答。
  • 存储层:基于 FAISS 的向量索引,承载所有 FAQ 的语义向量。

选型标准与推荐

本项目坚持免费开源、轻量易部署的原则,组件选择如下:

模块推荐方案说明
嵌入模型paraphrase-multilingual-MiniLM-L12-v2多语言轻量版,768维,中文效果中等但完全够用,加载速度快,CPU/GPU 通用
向量索引FAISS IndexFlatIP(归一化后)精确内积检索,适合 10 万条以内的 FAQ 数据,内存占用可控
后端框架FastAPI + Uvicorn异步高性能,自动生成 API 文档,调试友好
前端框架Streamlit10 分钟搭好交互界面,数据科学家友好
生成备选方案基于检索结果的规则匹配不依赖外部 API 也能运行,隐私安全,零成本

选择 IndexFlatIP 而非 IndexFlatL2 的原因:在向量归一化之后,内积等价于余弦相似度,且计算效率更高。


数据与向量化索引

1. 数据准备

FAQ 数据最简单的形式就是问题-答案对。为了提升检索效果,我们为每条 FAQ 增加一个 search_text 字段,把原始问题、常见相似问法、关键词等信息拼接在一起。

示例数据结构:

[
  {
    "id": "faq_001",
    "question": "密码忘记了怎么办?",
    "answer": "请访问登录页面→点击'忘记密码'→输入注册邮箱→查收重置链接;或联系客服人工处理。",
    "category": "账户",
    "search_text": "密码忘记了怎么办?忘记密码怎么找回?密码丢失如何重置?"
  }
]

提示:search_text 的质量直接影响检索召回率,建议根据真实用户日志,定期补充高频相似问法。

2. 向量化与索引构建

下面这段代码完成了模型加载、批量编码、归一化以及 FAISS 索引的构建与持久化。

from sentence_transformers import SentenceTransformer
import faiss
import numpy as np
import pickle

# ----------------------
# 1. 初始化嵌入模型
# ----------------------
embedder = SentenceTransformer("paraphrase-multilingual-MiniLM-L12-v2")

# ----------------------
# 2. 批量编码FAQ
# ----------------------
# 假设processed_faqs是预处理好的列表
search_texts = [faq["search_text"] for faq in processed_faqs]
embeddings = embedder.encode(search_texts, convert_to_numpy=True, show_progress_bar=True)

# ----------------------
# 3. 归一化+构建FAISS索引
# ----------------------
# 归一化后可使用内积近似余弦相似度,速度快
norms = np.linalg.norm(embeddings, axis=1, keepdims=True)
embeddings = embeddings / (norms + 1e-8)

dimension = embeddings.shape[1]
index = faiss.IndexFlatIP(dimension)   # 精确内积索引
index.add(embeddings.astype("float32"))

# ----------------------
# 4. 保存索引+数据
# ----------------------
faiss.write_index(index, "faq_index.faiss")
with open("faq_data.pkl", "wb") as f:
    pickle.dump(processed_faqs, f)

运行后,你会在本地得到两个文件:

  • faq_index.faiss:向量索引
  • faq_data.pkl:原始 FAQ 数据

以后检索时,只需加载这两个文件即可。


语义检索与RAG实现

1. 语义检索模块

我们将检索逻辑封装为一个类,方便后续调用。

class FAQRetriever:
    def __init__(self, index_path="faq_index.faiss", data_path="faq_data.pkl"):
        self.index = faiss.read_index(index_path)
        with open(data_path, "rb") as f:
            self.faqs = pickle.load(f)
    
    def search(self, query: str, top_k: int = 3, threshold: float = 0.3):
        # 编码查询并归一化
        query_emb = embedder.encode([query], convert_to_numpy=True)
        query_emb = query_emb / (np.linalg.norm(query_emb, axis=1, keepdims=True) + 1e-8)
        
        # 检索
        scores, indices = self.index.search(query_emb.astype("float32"), top_k)
        
        # 过滤低相似度结果,避免强答
        results = []
        for score, idx in zip(scores[0], indices[0]):
            if idx >= 0 and score >= threshold:
                results.append({**self.faqs[idx], "score": float(score)})
        return results

threshold 是相似度门槛,建议初期设为 0.3,后续可根据实际效果调整。低于该值的结果意味着语义差距过大,直接丢弃。

2. 轻量RAG引擎

检索到相关内容后,如何生成最终答案?这里提供了两种模式:

  • 规则模式(默认):直接返回最相似 FAQ 的标准答案,完全离线、零成本。
  • LLM 增强模式:将检索结果作为上下文,调用大语言模型生成更自然的回答,效果更好,但依赖外部 API。
class LightRAG:
    def __init__(self, retriever: FAQRetriever, llm_client=None):
        self.retriever = retriever
        self.llm = llm_client  # 如OpenAI()或本地LLM
    
    def query(self, user_question: str, use_llm: bool = False):
        # 1. 检索
        docs = self.retriever.search(user_question)
        if not docs:
            return "抱歉,未找到相关信息,请尝试重新表述问题或联系客服。"
        
        # 2. 构建上下文
        context = "\n\n".join([
            f"【问题】{d['question']}\n【答案】{d['answer']}"
            for d in docs
        ])
        
        # 3. 生成答案
        if use_llm and self.llm:
            prompt = f"""
            你是一个智能客服助手。请根据以下知识库信息准确回答用户问题,不要编造内容,控制在200字以内。
            知识库:
            {context}
            用户问题:{user_question}
            """
            try:
                resp = self.llm.chat.completions.create(
                    model="gpt-4o-mini",
                    messages=[{"role": "user", "content": prompt}],
                    max_tokens=300,
                    temperature=0.3
                )
                return resp.choices[0].message.content
            except Exception as e:
                print(f"LLM调用失败:{e}")
        
        # 无LLM备选方案:返回最相关的第一条答案
        return f"根据知识库信息:{docs[0]['answer']}"

生产环境中,可以将 Thresholduse_llm 做成可配置项,动态切换模式。


快速部署方案

1. 后端FastAPI核心

后端只保留核心功能,省略了日志、鉴权、缓存的代码,方便快速上手。

from fastapi import FastAPI
from pydantic import BaseModel
from typing import Optional, List

app = FastAPI(title="FAQ智能问答API")

# 初始化全局对象(通常在app启动时加载)
retriever = FAQRetriever()
rag = LightRAG(retriever)

class QueryRequest(BaseModel):
    question: str
    use_llm: bool = False
    top_k: int = 3

class QueryResponse(BaseModel):
    answer: str
    docs: Optional[List[dict]] = None

@app.post("/api/query", response_model=QueryResponse)
async def query_endpoint(req: QueryRequest):
    docs = retriever.search(req.question, req.top_k)
    answer = rag.query(req.question, req.use_llm)
    return QueryResponse(answer=answer, docs=docs if req.top_k > 0 else None)

启动命令:

uvicorn backend:app --host 0.0.0.0 --port 8000

访问 http://localhost:8000/docs 即可看到自动生成的 Swagger 文档,直接在线测试。

2. 前端Streamlit核心

前端代码极简,但功能完备:输入问题、切换 LLM 增强、查看参考来源。

import streamlit as st
import requests

# ----------------------
# 页面配置
# ----------------------
st.set_page_config(page_title="FAQ智能问答", page_icon="🤖")
st.title("🤖 FAQ 智能问答助手")

# ----------------------
# 侧边栏设置
# ----------------------
with st.sidebar:
    api_url = st.text_input("后端API", "http://localhost:8000/api/query")
    use_llm = st.checkbox("使用LLM增强(可选)", value=False)
    show_docs = st.checkbox("显示参考来源", value=True)

# ----------------------
# 主交互
# ----------------------
user_q = st.text_input("请输入您的问题:")
if st.button("🔍 提问", type="primary") and user_q:
    with st.spinner("思考中..."):
        try:
            resp = requests.post(
                api_url,
                json={"question": user_q, "use_llm": use_llm, "top_k": 3 if show_docs else 0}
            )
            resp_data = resp.json()
            
            st.success(resp_data["answer"])
            
            if show_docs and resp_data.get("docs"):
                with st.expander("📋 查看参考来源"):
                    for i, d in enumerate(resp_data["docs"], 1):
                        st.markdown(f"**来源{i}(相似度:{d['score']:.2f})**")
                        st.write(f"问题:{d['question']}")
                        st.write(f"答案:{d['answer']}")
                        st.divider()
        except Exception as e:
            st.error(f"请求失败:{e}")

启动前端:

streamlit run frontend.py

3. 一键启动命令

# 先安装后端依赖
pip install fastapi uvicorn sentence-transformers faiss-cpu numpy pickle5

# 终端1:启动后端
uvicorn backend:app --host 0.0.0.0 --port 8000

# 新终端2:安装前端依赖
pip install streamlit requests

# 启动前端
streamlit run frontend.py

打开浏览器,你就可以在 Streamlit 界面中用自然语言向 FAQ 知识库提问了。


优化要点总结

  1. 向量化优化

    • 把相似问、分类、标签合并到 search_text 字段,丰富语义信息。
    • 优先使用批量编码,避免逐条调用。
    • 如果有 GPU,初始化模型时加上 device="cuda" 参数,编码速度能提升数倍。
  2. 检索优化

    • 数据量小于 10 万条时,IndexFlatIP 既简单又精准。
    • 数据量更大时,可以换成 IndexIVFFlat,牺牲少量精度换取更快速度。
    • 设置合理的相似度阈值(例如 0.3)可以有效过滤无关结果。
    • 必要时引入混合检索:用语义相似度加关键词简单的精确匹配,提升长尾问题的召回率。
  3. 性能优化

    • 本地 LRU 缓存高频查询的嵌入向量和最终结果,避免重复计算。
    • 多实例部署时,用 Redis 作为集中式缓存。
    • 生产环境建议用 Uvicorn 的多 worker 模式(例如 --workers 4)或搭配 Gunicorn 提升并发能力。

本项目从数据处理、向量化、索引构建,到检索、问答、前后端部署,完整覆盖了一个语义问答系统的最小可行产品。你可以将其作为企业 FAQ、产品咨询、在线课程问答等场景的起点,后续再根据实际需求叠加意图识别、多轮对话、知识库自动更新等功能,逐步完善。