LlamaIndex:摄取、索引与检索

LlamaIndex:摄取、索引与检索

「本系列第 13/17 章」。基线仍是 LlamaIndex 0.14.23。上一章给出对象与配置;本章只讲“文档如何变成可检索的节点,以及查询如何取回它们”。读完应能独立回答三句话:嵌入发生在哪一步、文本存在哪一层、一次 query 经过哪些对象。答不全,就还没有把摄取和检索从“会调用 API”提升到“能排障”。

1. 两条摄取路径,不要叠用

把文档变成带向量的节点,框架提供两条官方入口。它们都会做嵌入,职责却不同。把“切分”和“嵌入”看成两个开关,就能看清为什么不能两条路一起走。

路径 A:VectorStoreIndex.from_documents()
BaseIndex.from_documents() 先调用 docstore.set_document_hash,再跑 Settings.transformations(通常只是切分,默认是 SentenceSplitter),然后把节点交给 Index 构造。VectorStoreIndex_get_node_with_embedding() 里自己调用 embed model,按批次写入 vector store。这条路径不要求 transformation 链里包含 embedding。use_async=True 只影响初次构建时的嵌入和写入,日常异步插入应使用 ainsert / ainsert_nodes

路径 B:IngestionPipeline
Pipeline 不是 Index,而是“依次变换节点,并可选写入存储”的执行器。未传入 transformations 时,默认链是:

1
[SentenceSplitter(), Settings.embed_model]

复用 Settings.transformations。因此你在 Settings 里换过 node_parser,空构造的 Pipeline 仍可能用自己的 SentenceSplitter()。嵌入发生在 pipeline 的最后一步 transform。只有 node.embedding is not None 的结果才会被 vector_store.add();自定义链如果只放了切分器,返回的节点看起来正常,向量库却会静默不写。这是本版本里最容易浪费一下午的行为,不是报错,是少写。

两条路径都做嵌入,所以不要叠用:先让 Pipeline 嵌入,再把同一批节点丢给 VectorStoreIndex(nodes=...),Index 仍会按自己的逻辑再算一遍。选一条,走完。一次性离线建库、逻辑简单,用路径 A。需要增量、去重、对某一跳 transform 做缓存,用路径 B,并显式写出 [splitter, embed_model]

生产上常见的干净组合是:Pipeline 负责把变化过的文档写入外部 store,再用 VectorStoreIndex.from_vector_store() 挂接。后者要求 stores_text=True,会丢弃你传入的旧 storage_context,并且不会重新扫描或重新嵌入已有 collection。挂接成功只表示对象组装成功,不表示维数、度量或过滤字段已经对齐。

Pipeline 的 docstore 主要承担输入文档的 hash 去重,写入的是变换前的输入,不等于 VectorStoreIndex 用来回填检索节点的那个 docstore。把两者当成同一个“文档仓库”,后续删除和回填会对不上。

2. 去重策略与存储拓扑

Pipeline 的 DocstoreStrategy 默认是 UPSERTS。它按 ref_doc_id(否则 id_)加 hash 判断:新文档执行;同 ID 内容变了,先对 docstore 和 vector store 按 ref 删除,再执行。它处理“本次输入里消失的文档”。若要把消失视为删除,才用 UPSERTS_AND_DELETE——而且本次输入必须是完整快照。拿一小批增量去跑这个策略,未出现的历史文档会被删掉。

同一批里相同 ref_doc_id 最终只保留最后一个输入。只有 docstore、没有 vector store 时,UPSERTS 会在本次执行降级为 DUPLICATES_ONLY(对象上的策略属性本身不变),并给出 warning。store_doc_text=False 只保留 hash 与关系;后面若还想从这份 docstore 取原文,会失败。DUPLICATES_ONLY 只按全局 hash 跳过重复,不会按 ID 更新旧向量。

Index 侧的拓扑由 vector store 的 stores_text 决定。这是 0.14.23 里比“选哪个品牌的库”更先要回答的问题:

stores_text 普通文本节点是否进 docstore / IndexDict 查询结果从哪来
False store 返回 ID,再按 IndexDict 回填节点
True(且未 override) store 直接返回节点

store_nodes_override=True 时,即便 store 自己存文本,也会在本地再存一份,便于部分 ref_doc 操作。from_vector_store()stores_text=False 时直接抛 ValueError,因为挂接后没有可回填的本地映射。选外部库之前先问清楚:文本是库自己存,还是要靠本地 docstore 回填。外部库的持久化也不由 StorageContext.persist() 代管。

