自用个人知识库的实现

410 阅读4分钟

不知道小伙伴儿们是怎么管理个人知识的。

我个人之前经历过多个阶段:

  • 最早刚出现博客的时候,将知识发布到博客上,比如 hexo 或者公共博客上,效果并不是很好,记录多了不好检索和维护
  • 笔记软件开始流行的时候,比如 印象笔记,以及目前在使用的 Obsidian,虽然增加了标签和双向链接,但这也增加了创建笔记的负担,你需要维护分类和链接,看起来就像古老的手动制作知识图谱
  • 为了简化检索知识的成本,我使用了第三种方式,基于 GitHub Gist,比较适合对技术知识的检索,这借助了 gist 的搜索框,可以根据关键字列出最多10条相关 gist,如果关键字相关笔记超过10条,比如 Ubuntu 相关的 gist 过多,我会合并整理部分条目。这个做法易于维护而不依赖搭建工具。但是,最近 gist 取消了搜索框的相关文档列表功能,因此这个方法也不能用了

目前正在尝试使用基于 RAG 的笔记检索查询方式。基本思路是:

  • 笔记使用 Obsidian 编写
  • 通过 rsync 命令将笔记同步到服务器端
  • 服务器端 LlamaIndex + Streamlit 实现了一个简单的 RAG 对话服务
    • 根据查询检索笔记并合成结果输出
    • 可以查看检索引用的笔记片段

使用体验一周的感受:

  • 基本完美替代了之前从分类目录、标签或者全文检索查找知识的方式,提高了效率
  • 如果发现提示词没有很好的召回笔记内容,随时调整笔记同步,逐步迭代趋于完善
  • 也因此对 RAG 有了新的体会
    • 信息提供者和使用者如果不同,就很难有效调整知识表达,造成 RAG 无法发挥作用,这是目前 RAG 问题的主要根源 -- 数据质量是最关键因素。
    • 人工智能和人,需要协作,而不是全部交给机器自动完成。在个人知识库场景,RAG 通过嵌入检索做语义查找,简化了之前人工维护知识关联的工作,在 RAG 粗粒度处理后,人做判别(结果是否可用)和反馈(针对问题提高数据质量)
    • 目前都在追求生成式人工智能的上限,搞一些惊艳的精选案例,但是在实际项目使用中却无法达到用户预期。不如使用大模型的能力下限,比如嵌入模型的基本语义检索,比如大模型的合成回答表达能力,其实能做很多事情
  • 目前100多条笔记,只出现个别1-2条回答有问题的情况,后续考虑更换嵌入模型,增加其他召回方式(比如 BM25)加以解决。

演示下使用效果:

my-KBS.gif

服务器代码就一个文件,main.py:

import streamlit as st
from streamlit.logger import get_logger

import os

from llama_index.core import Settings
from llama_index.llms.ollama import Ollama
from llama_index.embeddings.ollama import OllamaEmbedding
from llama_index.core import SimpleDirectoryReader
from llama_index.core import VectorStoreIndex
from llama_index.core import StorageContext, load_index_from_storage

logger = get_logger(__name__)

VERSION = "v0.2.0"
kb_dir = "/data/知识库"
index_dir = "/index"

st.set_page_config(
    "My KBS",
)

llm_base_url = os.getenv('LLM_BASE_URL', 'http://localhost:11434')
llm_model_name = os.getenv('LLM_MODEL_NAME', 'qwen2')

embedding_base_url = os.getenv(
    'EMBEDDING_BASE_URL', 'http://localhost:11434')
embedding_model_name = os.getenv(
    'EMBEDDING_MODEL_NAME', 'quentinz/bge-large-zh-v1.5')

Settings.llm = Ollama(
    base_url=llm_base_url,
    model=llm_model_name,
    is_chat_model=True,
    temperature=0.1,
    request_timeout=60.0
)

Settings.embed_model = OllamaEmbedding(
    model_name=embedding_model_name,
    base_url=embedding_base_url,
    # -mirostat N 使用 Mirostat 采样。
    ollama_additional_kwargs={"mirostat": 0},
)

documents = SimpleDirectoryReader(
    input_dir=kb_dir,
    recursive=True,
    filename_as_id=True,
    required_exts=[".md"],
).load_data()

if not os.path.exists(index_dir) or not os.path.exists(f"{index_dir}/docstore.json"):
    index = VectorStoreIndex.from_documents(documents)
    index.storage_context.persist(persist_dir=index_dir)
else:
    storage_context = StorageContext.from_defaults(persist_dir=index_dir)
    index = load_index_from_storage(storage_context)
    index.refresh_ref_docs(documents)
    index.storage_context.persist(persist_dir=index_dir)


def _show_sources(source_nodes):
    with st.expander("搜索结果"):
        filtered_nodes = [node for node in source_nodes if node.score > 0]

        if len(filtered_nodes) == 0:
            st.write('未检索到有效结果')
        else:
            for i, node in enumerate(filtered_nodes):
                st.write(node.score)
                st.write(node.text)

                if i < len(filtered_nodes) - 1:
                    st.divider()


def find_md_files(dir_path):
    md_files = []
    for root, dirs, files in os.walk(dir_path):
        for file in files:
            if file.endswith('.md'):
                md_files.append(os.path.join(root, file))
    return md_files


md_file_paths = find_md_files(kb_dir)

with st.sidebar:
    st.title('My KBS')
    st.caption(f'版本: {VERSION}')
    st.caption(f"知识库文件数: {len(md_file_paths)} 个")

    st.number_input("相似度前k条:", 0, 20, 2,
                    key='similarity_top_k',
                    help="检索语义最接近的前k条")

# 初始化消息
if "messages" not in st.session_state.keys():
    st.session_state.messages = [
        {"role": "assistant",
            "content": "请问我问题吧。"}
    ]

# 显示输入框
if prompt := st.chat_input(placeholder="这里输入问题"):
    st.session_state.messages.append({"role": "user", "content": prompt})

# 显示之前的消息
for message in st.session_state.messages:
    with st.chat_message(message["role"]):
        st.write(message["content"])

        if "source_nodes" in message:
            _show_sources(message['source_nodes'])

# 输出助手回答
if st.session_state.messages[-1]["role"] != "assistant":
    with st.chat_message("assistant"):
        query_engine = index.as_query_engine(
            streaming=True,
            similarity_top_k=st.session_state.similarity_top_k,
        )
        # 流式输出
        stream = query_engine.query(prompt)
        response = st.write_stream(stream.response_gen)
        _show_sources(stream.source_nodes)

        message = {"role": "assistant", "content": response,
                   "source_nodes": stream.source_nodes}
        st.session_state.messages.append(message)

同步笔记到服务器,我使用了 alias,方便调用,可以在 ~/.bashrc 或者 ~/.zshrc 中加入:

alias kbs='rsync -avz --delete "YOUR_OBSIDIAN_DIR" SERVER:/home/ubuntu/my-kbs/data'

大模型这里使用的是 Ollama,可参见 在 4GB 显存下运行 LLM 基础开发环境 中有关 Ollama 的设置。也可以很容易的基于这个环境修改为使用云端模型,这样自己不需要给予 GPU 硬件设备,更轻量级。