大模型应用:搭建本地知识库问答系统

RAG(Retrieval-Augmented Generation)是当前大模型落地最实用的方案之一——不用微调、不用海量 GPU,就能让模型基于你自己的文档回答问题。本文记录从零搭建一个本地 RAG 知识库问答系统的完整过程,所有组件都可以在本地运行,不依赖任何云端 API。

整体架构

用户提问
  │
  ▼
Query Embedding ──→ 向量检索(ChromaDB) ──→ Top-K 相关文档片段
                                              │
                                              ▼
                                    Prompt 组装(Context + Question)
                                              │
                                              ▼
                                    本地 LLM (Ollama + Qwen2)
                                              │
                                              ▼
                                           回答

技术栈选型:

组件 选型 理由
文档加载 LangChain Loaders 支持 PDF/Markdown/Word/HTML 等
文本切分 RecursiveCharacterTextSplitter 对中文友好,支持语义分段
Embedding bge-small-zh-v1.5 中文效果好,模型小(~90MB),CPU 可跑
向量数据库 ChromaDB 纯 Python,无需额外部署
LLM Ollama + Qwen2-7B 本地部署,隐私安全,中文能力强
编排 LangChain 粘合各组件

环境准备

pip install langchain langchain-community chromadb sentence-transformers
pip install pymupdf python-docx unstructured

# 安装 Ollama (macOS/Linux)
curl -fsSL https://ollama.com/install.sh | sh
# 拉取 Qwen2-7B
ollama pull qwen2:7b

文档加载与切分

支持多种文档格式是实用系统的基本要求:

from langchain_community.document_loaders import (
    PyMuPDFLoader,
    UnstructuredMarkdownLoader,
    Docx2txtLoader,
    DirectoryLoader,
)
from langchain.text_splitter import RecursiveCharacterTextSplitter
import os

def load_documents(docs_dir: str):
    '''从目录加载所有支持格式的文档'''
    documents = []

    # PDF
    for f in os.listdir(docs_dir):
        path = os.path.join(docs_dir, f)
        if f.endswith(".pdf"):
            loader = PyMuPDFLoader(path)
            documents.extend(loader.load())
        elif f.endswith(".md"):
            loader = UnstructuredMarkdownLoader(path)
            documents.extend(loader.load())
        elif f.endswith(".docx"):
            loader = Docx2txtLoader(path)
            documents.extend(loader.load())

    return documents


def split_documents(documents, chunk_size=500, chunk_overlap=80):
    '''
    切分文档。
    chunk_size=500 对中文比较合适,太大会稀释检索精度,
    太小会丢失上下文。
    '''
    splitter = RecursiveCharacterTextSplitter(
        chunk_size=chunk_size,
        chunk_overlap=chunk_overlap,
        separators=["\n\n", "\n", "。", "!", "?", ".", " ", ""],
    )
    return splitter.split_documents(documents)

RecursiveCharacterTextSplitterseparators 参数很关键。默认的分隔符对英文优化,处理中文时加上 "。" "!" "?" 可以让切分更贴合语义边界。

Embedding 模型

选择 bge-small-zh-v1.5,MTEB 中文榜排名靠前,且模型仅 90MB 左右,CPU 机器也能跑:

from langchain_community.embeddings import HuggingFaceBgeEmbeddings

def get_embedding_model():
    model_name = "BAAI/bge-small-zh-v1.5"
    encode_kwargs = {"normalize_embeddings": True}
    return HuggingFaceBgeEmbeddings(
        model_name=model_name,
        model_kwargs={"device": "cpu"},
        encode_kwargs=encode_kwargs,
        query_instruction="为这个句子生成表示以用于检索相关文章:",
    )

query_instruction 是 bge 模型的特性——query 和 document 用不同的指令前缀,能提升检索效果。

向量库构建(ChromaDB)

from langchain_community.vectorstores import Chroma

def build_vector_store(docs, embedding, persist_dir="./chroma_db"):
    '''构建并持久化向量库'''
    vectorstore = Chroma.from_documents(
        documents=docs,
        embedding=embedding,
        persist_directory=persist_dir,
        collection_metadata={"hnsw:space": "cosine"},
    )
    vectorstore.persist()
    print(f"向量库构建完成,共 {vectorstore._collection.count()} 条向量")
    return vectorstore


def load_vector_store(embedding, persist_dir="./chroma_db"):
    '''加载已有的向量库'''
    return Chroma(
        persist_directory=persist_dir,
        embedding_function=embedding,
    )

ChromaDB 默认用 HNSW 索引,对于几万条向量的规模完全够用。如果文档量上到百万级,需要换 Milvus 或 Qdrant。

本地 LLM 接入(Ollama)

