RAG 后端系统集成:FastAPI 与向量数据库的联动实践

📂 所属阶段:第六阶段 — 2026 特色专题(AI 集成篇)
🔗 相关章节:流式响应 StreamingResponse · 异步任务队列 Celery


1. 什么是 RAG?

1.1 一句话+流程串讲

RAG = 知识库的「语义索引器」+ LLM 的「实时知识补丁」

简单说就是先把内部/最新文档拆小、转成数字向量存起来,用户提问时先找「最像问题的文档片段」,再把片段塞进 LLM 的上下文,让它生成有依据、不瞎编的答案。

flowchart LR
    A[用户问题] --> B[问题向量化]
    B --> C[向量相似度检索]
    D[文档预处理] --> E[分块]
    E --> F[文档向量化]
    F --> G[存入向量库]
    G --> C
    C --> H[Top-K 高相关片段]
    H + A --> I[构建增强Prompt]
    I --> J[LLM 生成答案]
    J --> K[返回答案+引用来源]

1.2 为什么不用纯Prompt或微调?

方案优势劣势适用场景
纯Prompt开发0门槛知识停留在LLM训练截止日、Prompt太长塞不下、容易「幻觉」日常对话、通用科普类无定制需求
微调(Fine-tuning)模型深度贴合业务、输出稳定每次更新都要重新训练、GPU成本高、周期长(数天/周)知识固定、低频更新、对输出风格要求极高
RAG实时更新文档、可溯源来源、成本仅需微调1%检索质量决定上限知识库问答、产品手册、实时新闻/政策解读

2. 向量数据库选型:轻量Chroma vs 生产级Milvus

向量数据库就是存「数字向量」+ 做超高速相似度搜索的工具,选它不选普通SQL/NoSQL的原因是:普通数据库做线性搜索太慢(百万级向量要几秒到几分钟),向量数据库用索引算法(如HNSW)能降到毫秒级。

2.1 Chroma(中小规模/原型首选)

不需要额外服务端,直接pip安装就能存本地,Python API极简。

快速安装

pip install chromadb sentence-transformers

封装成全局向量库(vector_store/chroma_store.py)

import chromadb
from chromadb.config import Settings
from sentence_transformers import SentenceTransformer

class ChromaStore:
    def __init__(self, collection_name: str = "docs"):
        # 关闭匿名遥测,本地持久化存储
        self.client = chromadb.PersistentClient(
            path="./chroma_db",
            settings=Settings(anonymized_telemetry=False)
        )
        # 免费轻量的英文通用嵌入模型(中文可选shibing624/text2vec-base-chinese)
        self.embedding_model = SentenceTransformer("all-MiniLM-L6-v2")
        # 用余弦相似度创建/获取集合
        self.collection = self.client.get_or_create_collection(
            name=collection_name,
            metadata={"hnsw:space": "cosine"}
        )

    def add_doc(self, doc_id: str, text: str, metadata: dict | None = None):
        """单条文档入库"""
        embedding = self.embedding_model.encode(text).tolist()
        self.collection.add(
            ids=[doc_id],
            documents=[text],
            embeddings=[embedding],
            metadatas=[metadata or {}]
        )

    def search(self, query: str, top_k: int = 5) -> list[dict]:
        """语义检索,返回格式统一的结果"""
        query_emb = self.embedding_model.encode(query).tolist()
        raw_res = self.collection.query(
            query_embeddings=[query_emb],
            n_results=top_k,
            include=["documents", "metadatas", "distances"]
        )
        # 把距离(越小越相似)转成直观的相似度(0-1)
        return [
            {
                "id": raw_res["ids"][0][i],
                "text": raw_res["documents"][0][i],
                "metadata": raw_res["metadatas"][0][i],
                "score": round(1 - raw_res["distances"][0][i], 4)
            }
            for i in range(len(raw_res["ids"][0]))
        ]

    def delete_doc(self, doc_id: str):
        self.collection.delete(ids=[doc_id])

# 全局实例,服务启动时加载
vector_db = ChromaStore()

2.2 Milvus(百万级/千万级生产首选)

需要Docker启动服务端,但支持分布式部署、高并发检索,是很多大厂的选择。

快速启动(Docker)

# 拉取Milvus Standalone(适合单机器测试)镜像
wget https://github.com/milvus-io/milvus/releases/download/v2.4.10/milvus-standalone-docker-compose.yml -O docker-compose.yml
docker-compose up -d

封装核心接口(vector_store/milvus_store.py)

from pymilvus import MilvusClient, DataType

