向量库内容更新
更新知识库,若重新入库,清空旧 Qdrant collection 后重新索引,耗时很长;
总览:一次入库在干什么
data/*.md|txt|pdf
→ 判断变没变(MD5 + manifest)
→ 加载原文
→ 切成小块(chunk)
→ 打上 source / chunk_id 等元数据
→ embedding + 写入 Qdrant
→ 清掉磁盘上已删文件的残留
工具函数与变量
变量
py
DATA_DIR = Path(__file__).parents[1] / "data"
MANIFEST_PATH = DATA_DIR / ".index_manifest.json"
COLLECTION_NAME = os.getenv("QDRANT_COLLECTION")
SUPPORTED_SUFFIXES = {".md", ".txt", ".pdf"}
文件哈希
py
def file_md5(path: Path) -> str:
# 创建一个 MD5 哈希计算器对象 h,你可以不断往里面"喂"数据,最后它输出一个 固定 32 位的十六进制字符串(即文件指纹)。
h = hashlib.md5()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(8192), b""):
h.update(chunk)
return h.hexdigest()
manifest 读写
load_manifest返回一个 dictstr, str,即 { 文件名: 上次索引时的MD5哈希值 },类似于:
json
{
"langchain_guide.md": "a3f2c8d9e1b4...",
"api_reference.pdf": "7c4e9f2a0b3...",
"notes.txt": "1d5e8a3b7f2..."
}
py
def load_manifest() -> dict[str, str]:
"""返回 {filename: md5hex} 映射。"""
if MANIFEST_PATH.exists():
return json.loads(MANIFEST_PATH.read_text(encoding="utf-8"))
return {}
def save_manifest(manifest: dict[str, str]):
MANIFEST_PATH.write_text(
json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8"
)
文档处理
py
def load_local_documents(file_path: Path) -> list[Document]:
path = str(file_path).lower()
if path.endswith(".pdf"):
loader = PyPDFLoader(file_path)
elif path.endswith(".txt") or path.endswith(".md"):
loader = TextLoader(file_path, encoding="utf-8")
else:
raise ValueError(f"Unsupported file type: {file_path}")
return loader.load()
def split_documents(
documents: list[Document], chunk_size: int = 200, chunk_overlap: int = 20
) -> list[Document]:
splitter = RecursiveCharacterTextSplitter(
chunk_size=chunk_size, chunk_overlap=chunk_overlap
)
return splitter.split_documents(documents)
def enrich_documents(chunks: list[Document], file_path: Path) -> list[Document]:
doc_type = file_path.suffix.lstrip(".")
source = file_path.name
for i, chunk in enumerate(chunks):
page = chunk.metadata.get("page", -1)
chunk.metadata.update(
{
"source": source,
"doc_type": doc_type,
"chunk_id": f"{source}_p{page}_c{i}",
"page": page,
}
)
return chunks
给某个字段创建索引
默认情况下,payload 只是跟着点存着,并没有为某个字段建查询索引。
create_payload_index 就是告诉 Qdrant:请给某个 payload 字段建索引,之后按这个字段过滤会快很多。
collection_name:在哪张向量表上建索引
field_name="metadata.source":要索引的路径:payload 里 metadata 对象下的 source 字段
field_schema=PayloadSchemaType.KEYWORD:按 精确字符串匹配 来索引(等值过滤),不是全文分词
py
def create_source_payload_index(collection_name: str):
"""在 metadata.source 字段上建 keyword 索引,加速按文件名过滤删除。"""
client = get_qdrant_client()
client.create_payload_index(
collection_name=collection_name,
field_name="metadata.source",
field_schema=PayloadSchemaType.KEYWORD,
)
按索引删除
py
def delete_by_source(collection_name: str, source_name: str):
"""删除 collection 中所有 metadata.source == source_name 的 point。
Returns:
删除的 point 数量(Qdrant 返回的 operation_id,实际删除数需从日志确认)。
"""
client = get_qdrant_client()
client.delete(
collection_name=collection_name,
points_selector=FilterSelector(
filter=Filter(
must=[
FieldCondition(
key="metadata.source", match=MatchValue(value=source_name)
)
]
)
),
)
主流程
环境变量与目标表
py
def main():
if not COLLECTION_NAME:
raise SystemExit("QDRANT_COLLECTION 环境变量未设置")
manifest = load_manifest()
vec_store = get_vec_store(COLLECTION_NAME)
collection_initialized = False
total_added = 0
total_deleted_files = 0
扫描本地文件 + 增量判断
py
current_files: set[str] = set()
for file_path in DATA_DIR.rglob("*"):
if file_path.suffix.lower() not in SUPPORTED_SUFFIXES:
continue
if file_path.name.startswith("."): # 跳过 .index_manifest.json 等隐藏文件
continue
source_name = file_path.name
current_files.add(source_name)
current_hash = file_md5(file_path)
if manifest.get(source_name) == current_hash:
logger.info(f"[跳过] {source_name}(内容未变更)")
continue
# 文件有变更或是新文件 → 删旧 chunk,写新 chunk
if source_name in manifest:
logger.info(f"[变更] {source_name} → 删除旧 chunk ...")
delete_by_source(COLLECTION_NAME, source_name)
documents = load_local_documents(file_path)
if not documents:
logger.warning(f"[跳过] {source_name} 加载后为空")
continue
chunks = split_documents(documents)
enriched_chunks = enrich_documents(chunks, file_path)
# 首次写入前确保 collection 存在,并建 payload 索引
if not collection_initialized:
sample_vec = get_embeddings().embed_documents(
[enriched_chunks[0].page_content]
)
size = len(sample_vec[0])
ensure_collection_exists(COLLECTION_NAME, size)
create_source_payload_index(COLLECTION_NAME)
collection_initialized = True
vec_store.add_documents(enriched_chunks)
manifest[source_name] = current_hash
total_added += len(enriched_chunks)
logger.info(f"[完成] {source_name} → 写入 {len(enriched_chunks)} 条 chunk")
清理磁盘已删除的文件在 Qdrant 中的残留
py
stale_files = set(manifest.keys()) - current_files
for source_name in stale_files:
logger.info(f"[清理] {source_name} 已从磁盘删除 → 从 Qdrant 移除旧 chunk")
delete_by_source(COLLECTION_NAME, source_name)
del manifest[source_name]
total_deleted_files += 1
save_manifest(manifest)
logger.info(
f"增量索引完成:新增/更新 {total_added} 条 chunk,"
f"清理已删除文件 {total_deleted_files} 个"
)
向量库中读取
py
def load_documents_from_qdrant():
client = get_qdrant_client()
documents: list[Document] = []
offset = None
while True:
# scroll 适合「导出 / 重建索引 / 全量加载」这类场景。
# # 从某张表中按分页加载全量数据
points, offset = client.scroll(
collection_name=COLLECTION_NAME, # 要读取的 Qdrant collection 名称
limit=100, # 每页最多返回 100 条,避免一次拉太多导致内存或超时问题
offset=offset, # 上一页的结束点,第一次传 None 表示从头开始
with_payload=True, # 回每条 point 的 payload(文本、metadata 等业务字段)
with_vectors=False, # 不返回 embedding 向量。这里只重建 Document,不需要向量,设为 False 可减少网络传输和内存占用
)
for point in points:
payload = point.payload or {}
text = payload.get("page_content", "")
metadata = payload.get("metadata", {})
source = metadata.get(
"source",
)
chunk_id = metadata.get("chunk_id", "")
if not text:
continue
documents.append(
Document(
page_content=text,
metadata={
"source": source,
"chunk_id": chunk_id,
},
)
)
if offset is None:
break
return documents