用 Hermes 搭建本地 RAG 知识库:检索增强生成从零入门

从零搭建基于 Hermes 的本地 RAG 系统,覆盖文档切分、向量化、检索、生成全流程,附完整 Python 代码。

目录

  1. RAG 是什么,为什么需要它
  2. RAG 的完整流程
  3. 环境准备
  4. 第一步:文档加载和切分
  5. 第二步:向量化和存储
  6. 第三步:检索器配置
  7. 第四步:用 Hermes 做生成
  8. 提升 RAG 效果的实用技巧
  9. 评估 RAG 系统质量
  10. 性能优化

RAG 是什么,为什么需要它

大语言模型有一个根本性的局限:它只知道训练数据里的东西。你问它公司内部的产品文档、最新的技术规范、昨天刚更新的 API 文档,它一概不知道。

传统的解决思路是微调——把你的数据喂给模型重新训练。但微调成本高、周期长,而且数据更新后得重新来一遍。

RAG(Retrieval-Augmented Generation,检索增强生成)提供了一种更轻量的方案:不改模型,改输入。每次用户提问时,先从你的知识库里检索出相关内容,把检索到的内容塞到 prompt 里一起发给模型,模型基于这些上下文来回答。

打个比方:微调相当于让学生背下整本教材,RAG 相当于让学生带着教材开卷考试。后者更灵活、更新更方便。

RAG 的完整流程

一个 RAG 系统有两个阶段:

索引阶段(离线做一次):

  1. 加载文档(PDF、Markdown、网页、数据库记录等)
  2. 文本切分(把长文档切成小段)
  3. 向量化(用嵌入模型把文本段落转成向量)
  4. 存入向量数据库

查询阶段(每次用户提问):

  1. 用户提问 -> 向量化
  2. 在向量数据库中检索相似段落
  3. 把检索结果拼入 prompt
  4. 用 Hermes 生成回答

下面按这个流程,一步步写代码。

环境准备

1
pip install langchain langchain-community chromadb sentence-transformers llama-cpp-python

我们用的技术栈:

  • LangChain — 做 pipeline 编排
  • ChromaDB — 本地向量数据库,轻量好用
  • sentence-transformers — 加载嵌入模型
  • llama-cpp-python — 加载 GGUF 格式的 Hermes 模型

如果你还没有 Hermes 模型文件,先看 Hermes 入门指南 了解怎么获取。

第一步:文档加载和切分

文档切分是 RAG 质量的关键环节。切太大,检索精度低;切太小,上下文断裂,模型看不懂。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain_community.document_loaders import (
DirectoryLoader,
TextLoader,
PyPDFLoader,
UnstructuredMarkdownLoader
)


def load_documents(docs_dir: str) -> list:
"""加载目录下的所有文档"""
documents = []

# 加载 Markdown 文件
md_loader = DirectoryLoader(
docs_dir, glob="**/*.md",
loader_cls=UnstructuredMarkdownLoader,
show_progress=True
)
documents.extend(md_loader.load())

# 加载 PDF 文件
pdf_loader = DirectoryLoader(
docs_dir, glob="**/*.pdf",
loader_cls=PyPDFLoader,
show_progress=True
)
documents.extend(pdf_loader.load())

# 加载纯文本
txt_loader = DirectoryLoader(
docs_dir, glob="**/*.txt",
loader_cls=TextLoader,
loader_kwargs={"encoding": "utf-8"},
show_progress=True
)
documents.extend(txt_loader.load())

print(f"共加载 {len(documents)} 个文档")
return documents


def split_documents(documents: list, chunk_size: int = 500, chunk_overlap: int = 80) -> list:
"""切分文档"""
splitter = RecursiveCharacterTextSplitter(
chunk_size=chunk_size,
chunk_overlap=chunk_overlap,
separators=["\n\n", "\n", "。", "!", "?", ";", " ", ""],
length_function=len
)
chunks = splitter.split_documents(documents)
print(f"切分为 {len(chunks)} 个文本块")
return chunks

几个关键参数的选择依据:

  • chunk_size=500 — 中文场景下 500 字符大约是 200-300 个汉字,一个完整的段落。英文场景通常设 1000
  • chunk_overlap=80 — 前后段落有 80 字符的重叠,避免关键信息恰好被切断
  • separators — 优先按段落分(\n\n),其次按句子分(句号等标点),最后才按字符分。中文要特别注意加上中文标点

第二步:向量化和存储