from langchain_community.llms import Ollama

def get_llm():
    return Ollama(
        model="qwen2:7b",
        temperature=0.1,
        num_ctx=4096,       # 上下文窗口
        repeat_penalty=1.1, # 降低重复
    )

temperature=0.1 让回答更确定性,适合知识库问答场景。num_ctx=4096 对于拼接 context + question 通常够用。

LangChain 整合 RAG Chain

from langchain.prompts import PromptTemplate
from langchain.chains import RetrievalQA

PROMPT_TEMPLATE = '''基于以下已知信息来回答用户的问题。
如果已知信息中没有足够的内容来回答,请直接说明"根据已有资料无法回答该问题",不要编造答案。

已知信息:
{context}

用户问题:
{question}

请用中文回答:'''

def build_qa_chain(vectorstore, llm):
    retriever = vectorstore.as_retriever(
        search_type="mmr",       # 最大边际相关性,减少冗余
        search_kwargs={
            "k": 4,              # 返回 4 个最相关片段
            "fetch_k": 10,       # MMR 候选池大小
            "lambda_mult": 0.7,  # 相关性 vs 多样性平衡
        },
    )

    prompt = PromptTemplate(
        template=PROMPT_TEMPLATE,
        input_variables=["context", "question"],
    )

    qa_chain = RetrievalQA.from_chain_type(
        llm=llm,
        chain_type="stuff",
        retriever=retriever,
        return_source_documents=True,
        chain_type_kwargs={"prompt": prompt},
    )
    return qa_chain

这里用 search_type="mmr" 而不是默认的相似度搜索。MMR(Maximal Marginal Relevance)会在返回结果中兼顾相关性和多样性,避免检索到内容高度重复的片段。

完整使用流程

def main():
    # 1. 加载文档
    docs = load_documents("./knowledge_base")
    print(f"加载了 {len(docs)} 个文档")

    # 2. 切分
    chunks = split_documents(docs)
    print(f"切分为 {len(chunks)} 个片段")

    # 3. 构建向量库
    embedding = get_embedding_model()
    vectorstore = build_vector_store(chunks, embedding)

    # 4. 构建 QA Chain
    llm = get_llm()
    qa_chain = build_qa_chain(vectorstore, llm)

    # 5. 问答
    while True:
        question = input("\n请输入问题 (q 退出): ")
        if question.strip().lower() == "q":
            break

        result = qa_chain({"query": question})
        print(f"\n回答:{result['result']}")
        print(f"\n参考来源:")
        for i, doc in enumerate(result["source_documents"]):
            src = doc.metadata.get("source", "unknown")
            print(f"  [{i+1}] {src}")


if __name__ == "__main__":
    main()

效果优化方向

实际使用中效果不理想的话,可以从这几个方向调优:

1. 切分策略

chunk_size 对效果影响很大。我的经验值:

  • 技术文档、API 手册:300-500
  • 长篇报告、论文:500-800
  • FAQ/QA 对:尽量一个 QA 一个 chunk

2. 检索增强

# 混合检索:向量检索 + BM25 关键词检索
from langchain.retrievers import EnsembleRetriever
from langchain_community.retrievers import BM25Retriever

bm25 = BM25Retriever.from_documents(chunks, k=4)
vector_retriever = vectorstore.as_retriever(search_kwargs={"k": 4})

ensemble = EnsembleRetriever(
    retrievers=[bm25, vector_retriever],
    weights=[0.4, 0.6],
)

混合检索对于专有名词、代码片段等语义 Embedding 不擅长的内容有明显提升。

3. Reranker

检索后用 cross-encoder 模型做重排序,精度提升明显但会增加延迟:

from sentence_transformers import CrossEncoder

reranker = CrossEncoder("BAAI/bge-reranker-base")

def rerank(query, docs, top_k=3):
    pairs = [(query, doc.page_content) for doc in docs]
    scores = reranker.predict(pairs)
    ranked = sorted(zip(docs, scores), key=lambda x: x[1], reverse=True)
    return [doc for doc, _ in ranked[:top_k]]

4. Query 改写

用户的口语化提问往往检索效果不佳,可以让 LLM 先把问题改写成更适合检索的形式:

rewrite_prompt = '''请将以下用户问题改写为更适合在知识库中检索的查询语句,
保持原意但使用更精确的关键词。只输出改写后的查询,不要解释。

用户问题:{question}
改写查询:'''

总结

这套方案的硬件需求不高——8GB 内存 + 一块中端显卡(甚至纯 CPU)就能跑起来。实际效果取决于文档质量和切分策略,建议先用小规模文档验证效果,再逐步扩充知识库。生产环境部署时记得加上文档更新的增量索引机制,避免每次全量重建向量库。