共计 13614 个字符,预计需要花费 35 分钟才能阅读完成。
教程一 · 最小可用版(两条路线)
路线 A:GPU 生产级(vLLM + OpenAI 兼容 API)
适合:有 NVIDIA GPU 的机器,需要高吞吐与低延迟。
0. 前置
- Docker 已安装;NVIDIA 驱动已装好(
nvidia-smi有输出)。 - 安装 nvidia-container-toolkit 后,Docker 才能访问 GPU。
检查 GPU 可用性
docker run --rm --gpus all nvidia/cuda:12.1.1-base-ubuntu22.04 nvidia-smi
1. 一条命令跑起来(以 Llama 3.1 8B Instruct 为例)
# 可选:如果你有 HF Token(私有或 gated 模型会用到)export HUGGING_FACE_HUB_TOKEN= 你的 token
# 挂载缓存目录,首次拉权重更快,重启不重复下载
mkdir -p $HOME/.cache/huggingface
docker run -d --name vllm \
--gpus all \
-p 8000:8000 \
-e HUGGING_FACE_HUB_TOKEN=$HUGGING_FACE_HUB_TOKEN \
-v $HOME/.cache/huggingface:/root/.cache/huggingface \
vllm/vllm-openai:latest \
--model meta-llama/Meta-Llama-3.1-8B-Instruct \
--dtype bfloat16 \
--max-model-len 8192 \
--gpu-memory-utilization 0.90
小贴士:
--gpu-memory-utilization控制显存占用上限;--max-model-len是上下文长度;没有 bfloat16 就用--dtype float16。
2. 健康检查与快速验证
# 看容器日志(拉权重会花点时间)docker logs -f vllm
# 健康探针(服务正常会有 JSON 返回)curl -s localhost:8000/health
3. 调用方式(OpenAI 兼容)
cURL
curl -s http://localhost:8000/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{"model":"meta-llama/Meta-Llama-3.1-8B-Instruct","messages": [{"role":"user","content":" 用一句话解释量子纠缠 "}],"temperature": 0.7
}'
Python(openai 官方 SDK,改 base_url 即可)
from openai import OpenAI
client = OpenAI(base_url="http://localhost:8000/v1", api_key="EMPTY")
resp = client.chat.completions.create(
model="meta-llama/Meta-Llama-3.1-8B-Instruct",
messages=[{"role": "user", "content": "用一句话解释量子纠缠"}],
temperature=0.7,
)
print(resp.choices[0].message.content)
4. 常用参数速查
--tensor-parallel-size N:多卡并行(N=GPU 数)。--max-num-seqs:并发生成序列上限,提升吞吐。--enforce-eager:调试更直观,极限性能可去掉。- 显存不够? 优先:更小模型 / 量化权重(AWQ/GPTQ 版本)/ 降低
--max-model-len。
路线 B:CPU/ 入门最省心(Ollama)
适合:无 GPU 或想先无痛试跑。接口不是 100% OpenAI 兼容,但足够易用。
1. 启动服务
docker run -d --name ollama -p 11434:11434 ollama/ollama
2. 拉一个轻量模型并试跑
# 进入容器内拉权重
docker exec -it ollama bash -lc "ollama pull llama3.2:3b"
# 生成一条
curl -s http://localhost:11434/api/generate -d '{"model":"llama3.2:3b","prompt":" 用一句话解释量子纠缠 "}'
想要“接近”OpenAI 调用体验,可以在应用里做一层轻量适配,或选择路线 A 的 vLLM。
进阶(选做)
A. Docker Compose(单机示例)
不同 Compose 版本对 GPU 声明支持差异较大,最稳是 命令行加
--gpus all。若你已确认 Compose 支持gpus,可参考下例。
# docker-compose.yml
services:
vllm:
image: vllm/vllm-openai:latest
container_name: vllm
ports:
- "8000:8000"
environment:
- HUGGING_FACE_HUB_TOKEN=${HUGGING_FACE_HUB_TOKEN:-}
volumes:
- ~/.cache/huggingface:/root/.cache/huggingface
# 仅在你的 Compose 支持时启用:deploy:
resources:
reservations:
devices:
- capabilities: ["gpu"]
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 10s
timeout: 3s
retries: 10
运行:
export HUGGING_FACE_HUB_TOKEN= 你的 token # 可选
docker compose up -d
B. 反向代理与鉴权
- 只在内网开放
8000,对外用 Nginx/Traefik 做鉴权(基本认证 / 签名网关)。 - 给应用层配置“伪 API Key”校验,避免裸接口外泄。
C. 观测与运维
docker logs -f vllm看服务日志。- vLLM 暴露的
/metrics(若镜像 / 版本支持)可被 Prometheus 抓取,结合 Grafana 做面板。 - 压测:
vegeta/wrk用并发对POST /v1/chat/completions打压,观察 QPS 与 Token/s。
D. 常见报错排查
- 拉权重失败:确认模型是否需要许可证 /Token;网络是否能访问 Hugging Face;挂载缓存目录权限是否正确。
- 显存不足 OOM:换小模型;降低
--max-model-len;使用量化;或多卡--tensor-parallel-size。 - GPU 不可见:
nvidia-smi正常?nvidia-container-toolkit已安装?docker run是否带了--gpus all? - 延迟高:调低
temperature与top_p;开启更高并发前先观察--max-num-seqs的影响。
教程二:RAG 与多模型编排(含 Docker Compose)
一、原理小抄(把黑盒拧开看一眼)
**RAG(Retrieval-Augmented Generation,检索增强生成)** 的核心是分两步:
- 先找知识(从你自己的语料 / 文档里检索相关内容);
- 再让大模型回答(把检索出来的片段塞进提示词,限制模型“胡编”)。
把条件概率写得直白点:
$$[
\text{Answer} = \mathrm{LLM}\big(\text{Question},\underbrace{\mathrm{TopK}(\text{Search}(\text{Question}))}_{\text{你的知识}}\big)
]$$
想要它靠谱,四个关键点:
- 切块(Chunking):文本按语义或结构切片(段落、标题、代码块),别把一整本 PDF 塞成一个向量。
- 嵌入(Embedding):把文本变成向量;中文 / 多语种优先用 M3/BGE/Jina 这类强检索模型。
- 检索(Vector Search):Top-K 召回 +(可选)交叉编码器重排(cross-encoder rerank)提纯相关性。
- 提示构造(Prompting):把片段拼成「给模型看的开放书」,控制长度、排序、来源标注与“不会就说不知道”。
** 多模型编排(Orchestration)** 做的事更像“分诊台”:
- 路由(Routing):根据问题特征挑最合适的模型(如:代码问题走 Code LLM,数学走 Math LLM,其它走通用 LLM)。
- 级联(Cascade):先用小模型尝试;不确定或答不全再升级到大模型(省钱提速)。
- 工具化(Tools):把“检索、计算器、表格解析”等当可调用工具,模型按需调用。
二、平台组件图(最小闭环)
[ 客户端 / 你的应用]
|
v
[RAG 后端编排 API] <--(A) 嵌入 / 检索 / 重排 / 路由 / 拼 Prompt
| | \
| | \--(B) 生成模型 API(通用 / 代码等,OpenAI 兼容)
| \
| \--(C) 嵌入服务(TEI /embed)
|
\--(D) 向量数据库(Qdrant)
- (C) 用 Hugging Face Text Embeddings Inference (TEI) 跑嵌入模型(开箱即用 REST)。
- (D) 用 Qdrant 存向量与元数据,支持相似度搜索。
- (B) 用 vLLM + OpenAI 兼容 暴露聊天 / 补全接口(生成答案)。
- (A) 自研一个轻量 FastAPI 服务,把检索、重排、拼 Prompt、路由整合到一个统一接口。
三、Docker Compose 一把梭
目录结构(建议):
rag-stack/
├─ docker-compose.yml
├─ .env # 放可配参数(HF_TOKEN 等)├─ data/ # 你的原始文档(pdf/txt/md/...)└─ rag-api/
├─ Dockerfile
├─ requirements.txt
└─ app.py # 编排后端(FastAPI)
1) .env(示例,可按需修改)
# vLLM 主模型(通用问答)GEN_MODEL_ID=meta-llama/Meta-Llama-3.1-8B-Instruct
GEN_PORT=8000
#(可选)代码模型,使用 Compose profiles 控制启用
CODE_MODEL_ID=Qwen/Qwen2.5-Coder-7B-Instruct
CODE_PORT=8002
# TEI 嵌入服务
EMBED_MODEL_ID=BAAI/bge-m3
TEI_PORT=8080
# Qdrant
QDRANT_PORT=6333
QDRANT_COLLECTION=rag_chunks
#(如需下载 gated 模型)Hugging Face Token
HUGGING_FACE_HUB_TOKEN=
# RAG API
RAG_API_PORT=8001
2) docker-compose.yml
version: "3.9"
services:
qdrant:
image: qdrant/qdrant:latest
container_name: qdrant
ports: ["${QDRANT_PORT}:6333"]
volumes:
- qdrant_storage:/qdrant/storage
restart: unless-stopped
tei-embeddings:
image: ghcr.io/huggingface/text-embeddings-inference:cpu-latest
# 如有闲置 GPU,可换: ...:latest 并加上 --gpus all
container_name: tei-embeddings
environment:
- MODEL_ID=${EMBED_MODEL_ID}
- HF_TOKEN=${HUGGING_FACE_HUB_TOKEN}
ports: ["${TEI_PORT}:80"]
restart: unless-stopped
vllm-main:
image: vllm/vllm-openai:latest
container_name: vllm-main
ports: ["${GEN_PORT}:8000"]
environment:
- HUGGING_FACE_HUB_TOKEN=${HUGGING_FACE_HUB_TOKEN}
volumes:
- ~/.cache/huggingface:/root/.cache/huggingface
command:
- --model
- ${GEN_MODEL_ID}
- --max-model-len
- "8192"
- --gpu-memory-utilization
- "0.90"
deploy:
resources:
reservations:
devices:
- capabilities: ["gpu"]
restart: unless-stopped
vllm-code:
image: vllm/vllm-openai:latest
container_name: vllm-code
ports: ["${CODE_PORT}:8000"]
environment:
- HUGGING_FACE_HUB_TOKEN=${HUGGING_FACE_HUB_TOKEN}
volumes:
- ~/.cache/huggingface:/root/.cache/huggingface
command:
- --model
- ${CODE_MODEL_ID}
- --max-model-len
- "8192"
- --gpu-memory-utilization
- "0.80"
deploy:
resources:
reservations:
devices:
- capabilities: ["gpu"]
restart: unless-stopped
profiles: ["code"] # 默认不启,用 `--profile code` 开
rag-api:
build: ./rag-api
container_name: rag-api
ports: ["${RAG_API_PORT}:8001"]
environment:
- TEI_URL=http://tei-embeddings:80
- QDRANT_URL=http://qdrant:6333
- QDRANT_COLLECTION=${QDRANT_COLLECTION}
- GEN_URL=http://vllm-main:8000
- CODE_URL=http://vllm-code:8000
volumes:
- ./data:/app/data:ro
depends_on:
- qdrant
- tei-embeddings
- vllm-main
restart: unless-stopped
volumes:
qdrant_storage:
单卡机器显存不够同时跑两个 vLLM?很正常。先只开
vllm-main(不带--profile code),等扩容或换更小的 code 模型再启。
四、RAG 编排后端(FastAPI)
1) rag-api/requirements.txt
fastapi
uvicorn[standard]
httpx
pydantic
qdrant-client
sentence-transformers
pypdf
python-multipart
2) rag-api/Dockerfile
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py .
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8001"]
3) rag-api/app.py(精简但能跑的编排逻辑)
import os, glob, json, re
from typing import List, Optional
from fastapi import FastAPI, UploadFile, File, Form
from pydantic import BaseModel
import httpx
from qdrant_client import QdrantClient
from qdrant_client.http.models import Distance, VectorParams, PointStruct
from sentence_transformers import CrossEncoder
from pypdf import PdfReader
TEI_URL = os.getenv("TEI_URL", "http://localhost:8080")
QDRANT_URL = os.getenv("QDRANT_URL", "http://localhost:6333")
QDRANT_COLLECTION = os.getenv("QDRANT_COLLECTION", "rag_chunks")
GEN_URL = os.getenv("GEN_URL", "http://localhost:8000")
CODE_URL = os.getenv("CODE_URL", "")
DATA_DIR = "/app/data"
app = FastAPI(title="RAG Orchestrator")
# ----- utils -----
async def tei_embed(texts: List[str]) -> List[List[float]]:
async with httpx.AsyncClient(timeout=120) as client:
r = await client.post(f"{TEI_URL}/embed", json={"inputs": texts})
r.raise_for_status()
return r.json()["embeddings"] if isinstance(r.json(), dict) else r.json()
def read_any(path: str) -> str:
if path.lower().endswith(".pdf"):
reader = PdfReader(path)
return "\n".join([p.extract_text() or "" for p in reader.pages])
with open(path, "r", encoding="utf-8", errors="ignore") as f:
return f.read()
def split_chunks(text: str, chunk_size=1200, overlap=150) -> List[str]:
# 简单但实用:按双换行切,再做拼接与重叠
paras = [p.strip() for p in re.split(r"\n\s*\n", text) if p.strip()]
out, buf = [], ""
for p in paras:
if len(buf) + len(p) + 1 <= chunk_size:
buf = (buf + "\n" + p).strip()
else:
if buf: out.append(buf)
# 重叠:保留末尾 overlap 字符
buf = (buf[-overlap:] + "\n" + p)[-chunk_size:]
if buf: out.append(buf)
# 兜底:超长段再切
final = []
for c in out:
if len(c) <= chunk_size:
final.append(c)
else:
for i in range(0, len(c), chunk_size - overlap):
final.append(c[i:i+chunk_size])
return final
# ----- Qdrant init -----
qdrant = QdrantClient(url=QDRANT_URL)
async def ensure_collection(dim: int):
collections = qdrant.get_collections().collections
names = {c.name for c in collections}
if QDRANT_COLLECTION not in names:
qdrant.create_collection(
collection_name=QDRANT_COLLECTION,
vectors_config=VectorParams(size=dim, distance=Distance.COSINE)
)
# ----- Cross-encoder reranker (惰性加载,CPU 可跑) -----
_reranker = None
def get_reranker():
global _reranker
if _reranker is None:
# 多语种强相关重排器
_reranker = CrossEncoder("BAAI/bge-reranker-v2-m3")
return _reranker
# ----- ingest APIs -----
class IngestReq(BaseModel):
paths: Optional[List[str]] = None
chunk_size: int = 1200
overlap: int = 150
namespace: str = "default"
@app.post("/ingest")
async def ingest(req: IngestReq):
files = req.paths or sorted(glob.glob(f"{DATA_DIR}/**/*.pdf", recursive=True)
+ glob.glob(f"{DATA_DIR}/**/*.txt", recursive=True)
+ glob.glob(f"{DATA_DIR}/**/*.md", recursive=True))
if not files:
return {"status": "no_files", "hint": "把文档放到 ./data/ 再试"}
# 用首个片段获取维度 -> 创建 / 确保 collection
sample_text = "你好,向量维度探测"
dim = len((await tei_embed([sample_text]))[0])
await ensure_collection(dim)
points = []
pid = 0
for path in files:
txt = read_any(path)
chunks = split_chunks(txt, req.chunk_size, req.overlap)
embeds = await tei_embed(chunks)
for i, (c, v) in enumerate(zip(chunks, embeds)):
points.append(PointStruct(
id=pid,
vector=v,
payload={
"text": c,
"source": path.replace("/app/data/", ""),"chunk_id": i,"namespace": req.namespace
}
))
pid += 1
# 分批写入,避免一次性过大
if len(points) >= 1024:
qdrant.upsert(collection_name=QDRANT_COLLECTION, points=points)
points = []
if points:
qdrant.upsert(collection_name=QDRANT_COLLECTION, points=points)
return {"status": "ok", "files": files, "points": pid}
# ----- chat APIs -----
class ChatReq(BaseModel):
query: str
top_k: int = 24
use_rerank: bool = True
namespace: str = "default"
model_pref: str = "auto" # auto | primary | code
def pick_model(query: str, pref: str) -> str:
# 简单路由:代码 / 报错 / 函数关键词 -> code 模型
code_keywords = ["报错", "stack trace", "traceback", "error", "异常",
"函数", "类", "接口", "API", "实现", "bug", "python", "java", "go", "rust"]
if pref == "primary": return GEN_URL
if pref == "code" and CODE_URL: return CODE_URL
if any(k.lower() in query.lower() for k in code_keywords) and CODE_URL:
return CODE_URL
return GEN_URL
def build_prompt(query: str, contexts: List[dict]) -> List[dict]:
context_block = "\n\n".join([f"[{i+1}] 来源: {c['payload']['source']}#{c['payload']['chunk_id']}\n{c['payload']['text']}"
for i, c in enumerate(contexts)]
)
sys = (
"你是严谨的检索增强助手。仅使用提供的【上下文】回答;"
"若上下文不足以作答,请明确说明“根据现有资料无法确定”。"
"回答末尾给出引用片段编号。"
)
user = f"问题:{query}\n\n【上下文】\n{context_block}\n\n 请基于以上内容作答。"
return [{"role": "system", "content": sys}, {"role": "user", "content": user}]
async def openai_chat(base_url: str, messages: List[dict], temperature=0.2, max_tokens=512) -> str:
async with httpx.AsyncClient(timeout=120) as client:
r = await client.post(f"{base_url}/v1/chat/completions",
json={"model": "placeholder", "messages": messages, "temperature": temperature, "max_tokens": max_tokens}
)
r.raise_for_status()
data = r.json()
return data["choices"][0]["message"]["content"]
@app.post("/chat")
async def chat(req: ChatReq):
# 1) 向量检索
q_vec = (await tei_embed([req.query]))[0]
hits = qdrant.search(
collection_name=QDRANT_COLLECTION,
query_vector=q_vec,
limit=max(req.top_k, 8),
query_filter={"must": [{"key": "namespace", "match": {"value": req.namespace}}]}
)
points = [h.dict() for h in hits]
# 2) 可选:交叉编码器重排(提纯 Top-K)if req.use_rerank and points:
reranker = get_reranker()
pairs = [(req.query, p["payload"]["text"]) for p in points]
scores = reranker.predict(pairs, batch_size=32, convert_to_numpy=True)
ranked = sorted(zip(points, scores), key=lambda x: float(x[1]), reverse=True)
points = [p for p, _ in ranked[: min(12, req.top_k)]]
else:
points = points[: min(12, req.top_k)]
# 3) 选择模型 & 构造消息
model_url = pick_model(req.query, req.model_pref)
messages = build_prompt(req.query, points)
# 4) 生成
answer = await openai_chat(model_url, messages)
return {"answer": answer, "citations": [
{"idx": i+1, "source": p["payload"]["source"], "chunk_id": p["payload"]["chunk_id"]}
for i, p in enumerate(points)
]}
五、启动与验证
1) 启动服务
# 仅通用模型
docker compose up -d
# 若你要启用代码模型(且显存允许)docker compose --profile code up -d
2) 准备测试语料
把你的 PDF/TXT/Markdown 丢进 ./data/。也可以先放几篇 Markdown 试跑。
3) 构建索引(向量化并入库)
curl -X POST "http://localhost:${RAG_API_PORT}/ingest" \
-H "Content-Type: application/json" \
-d '{"namespace":"demo","chunk_size":1200,"overlap":150}'
返回里有 points 代表写入的切块数量。
4) 提问(RAG 生效)
curl -X POST "http://localhost:${RAG_API_PORT}/chat" \
-H "Content-Type: application/json" \
-d '{"query":" 用要点说明文档中关于数据脱敏流程 ","namespace":"demo"}'
你会得到:
answer:基于检索结果生成的答案;citations:每条引用包含source与chunk_id,方便追溯。
5) 代码类问题路由(若启用 code 模型)
curl -X POST "http://localhost:${RAG_API_PORT}/chat" \
-H "Content-Type: application/json" \
-d '{"query":" 用 Python 写个分页游标示例,并解释复杂度 ","namespace":"demo","model_pref":"auto"}'
命中简单关键词路由则会走 vllm-code;也可以显式指定:"model_pref":"code" 或 "primary"。
六、实践要领与可选增强
1) 切块策略
- 结构优先:优先按标题 / 小节拆分;代码块整体保留。
- 长度权衡:模型上下文贵,块太大浪费、太小丢语义。一般 600–1500 字符 + 10–20% 重叠。
- 去噪:移除目录、水印、页眉页脚;保留有用表格(必要时抽取为 Markdown 表)。
2) 检索与重排
- 召回 Top-K 比“拿前 5 条”更保险;再用交叉编码器 重排成 Top-N(例如 40 → 8)。
- 多字段权重:标题 / 小节名可拼入 chunk 文本前部,微妙地提高相关性。
- 命名空间(namespace):按业务线或数据域隔离,避免串味。
3) 提示构造(Prompting)
- 明确 “不在上下文就别编”。
- 引用编号 + 来源,鼓励可审计回答。
- 控制拼接顺序(最相关在前)与总长度(别超过主模型上下文 70–80%)。
4) 多模型编排
- 路由规则:关键词(代码 / 数学 / 表格)、长度阈值、正则(是否包含代码围栏),都能做一层轻量路由。
- 级联:小模型先答;若包含“不确定 / 无法确定 / 和资料不符”关键短语,则二次升级到大模型。
- 成本控制:统计每问 token 花费与延迟,给路由器一个“代价上限”。
5) 观测与评测
- 日志:记录 query、召回的 chunk id、rerank 分数、最终 prompt 长度、用的是哪个模型。
- 离线评测:构造一批带标注的问答数据(问题、应命中片段、参考答案),评对齐率与 BLEU/ROUGE/F1。
- 在线 A/B:渐进启 / 停某些策略(如改 chunk_size 或是否启用重排)。
6) 安全与权限
- 只读挂载
./data:/app/data:ro,避免后端误写源文件。 - 鉴权:对外只暴露
rag-api,其余容器走内网;rag-api层做 API Key 或网关签名。 - 合规:把隐私字段在切块前做脱敏(可在
split_chunks前先跑正则替换)。
七、常见问题速修
- 索引建不起来 / 向量维度不对:本教程在首次嵌入时动态探测维度,再建 Qdrant collection,避免模型换了维度不匹配。
- 检索命中但答案“发散”:增大 Top-K、启用重排、缩短每块长度;提示里再强调“只用上下文”。
- 速度慢:把嵌入模型换小(如 bge-small 系列)、降低 Top-K、减少重排条数;生成模型可先上 7B/8B。
- 显存爆:缩短
--max-model-len、换更小模型、或单机只开一个 vLLM 服务。 - 中文效果差:优先中文 / 多语种嵌入模型(如 BGE-M3 / bge-small-zh / jina-zh),不要用只擅长英文的嵌入。
八、立刻做的三件事
- 把你的文档丢进
./data/,跑/ingest。 - 用
/chat对比:开 / 关重排、调top_k、换不同模型。 - 逐步加“路由 + 级联”,记录每次实验的延迟与命中率,形成你自己的 RAG“技战术”手册。