class MilvusStore:
    def __init__(self, uri: str = "http://localhost:19530"):
        self.client = MilvusClient(uri=uri)
        self.collection_name = "docs"
        self.embedding_dim = 384  # 对应all-MiniLM-L6-v2的输出维度
        # 服务启动时自动创建不存在的集合
        if not self.client.has_collection(self.collection_name):
            self._create_collection()

    def _create_collection(self):
        schema = MilvusClient.create_schema(
            auto_id=True,
            enable_dynamic_field=True  # 允许存任意额外metadata
        )
        schema.add_field("id", DataType.INT64, is_primary=True)
        schema.add_field("vector", DataType.FLOAT_VECTOR, dim=self.embedding_dim)
        schema.add_field("text", DataType.VARCHAR, max_length=4096)
        schema.add_field("source", DataType.VARCHAR, max_length=255)

        self.client.create_collection(
            collection_name=self.collection_name,
            schema=schema,
            index_params={
                "vector": {"type": "HNSW", "metric_type": "COSINE", "params": {"M": 8, "efConstruction": 64}}
            }
        )
        # 加载到内存提高检索速度
        self.client.load_collection(self.collection_name)

    def batch_insert(self, texts: list[str], sources: list[str], embeddings: list[list[float]]):
        """批量文档入库(单条慢,生产必须用批量)"""
        data = [
            {"vector": emb, "text": text, "source": source}
            for text, source, emb in zip(texts, sources, embeddings)
        ]
        return self.client.insert(collection_name=self.collection_name, data=data)

3. 文档处理:从纯文本到向量的管道

3.1 分块策略(核心优化点)

文档不能直接丢进去,太大会导致上下文噪声多,太小会丢失语义关联。这里写一个通用的段落优先+滑动窗口的分块器:

# processing/chunker.py
from typing import List
import re

class TextChunker:
    def __init__(self, chunk_size: int = 500, overlap: int = 50):
        self.chunk_size = chunk_size  # 每块最多字符数(英文≈80token,中文≈250token)
        self.overlap = overlap        # 相邻块的重叠字符数,保留上下文

    def chunk_text(self, text: str, source: str = "") -> List[dict]:
        """优先按段落分割,不够再补,超了再切"""
        # 先清理多余空行
        paragraphs = [p.strip() for p in re.split(r'\n{2,}', text) if p.strip()]
        chunks = []
        current_chunk = ""
        chunk_idx = 0

        for para in paragraphs:
            # 当前块+下一段没超,直接加
            if len(current_chunk) + len(para) + 2 < self.chunk_size:
                current_chunk += para + "\n\n"
            else:
                # 当前块不为空,先存
                if current_chunk.strip():
                    chunks.append({
                        "id": f"{source.replace('/', '_')}_{chunk_idx}",
                        "text": current_chunk.strip(),
                        "metadata": {"source": source, "chunk_idx": chunk_idx}
                    })
                    chunk_idx += 1
                # 滑动窗口初始化:下一段的最后overlap字符作为开头(可选)
                if len(para) > self.overlap:
                    current_chunk = para[-self.overlap:] + "\n\n" + para + "\n\n"
                else:
                    current_chunk = para + "\n\n"

        # 最后补存剩余的块
        if current_chunk.strip():
            chunks.append({
                "id": f"{source.replace('/', '_')}_{chunk_idx}",
                "text": current_chunk.strip(),
                "metadata": {"source": source, "chunk_idx": chunk_idx}
            })
        return chunks

4. FastAPI+RAG:写一个可部署的问答接口

4.1 核心RAG服务封装(services/rag_service.py)

from vector_store.chroma_store import vector_db
from sentence_transformers import SentenceTransformer
from openai import AsyncOpenAI
import os
from dotenv import load_dotenv

# 加载环境变量(API Key不要写在代码里!)
load_dotenv()