删除必须能按 ref_doc_id 找到一个 Document 的全部 chunk。集成如果没把这个字段写进 metadata,delete_ref_doc 就删不干净。refresh_ref_docs 会比较 docstore 里的 hash,只处理新增和内容变化项;前提是那份 hash 真的被存下来了。

3. 标准 RAG 查询链

查询不是 Index 的私有魔法,而是固定三件套:

1
2
3
4
RetrieverQueryEngine
= BaseRetriever
+ BaseNodePostprocessor[]
+ BaseSynthesizer

index.as_query_engine(similarity_top_k=10, node_postprocessors=[...], response_mode="compact") 会先 as_retriever(**kwargs),再 RetrieverQueryEngine.from_args()。同一组关键字会先后经过两层工厂。similarity_top_k 必须在创建 Retriever 时生效;事后把它传给已经构造好的 Retriever 的 from_args(),会落入未使用的 **kwargs,看起来像设了 top-k,实际检索宽度没变。

一次同步查询的顺序是:字符串变成 QueryBundleretrieve(含子类 _retrieve 与 IndexNode 递归展开)→ 按配置顺序跑每个 postprocessor → synthesize。异步对应 aretrieve / apostprocess_nodes / asynthesize。后处理器是顺序执行,因为每一步的输出是下一步的输入,不能 gather。基类 _aretrieve() 默认回退同步 _retrieve(),所以“调用了 aquery”不等于整条链非阻塞。

VectorIndexRetriever 先判断是否需要 query embedding:TEXT_SEARCHSPARSE 通常不要,其余在 store 认为这是 embedding 查询时才生成。然后组装 VectorStoreQuery(含 similarity_top_k、filters、mode、hybrid 相关字段),调用 store,必要时用 docstore 回填。source_nodes 是合成输入,不保证每段都被 LLM 完整看见,Synthesizer 还可能 truncate 或 repack。

过滤器应尽量下推到 store 的 MetadataFilters。权限尤其不要只写在 prompt 里“请忽略无权限段落”;必须在 store 预过滤或 postprocessor 里删掉。后处理顺序也有教学法上的常规:先做廉价过滤和相似度截断,再 rerank,最后做必须执行的 ACL。先截成很小的 top-N 再重排,可能把正确答案提前丢掉。

调试时把引擎拆开用:engine.retrieve() 只看检索加后处理,engine.synthesize() 只看合成。候选集合不对,先不要调 prompt。IndexNode 若指向另一个 QueryEngine,展开后的响应会变成新的 TextNode,原来的 source 不会自动并进顶层来源,引用展示时要自己处理。

4. 本章的选型建议

  • 一次性离线建库、逻辑简单:from_documents(),让 Index 内部嵌入。
  • 要增量、去重、可缓存某一跳 transform:单独用 IngestionPipeline,显式写出切分器和 embed model,默认 UPSERTS
  • 外部向量库已存文本:from_vector_store(),并在连接前保证 embedding 模型、维数和距离度量一致。
  • 需要按文档更新:保证 ref_doc_id 从切分阶段就写对,删除走 delete_ref_doc
  • 查询调试:先 retrieve 再 synthesize;权限过滤放在 store 或 postprocessor,不放在提示词。

把“嵌入发生在哪、文本存在哪、query 经过谁”写成部署记录,比抄一份通用 RAG 模板更有用。同一套对象换了存储后端,记录仍能指导你该不该回填、该不该重建。团队交接时与其给一份 Notebook,不如给这三句话和对应的 stores_text、策略枚举。

还可以用一次失败演练来巩固。假设业务更新了一份政策 PDF:路径 A 往往意味着整库重做,或自己调用 update_ref_doc / refresh_ref_docs;路径 B 则应保证 docstore 已持久化,并且本批输入不要误用 UPSERTS_AND_DELETE。假设检索能返回节点、答案却像没看见正文:先查 stores_text 是否让文本留在了你没有回填的那一侧,再查 postprocessor 是否把高分节点裁掉。假设异步接口仍然堵住事件循环:回到 Retriever 和 vector store 有没有真正的 _aretrieve / aquery,不要责怪编排器。

下一章进入合成模式、多轮对话,以及本系列推荐的本地推理接法。到那时,调用次数将按 repack 后的块数来算,而不是按你检索了几条;如果本章的候选集合已经偏了,合成章无论怎么换模式都救不回来。先把节点找对,再谈怎样把节点写成答案。

文章互动

阅读 --

留言

0 条留言

正在加载留言…