前端转 AI · 第 02 篇|P1:FastAPI + Next.js 做出能演示的知识库
本文是系列第 2 篇。上一篇 P0:用 CLI 跑通 RAG 闭环 在命令行把 RAG 链路跑通了,也讲清了 chunk → embed → retrieve → generate 的每一步。
但 CLI 有个致命短板:没法演示。你总不能把终端戳到同事/老板面前说"看,它在跑"。
这一篇的目标就一个:给那条链路套上 Web 外壳,做一个最小可用、能现场演示的知识库——创建知识库 → 上传文档 → 流式对话,并且回答带引用。
路线总纲见 总纲。
阅读约定:
⚠️ 易错点都是真实踩过的坑,紧跟的✅ 解决方案可直接照抄。
1. 这篇要做出什么
P1 的目标不是追求产品完整度,而是把 P0 的链路变成别人点开就能用的东西。具体就四件事:
- 能创建知识库(一个隔离的文档空间)
- 能上传文档(TXT / MD / PDF),自动切分、向量化、入库
- 能对话提问,回答流式吐字,并且带引用(告诉你答案来自哪份文档的哪段)
- 能看演示:打开网页,上传一份制度文档,问"年假有多少天",看到答案和引用
做出来之后,整体长这样:
┌────────────────────┐ /api/* ┌──────────────────────┐
│ Browser (Next.js) │ ───────────────────────▶│ FastAPI │
│ 3000 │ ◀── SSE 流式事件 ───────│ :8000 │
└────────────────────┘ └──────────┬───────────┘
│
┌──────────────────────────┼──────────────────┐
▼ ▼ ▼
┌──────────────┐ ┌─────────────┐ ┌───────────────┐
│ meta.json │ │ Chroma │ │ OpenAI 兼容 │
│ (KB/文档元数据)│ │ (向量索引) │ │ LLM + Embedding│
└──────────────┘ └─────────────┘ └───────────────┘
注意一个边界:前端不直接连 LLM,所有智能都收敛到后端 /api/chat/stream,前端只负责把事件渲染出来。这样后面换模型、换向量库,前端一行都不用动。
对应的代码结构(P1 结束时的样子):
backend/
app/
main.py # FastAPI 入口:注册路由 + CORS
config.py # 配置(pydantic-settings,读 .env)
api/
deps.py # 依赖注入(拿 MetaStore 单例)
routes_kb.py # 知识库:列表 / 创建 / 获取
routes_docs.py # 文档:列表 / 上传 / 删除 / 重建索引
routes_chat.py # 对话:非流式 + SSE 流式
routes_health.py # /health
models/
domain.py # 领域模型:KnowledgeBase / DocumentRecord
schemas.py # API 层模型(请求/响应)
services/
chunking.py # 纯函数:文本切分
store.py # JSON 元数据存储(MetaStore)
index_manager.py # Chroma + Embedding 管理(按 kb 分 collection)
ingest.py # 入库管线
retrieve.py # 检索
chat.py # 生成(含 SSE 流式)
prompts/rag.py # 系统提示词 + 用户提示词拼装
scripts/ # P0 遗留的 CLI
tests/ # pytest(不依赖网络的单测 + mock)
.env / .env.example
frontend/
src/
app/
page.tsx # 知识库列表 + 创建
kb/[id]/page.tsx # 文档管理(上传 / 删除 / 重建索引)
kb/[id]/chat/page.tsx # 对话页
components/
ChatPanel.tsx # 对话交互 + 流式渲染
CitationList.tsx # 引用展示
DocList.tsx # 文档表格
FileUploader.tsx # 文件上传
lib/
api.ts # 后端接口封装(含 SSE 解析)
types.ts # 前端类型
next.config.ts # /api/* rewrite 到后端
package.json # Next.js 15 + React 19
docker-compose.yml # api + web 一起编排
前面 5 章带你把这套东西从零搭起来(其实大部分已经有,关键是讲清为什么这么写、哪里会塌)。第 6 章做选型复盘。
2. 后端:把 CLI 升级成 HTTP 服务
P0 的 scripts/ 已经把 chunking / ingest / retrieve / chat 写成了纯函数,这一章只是把它们挂到 FastAPI 上,并补上"资源"概念(知识库、文档)和"流式"。
2.1 入口与路由拆分
main.py 很薄,只做三件事:建 app、配 CORS、把各路由挂上。
# backend/app/main.py
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.api.routes_chat import router as chat_router
from app.api.routes_docs import reindex_router, router as docs_router
from app.api.routes_health import router as health_router
from app.api.routes_kb import router as kb_router
from app.config import settings
app = FastAPI(title=settings.app_name)
app.add_middleware(
CORSMiddleware,
allow_origins=[
"http://localhost:3000",
"http://127.0.0.1:3000",
"http://192.168.31.90:3000", # 局域网 IP 也要进白名单(见 4.2)
],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(health_router)
app.include_router(kb_router)
app.include_router(docs_router)
app.include_router(reindex_router)
app.include_router(chat_router)
⚠️ 易错点 1(CORS 第一坑):
allow_credentials=True时,allow_origins不能用"*"。浏览器规则是"带凭证的跨域请求,Origin 必须精确到某一个源",通配符"*"会被直接拒绝,控制台报The value of the 'Access-Control-Allow-Origin' header must not be '*' when the request's credentials mode is 'include'。
✅ 解决方案:老老实实把前端实际访问源列进allow_origins。开发用localhost/127.0.0.1:3000,局域网演示就把那台机器的IP:3000也加进去(见 4.2)。
⚠️ 易错点 2:路由写好了,
main.py却忘了include_router,或者prefix拼错——前端一调就是 404,你还在纠结前端代码。
✅ 解决方案:路由文件的prefix是约定好的(/api/kbs、/api/chats之类),main.py统一include_router。本地起服务后,先拿 curl 把每个接口打一遍,确认 200 再接前端,比盲联调省一小时。
2.2 数据模型:从「全局变量」到「API 资源」
P0 的 CLI 里,知识库就是个字符串参数。到了 API,必须有"资源"概念。领域模型放在 models/domain.py:
# backend/app/models/domain.py
from datetime import datetime, timezone
from enum import Enum
from uuid import uuid4
from pydantic import BaseModel, Field
def utcnow() -> datetime:
return datetime.now(timezone.utc)
def new_id() -> str:
return uuid4().hex
class DocStatus(str, Enum):
pending = "pending"
ready = "ready"
failed = "failed"
class KnowledgeBase(BaseModel):
id: str = Field(default_factory=new_id)
name: str
description: str = ""
created_at: datetime = Field(default_factory=utcnow)
class DocumentRecord(BaseModel):
id: str = Field(default_factory=new_id)
kb_id: str
filename: str
status: DocStatus = DocStatus.pending
error: str | None = None
created_at: datetime = Field(default_factory=utcnow)
API 层再包一层 schemas.py,不要直接把 domain 当响应模型——domain 可能以后有内部字段,混用会泄露或耦合。
# backend/app/models/schemas.py(节选)
class KBResponse(KnowledgeBase):
pass
class DocumentResponse(DocumentRecord):
pass
class ChatRequest(BaseModel):
kb_id: str
message: str = Field(min_length=1)
history: list[dict[str, str]] = Field(default_factory=list)
⚠️ 易错点 3:有人图省事,直接
response_model=KnowledgeBase。短期没问题,但一旦 domain 加了不该暴露的字段(比如内部embedding_ref),就被一起序列化出去了。
✅ 解决方案:API 层模型(KBResponse/DocumentResponse)继承 domain,如果将来要裁剪/改名,只改 schemas 不动 domain。这里虽然只是pass,但这层隔离的习惯要现在养成。
知识库的接口很简单——列表、创建、获取:
# backend/app/api/routes_kb.py(节选)
@router.get("", response_model=list[KBResponse])
def list_kbs(meta: MetaStore = Depends(get_store)) -> list[KBResponse]:
return [KBResponse.model_validate(kb.model_dump()) for kb in meta.list_kbs()]
@router.post("", response_model=KBResponse)
def create_kb(body: CreateKBRequest, meta: MetaStore = Depends(get_store)) -> KBResponse:
kb = meta.create_kb(name=body.name, description=body.description)
return KBResponse.model_validate(kb.model_dump())
@router.get("/{kb_id}", response_model=KBResponse)
def get_kb(kb_id: str, meta: MetaStore = Depends(get_store)) -> KBResponse:
kb = meta.get_kb(kb_id)
if kb is None:
raise HTTPException(status_code=404, detail="knowledge base not found")
return KBResponse.model_validate(kb.model_dump())
⚠️ 易错点 4:删/查资源时,忘了先
get_kb判空,直接拿kb_id去查文档,结果 KeyError 抛 500。
✅ 解决方案:所有带{kb_id}/{doc_id}的接口,第一步就get_kb判空,空了raise HTTPException(404)。前端拿到的是干净的 404 + 提示,而不是一堆红色堆栈。
2.3 元数据存储:JSON 文件 + 线程锁
P1 不想引入 Postgres(那是 P3 的事),元数据先用 JSON 文件顶着。关键是 MetaStore 这个类:
# backend/app/services/store.py(节选)
import json, threading
from pathlib import Path
from app.config import settings
from app.models.domain import DocumentRecord, KnowledgeBase
class MetaStore:
def __init__(self, path: Path | None = None) -> None:
self._path = path or settings.meta_path
self._lock = threading.Lock()
self._ensure_file()
def create_kb(self, name: str, description: str = "") -> KnowledgeBase:
kb = KnowledgeBase(name=name, description=description)
with self._lock:
data = self._read()
data["knowledge_bases"].append(kb.model_dump(mode="json"))
self._write(data)
return kb
# list_kbs / get_kb / add_document / update_document / delete_document ...
# 全部用 `with self._lock:` 包住「读-改-写」
store = MetaStore() # 模块级单例
⚠️ 易错点 5:
store = MetaStore()是模块级单例,import 这行代码的瞬间就会去data/meta.json创建文件。写测试时如果直接 import 业务模块,测试会去读写真实的meta.json,污染数据。
✅ 解决方案:MetaStore构造函数留了path参数。测试里一律MetaStore(path=tmp_path / "meta.json")注入临时路径,业务代码才用全局store。也是因为留了这个口子,测试才干净。
⚠️ 易错点 6:看到
threading.Lock()就以为"并发安全了"。线程锁只在单进程内有效。一旦 uvicorn 开多个 worker(uvicorn --workers N),每个进程各有一把锁,进程间互不认识,JSON 照样被写坏。
✅ 解决方案:P1 明确接受"单进程假设"——uvicorn不挂--workers,或者只跑一个容器实例。这不是偷懒,是诚实。P3 迁 Postgres 时,并发安全和多租户一并解决。
2.4 上传接口:落盘 + 入库 + 状态机
文档上传是 P1 里最容易出状况的接口,因为同时涉及文件 IO、格式校验、入库失败处理。
# backend/app/api/routes_docs.py(节选)
ALLOWED_SUFFIXES = {".txt", ".md", ".markdown", ".pdf"}
@router.post("", response_model=DocumentResponse)
async def upload_document(
kb_id: str,
file: UploadFile = File(...),
meta: MetaStore = Depends(get_store),
) -> DocumentResponse:
if meta.get_kb(kb_id) is None:
raise HTTPException(status_code=404, detail="knowledge base not found")
filename = file.filename or "upload.bin"
suffix = Path(filename).suffix.lower()
if suffix not in ALLOWED_SUFFIXES:
raise HTTPException(status_code=400, detail=f"unsupported file type: {suffix}")
doc = DocumentRecord(kb_id=kb_id, filename=filename)
dest_dir = settings.upload_dir / kb_id
dest_dir.mkdir(parents=True, exist_ok=True)
dest = dest_dir / f"{doc.id}_{filename}"
content = await file.read() # 关键:必须 await
dest.write_bytes(content)
meta.add_document(doc)
result = ingest_document(dest, doc, meta=meta)
return DocumentResponse.model_validate(result.model_dump())
ingest_document 内部把"空文本"和"入库失败"都兜住了,并写回状态:
# backend/app/services/ingest.py(节选)
def ingest_document(file_path, doc, *, meta=None, manager=None, cfg=None):
meta = meta or store
manager = manager or index_manager
cfg = cfg or settings
try:
text = read_file_text(file_path)
if not text.strip():
# 扫描件 PDF / 空文档会提取出空文本,不拦截会「入库成功但永远检索不到」
raise ValueError("no text extracted from file; it may be a scanned PDF needing OCR")
node_dicts = build_nodes_from_text(
text, kb_id=doc.kb_id, document_id=doc.id, filename=doc.filename,
chunk_size=cfg.chunk_size, chunk_overlap=cfg.chunk_overlap,
)
manager.delete_document_nodes(doc.kb_id, doc.id) # 先删旧的,保证幂等
index = manager.get_or_create_index(doc.kb_id)
nodes = [TextNode(text=n["text"], id_=n["id"], metadata=n["metadata"]) for n in node_dicts]
if nodes:
index.insert_nodes(nodes)
doc.status = DocStatus.ready
doc.error = None
except Exception as exc:
doc.status = DocStatus.failed
doc.error = str(exc)
meta.update_document(doc)
return doc
⚠️ 易错点 7:
UploadFile是异步的,必须用await file.read()拿到 bytes 再写盘。有人图省事直接读file.file,在 async 路由下经常读不完整或阻塞事件循环。
✅ 解决方案:如上面代码,content = await file.read()然后dest.write_bytes(content)。简单、可靠、不会阻塞。
⚠️ 易错点 8:不限制文件类型,有人传个
.exe、.zip也进了库,read_file_text直接抛不支持的错,前端只看到一堆失败。
✅ 解决方案:ALLOWED_SUFFIXES白名单,非法后缀直接400。MVP 阶段支持 TXT / MD / PDF 足够。
⚠️ 易错点 9(很隐蔽):扫描件 PDF 或空文档,文本提取出来是空串。不拦截的话,
insert_nodes([])啥也没插,接口返回"成功",但这份文档永远检索不到——你问什么它都答不上来,还以为是模型的问题。
✅ 解决方案:if not text.strip(): raise ValueError(...)。入库前先校验文本非空,失败时把status置为failed并写error,前端文档列表里能直接看到"(需要 OCR)"之类的提示。
⚠️ 易错点 10:文档状态一定要用枚举
pending / ready / failed,并且入库失败也要写回error。否则前端永远显示"处理中",用户不知道是挂了还是慢。
✅ 解决方案:DocumentRecord.status+error字段,前端DocList直接渲染状态,失败时把error一并显示出来。这是"能演示"和"能排错"的分水岭。
2.5 检索 + 流式对话:SSE
对话是 P1 的精华。非流式版本很简单,流式版本(演示用)才是重点。
# backend/app/api/routes_chat.py(节选)
@router.post("/stream")
def chat_stream(body: ChatRequest) -> StreamingResponse:
def event_generator() -> Iterator[str]:
try:
for event in chat_service.stream_answer(body.kb_id, body.message, body.history):
yield f"data: {json.dumps(event, ensure_ascii=False)}\n\n"
except Exception as exc:
yield f"data: {json.dumps({'type': 'error', 'content': str(exc)}, ensure_ascii=False)}\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",
"Connection": "keep-alive",
"X-Accel-Buffering": "no", # 关键:让前置 nginx 别缓冲
},
)
stream_answer 产出四种事件:citation(先把检索到的引用推给前端)/ reasoning(思考过程)/ token(正文 token)/ done。
# backend/app/services/chat.py(节选)
def build_llm(cfg: Settings | None = None) -> OpenAILike:
cfg = cfg or settings
# OpenAILike 跳过 OpenAI 的模型名 / 上下文窗口枚举(DashScope、qwen 等都能直接填)
return OpenAILike(
model=cfg.llm_model,
api_key=cfg.openai_api_key or "EMPTY",
api_base=cfg.openai_api_base,
temperature=0.1,
is_chat_model=True,
context_window=cfg.llm_context_window,
)
def stream_answer(kb_id, message, history=None, *, manager=None, cfg=None, llm=None):
cfg = cfg or settings
manager = manager or index_manager
llm = llm or build_llm(cfg)
chunks = retrieve(kb_id, message, manager=manager, cfg=cfg)
for c in chunks:
yield {"type": "citation", "citation": { # 先发引用
"document_id": c.document_id, "filename": c.filename,
"snippet": c.snippet[:500], "score": c.score,
}}
contexts = [c.snippet for c in chunks]
user_prompt = build_user_prompt(message, contexts)
# ...(拼接 history 略)...
stream = llm.stream_complete(f"{SYSTEM_PROMPT}\n\n{user_prompt}")
prev_text = ""
for chunk in stream:
reasoning, content, text = _stream_piece(chunk, prev_text) # 见下
if reasoning:
yield {"type": "reasoning", "content": reasoning}
if content:
yield {"type": "token", "content": content}
yield {"type": "done"}
⚠️ 易错点 11:用 OpenAI 官方客户端
OpenAI接 DashScope / 通义 / 本地 Ollama 网关时,模型名(如qwen3.7-max)不在 OpenAI 的枚举里,直接ValidationError: model not supported。
✅ 解决方案:用llama_index.llms.openai_like.OpenAILike替代OpenAI。它不校验模型名、不强制上下文窗口枚举,任何 OpenAI 兼容的 chat 模型都能直接填LLM_MODEL。embedding 同理用OpenAIEmbedding(model_name=...)绕过枚举。
⚠️ 易错点 12(SSE 头):
StreamingResponse的media_type必须是text/event-stream,否则浏览器不会当流处理。更坑的是,如果前面挂了 nginx,默认会缓冲 SSE,导致你等半天才一次性收到所有字。
✅ 解决方案:media_type="text/event-stream"+ 三个头Cache-Control: no-cache、Connection: keep-alive、X-Accel-Buffering: no。最后那个头是专门让 nginx 关缓冲的。本地演示没 nginx 时不写也行,但写上不亏。
⚠️ 易错点 13:很多人前端用
EventSource收 SSE,结果一调对话接口就报"POST 不支持"。EventSource只支持 GET,且不能带请求体(body)。我们要把kb_id/message/history用 POST 传过去,用不了它。
✅ 解决方案:用fetch+response.body.getReader()手动读流,自己按\n\n切分、按data:前缀解析。前端lib/api.ts就是这么干的(见第 3 章)。这是 SSE + 带 body 的标准解法。
⚠️ 易错点 14(思考模型专属):
qwen3这类"思考模型",推理过程不走标准的delta.content,而是放在delta.reasoning(或reasoning_content)里。LlamaIndex 默认只读chunk.delta,结果就是:你以为在流式,其实模型在后台默默想了 40 秒,然后一次性把答案全吐出来——演示当场翻车。
✅ 解决方案:chat.py里用_raw_delta_fields从chunk.raw里把reasoning/reasoning_content抠出来,通过_stream_piece一并 yield 成reasoning事件,前端用可折叠的"思考过程"展示。代码已经写好,演示时把"思考过程"展开,反而比普通模型更有说服力。
⚠️ 易错点 15(切换模型必踩):切换 embedding 模型后,向量维度会变(比如从 1024 维换到 2560 维)。旧的 Chroma collection 还是老维度,新文档插进去直接报错
dimension mismatch,老文档也检索不出。
✅ 解决方案:routes_docs.py里留了/api/kbs/{kb_id}/reindex接口——它reset_kb(删掉旧 collection 再重建)然后重新入库所有文档。换模型后调一次即可。仓库里也有scripts/reindex_cli.py可以做同样的事。
3. 前端:Next.js 把接口用起来
后端有接口了,前端就是把它们串成页面。技术栈 Next.js 15(App Router)+ React 19,三页:列表、文档管理、对话。
3.1 路由与页面
// frontend/src/app/page.tsx(节选,"use client")
"use client";
import { createKb, listKbs } from "@/lib/api";
export default function HomePage() {
const [kbs, setKbs] = useState<KnowledgeBase[]>([]);
const [name, setName] = useState("");
async function refresh() { setKbs(await listKbs()); }
useEffect(() => { void refresh().catch((e) => setError(String(e))); }, []);
async function onCreate() {
if (!name.trim()) return;
await createKb(name.trim());
setName("");
await refresh();
}
// ...渲染输入框 + 知识库列表(每项链接到 /kb/{id} 和 /kb/{id}/chat)
}
文档管理页和对话页用动态路由 [id]:
// frontend/src/app/kb/[id]/page.tsx(节选)
"use client";
import { useParams } from "next/navigation";
import { DocList } from "@/components/DocList";
import { FileUploader } from "@/components/FileUploader";
export default function KbPage() {
const params = useParams<{ id: string }>();
const kbId = params.id;
// listDocuments(kbId) → 渲染 DocList + FileUploader + 重建索引按钮
}
⚠️ 易错点 16:Next.js 15 的 App Router 里,
useParams()在客户端组件中返回的是同步对象,可以直接const kbId = params.id。但前提是组件顶层有"use client"——因为useState/useEffect这些 hook 只能在客户端组件用。
✅ 解决方案:所有用到状态/副作用的页面和组件,第一行就加"use client"。漏了会报 "You're importing a component that needs useState... This will work only in a Client Component"。
3.2 接口封装与 SSE 解析
lib/api.ts 是所有后端调用的入口。最关键的是 chatStream——手动消费 SSE:
// frontend/src/lib/api.ts(节选)
const API_BASE = process.env.NEXT_PUBLIC_API_BASE || "http://127.0.0.1:8000";
export async function chatStream(
kbId: string, message: string,
onEvent: (event: ChatEvent) => void,
history: { role: string; content: string }[] = [],
) {
const res = await fetch(`${API_BASE}/api/chat/stream`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ kb_id: kbId, message, history }),
});
if (!res.ok || !res.body) throw new Error(await res.text());
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const parts = buffer.split("\n\n"); // SSE 以空行分隔事件
buffer = parts.pop() || ""; // 最后一段可能不完整,留到下次
for (const part of parts) {
const line = part.trim();
if (!line.startsWith("data:")) continue;
const payload = line.slice(5).trim();
if (!payload) continue;
onEvent(JSON.parse(payload) as ChatEvent);
}
}
}
⚠️ 易错点 17(NEXT_PUBLIC_ 前缀):只有带
NEXT_PUBLIC_前缀的环境变量,才会被打包进浏览器代码。API_BASE想在前端用,必须叫NEXT_PUBLIC_API_BASE,少一个下划线就拿不到,变成undefined然后所有请求打到undefined/api/...。
✅ 解决方案:命名严格带前缀。开发时其实可以不设它,代码里|| "http://127.0.0.1:8000"给了默认值(而且我们用 next rewrite 同源,根本不需要这个变量,见 4.1)。
⚠️ 易错点 18(SSE 分片截断):网络分包可能把一条
data: {...}\n\n从中间切断,reader.read()一次只拿到半条。如果不做缓冲,下次JSON.parse半截字符串直接抛错,对话中断。
✅ 解决方案:用buffer累积,每次按\n\n切分后,把最后一段(可能不完整)留回 buffer,等下个 chunk 拼上再解析。上面代码buffer = parts.pop() || ""就是干这个的。
3.3 流式渲染:引用、思考、token
ChatPanel.tsx 把事件贴到一条 assistant 消息上:
// frontend/src/components/ChatPanel.tsx(节选)
async function send() {
// ...push user + 空的 assistant 消息...
let answer = "", reasoning = "";
const citations: Citation[] = [];
try {
await chatStream(kbId, text, (event) => {
if (event.type === "citation") {
citations.push(event.citation);
patchAssistant({ citations: [...citations] });
} else if (event.type === "reasoning") {
reasoning += event.content;
patchAssistant({ reasoning, citations: [...citations] });
} else if (event.type === "token") {
answer += event.content;
patchAssistant({ content: answer, citations: [...citations] });
} else if (event.type === "done") {
patchAssistant({ content: answer, reasoning: reasoning || undefined, citations: [...citations] });
}
}, history);
} finally { setLoading(false); }
}
patchAssistant 是一个 helper:找到最后一条 assistant 消息,用 {...last, ...partial} 合并后 setMessages 触发重渲染。
⚠️ 易错点 19:直接
citations.push(...)后再setMessages,数组引用没变,React 不重渲染——你明明 push 了引用,界面却不动。
✅ 解决方案:每次更新都传新数组([...citations]),或者用patchAssistant这种"取旧数组、拼新对象、整体 setState"的模式。React 靠引用变化决定要不要重渲染,这是铁律。
4. 前后端联调:最容易被卡的两件事
代码都对了,联调时还有两道坎,几乎人人都中。
4.1 同源还是跨域:优先「同源 /api」
开发时最省心的做法:前端不设 NEXT_PUBLIC_API_BASE,让请求走同源的 /api/*,再由 Next 的 rewrite 转到后端。
// frontend/next.config.ts
const apiProxyTarget = process.env.API_PROXY_TARGET || "http://127.0.0.1:8000";
const nextConfig: NextConfig = {
output: "standalone",
async rewrites() {
return [{ source: "/api/:path*", destination: `${apiProxyTarget}/api/:path*` }];
},
};
这样浏览器眼里只有 http://127.0.0.1:3000,同源,无 CORS 烦恼;/api/chat/stream 被 Next 透明转发到 8000。
⚠️ 易错点 20(Private Network Access):把
NEXT_PUBLIC_API_BASE直接写成容器内网 IP(比如http://192.168.x.x:8000),又用公网域名访问前端页面——浏览器会报blocked by Private Network Access(公网页不能直连内网 IP)。这个坑在服务器部署时尤其常见。
✅ 解决方案:别把内网 IP 写进前端。开发走同源/api(rewrite 转发);生产用前置 nginx 把/指前端、/api/指后端,前端NEXT_PUBLIC_API_BASE保持空(同源)。docker-compose里把NEXT_PUBLIC_API_BASE设成""、把API_PROXY_TARGET设成http://api:8000(compose 服务名)就是这个用意。
4.2 CORS 白名单要包含「实际访问源」
前面 main.py 的 allow_origins 写了 localhost / 127.0.0.1:3000。但如果你用局域网 IP 访问前端(比如 http://192.168.31.90:3000),浏览器发的 Origin 就是 http://192.168.31.90:3000,不在白名单里,预检 OPTIONS 直接失败。
⚠️ 易错点 21:跨域请求浏览器先发一个
OPTIONS预检,CORS 白名单没覆盖实际 Origin,预检失败,真正的请求根本发不出去,控制台只看到CORS policy: No 'Access-Control-Allow-Origin'。
✅ 解决方案:allow_origins把你实际用来访问前端的那个源加进去(本地localhost/127.0.0.1,局域网演示加IP:3000)。不确定的话,临时把allow_origins=["*"]验证一下(注意这要求allow_credentials=False),定位到是 CORS 问题后再收窄。
5. 跑起来 + 5 分钟演示脚本
5.1 本地开发
# 后端
cd backend
cp .env.example .env # 填 OPENAI_API_KEY;chat/embedding 厂商不同就改 OPENAI_API_BASE / LLM_MODEL / EMBEDDING_MODEL
uv sync
uv run uvicorn app.main:app --reload --port 8000
# 前端(另开一个终端)
cd frontend
npm install --registry=https://registry.npmmirror.com
npm run dev
打开 http://127.0.0.1:3000,前端默认同源 /api,无需任何额外配置。
5.2 一键 Docker
cd backend
cp .env.example .env # 填好密钥
cd ..
docker compose up --build
- Web:
http://127.0.0.1:3000 - API:
http://127.0.0.1:8000/health
docker-compose.yml 里 api 服务通过 environment 注入 CHROMA_PATH / UPLOAD_DIR / META_PATH,web 服务用 args 传入 NEXT_PUBLIC_API_BASE="" 和 API_PROXY_TARGET=http://api:8000(compose 内服务名)。
⚠️ 易错点 22:
IndexManager()是模块级单例,importapp.main的瞬间就按settings.chroma_path建了PersistentClient。所以CHROMA_PATH这类环境变量必须在进程启动前就生效——好消息是 compose 的environment和.env都满足这点(进程启动即加载),只要你别在代码跑起来之后再改环境变量。⚠️ 易错点 23:
next.config.ts用了output: "standalone",构建产物是server.js。public/和.next/static必须 COPY 到server.js同级,否则页面能开、但静态资源和图片 404。Dockerfile里那两行COPY --from=builder /app/public ./public和COPY --from=builder /app/.next/static ./.next/static不能省。
5.3 演示脚本(验收用)
- 创建知识库「人事制度」
- 上传
samples/hello.md - 对话提问:「年假有多少天?」
- 确认回答含「10 天」且下方有引用(来自
hello.md) - 删除该文档后再问,应无法再引用该内容(或明确说资料不足)
- 点「重建索引」,确认提示「已重建 1 个文档」
这六步走完,P1 就算验收通过了——你已经有了一个能现场演示、回答带引用的知识库。
⚠️ 易错点 24(删文档的「幽灵引用」):演示脚本第 5 步让你看到"删除文档后再问,新回答不再引用它"——这没错,但老对话里那条已经生成过的引用并不会消失。删完文档回到之前的聊天,你仍能看到那条引用的文件名和原文片段。原因有两点:① 引用在生成那一刻就被反范式拷贝进了消息对象——
chat.py里snippet=c.snippet[:500]把原文片段直接写死在citation中,ChatPanel又把整段对话存在 React state,所以引用是"冻结的快照";② 删除接口只调用index_manager.delete_document_nodes把 Chroma 里该document_id的向量删掉,切断的是"未来的检索",动不了"已经生成过的历史"。更彻底地说:后端store.py只持久化了知识库和文档,聊天记录根本没落库(请求里的history是前端每次传进来的),所以"哪条消息引用了哪篇文档"服务端毫无索引,删除时也不可能去清洗历史。
✅ 解决方案(按成本从低到高):
- MVP 推荐——引用加「失效态」标灰:前端渲染
CitationList时,调一次GET /api/kbs/{kb_id}/documents拿到"还存在的文档 id 集合",把document_id不在集合中的引用标灰并加注"源文档已删除"。改动只在CitationList.tsx+ 一个列表查询,零后端存储改动。- 引用只展示不跳转:当前引用本就是纯展示(文件名 + 片段),没做"点开看源"的跳转,所以"幽灵引用"只是视觉残留,不影响检索正确性——演示场景完全可接受,只要你不对用户承诺"点引用能回看原文"。
- 进阶——软删除:删文档时不真删向量/文件,只给
DocumentRecord打deleted标记(不调用delete_document_nodes);再在retrieve()拿到 chunks 后,把document_id对应文档已deleted的 chunk 过滤掉。新检索不再命中,旧引用仍可展示溯源,兼顾一致性与审计。- 彻底——删除即清洗历史:后端持久化聊天记录,并为每条 assistant 消息索引其
citations[].document_id;删文档时扫一遍相关会话,把命中引用的消息标记stale。成本最高,只有"实时强一致"的产品才需要。
6. 选型复盘:为什么这么搭
P1 的几个关键决策,记在仓库 docs/learning/2026-08-p1-decisions.md,这里浓缩成四点,方便你面试/分享时讲清楚:
- 为什么 Chroma:本地
PersistentClient零运维,适合 MVP 演示;按kb_id分 collection,后续想换 Qdrant / pgvector 只改index_manager.py一处。 - 为什么 LlamaIndex:入库/检索管线开箱即用,对接 OpenAI 兼容的 embedding/LLM 成本极低;而业务 HTTP 接口仍用 FastAPI 自管,不绑死框架。
- 为什么 SSE:浏览器原生可读流、实现简单;事件类型
citation / token / done便于前端渐进渲染(思考过程还能单独成一类)。 - 元数据为什么用 JSON:P1 刻意不引入 Postgres,避免早期过度设计;P3 再迁移到 Postgres,让向量库与业务库分离。
一句话总结 P1:后端用 FastAPI 把 P0 的纯函数挂成资源化的 HTTP 接口,前端用 Next.js 把接口变成能上传、能对话、能看引用的页面,中间用 SSE 把"思考 + 正文 + 引用"流式推到浏览器。 真正难的不是写功能,而是上面那 20 多个坑——每一个都值得在面试里展开讲。
下一篇 P2 质量:混合检索、rerank 与可量化的 RAG 评测会聊:怎么把"按字符硬切"升级成"保留标题/段落结构的语义切分",用 Golden Set 做召回评测,以及当 chat 和 embedding 来自不同厂商时怎么拆分两个 base。
本文代码均来自仓库当前实现(FastAPI + Next.js 15 + LlamaIndex + Chroma),可直接对照
backend/与frontend/阅读。模型通过backend/.env配置,示例使用 DashScope 的qwen3.7-max+qwen3.7-text-embedding,换成任何 OpenAI 兼容的 chat/embedding 均可。
如果你觉得本文还可以,那就点击一下推荐,让更多人看到吧!
限于本人水平,如果文章和代码有表述不当之处,还请不吝赐教。

浙公网安备 33010602011771号