综合实战:知识库问答智能体

把前面几章攒的能力组装成第一个完整项目:基于本地 Markdown/文本文档的知识库问答 Agent——离线资料先分块入库,用户提问时由 Agent 检索相关片段、依据片段作答并标注来源。整体链路:数据准备(分块)→ 嵌入入库(向量库)→ 检索工具(top-k + 来源)→ LangGraph 问答 Agent(检索增强 + 引用 + 流式)→ FastAPI 暴露。项目按文件拆分,便于替换任意一环。

目录与主链路

kb_agent/
├── data/          # 放你要喂的 .md / .txt
├── embed.py       # 文本 -> 向量
├── ingest.py      # 读文档 -> 分块 -> 向量化 -> 落库
├── retriever.py   # 向量检索,返回 top-k + 来源
├── agent.py       # LangGraph:检索工具 + 带引用回答
└── app.py         # FastAPI /chat,直接套第 28 章模板

embed.py:向量化(沿用第 13 章约定)

# embed.py
import os, zlib

def _debug(text: str, dim: int = 256) -> list:
    """离线演示用伪向量:无语义、仅验证流程,正式环境勿用。"""
    v = [0.0] * dim
    for i in range(len(text) - 1):
        v[zlib.crc32(text[i:i + 2].encode("utf-8")) % dim] += 1.0
    norm = sum(x * x for x in v) ** 0.5 or 1.0
    return [x / norm for x in v]

def get_embedding(text: str) -> list:
    mode = os.environ.get("EMBED_MODE", "api")     # api / debug
    if mode == "debug":
        return _debug(text)
    from openai import OpenAI                      # OpenAI 兼容 embedding 接口
    client = OpenAI(api_key=os.environ.get("EMBED_API_KEY", os.environ.get("LLM_API_KEY")),
                    base_url=os.environ.get("EMBED_BASE_URL", os.environ.get("LLM_BASE_URL")))
    return client.embeddings.create(
        model=os.environ.get("EMBED_MODEL", "text-embedding-3-small"),
        input=text).data[0].embedding

注意:deepseek 等纯对话服务商不提供 embedding,演示用 EMBED_MODE=debug,正式接入通义/OpenAI/Ollama 的 embedding 即可,代码不变。

ingest.py:md/txt 分块并入库

# ingest.py —— 用法:EMBED_MODE=debug python ingest.py
import glob, json, os, pathlib
from embed import get_embedding

def chunk_md(text: str, max_len: int = 300, overlap: int = 30) -> list:
    """极简分块:先按 '#' 标题拆,再把大段按字符窗口切并留重叠。"""
    parts, buf = [], ""
    for line in text.splitlines():
        buf += line + "\n"
        if line.startswith("#") and buf.strip() and len(buf) > max_len // 2:
            parts.append(buf); buf = ""
    parts.append(buf)
    chunks = []
    for p in parts:
        p = p.strip()
        while len(p) > max_len:
            chunks.append(p[:max_len]); p = p[max_len - overlap:]
        if p:
            chunks.append(p)
    return chunks

store_path = "kb_store.json"

def ingest(doc_dir: str = "data") -> None:
    store = json.load(open(store_path, encoding="utf-8")) if os.path.exists(store_path) else []
    have = {s["id"] for s in store}
    for fp in glob.glob(os.path.join(doc_dir, "*.md")) + glob.glob(os.path.join(doc_dir, "*.txt")):
        text = pathlib.Path(fp).read_text(encoding="utf-8")
        for i, chunk in enumerate(chunk_md(text)):
            sid = f"{os.path.basename(fp)}#{i}"
            if sid in have:
                continue
            store.append({"id": sid, "source": os.path.basename(fp),
                          "text": chunk, "vec": get_embedding(chunk)})
    json.dump(store, open(store_path, "w", encoding="utf-8"), ensure_ascii=False)
    print(f"入库完成,共 {len(store)} 个片段")

分块是 RAG 质量的第一道关卡:生产建议用 RecursiveCharacterTextSplitter 之类按语义切分,块长 300~800 字、保留重叠,太长召回了"半篇文章"、太短信息割裂。

