39 查询/更新向量库

向量库内容更新

更新知识库,若重新入库,清空旧 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