你有没有遇到过这种场景:公司内部文档堆积如山,想找一个信息得翻半天。或者你手上有几十份 PDF 研究报告,想快速找到某个数据点但又记不清在哪份报告里。
这种需求用传统搜索引擎解决不了——它只能做关键词匹配,理解不了你真正想问什么。但大模型可以。问题是大模型没读过你的私有文档。
解决方案就是 RAG(检索增强生成):先从文档里检索相关内容,再让大模型基于这些内容回答问题。而 LlamaIndex 就是做这件事最专业的框架。
今天用 LlamaIndex + Hermes 搭一个文档问答系统。
架构概览 整个流程分两个阶段:
索引阶段 (离线执行一次):
加载文档(PDF、Word、TXT、网页……)
把文档切成小块(chunking)
用嵌入模型把每个块转成向量
存入向量数据库
查询阶段 (每次提问都执行):
把用户问题转成向量
在向量数据库里找最相似的文档块
把这些块作为上下文,连同问题一起发给 Hermes
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
快速上手:五分钟跑通 先用最少的代码跑通整个流程:
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, Settingsfrom llama_index.llms.ollama import Ollamafrom llama_index.embeddings.ollama import OllamaEmbeddingSettings.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" ) 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 PDFReaderloader = PDFReader() documents = loader.load_data(file="./report.pdf" ) 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 SimpleWebPageReaderloader = 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 DatabaseReaderreader = 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 Documentdocuments = [] 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 SentenceSplitterparser = SentenceSplitter( chunk_size=1024 , chunk_overlap=200 ) 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 SemanticSplitterNodeParserparser = 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_storageindex = 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 chromadbfrom llama_index.vector_stores.chroma import ChromaVectorStorechroma_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 , response_mode="compact" ) 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 query_engine = index.as_query_engine( similarity_top_k=10 , response_mode="refine" ) 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, MetadataFilterfilters = 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 """基于 LlamaIndex + Hermes 的文档问答系统""" import osfrom llama_index.core import ( VectorStoreIndex, SimpleDirectoryReader, StorageContext, load_index_from_storage, Settings ) from llama_index.llms.ollama import Ollamafrom llama_index.embeddings.ollama import OllamaEmbeddingSTORAGE_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 )
2. 查询重写
用户的问题经常不够精确。可以让模型先改写问题再检索:
1 2 3 4 5 6 7 from llama_index.core.query_engine import RetrieverQueryEnginefrom llama_index.core.indices.query.query_transform import HyDEQueryTransformhyde = 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 SimilarityPostprocessorquery_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 场景下有个天然优势——它的指令遵循能力很强,让它”只根据给定上下文回答”这种约束它能执行得很到位,幻觉率比一些同级别模型低不少。这对文档问答系统来说太关键了。