大家好,我是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页

博客建站
选型
生成博客的工具有很多,比如:docmd、jekyll、hugo、hexo、MkDocs等。
最终选择了docmd,是因为想低成本构建,不用额外了解各种前端框架、或者安装额外的工具链,在零配置、或少量配置的情况下,快速构建出静态页面。
docmd让人眼前一亮的功能有:导航栏、全文搜索、mermaid文本绘图支持、站点地图、自动生成适合LLM阅读的文档、多语言支持等,能开箱即用。
至于其它方案,多少都有点门槛:jekyll虽然是gitHub pages原生支持,但它基于ruby工具链,安装搭建比较麻烦;hugo编译速度快,但主题用的是Go模板语法,想自定义样式就得学一套模板写法;hexo更偏前端工程化,选主题、改组件、配构建,多少都得懂点前端框架。
实现细节
- 全局安装docmd
bash
npm install -g @docmd/core
- 启动本地开发服务器,并修改配置文件(可选)
bash
# 这一步可以零配置,快速预览效果
docmd dev
# 长期项目,建议初始化配置文件、并做修改
docmd init
- 构建静态页面
bash
docmd build
其它注意事项:
- 本地目录、文件名,改为短线分隔的英文翻译,因为会出现在导航url中,更通用些
- 批量下载markdown文档里的图片,并替换为本地相对路径。因为我是先在博客平台上发布,再转成本地markdown文件,所以需要这一步。
bash
npx @wll8/md-img -i . -o output --imgdir ./assets/images
- 上传到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实现细节
-
创建"知识库"模板应用
"知识库"模板里已经预设了"用户输入" -> "知识检索" -> "大语言模型" -> "输出"的流程,只需要按提示修改其中的节点。

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

-
在"大语言模型"节点选择模型、上下文
部分模型有免费试用额度,上下文选择第2步经过向量查询的"知识检索"结果。
-
调试运行
输入一个问句,会先从知识库检索相关内容,再一起作为prompt给到大语言模型,最终得到靠谱的回答。

-
发布上线
可以选择"嵌入到网站中",这样就能在已有网站里出现一个问答对话框了。
开源框架
用开源框架的好处是更加灵活、自主可控。llamaIndex用来做知识库,是专用工具。
之所以不用LangChain、LangGraph、AutoGen这类agent开发框架,是因为它们面向的是多步工具调用、自主规划的复杂场景,做知识库检索问答太重了,属于杀鸡用牛刀了,而llamaIndex开箱就带文档解析、向量库对接、检索器这些现成能力。
llamaIndex实现细节
把markdown文档向量化存入本地chroma,然后用自然语言提问,得到带来源引用的回答。
整体分离线构建索引 (一次性)和在线问答(每次提问)两条线,共享本地Chroma向量库与云端向量模型、大语言模型。
离线建索引(一次性)
切分成多少个文档chunk,就会调多少次云端embedding模型,第一次构建会比较耗时。
- 设置云端API key、模型名、endpoint
python
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,
)
- 加载文档
python
# 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)
- 文档切分
python
# 文档切分:经MarkdownNodeParser按标题层级切成几百个chunk
parser = MarkdownNodeParser()
nodes = parser.get_nodes_from_documents(documents)
- 向量化、向量结果保存至本地
这一步会调云端的embedding模型API,不过从代码看不出来调用过程,是因为llamaIndex封装好了,会读取全局Settings变量,没有显式调用过程。
向量结果存储至本地向量库chroma,作为后续查询知识库的索引,避免每次重建。
python
# 向量化并写入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模型做最终回答。
- 加载本地向量库索引
python
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)
- query向量化,调用云端embedding模型获取query的向量结果,再查找本地向量库里匹配的内容;拼上文件名、标题名,得到检索结果
python
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)
- 生成最终回答:知识库检索结果,拼上query,一起作为大语言模型的prompt提示词,调用云端的LLM模型
python
# 拼接 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模型,这样就完全自主可控,不会有泄露敏感信息的风险了。