个人主页: > for_ever_love__ <(欢迎各位大佬莅临😊)
其他栏目: > 大模型开发从0到1 <
其他栏目: > iOS项目总结大全 <
其他栏目: > 我想学python了 <
其他栏目: > iOS UI <
文章目录
- [LangChain MultiVectorRetriever:一个文档存多个向量](#LangChain MultiVectorRetriever:一个文档存多个向量)
-
- 一、核心思路
- 二、用法一:小块检索,返回大块
- 三、用法二:用"摘要"做向量
- 四、用法三:用"假设问题"做向量(强烈推荐)
- 五、用法四:混合多种表示
- [六、持久化:别用 InMemoryStore](#六、持久化:别用 InMemoryStore)
- [七、什么时候用它,什么时候用 ParentDocument](#七、什么时候用它,什么时候用 ParentDocument)
- 八、常见坑
- 九、小结
LangChain MultiVectorRetriever:一个文档存多个向量
切分有个绕不开的矛盾:小块检索准但缺上下文,大块上下文全但检索不准 。
父子文档检索(前面讲过)是一种解法,
MultiVectorRetriever 是另一种更灵活的------同一个文档,存多种向量表示。
这篇讲它的四种用法,以及什么时候它比父子文档更合适。
一、核心思路
传统做法:一个 chunk → 一个向量。
MultiVector 的做法:一个文档 → 多个向量,都指向它。
原始文档 D(完整内容,存在 docstore)
↑ ↑ ↑
向量1 向量2 向量3
(摘要) (子块A) (假设问题)
检索时:
- 用任意一种向量去匹配,命中哪个都算数;
- 但返回的始终是原始文档 D 的完整内容。
这就同时拿到了"精准匹配"和"完整上下文"。
二、用法一:小块检索,返回大块
这是最常用、也最直观的一种。
python
from langchain.retrievers.multi_vector import MultiVectorRetriever
from langchain_community.vectorstores import Chroma
from langchain_openai import OpenAIEmbeddings
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain.storage import InMemoryStore
from langchain_core.documents import Document
import uuid
# 原始文档
docs = [
Document(page_content="第三章 差旅报销......(2000 字的完整章节)",
metadata={"source": "handbook.md", "chapter": "3"}),
]
# 1. 给每个文档分配 id
doc_ids = [str(uuid.uuid4()) for _ in docs]
# 2. 把每个文档切成小块(这些小块用来做向量)
child_splitter = RecursiveCharacterTextSplitter(chunk_size=200)
sub_docs = []
for i, doc in enumerate(docs):
for chunk in child_splitter.split_text(doc.page_content):
sub_docs.append(Document(
page_content=chunk,
metadata={"doc_id": doc_ids[i]}, # 关键:指回父文档
))
# 3. 向量库存小块
vectorstore = Chroma(collection_name="multi",
embedding_function=OpenAIEmbeddings())
vectorstore.add_documents(sub_docs)
# 4. docstore 存完整文档
store = InMemoryStore()
store.mset([(doc_ids[i], docs[i]) for i in range(len(docs))])
# 5. 组装
retriever = MultiVectorRetriever(
vectorstore=vectorstore,
docstore=store,
id_key="doc_id",
)
results = retriever.invoke("住宿费标准上浮条件是什么?")
print(len(results[0].page_content)) # 完整的 2000 字章节
和 ParentDocumentRetriever 的区别:
| ParentDocumentRetriever | MultiVectorRetriever | |
|---|---|---|
| 父子关系 | 自动维护 | 手动用 id 关联 |
| 每个父文档的向量 | 一组(同粒度子块) | 可以是多组、不同粒度/不同表示 |
| 去重 | 自动 | 默认不去重,可能返回重复 |
所以要手动去重:
python
def dedup(docs):
seen, out = set(), []
for d in docs:
key = d.metadata.get("doc_id") or d.page_content[:80]
if key not in seen:
seen.add(key)
out.append(d)
return out
results = dedup(retriever.invoke("问题"))
三、用法二:用"摘要"做向量
有些文档很长很啰嗦,直接用原文向量化效果不好。
先让 LLM 生成一段摘要,用摘要做向量。
python
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
summary_chain = (
ChatPromptTemplate.from_template("用 100 字概括下面文档的核心内容:\n{doc}")
| ChatOpenAI(model="gpt-4o-mini", temperature=0)
| StrOutputParser()
)
summaries = summary_chain.batch([{"doc": d.page_content} for d in docs],
config={"max_concurrency": 5})
summary_docs = [
Document(page_content=s, metadata={"doc_id": doc_ids[i]})
for i, s in enumerate(summaries)
]
vectorstore.add_documents(summary_docs)
为什么有用? 摘要去掉了细节噪声,
向量更能代表"这篇文档在讲什么"。
对于"这篇文档是讲什么的"这类宽泛提问效果特别好。
代价 :建索引时要对每篇文档调一次 LLM。
1000 篇文档就是 1000 次调用------用便宜模型批量跑。
四、用法三:用"假设问题"做向量(强烈推荐)
这是我最喜欢的一种。思路是:
让 LLM 反过来生成"这篇文档能回答哪些问题",
用这些问题做向量。
python
QUESTION_PROMPT = ChatPromptTemplate.from_template("""
下面是文档的一段内容。请生成 3 个「这段内容能够回答的问题」。
要求:问题要具体、用词接近真实用户的提问方式。每行一个,不要编号。
文档内容:
{doc}
""")
question_chain = (
QUESTION_PROMPT
| ChatOpenAI(model="gpt-4o-mini", temperature=0)
| StrOutputParser()
)
def build_question_vectors(docs, doc_ids):
out = []
for i, doc in enumerate(docs):
raw = question_chain.invoke({"doc": doc.page_content[:2000]})
for q in raw.strip().split("\n"):
q = q.strip("- ").strip()
if q:
out.append(Document(page_content=q,
metadata={"doc_id": doc_ids[i]}))
return out
question_docs = build_question_vectors(docs, doc_ids)
vectorstore.add_documents(question_docs)
为什么效果好?
因为检索本质是"用问题匹配文档 ",
但问题和文档的表述方式天然不同 ------
用户问"咋报销",文档写"费用报销流程"。
用"假设问题"做向量后,
检索变成了"用户问题 vs 假设问题"的匹配,两边都是问题,
表述空间一致了,匹配准确率明显提升。
这在 FAQ、制度文档、产品手册上效果非常显著。
五、用法四:混合多种表示
最强的做法是几种向量都存,让它们互相补:
python
# 1. 小块向量(针对细节问题)
vectorstore.add_documents(sub_docs)
# 2. 摘要向量(针对宽泛问题)
vectorstore.add_documents(summary_docs)
# 3. 假设问题向量(针对口语化提问)
vectorstore.add_documents(question_docs)
三者指向同一个 doc_id,
任何一路命中都会返回完整原文。
代价是向量库体积变成 3 倍。
我的建议排序:
| 预算 | 选哪种 |
|---|---|
| 只能选一种 | 假设问题(提升最明显) |
| 能选两种 | 假设问题 + 小块 |
| 不差钱 | 三种全上 |
六、持久化:别用 InMemoryStore
和父子文档那篇一样,InMemoryStore 重启就丢 ,
会导致"命中了向量却取不到原文"。
python
from langchain_community.storage import RedisStore
store = RedisStore(redis_url="redis://localhost:6379")
# 或自己实现基于数据库/文件的 BaseStore
七、什么时候用它,什么时候用 ParentDocument
| 场景 | 推荐 |
|---|---|
| 只需"小块检索 + 大块返回" | ParentDocumentRetriever(代码少,自动去重) |
| 想用摘要/假设问题做向量 | MultiVectorRetriever |
| 想同时存多种表示 | MultiVectorRetriever |
| 文档很短、不需要切分 | MultiVector(直接存原文 + 假设问题) |
一句话:要"多种表示"就用 MultiVector,
只是"检索小、返回大"用 ParentDocument 更省事。
八、常见坑
| 现象 | 原因 | 解法 |
|---|---|---|
| 返回了重复的文档 | MultiVector 不去重 | 按 doc_id 手动去重 |
| 报"找不到 doc" | docstore 里没有对应 id |
检查 mset 是否都写进去了 |
| 重启后取不到原文 | 用了 InMemoryStore |
换 Redis / 数据库 |
| 向量库巨大 | 存了太多种表示 | 优先保留假设问题 |
| 建索引很慢 | 逐篇调 LLM 生成摘要/问题 | batch + max_concurrency + 便宜模型 |
九、小结
- MultiVector 的核心:一个文档多个向量,命中任一都返回完整原文;
- 四种表示:子块(细节)、摘要(宽泛)、假设问题(口语化,效果最好)、混合;
- 假设问题解决了"问题和文档表述空间不一致"的根本问题,性价比最高;
- 和 ParentDocument 的区别:它不去重、需手动关联 id,
但支持任意多种表示; - 必须用持久化的 docstore (Redis/数据库),
InMemoryStore重启即丢; - 建索引的 LLM 调用要批量 + 并发 + 便宜模型。
下一篇讲 SelfQueryRetriever------让模型自己把自然语言转成结构化查询。