嵌入模型(Embedding Model)负责把文本转成固定维度的向量。选一个好的嵌入模型很重要——如果嵌入质量差,语义相近的文本在向量空间里不相近,检索就会拉胯。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
from langchain_community.embeddings import HuggingFaceEmbeddings
from langchain_community.vectorstores import Chroma


def create_vector_store(chunks: list, persist_dir: str = "./chroma_db") -> Chroma:
"""创建向量数据库"""

# 使用 BGE-M3 嵌入模型(中英文双语效果好)
embeddings = HuggingFaceEmbeddings(
model_name="BAAI/bge-m3",
model_kwargs={"device": "cuda"}, # 没有GPU改成 "cpu"
encode_kwargs={
"normalize_embeddings": True,
"batch_size": 32
}
)

# 创建 ChromaDB 向量数据库
vectorstore = Chroma.from_documents(
documents=chunks,
embedding=embeddings,
persist_directory=persist_dir,
collection_metadata={"hnsw:space": "cosine"}
)

print(f"向量数据库创建完成,保存在 {persist_dir}")
return vectorstore

嵌入模型选择建议:

模型 维度 中文效果 模型大小 推荐场景
BAAI/bge-m3 1024 优秀 ~2.2 GB 中英混合首选
BAAI/bge-large-zh-v1.5 1024 优秀 ~1.3 GB 纯中文场景
intfloat/multilingual-e5-large 1024 良好 ~1.1 GB 多语言场景
BAAI/bge-small-zh-v1.5 512 良好 ~96 MB 资源受限

关于嵌入模型和语义搜索的更多细节,后续会在 语义搜索入门 里展开讲。

第三步:检索器配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
def create_retriever(vectorstore: Chroma, search_type: str = "mmr", k: int = 4):
"""创建检索器"""
if search_type == "mmr":
# MMR 在相关性和多样性之间取平衡
retriever = vectorstore.as_retriever(
search_type="mmr",
search_kwargs={
"k": k,
"fetch_k": 20,
"lambda_mult": 0.6
}
)
elif search_type == "similarity":
retriever = vectorstore.as_retriever(
search_type="similarity",
search_kwargs={"k": k}
)
return retriever

推荐用 MMR 而不是简单的相似度检索。简单相似度检索容易返回几条内容高度相似的段落(可能来自同一段文字的不同切片),浪费了宝贵的 context 空间。MMR 会在保证相关性的前提下尽量让结果多样化。

第四步:用 Hermes 做生成

把检索和生成串起来:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
from llama_cpp import Llama


class HermesRAG:
def __init__(self, model_path: str, vectorstore_dir: str = "./chroma_db"):
self.llm = Llama(
model_path=model_path,
n_ctx=8192,
n_gpu_layers=-1,
chat_format="chatml"
)
self.vectorstore = load_vector_store(vectorstore_dir)
self.retriever = create_retriever(self.vectorstore, search_type="mmr", k=4)

def query(self, question: str, temperature: float = 0.3) -> dict:
"""RAG 查询"""
# 检索相关文档
docs = self.retriever.invoke(question)

# 构建带上下文的 prompt
context = "\n\n---\n\n".join([
f"[来源: {doc.metadata.get('source', '未知')}]\n{doc.page_content}"
for doc in docs
])

system_prompt = """你是一个专业的知识库助手。请基于下面提供的参考资料来回答用户的问题。

规则:
1. 只基于参考资料中的信息回答,不要编造
2. 如果参考资料中没有相关信息,明确告诉用户"根据现有资料无法回答"
3. 回答时尽量引用具体的信息来源
4. 用清晰简洁的中文回答"""

user_prompt = f"参考资料:\n{context}\n\n问题:{question}"

messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt}
]

response = self.llm.create_chat_completion(
messages=messages, max_tokens=1024, temperature=temperature
)

answer = response["choices"][0]["message"]["content"]

return {
"question": question,
"answer": answer,
"source_documents": [
{"content": doc.page_content[:200], "source": doc.metadata.get("source", "未知")}
for doc in docs
]
}

使用方式:

1
2
3
4
5
6
7
8
rag = HermesRAG(
model_path="./Hermes-3-Llama-3.1-8B-Q5_K_M.gguf",
vectorstore_dir="./chroma_db"
)

result = rag.query("我们的退款政策是什么?")
print(result["answer"])
print("参考来源:", [d["source"] for d in result["source_documents"]])

提升 RAG 效果的实用技巧

基础 RAG 搭起来只是起步,想在实际场景里用好还需要不少优化。

1. 查询改写(Query Rewriting)

