大家好,我是Java烘焙师。最近利用业余时间,完成了博客建站+RAG知识库的搭建,分享一下过程中遇到的选型问题、实现步骤。
搭建博客站点和RAG知识库的初衷,是因为日积月累写了几十篇技术文章,希望有一个独立的站点,并且能用自然语言问答、查找知识点。
下面是用到的技术栈:

  • 静态页面构建:docmd
  • 网页托管:github pages
  • RAG知识库:llamaIndex、coding plan包含的云端embedding向量模型、chroma本地向量库
  • LLM:coding plan包含的云端大语言模型
  • 知识库问答web页:gradio

效果

博客github pages地址:https://topcoding.github.io/arch-notes/
包含了所有的技术文章,后续除了在各大博客平台更新,也会维护github pages(时效性低一些,有空才会操作)。

本地RAG知识库web页
rag知识库

博客建站

选型

生成博客的工具有很多,比如:docmd、jekyll、hugo、hexo、MkDocs等。
最终选择了docmd,是因为想低成本构建,不用额外了解各种前端框架、或者安装额外的工具链,在零配置、或少量配置的情况下,快速构建出静态页面。
docmd让人眼前一亮的功能有:导航栏、全文搜索、mermaid文本绘图支持、站点地图、自动生成适合LLM阅读的文档、多语言支持等,能开箱即用。
至于其它方案,多少都有点门槛:jekyll虽然是gitHub pages原生支持,但它基于ruby工具链,安装搭建比较麻烦;hugo编译速度快,但主题用的是Go模板语法,想自定义样式就得学一套模板写法;hexo更偏前端工程化,选主题、改组件、配构建,多少都得懂点前端框架。

实现细节

  1. 全局安装docmd
npm install -g @docmd/core
  1. 启动本地开发服务器,并修改配置文件(可选)
# 这一步可以零配置,快速预览效果
docmd dev

# 长期项目,建议初始化配置文件、并做修改
docmd init
  1. 构建静态页面
docmd build

其它注意事项:

  • 本地目录、文件名,改为短线分隔的英文翻译,因为会出现在导航url中,更通用些
  • 批量下载markdown文档里的图片,并替换为本地相对路径。因为我是先在博客平台上发布,再转成本地markdown文件,所以需要这一步。
npx @wll8/md-img -i . -o output --imgdir ./assets/images
  1. 上传到github,通过github actions自动构建和部署
    如果本地构建输出了静态页面(site目录),就会有两份图片文件,如果直接上传github会占用git仓库空间。所以仅上传必要文件(排除掉site目录),依靠github actions来构建和部署站点。

RAG知识库

原理

RAG是检索增强生成(Retrieval Augmented Generation):预先把私有知识(这里是博客文章)切块、向量化存入向量库;提问时先用问题去向量库检索最相关的片段,再把检索结果、问题一起拼进prompt,交给大语言模型生成回答。之所以在大语言模型前,先过一道向量检索,是为了缩小查询范围,并且避免大模型产生幻觉、胡言乱语。
这里的向量化,是把文本映射到一个多维数字向量,比如[1.12, 0.98, 3.76, ...]。两个文本的语义越相近,则向量距离越近。
经过一番调研,发现有两个方向,一是低代码平台,二是用开源框架搭建。

低代码平台

用低代码平台的好处是可以几乎不写代码、快速搭建demo原型。
最终选择了dify,是目前较为流行的AI工作流平台,模板和生态丰富,可以在页面上拖拖拽拽,控制数据流向、节点操作。

dify实现细节

  1. 创建“知识库”模板应用
    “知识库”模板里已经预设了“用户输入” -> “知识检索” -> “大语言模型” -> “输出”的流程,只需要按提示修改其中的节点。

  2. 在知识库页面导入docmd生成的llms-full.txt文件,在“知识检索”节点选择该知识库。

  3. 在“大语言模型”节点选择模型、上下文
    部分模型有免费试用额度,上下文选择第2步经过向量查询的“知识检索”结果。

  4. 调试运行
    输入一个问句,会先从知识库检索相关内容,再一起作为prompt给到大语言模型,最终得到靠谱的回答。

  5. 发布上线
    可以选择“嵌入到网站中”,这样就能在已有网站里出现一个问答对话框了。

开源框架

