前端转 AI · 第 02 篇|P1:FastAPI + Next.js 做出能演示的知识库

本文是系列第 2 篇。上一篇 P0:用 CLI 跑通 RAG 闭环 在命令行把 RAG 链路跑通了,也讲清了 chunk → embed → retrieve → generate 的每一步。
但 CLI 有个致命短板:没法演示。你总不能把终端戳到同事/老板面前说"看,它在跑"。
这一篇的目标就一个:给那条链路套上 Web 外壳,做一个最小可用、能现场演示的知识库——创建知识库 → 上传文档 → 流式对话,并且回答带引用。
路线总纲见 总纲

阅读约定⚠️ 易错点 都是真实踩过的坑,紧跟的 ✅ 解决方案 可直接照抄。


1. 这篇要做出什么

P1 的目标不是追求产品完整度,而是把 P0 的链路变成别人点开就能用的东西。具体就四件事:

  1. 创建知识库(一个隔离的文档空间)
  2. 上传文档(TXT / MD / PDF),自动切分、向量化、入库
  3. 对话提问,回答流式吐字,并且带引用(告诉你答案来自哪份文档的哪段)
  4. 看演示:打开网页,上传一份制度文档,问"年假有多少天",看到答案和引用

做出来之后,整体长这样:

  ┌────────────────────┐         /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()   # 模块级单例

⚠️ 易错点 5store = 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

⚠️ 易错点 7UploadFile 是异步的,必须用 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 头)StreamingResponsemedia_type 必须是 text/event-stream,否则浏览器不会当流处理。更坑的是,如果前面挂了 nginx,默认会缓冲 SSE,导致你等半天才一次性收到所有字。
解决方案media_type="text/event-stream" + 三个头 Cache-Control: no-cacheConnection: keep-aliveX-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_fieldschunk.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.pyallow_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.ymlapi 服务通过 environment 注入 CHROMA_PATH / UPLOAD_DIR / META_PATHweb 服务用 args 传入 NEXT_PUBLIC_API_BASE=""API_PROXY_TARGET=http://api:8000(compose 内服务名)。

⚠️ 易错点 22IndexManager() 是模块级单例,import app.main 的瞬间就按 settings.chroma_path 建了 PersistentClient。所以 CHROMA_PATH 这类环境变量必须在进程启动前就生效——好消息是 compose 的 environment.env 都满足这点(进程启动即加载),只要你别在代码跑起来之后再改环境变量。

⚠️ 易错点 23next.config.ts 用了 output: "standalone",构建产物是 server.jspublic/.next/static 必须 COPY 到 server.js 同级,否则页面能开、但静态资源和图片 404。Dockerfile 里那两行 COPY --from=builder /app/public ./publicCOPY --from=builder /app/.next/static ./.next/static 不能省。

5.3 演示脚本(验收用)

  1. 创建知识库「人事制度」
  2. 上传 samples/hello.md
  3. 对话提问:「年假有多少天?」
  4. 确认回答含「10 天」且下方有引用(来自 hello.md
  5. 删除该文档后再问,应无法再引用该内容(或明确说资料不足)
  6. 点「重建索引」,确认提示「已重建 1 个文档」

这六步走完,P1 就算验收通过了——你已经有了一个能现场演示、回答带引用的知识库。

⚠️ 易错点 24(删文档的「幽灵引用」):演示脚本第 5 步让你看到"删除文档后再问,新回答不再引用它"——这没错,但老对话里那条已经生成过的引用并不会消失。删完文档回到之前的聊天,你仍能看到那条引用的文件名和原文片段。原因有两点:① 引用在生成那一刻就被反范式拷贝进了消息对象——chat.pysnippet=c.snippet[:500] 把原文片段直接写死在 citation 中,ChatPanel 又把整段对话存在 React state,所以引用是"冻结的快照";② 删除接口只调用 index_manager.delete_document_nodes 把 Chroma 里该 document_id 的向量删掉,切断的是"未来的检索",动不了"已经生成过的历史"。更彻底地说:后端 store.py 只持久化了知识库和文档,聊天记录根本没落库(请求里的 history 是前端每次传进来的),所以"哪条消息引用了哪篇文档"服务端毫无索引,删除时也不可能去清洗历史。
解决方案(按成本从低到高):

  1. MVP 推荐——引用加「失效态」标灰:前端渲染 CitationList 时,调一次 GET /api/kbs/{kb_id}/documents 拿到"还存在的文档 id 集合",把 document_id 不在集合中的引用标灰并加注"源文档已删除"。改动只在 CitationList.tsx + 一个列表查询,零后端存储改动。
  2. 引用只展示不跳转:当前引用本就是纯展示(文件名 + 片段),没做"点开看源"的跳转,所以"幽灵引用"只是视觉残留,不影响检索正确性——演示场景完全可接受,只要你不对用户承诺"点引用能回看原文"。
  3. 进阶——软删除:删文档时不真删向量/文件,只给 DocumentRecorddeleted 标记(不调用 delete_document_nodes);再在 retrieve() 拿到 chunks 后,把 document_id 对应文档已 deleted 的 chunk 过滤掉。新检索不再命中,旧引用仍可展示溯源,兼顾一致性与审计。
  4. 彻底——删除即清洗历史:后端持久化聊天记录,并为每条 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 均可。

posted @ 2026-08-10 18:02  南珂丶一梦  阅读(11)  评论(0)    收藏  举报