retriever.py:检索工具(top-k + 来源)

# retriever.py
import json
from embed import get_embedding

def _load():
    return json.load(open("kb_store.json", encoding="utf-8"))

def _cos(a, b):
    return (sum(x * y for x, y in zip(a, b))
            / ((sum(x*x for x in a) ** 0.5 or 1.0) * (sum(y*y for y in b) ** 0.5 or 1.0)))

def retrieve(query: str, top_k: int = 3) -> list:
    qv = get_embedding(query)
    hits = sorted(((_cos(qv, s["vec"]), s) for s in _load()),
                  key=lambda t: t[0], reverse=True)[:top_k]
    return [{"score": round(sc, 3), "source": s["source"], "text": s["text"]}
            for sc, s in hits]

agent.py:LangGraph 检索问答(带引用 + 流式)

第 18 章的结构直接复用:把检索包成工具,让模型按需调用;系统提示里强制"只依据检索结果回答并给出来源文件名":

# agent.py —— 结构同第 18 章,仅换工具与提示词
import os
from typing import Annotated, TypedDict
from langchain_core.messages import HumanMessage, SystemMessage
from langchain_core.tools import tool
from langchain_openai import ChatOpenAI
from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import add_messages
from langgraph.prebuilt import ToolNode
from retriever import retrieve

SYSTEM = ("你是知识库问答助手。只依据检索到的片段回答;引用格式【文件名】;"
          "检索不到就明说不知道,禁止编造。")

@tool
def search_kb(query: str) -> str:
    """从本地知识库检索与 query 相关的片段,返回带相似度与来源的文本。"""
    return "\n".join(f"[{h['score']}|{h['source']}] {h['text']}"
                     for h in retrieve(query, top_k=3))

model = ChatOpenAI(model=os.getenv("LLM_MODEL", "deepseek-chat"),
                   api_key=os.getenv("LLM_API_KEY"),
                   base_url=os.getenv("LLM_BASE_URL")).bind_tools([search_kb])

class State(TypedDict):
    messages: Annotated[list, add_messages]

def agent(state):
    return {"messages": [model.invoke(state["messages"])]}

def should_continue(state):
    last = state["messages"][-1]
    return "tools" if getattr(last, "tool_calls", None) else END

builder = StateGraph(State)
builder.add_node("agent", agent)
builder.add_node("tools", ToolNode([search_kb]))
builder.add_edge(START, "agent")
builder.add_conditional_edges("agent", should_continue, {"tools": "tools", END: END})
builder.add_edge("tools", "agent")
graph = builder.compile()          # 与第 28 章同样的 invoke / stream 用法

def ask(question: str) -> str:
    out = graph.invoke({"messages": [SystemMessage(SYSTEM), HumanMessage(question)]})
    return next((m.content for m in reversed(out["messages"]) if m.content), "")

app.py:暴露成服务

与第 28 章完全一致,仅把 import 换成 from agent import graph/chat 用 invoke、/chat/streamgraph.stream(stream_mode="messages") 逐 token 包成 SSE——重复样板不再贴出,完整代码按本章各文件拼装即可。

运行效果示例

# 1) 喂文档(把瑞林工具站帮助文档放进 data/)
EMBED_MODE=debug python ingest.py
# 输出:入库完成,共 23 个片段

# 2) 提问(模型需能联网;先本地验证检索)
python -c "import retriever; [print(h) for h in retriever.retrieve('怎么充值会员', 3)]"
# 输出:[{'score': 0.31, 'source': 'help.md', 'text': '充值会员:…'}, …](debug 得分仅供参考)

# 3) 问答
python -c "from agent import ask; print(ask('会员能退款吗?'))"
# 输出(示意):按知识库说明,会员开通后不支持退款(见【help.md】)。

小结:知识库问答智能体 = 分块(ingest) + 向量入库 + top-k 检索工具 + LangGraph"检索-作答-引用"循环 + FastAPI 出口;每层独立成文件,换 embedding、换向量库(chromadb/FAISS)、换文档源都只动对应文件——这也是综合项目应有的拆分方式。

笔记加载中…