用开源框架的好处是更加灵活、自主可控。llamaIndex用来做知识库,是专用工具。
之所以不用LangChain、LangGraph、AutoGen这类agent开发框架,是因为它们面向的是多步工具调用、自主规划的复杂场景,做知识库检索问答太重了,属于杀鸡用牛刀了,而llamaIndex开箱就带文档解析、向量库对接、检索器这些现成能力。

llamaIndex实现细节

把markdown文档向量化存入本地chroma,然后用自然语言提问,得到带来源引用的回答。

整体分离线构建索引(一次性)和在线问答(每次提问)两条线,共享本地Chroma向量库与云端向量模型、大语言模型。

离线建索引(一次性)
切分成多少个文档chunk,就会调多少次云端embedding模型,第一次构建会比较耗时。

  1. 设置云端API key、模型名、endpoint
def _make_embedding(model: str, api_key: str, api_base: str):
    """构造兼容 OpenAI SDK 的某coding plan的embeddin模型,兼容 LlamaIndex BaseEmbedding。
    """
    import time
    import openai
    from llama_index.core.embeddings import BaseEmbedding
    from openai import OpenAI

    class _Impl(BaseEmbedding):
        _client: Any = PrivateAttr(default=None)

        def __init__(self, model_name: str, api_key: str, api_base: str, **kwargs):
            super().__init__(model_name=model_name, **kwargs)
            self._client = OpenAI(api_key=api_key, base_url=api_base)

        def _create(self, input_data):
            return self._client.embeddings.create(model=self.model_name, input=input_data)

    return _Impl(model_name=model, api_key=api_key, api_base=api_base)

# 设置Settings全局变量,指定embedding模型的API key、模型名、endpoint
Settings.embed_model = _make_embedding(
    model=EMBED_MODEL,
    api_key=API_KEY,
    api_base=BASE_URL,
)
  1. 加载文档
    # SimpleDirectoryReader递归读取博客目录下的几十篇markdown文档
    reader = SimpleDirectoryReader(
        input_dir=BLOG_DATA_DIR,
        required_exts=[".md"],
        recursive=True,
        filename_as_id=True
    )
    documents = reader.load_data(show_progress=True)
  1. 文档切分
    # 文档切分:经MarkdownNodeParser按标题层级切成几百个chunk
    parser = MarkdownNodeParser()
    nodes = parser.get_nodes_from_documents(documents)
  1. 向量化、向量结果保存至本地
    这一步会调云端的embedding模型API,不过从代码看不出来调用过程,是因为llamaIndex封装好了,会读取全局Settings变量,没有显式调用过程。
    向量结果存储至本地向量库chroma,作为后续查询知识库的索引,避免每次重建。
    # 向量化并写入chroma(本地持久化)
    db = chromadb.PersistentClient(path=CHROMA_PATH)
    if rebuild:
        try:
            db.delete_collection(COLLECTION_NAME)
            logger.info("已清空旧索引")
        except Exception:
            logger.error("清空索引失败")

    collection = db.get_or_create_collection(COLLECTION_NAME)
    vector_store = ChromaVectorStore(chroma_collection=collection)

    # 向量化,调用云端embedding模型API,逐个向量化(仅首次构建索引时会调云端)
    storage_context = StorageContext.from_defaults(vector_store=vector_store)
    VectorStoreIndex(nodes, storage_context=storage_context, show_progress=True)

在线问答(每次提问)
每次问答,会调1次云端embedding模型做query向量化、调1次本地chroma向量库检索top-k相近文档chunk、调1次云端LLM模型做最终回答。

  1. 加载本地向量库索引
    db = chromadb.PersistentClient(path=CHROMA_PATH)
    try:
        collection = db.get_collection(COLLECTION_NAME)
    except Exception:
        print("未找到向量库,请先构建索引")
        sys.exit(1)
    if collection.count() == 0:
        print("向量库为空,请先构建索引")
        sys.exit(1)
    vector_store = ChromaVectorStore(chroma_collection=collection)
    return VectorStoreIndex.from_vector_store(vector_store)
  1. query向量化,调用云端embedding模型获取query的向量结果,再查找本地向量库里匹配的内容;拼上文件名、标题名,得到检索结果
