#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. 快速上手测试
- 把所有代码按结构整理好,创建
.env文件:
OPENAI_API_KEY=your_actual_key
OPENAI_BASE_URL=https://api.deepseek.com/v1 # 换成你用的接口
LLM_MODEL=deepseek-chat- 启动FastAPI:
pip install python-multipart fastapi uvicorn python-dotenv
uvicorn main:app --reload- 访问
http://localhost:8000/docs用Swagger UI测试!
#6. 小结&优化方向
#小结
今天我们从0到1搭了一个:
- ✅ 可本地原型/可生产扩展的向量库
- ✅ 通用段落优先+滑动窗口的分块器
- ✅ 带引用来源、支持流式的RAG接口
#优化方向(写在文末留坑)
- 🚀 分块优化:按标题层级、表格/图片单独处理
- 🚀 检索优化:用Cohere/BGE-Rerank做重排序
- 🚀 异步入库:用Celery处理大文件上传入库
- 🚀 中文适配:替换成shibing624/text2vec-base-chinese等中文嵌入模型
🔗 扩展阅读

