Hermes + LlamaIndex:搭建文档问答系统实操

用LlamaIndex配合Hermes模型搭建一个完整的文档问答系统,涵盖文档加载、向量化、索引创建、查询引擎配置全流程。

目录

  1. 架构概览
  2. 环境准备
  3. 快速上手:五分钟跑通
  4. 文档加载详解
    1. 加载 PDF
    2. 加载网页
    3. 加载数据库
    4. 自定义元数据
  5. 文本切分策略
    1. 默认切分
    2. 按语义切分
    3. 切分参数怎么选
  6. 向量索引深入
    1. 默认的内存索引
    2. 持久化到磁盘
    3. 用 Chroma 做向量存储
  7. 查询引擎配置
    1. 基础查询
    2. response_mode 的几种选择
    3. 带元数据过滤的查询
  8. 完整项目示例
  9. 性能优化技巧
  10. 和 LangChain 的 RAG 方案对比

你有没有遇到过这种场景:公司内部文档堆积如山,想找一个信息得翻半天。或者你手上有几十份 PDF 研究报告,想快速找到某个数据点但又记不清在哪份报告里。

这种需求用传统搜索引擎解决不了——它只能做关键词匹配,理解不了你真正想问什么。但大模型可以。问题是大模型没读过你的私有文档。

解决方案就是 RAG(检索增强生成):先从文档里检索相关内容,再让大模型基于这些内容回答问题。而 LlamaIndex 就是做这件事最专业的框架。

今天用 LlamaIndex + Hermes 搭一个文档问答系统。

架构概览

整个流程分两个阶段:

索引阶段(离线执行一次):

  1. 加载文档(PDF、Word、TXT、网页……)
  2. 把文档切成小块(chunking)
  3. 用嵌入模型把每个块转成向量
  4. 存入向量数据库

查询阶段(每次提问都执行):

  1. 把用户问题转成向量
  2. 在向量数据库里找最相似的文档块
  3. 把这些块作为上下文,连同问题一起发给 Hermes
  4. Hermes 基于上下文生成答案

环境准备

先确保 Hermes 在本地跑着。最方便的方式还是 Ollama

1
2
ollama pull hermes3:8b
ollama pull nomic-embed-text # 嵌入模型

安装 LlamaIndex 和依赖:

1
2
pip install llama-index llama-index-llms-ollama llama-index-embeddings-ollama
pip install llama-index-readers-file # PDF/DOCX 等格式支持

快速上手:五分钟跑通

先用最少的代码跑通整个流程:

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
from llama_index.core import VectorStoreIndex, SimpleDirectoryReader, Settings
from llama_index.llms.ollama import Ollama
from llama_index.embeddings.ollama import OllamaEmbedding

# 配置模型
Settings.llm = Ollama(
model="hermes3:8b",
base_url="http://localhost:11434",
request_timeout=120.0,
temperature=0.3
)

Settings.embed_model = OllamaEmbedding(
model_name="nomic-embed-text",
base_url="http://localhost:11434"
)

# 加载文档(把你的文档放到 ./docs 目录下)
documents = SimpleDirectoryReader("./docs").load_data()
print(f"加载了 {len(documents)} 个文档")

# 创建索引
index = VectorStoreIndex.from_documents(documents, show_progress=True)

# 创建查询引擎
query_engine = index.as_query_engine()

# 开始提问
response = query_engine.query("这些文档的主要内容是什么?")
print(response)

把你的 PDF、TXT、Markdown 文件丢到 ./docs 目录下,跑这段代码就行。

文档加载详解

SimpleDirectoryReader 是最省事的加载器,会自动识别文件格式。但实际项目中你可能需要更精细的控制。

加载 PDF

1
2
3
4
5
6
7
8
9
from llama_index.readers.file import PDFReader

loader = PDFReader()
documents = loader.load_data(file="./report.pdf")

# 每个 document 对象包含:
for doc in documents:
print(f"页码: {doc.metadata.get('page_label')}")
print(f"内容长度: {len(doc.text)}")

加载网页

1
2
3
4
5
6
from llama_index.readers.web import SimpleWebPageReader

loader = SimpleWebPageReader()
documents = loader.load_data(
urls=["https://example.com/article1", "https://example.com/article2"]
)

加载数据库

1
2
3
4
5
6
7
8
from llama_index.readers.database import DatabaseReader

reader = DatabaseReader(
uri="postgresql://user:pass@localhost/mydb"
)
documents = reader.load_data(
query="SELECT title, content FROM articles WHERE status = 'published'"
)

自定义元数据

给文档打标签,后续检索时可以按标签过滤:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from llama_index.core import Document

documents = []
for filepath in pdf_files:
doc = Document(
text=read_pdf(filepath),
metadata={
"source": filepath,
"department": "engineering",
"year": 2024,
"doc_type": "technical_report"
}
)
documents.append(doc)