class RAGService:
    def __init__(self):
        self.embedding_model = SentenceTransformer("all-MiniLM-L6-v2")
        # 支持OpenAI兼容接口(如Ollama、DeepSeek、通义千问)
        self.llm_client = AsyncOpenAI(
            api_key=os.getenv("OPENAI_API_KEY"),
            base_url=os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1")
        )

    async def _retrieve(self, query: str, top_k: int = 5, min_score: float = 0.4) -> list[dict]:
        """内部检索方法,过滤掉低相似度的噪声"""
        results = vector_db.search(query, top_k=top_k)
        return [r for r in results if r["score"] >= min_score]

    async def generate_answer(self, query: str, top_k: int = 5, min_score: float = 0.4, stream: bool = False):
        """对外暴露的检索+生成方法"""
        # 1. 检索
        docs = await self._retrieve(query, top_k, min_score)
        if not docs:
            return {"answer": "抱歉,知识库中未找到相关内容,请尝试换个说法。", "sources": []}

        # 2. 构建增强Prompt
        context = "\n\n".join([
            f"[来源 {i+1}]({doc['metadata']['source']}, 块号 {doc['metadata']['chunk_idx']})\n{doc['text']}"
            for i, doc in enumerate(docs)
        ])
        prompt = f"""你是一个严谨的知识库助手,请严格遵循以下规则回答:
1. 仅参考【参考资料】回答,资料外的内容不要编造
2. 答案要简洁明了
3. 如果资料中没有明确答案,请直接说「未找到相关信息」

参考资料:
{context}

用户问题:{query}"""

        # 3. 调用LLM生成
        llm_response = await self.llm_client.chat.completions.create(
            model=os.getenv("LLM_MODEL", "gpt-4o-mini"),
            messages=[{"role": "user", "content": prompt}],
            temperature=0.2,  # 低温度保证稳定
            max_tokens=1000,
            stream=stream
        )
        return {"response": llm_response, "sources": docs}

4.2 FastAPI路由(routers/rag.py)

from fastapi import APIRouter, HTTPException
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from services.rag_service import RAGService
from processing.chunker import TextChunker
from vector_store.chroma_store import vector_db
from sentence_transformers import SentenceTransformer
import json
import asyncio

router = APIRouter(prefix="/api/rag", tags=["RAG 接口"])
rag_service = RAGService()
chunker = TextChunker()
embedding_model = SentenceTransformer("all-MiniLM-L6-v2")

# 请求/响应模型定义
class QueryReq(BaseModel):
    query: str
    top_k: int = 5
    min_score: float = 0.4
    stream: bool = False

class IngestReq(BaseModel):
    text: str
    source: str = "manual_input"

# ──────────────────────────────────────
# 普通问答接口
# ──────────────────────────────────────
@router.post("/query")
async def rag_query(req: QueryReq):
    result = await rag_service.generate_answer(**req.model_dump())
    if not req.stream:
        return {
            "answer": result["response"].choices[0].message.content,
            "sources": [{"text": d["text"][:200] + "...", "source": d["metadata"]["source"], "score": d["score"]} for d in result["sources"]]
        }

# ──────────────────────────────────────
# 流式问答接口(打字机效果)
# ──────────────────────────────────────
@router.post("/query/stream")
async def rag_query_stream(req: QueryReq):
    result = await rag_service.generate_answer(**req.model_dump())
    if not result["sources"]:
        return {"answer": "未找到相关内容", "sources": []}

    async def event_generator():
        # 先推送来源(可选)
        yield f"data: {json.dumps({'type': 'sources', 'content': result['sources']})}\n\n"
        # 推送LLM token流
        async for chunk in result["response"]:
            if token := chunk.choices[0].delta.content:
                yield f"data: {json.dumps({'type': 'token', 'content': token})}\n\n"
        # 推送结束标记
        yield f"data: {json.dumps({'type': 'done'})}\n\n"

    return StreamingResponse(
        event_generator(),
        media_type="text/event-stream",
        headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}
    )

# ──────────────────────────────────────
# 文档手动入库接口(测试用)
# ──────────────────────────────────────
@router.post("/ingest")
async def ingest(req: IngestReq):
    chunks = chunker.chunk_text(req.text, req.source)
    for chunk in chunks:
        vector_db.add_doc(**chunk)
    return {"status": "success", "chunks_count": len(chunks)}

5. 快速上手测试

  1. 把所有代码按结构整理好,创建.env文件:
OPENAI_API_KEY=your_actual_key
OPENAI_BASE_URL=https://api.deepseek.com/v1  # 换成你用的接口
LLM_MODEL=deepseek-chat
  1. 启动FastAPI:
pip install python-multipart fastapi uvicorn python-dotenv
uvicorn main:app --reload
  1. 访问http://localhost:8000/docs用Swagger UI测试!

6. 小结&优化方向

小结

今天我们从0到1搭了一个:

  • ✅ 可本地原型/可生产扩展的向量库
  • ✅ 通用段落优先+滑动窗口的分块器
  • ✅ 带引用来源、支持流式的RAG接口

优化方向(写在文末留坑)

  • 🚀 分块优化:按标题层级、表格/图片单独处理
  • 🚀 检索优化:用Cohere/BGE-Rerank做重排序
  • 🚀 异步入库:用Celery处理大文件上传入库
  • 🚀 中文适配:替换成shibing624/text2vec-base-chinese等中文嵌入模型

🔗 扩展阅读