用户的原始提问可能太口语化或太模糊,直接拿去检索效果不好。可以用 Hermes 先改写一下查询:

1
2
3
4
5
6
7
8
9
10
def rewrite_query(self, original_query: str) -> str:
messages = [
{
"role": "system",
"content": "你是一个搜索查询优化专家。把用户的口语化问题改写成更适合检索的形式。只输出改写后的查询,不要解释。"
},
{"role": "user", "content": original_query}
]
response = self.llm.create_chat_completion(messages=messages, max_tokens=100, temperature=0.1)
return response["choices"][0]["message"]["content"]

比如用户问”那个退款要多久啊”,改写后可能变成”退款处理时间周期政策”,更容易匹配到知识库里的相关段落。

2. 混合检索(Hybrid Search)

纯向量检索有时候会”过度语义化”——用户明确搜一个专有名词(如 “ERR_4012”),向量检索可能找不到。解决方案是混合使用向量检索和关键词检索:

1
2
3
4
5
6
7
8
9
10
11
12
13
from langchain.retrievers import EnsembleRetriever
from langchain_community.retrievers import BM25Retriever

def create_hybrid_retriever(chunks, vectorstore, k=4):
bm25_retriever = BM25Retriever.from_documents(chunks)
bm25_retriever.k = k
vector_retriever = vectorstore.as_retriever(search_type="mmr", search_kwargs={"k": k})

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

3. 重排序(Reranking)

检索到一批候选段落后,用一个 cross-encoder 模型重新排序,把最相关的排在前面:

1
2
3
4
5
6
7
8
9
10
11
from sentence_transformers import CrossEncoder

class Reranker:
def __init__(self, model_name="BAAI/bge-reranker-v2-m3"):
self.model = CrossEncoder(model_name, max_length=512)

def rerank(self, query: str, documents: list, top_k: int = 3) -> list:
pairs = [(query, doc.page_content) for doc in documents]
scores = self.model.predict(pairs)
scored_docs = sorted(zip(documents, scores), key=lambda x: x[1], reverse=True)
return [doc for doc, score in scored_docs[:top_k]]

重排序的效果非常显著——初次检索可能返回 10 个结果里有 3 个不太相关的,经过重排序后把最相关的 3-4 个挑出来,送给 Hermes 生成,回答质量明显提升。

4. 上下文压缩

检索到的段落可能包含很多与问题无关的信息。在送给 Hermes 之前,可以先做一轮压缩,只保留和问题相关的部分。这个方法会增加一轮 LLM 调用的延迟,但能显著减少塞入 prompt 的无关信息,让 Hermes 生成的回答更聚焦。

评估 RAG 系统质量

搭完不测等于没搭。评估 RAG 系统需要关注几个维度:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
def evaluate_rag(rag_system, test_cases: list) -> dict:
results = {"total": len(test_cases), "retrieval_hit": 0, "answer_correct": 0}

for case in test_cases:
result = rag_system.query(case["question"])

# 检查检索是否命中了正确的文档
sources = [d["source"] for d in result["source_documents"]]
if case.get("expected_source") and any(case["expected_source"] in s for s in sources):
results["retrieval_hit"] += 1

# 检查回答是否包含预期的关键信息
if case.get("expected_keywords") and all(kw in result["answer"] for kw in case["expected_keywords"]):
results["answer_correct"] += 1

for key in ["retrieval_hit", "answer_correct"]:
results[f"{key}_rate"] = results[key] / results["total"]
return results

准备一组测试用例(问题 + 期望答案中应包含的关键词 + 期望来源文档),定期跑一遍评估,确保系统迭代过程中质量不退化。

性能优化

索引阶段优化:

  • 批量嵌入,别一条一条处理
  • GPU 做嵌入比 CPU 快 10 倍以上
  • 如果文档量大(10万+),考虑用 Milvus 替代 ChromaDB

查询阶段优化:

  • 嵌入模型做查询向量化通常只需 10-50ms
  • ChromaDB 检索在百万量级文档上也能做到 50ms 以内
  • 主要瓶颈在 Hermes 生成这一步——具体怎么优化推理速度,看你选的 量化方案 和推理框架

RAG 不是一个搭完就不管的系统,它需要根据实际使用反馈持续调优——调整切分粒度、换嵌入模型、优化 prompt、改检索策略。cocoloop 社区里有不少同学在分享自己的 RAG 实践经验,遇到问题可以去交流。

参与讨论

对这篇文章有疑问或想法?cocoloop 社区有不少开发者在讨论 Hermes 相关话题,欢迎加入交流。

前往 cocoloop 社区 →