文本切分策略

文档太长塞不进模型的上下文窗口,所以得切块。切法很有讲究。

默认切分

1
2
3
4
5
6
7
8
9
10
from llama_index.core.node_parser import SentenceSplitter

# 默认按句子边界切分
parser = SentenceSplitter(
chunk_size=1024, # 每块最大 token 数
chunk_overlap=200 # 相邻块重叠的 token 数
)

nodes = parser.get_nodes_from_documents(documents)
print(f"切成了 {len(nodes)} 个块")

chunk_overlap 很重要——如果一个信息点恰好被切在两块的边界上,重叠部分能保证它至少完整出现在一块里。

按语义切分

更高级的做法——用嵌入模型判断语义边界:

1
2
3
4
5
6
7
8
9
from llama_index.core.node_parser import SemanticSplitterNodeParser

parser = SemanticSplitterNodeParser(
buffer_size=1,
breakpoint_percentile_threshold=95,
embed_model=Settings.embed_model
)

nodes = parser.get_nodes_from_documents(documents)

语义切分的好处是每个块内容更连贯,不会把一个完整的概念切成两半。缺点是慢一些,因为每个潜在的切分点都需要计算嵌入。

切分参数怎么选

这里有几个经验值:

  • 技术文档:chunk_size=1024, overlap=200。技术文档结构清晰,适中的块大小就够了。
  • 法律合同:chunk_size=512, overlap=100。法律文本每句话都可能很关键,切小一点检索更精准。
  • 聊天记录:chunk_size=256, overlap=50。短文本适合小块。
  • 长篇论文:chunk_size=2048, overlap=400。保留更多上下文有助于理解复杂论述。

没有万能参数,建议根据实际效果调整。

向量索引深入

默认的内存索引

前面用的 VectorStoreIndex 是纯内存的,重启就没了。小数据集无所谓,但文档多了你肯定想持久化。

持久化到磁盘

1
2
3
4
5
6
7
8
9
from llama_index.core import VectorStoreIndex, StorageContext, load_index_from_storage

# 创建索引并保存
index = VectorStoreIndex.from_documents(documents)
index.storage_context.persist(persist_dir="./storage")

# 下次直接从磁盘加载
storage_context = StorageContext.from_defaults(persist_dir="./storage")
index = load_index_from_storage(storage_context)

用 Chroma 做向量存储

生产环境推荐用专门的向量数据库:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import chromadb
from llama_index.vector_stores.chroma import ChromaVectorStore

# 创建 Chroma 客户端
chroma_client = chromadb.PersistentClient(path="./chroma_db")
chroma_collection = chroma_client.get_or_create_collection("my_docs")

# 创建向量存储
vector_store = ChromaVectorStore(chroma_collection=chroma_collection)
storage_context = StorageContext.from_defaults(vector_store=vector_store)

# 建索引
index = VectorStoreIndex.from_documents(
documents,
storage_context=storage_context,
show_progress=True
)

Chroma 支持元数据过滤,这在文档量大的时候非常有用。

查询引擎配置

基础查询

1
2
3
4
5
6
7
8
query_engine = index.as_query_engine(
similarity_top_k=5, # 检索最相似的5个文档块
response_mode="compact" # 把所有块拼成一个 prompt
)

response = query_engine.query("项目的技术架构是什么?")
print(response)
print(f"\n来源: {response.source_nodes}") # 查看引用了哪些文档块

response_mode 的几种选择

  • compact:把所有检索到的块塞进一个 prompt,让模型一次性回答。最快,但如果块太多可能超出上下文。
  • refine:逐块处理,每处理一块就优化一次答案。质量高但慢。
  • tree_summarize:对检索结果做树状汇总。适合需要综合大量信息的问题。
  • simple_summarize:简单拼接后让模型总结。
1
2
3
4
5
6
7
8
9
10
11
# refine 模式,质量优先
query_engine = index.as_query_engine(
similarity_top_k=10,
response_mode="refine"
)

# tree_summarize 模式,适合综合性问题
query_engine = index.as_query_engine(
similarity_top_k=20,
response_mode="tree_summarize"
)

带元数据过滤的查询

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
from llama_index.core.vector_stores import MetadataFilters, MetadataFilter

filters = MetadataFilters(
filters=[
MetadataFilter(key="department", value="engineering"),
MetadataFilter(key="year", value=2024)
]
)

query_engine = index.as_query_engine(
similarity_top_k=5,
filters=filters
)

response = query_engine.query("今年工程部有什么新项目?")

完整项目示例

把所有部分组合起来,做一个可以不断添加文档、支持持久化的问答系统:

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
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
#!/usr/bin/env python3
"""基于 LlamaIndex + Hermes 的文档问答系统"""

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