def format_source(meta: dict) -> str:
    """从节点的metadata组装来源信息:标题路径(文件名)。"""
    file_name = meta.get("file_name", "未知文件")
    # MarkdownNodeParser 把各级标题存为 header_path(形如 "/H1/H2/")
    header_str = meta.get("header_path", "").strip("/").replace("/", " / ")
    return f"{header_str}({file_name})" if header_str else file_name

def answer(index, question: str):
    """检索本地向量库里top-k匹配的内容,并生成答案。"""
    retriever = index.as_retriever(similarity_top_k=TOP_K)
    nodes = retriever.retrieve(question)

    if not nodes:
        return ("知识库中未找到相关内容。", "")

    # 拼接带编号的context,LLM根据此标注 [序号],与下方来源列表编号一致
    context_parts = []
    for i, node in enumerate(nodes, start=1):
        source = format_source(node.node.metadata)
        context_parts.append(f"【{i}】来源:{source}\n{node.node.text}")
    context = "\n\n".join(context_parts)
  1. 生成最终回答:知识库检索结果,拼上query,一起作为大语言模型的prompt提示词,调用云端的LLM模型
    # 拼接 prompt
    prompt = (
        "你是一个博客知识库助手。请仅根据下方「参考资料」回答用户问题。\n\n"
        "要求:\n"
        "1. 只使用参考资料中的信息,不要编造。\n"
        "2. 如果参考资料中没有相关内容,直接回答「知识库中未找到相关内容」。\n"
        "3. 引用信息时在句末标注 [序号],序号对应下方资料编号,例如 [1]、[2]。\n\n"
        "参考资料:\n"
        + context
        + "\n\n用户问题:"
        + question
        + "\n\n回答:"
    )
    answer_text = complete_answer(prompt).strip()

    sources_lines = []
    for i, node in enumerate(nodes, start=1):
        score = node.score if node.score is not None else 0.0
        sources_lines.append(f"  [{i}] {format_source(node.node.metadata)}  (相似度 {score:.3f})")
    sources_text = "\n".join(sources_lines)
    return (answer_text, sources_text)

def complete_answer(prompt: str) -> str:
    """调用云端LLM,生成流式回答(OpenAI兼容chat接口)。"""
    import time

    t0 = time.time()
    resp = _get_client().chat.completions.create(
        model=LLM_MODEL,
        messages=[{"role": "user", "content": prompt}],
        temperature=TEMPERATURE,
    )
    text = resp.choices[0].message.content or ""
    logger.info(f"LLM 返回: {len(text)}字, 耗时 {time.time()-t0:.2f}s")
    return text

流程图如下:

flowchart TB MD[("博客markdown文档目录")] subgraph OFF["离线建索引(一次性)"] direction TB R["SimpleDirectoryReader
递归读取 .md 文件"] P["MarkdownNodeParser
按标题切分chunk"] E1["向量化每个chunk
(调多次云端embedding模型)"] R --> P --> E1 end ARK["云端API(OpenAI兼容)
embedding模型
+ LLM模型"] CH[("chroma本地向量库
2048维")] subgraph ON["在线问答(每次提问)"] direction TB UI["query提问
web页 / CLI"] RV["query向量化
(调1次云端embedding模型)"] RT["向量检索知识库文档片段
(检索本地chroma向量库)"] PG["prompt拼接
向量检索结果 + query"] GEN["流式生成答案
(调1次云端LLM模型)"] SRC["返回答案、知识库文档来源"] UI --> RV RV --> RT --> PG --> GEN --> SRC --> UI end MD --> R E1 -- 写入向量与原文 --> CH RT -- top-k近邻查询 --> CH E1 -. embedding .-> ARK RV -. embedding .-> ARK GEN -. LLM 流式 .-> ARK classDef store fill:#fff3e0,stroke:#e65100,color:#bf360c classDef cloud fill:#f3e5f5,stroke:#6a1b9a,color:#4a148c class CH,MD store class ARK cloud style OFF fill:#e8f5e9,stroke:#2e7d32 style ON fill:#e3f2fd,stroke:#1565c0

更进一步

以上就是完整的 博客建站 + RAG知识库 的流程了,欢迎一起探讨。
如果想做得更深入,还可以考虑搭建本地embedding模型、LLM模型,这样就完全自主可控,不会有泄露敏感信息的风险了。


原文地址: https://www.cveoy.top/t/topic/qHr0 著作权归作者所有。请勿转载和采集!

免费AI点我,无需注册和登录