大家好,我是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
npminstall-g@docmd/core- 启动本地开发服务器,并修改配置文件(可选)
# 这一步可以零配置,快速预览效果docmd dev# 长期项目,建议初始化配置文件、并做修改docmd init- 构建静态页面
docmd build其它注意事项:
- 本地目录、文件名,改为短线分隔的英文翻译,因为会出现在导航url中,更通用些
- 批量下载markdown文档里的图片,并替换为本地相对路径。因为我是先在博客平台上发布,再转成本地markdown文件,所以需要这一步。
npx @wll8/md-img-i.-ooutput--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
def_make_embedding(model:str,api_key:str,api_base:str):"""构造兼容 OpenAI SDK 的某coding plan的embeddin模型,兼容 LlamaIndex BaseEmbedding。 """importtimeimportopenaifromllama_index.core.embeddingsimportBaseEmbeddingfromopenaiimportOpenAIclass_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):returnself._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、模型名、endpointSettings.embed_model=_make_embedding(model=EMBED_MODEL,api_key=API_KEY,api_base=BASE_URL,)- 加载文档
# 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)- 文档切分
# 文档切分:经MarkdownNodeParser按标题层级切成几百个chunkparser=MarkdownNodeParser()nodes=parser.get_nodes_from_documents(documents)- 向量化、向量结果保存至本地
这一步会调云端的embedding模型API,不过从代码看不出来调用过程,是因为llamaIndex封装好了,会读取全局Settings变量,没有显式调用过程。
向量结果存储至本地向量库chroma,作为后续查询知识库的索引,避免每次重建。
# 向量化并写入chroma(本地持久化)db=chromadb.PersistentClient(path=CHROMA_PATH)ifrebuild:try:db.delete_collection(COLLECTION_NAME)logger.info("已清空旧索引")exceptException: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模型做最终回答。
- 加载本地向量库索引
db=chromadb.PersistentClient(path=CHROMA_PATH)try:collection=db.get_collection(COLLECTION_NAME)exceptException:print("未找到向量库,请先构建索引")sys.exit(1)ifcollection.count()==0:print("向量库为空,请先构建索引")sys.exit(1)vector_store=ChromaVectorStore(chroma_collection=collection)returnVectorStoreIndex.from_vector_store(vector_store)- query向量化,调用云端embedding模型获取query的向量结果,再查找本地向量库里匹配的内容;拼上文件名、标题名,得到检索结果
defformat_source(meta:dict)->str:"""从节点的metadata组装来源信息:标题路径(文件名)。"""file_name=meta.get("file_name","未知文件")# MarkdownNodeParser 把各级标题存为 header_path(形如 "/H1/H2/")header_str=meta.get("header_path","").strip("/").replace("/"," / ")returnf"{header_str}({file_name})"ifheader_strelsefile_namedefanswer(index,question:str):"""检索本地向量库里top-k匹配的内容,并生成答案。"""retriever=index.as_retriever(similarity_top_k=TOP_K)nodes=retriever.retrieve(question)ifnotnodes:return("知识库中未找到相关内容。","")# 拼接带编号的context,LLM根据此标注 [序号],与下方来源列表编号一致context_parts=[]fori,nodeinenumerate(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模型
# 拼接 promptprompt=("你是一个博客知识库助手。请仅根据下方「参考资料」回答用户问题。\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=[]fori,nodeinenumerate(nodes,start=1):score=node.scoreifnode.scoreisnotNoneelse0.0sources_lines.append(f" [{i}]{format_source(node.node.metadata)}(相似度{score:.3f})")sources_text="\n".join(sources_lines)return(answer_text,sources_text)defcomplete_answer(prompt:str)->str:"""调用云端LLM,生成流式回答(OpenAI兼容chat接口)。"""importtime t0=time.time()resp=_get_client().chat.completions.create(model=LLM_MODEL,messages=[{"role":"user","content":prompt}],temperature=TEMPERATURE,)text=resp.choices[0].message.contentor""logger.info(f"LLM 返回:{len(text)}字, 耗时{time.time()-t0:.2f}s")returntext流程图如下:
更进一步
以上就是完整的 博客建站 + RAG知识库 的流程了,欢迎一起探讨。
如果想做得更深入,还可以考虑搭建本地embedding模型、LLM模型,这样就完全自主可控,不会有泄露敏感信息的风险了。