STORAGE_DIR = "./qa_storage"
DOCS_DIR = "./docs"

def init_models():
"""初始化模型配置"""
Settings.llm = Ollama(
model="hermes3:8b",
base_url="http://localhost:11434",
request_timeout=120.0,
temperature=0.3
)
Settings.embed_model = OllamaEmbedding(
model_name="nomic-embed-text",
base_url="http://localhost:11434"
)

def build_index():
"""从文档构建索引"""
if not os.path.exists(DOCS_DIR):
os.makedirs(DOCS_DIR)
print(f"请将文档放入 {DOCS_DIR} 目录")
return None

documents = SimpleDirectoryReader(DOCS_DIR).load_data()
if not documents:
print("没有找到文档")
return None

print(f"加载了 {len(documents)} 个文档,正在建立索引...")
index = VectorStoreIndex.from_documents(
documents,
show_progress=True
)

# 持久化
index.storage_context.persist(persist_dir=STORAGE_DIR)
print(f"索引已保存到 {STORAGE_DIR}")
return index

def load_index():
"""加载已有索引"""
if not os.path.exists(STORAGE_DIR):
return None
storage_context = StorageContext.from_defaults(persist_dir=STORAGE_DIR)
return load_index_from_storage(storage_context)

def main():
init_models()

# 尝试加载已有索引,没有就重新构建
index = load_index()
if index is None:
index = build_index()
if index is None:
return

query_engine = index.as_query_engine(
similarity_top_k=5,
response_mode="compact"
)

print("\n文档问答系统已启动(输入 quit 退出,输入 rebuild 重建索引)")
print("-" * 60)

while True:
question = input("\n你的问题: ").strip()

if question.lower() in ["quit", "exit", "q"]:
break

if question.lower() == "rebuild":
index = build_index()
if index:
query_engine = index.as_query_engine(
similarity_top_k=5,
response_mode="compact"
)
continue

if not question:
continue

print("\n正在检索和生成答案...")
response = query_engine.query(question)
print(f"\n回答: {response}")

# 显示引用来源
if response.source_nodes:
print("\n--- 参考来源 ---")
for i, node in enumerate(response.source_nodes, 1):
source = node.metadata.get("file_name", "未知")
score = node.score if node.score else "N/A"
print(f" [{i}] {source} (相似度: {score})")

if __name__ == "__main__":
main()

性能优化技巧

1. 嵌入计算加速

如果文档量大,嵌入计算是瓶颈。可以用批量处理:

1
2
3
4
5
Settings.embed_model = OllamaEmbedding(
model_name="nomic-embed-text",
base_url="http://localhost:11434",
embed_batch_size=32 # 一次处理32个块
)

2. 查询重写

用户的问题经常不够精确。可以让模型先改写问题再检索:

1
2
3
4
5
6
7
from llama_index.core.query_engine import RetrieverQueryEngine
from llama_index.core.indices.query.query_transform import HyDEQueryTransform

# HyDE:先让模型生成一个假设性答案,用这个答案去检索
hyde = HyDEQueryTransform(include_original=True)
query_engine = index.as_query_engine()
hyde_query_engine = TransformQueryEngine(query_engine, hyde)

3. 重排序

向量检索的结果不一定都相关,加一层重排序能提升质量:

1
2
3
4
5
6
7
8
from llama_index.core.postprocessor import SimilarityPostprocessor

query_engine = index.as_query_engine(
similarity_top_k=10, # 先检索多一些
node_postprocessors=[
SimilarityPostprocessor(similarity_cutoff=0.7) # 过滤掉相似度低的
]
)

和 LangChain 的 RAG 方案对比

如果你之前看过 Hermes + LangChain 那篇里的 RAG 部分,可能会问:这俩有啥区别?

简单说:LangChain 是通用框架,RAG 只是它的一个功能;LlamaIndex 是专门为 RAG 设计的。在文档加载、切分、索引、查询这条链路上,LlamaIndex 的抽象更完整,开箱即用的功能更多。

如果你的项目主要就是做文档问答,用 LlamaIndex 会舒服很多。如果你还需要做其他 LLM 应用(对话、Agent、工作流),那 LangChain 的生态更全面。

当然,两个框架也可以混用——LlamaIndex 做索引和检索,LangChain 做上层应用逻辑,这种搭配在 cocoloop 社区里有人实践过,效果不错。

如果你对 Agent 方面更感兴趣,可以接着看 Hermes + CrewAI 组建 AI 团队 那篇。

Hermes 在 RAG 场景下有个天然优势——它的指令遵循能力很强,让它”只根据给定上下文回答”这种约束它能执行得很到位,幻觉率比一些同级别模型低不少。这对文档问答系统来说太关键了。

参与讨论

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

前往 cocoloop 社区 →