LlamaIndex 搭本地文档问答,可验证的最短路径是:先拿 3~5 个文件跑通“加载 → 切分 → 建索引”三段,把索引持久化到本地目录,然后单独调检索接口,看候选段落里到底出现了哪一句关键内容。检索这一层没确认之前就接生成模型,出问题时很难判断是切分把答案切碎了、嵌入模型不匹配中文,还是生成阶段漏掉了正确段落。所以顺序建议是:先落盘,再查检索,最后才接问答。
适用场景:本地小规模文档问答的第一次搭建。操作动作:固定一份带唯一关键句的小文档集,跑通加载、切分、建索引并 persist 到本地目录,再用 as_retriever 打印候选段落文本与分数。验证方式:候选段落里是否出现那句唯一关键句、命中的文件名与排名是否稳定。风险边界:相似度分数只在同一嵌入模型、同一索引内可比;换嵌入模型必须重建索引并重新对照,旧索引文件不能直接复用。
准备一份可对照的小文档集并写下预期答案
不要一上手就丢进几百个 PDF。先用几个内容互不重叠的小文件,让每次检索结果都能一眼看出命中来源。目录可以简单成这样:
docs/
├── 巡检制度.md
├── 值班安排.md
└── 故障上报流程.md
每个文件里埋一句唯一关键句,这句话在其它文件里不要重复出现,否则命中哪个文件都算“对”,没法判断嵌入模型的好坏。例如“巡检制度.md”里写“夜班巡检每四小时一次”,“值班安排.md”里写“节假日值班由第二轮班次承担”。关键句最好带一个具体名词(时间、角色、阈值),方便肉眼核对。
同时把你的问题清单先写下来,三到五条就够,每条后面写上“预期命中的文件”和“预期包含的关键句”。这一步不做,后面打印出来的候选段落就只能凭感觉判断,不同嵌入模型之间的差异也无从比较。问题建议覆盖两类:一类关键词几乎原样出现在文档里,一类是换个说法提问,用来看检索是否只做字面匹配。
写出建索引的最小脚本骨架
先把依赖装好,本地嵌入模型可以走 HuggingFace 后端,不依赖外部接口:
pip install llama-index-core llama-index-embeddings-huggingface
# 如需本地生成,再按所选模型补装 llama-index-llms-* 对应包
建索引脚本只做三件事:读文件、切分、组装索引。生成用的 llm 在检索阶段暂时用不到,可以先不配置。
# build_index.py
from llama_index.core import SimpleDirectoryReader, VectorStoreIndex, Settings
from llama_index.core.node_parser import SentenceSplitter
from llama_index.embeddings.huggingface import HuggingFaceEmbedding
Settings.embed_model = HuggingFaceEmbedding(model_name="BAAI/bge-small-zh-v1.5")
docs = SimpleDirectoryReader("docs", recursive=True).load_data()
splitter = SentenceSplitter(chunk_size=256, chunk_overlap=32)
nodes = splitter.get_nodes_from_documents(docs)
print("documents:", len(docs), "nodes:", len(nodes))
index = VectorStoreIndex(nodes)
print("index:", type(index).__name__)
运行后必须能看到 documents 和 nodes 两个数字,节点数通常大于文件数,如果两者相等或节点数为 0,说明切分没生效或文件没被读到。切分依赖的 tokenizer 需要本地可用,若运行时报词表下载失败,把它替换成按字符切分的配置,或提前把词表文件准备好,再重新跑一次看节点数是否变化。
把索引保存到本地目录并确认文件真的落盘
在上一段脚本末尾追加持久化调用,目录名带上嵌入模型标识,便于后面区分:
index.storage_context.persist(persist_dir="./storage_bge_small")
print("persisted to ./storage_bge_small")
跑完后到目录下确认产物。默认配置下通常会看到 docstore.json、index_store.json、vector_store.json、graph_store.json、image__vector_store.json 这类文件;如果某个文件缺失,说明对应存储没写入,要结合日志确认。文件大小不会为 0,节点越多样式文件越大,这一点可以直接用 ls -l 观察。
重新加载时不要再读原始文档,直接读这个目录:
from llama_index.core import StorageContext, load_index_from_storage, Settings
from llama_index.embeddings.huggingface import HuggingFaceEmbedding
Settings.embed_model = HuggingFaceEmbedding(model_name="BAAI/bge-small-zh-v1.5")
storage_context = StorageContext.from_defaults(persist_dir="./storage_bge_small")
index = load_index_from_storage(storage_context)
print("nodes loaded:", len(index.docstore.docs))
关键约束:加载时用的嵌入模型必须和落盘时一致,否则查询向量和库里的向量不在同一个空间,检索结果基本没有意义。加载后的节点数应与建索引时打印的数字一致,不一致就说明落盘不完整。
用检索接口取回候选段落再交给问答
检索和生成分开看。先只调 retriever,拿到带分数的候选节点:
retriever = index.as_retriever(similarity_top_k=3)
results = retriever.retrieve("夜班巡检多长时间一次?")
for i, node in enumerate(results, 1):
score = None if node.score is None else round(node.score, 4)
print(i, "score=", score, "file=", node.metadata.get("file_name"))
print(node.get_content()[:150])
print("-" * 40)
用 top_k=3 先试,命中太散时再调整。逐个对照的检查表可以固定成这几项:候选段落是否出现了预定的唯一关键句(是/否)、命中的文件名是否与预期一致、关键句排在第几位、相似度分数是否明显高于其余候选。分数只用于同一索引内的排序参考,不同嵌入模型的绝对值不要直接横向比较。
只有关键句稳定出现在前三时,再把 index.as_query_engine() 接上生成模型做问答。如果生成答案和候选段落矛盾,问题多半在生成阶段;如果候选段落里根本没有关键句,回去调 top_k、chunk_size 或换嵌入模型,别先动提示词。
换掉嵌入模型后重跑同一问题,观察命中变化
确认切分没问题后,只改嵌入模型这一处变量,其余参数全部保持原样,落盘目录也换成新的,避免覆盖旧索引:
Settings.embed_model = HuggingFaceEmbedding(model_name="BAAI/bge-m3")
# 其余代码不变,persist_dir 改为 ./storage_bge_m3
重建索引后,用同一份问题清单逐条重跑,记录三件事:命中的文件名是否变化、关键句的排名从第几位变成第几位、分数分布是否更集中。如果两条记录只有嵌入模型不同,差异就可以归因到嵌入;如果连切分参数也一起改了,就无法区分是哪一项造成的。注意不同模型的向量维度可能不同,旧目录里的 vector_store.json 不能和新模型混用,重建而不是追加。
做完这轮对照,你会得到一份可复用的基线:文档集、问题清单、每个嵌入模型的命中记录。以后调整 chunk_size、top_k 或换成别的文档时,都拿同一份清单重跑一遍,判断依据始终是“那句唯一关键句有没有进候选段落”,而不是感觉回答看起来还行。