AI项目博客
记录 AI 工程实践与大模型应用

从 0 搭一套 Python AI 知识库(五):把切片变成向量

本系列共 9 篇,从空目录搭到可部署的 Python RAG 知识库。这是第 5 篇。

切片有了,但还只是一堆文本。这一篇把每张切片变成一串 1024 个数字的向量,存进 PostgreSQL 的 pgvector,并处理失败重试、状态回写和卡死任务。

切片向量化

图:切片管理,每条切片显示字符数、token 估算与向量化状态。

一、Embedding:把“意思”变成数字

调用的是 OpenAI 兼容的 /v1/embeddings 接口,本机 Ollama、云端供应商都能接。一次可以传多段文本,这里按 64 条一批。

async def embed_texts(texts: list[str]) -> tuple[list[list[float]], int]:

    response = await client.post(

        f"{settings.embed_base_url.rstrip('/')}/v1/embeddings",

        json={"model": settings.embed_model, "input": texts},

        headers=headers,

    )

    body = response.json()

    data = sorted(body["data"], key=lambda item: item.get("index", 0))

    vectors = [item["embedding"] for item in data]

    if vectors and len(vectors[0]) != settings.vector_dim:

        raise PlatformException(

            f"向量维度不符:模型返回 {len(vectors[0])},"

            f"配置 KB_VECTOR_DIM={settings.vector_dim}"

        )

    return vectors, tokens

两个细节:返回结果一定要按 index 排序,供应商乱序时不会报错,但向量会和切片错配,检索结果会变得莫名其妙;维度必须校验,1024 的表塞不进 768 的向量。

二、pgvector:向量就是普通的一列

向量表和切片表同库,主键 chunk_id 唯一,一个切片一条向量。除了 embedding,还冗余存一份 content,召回后拼提示词时就不用再回表。

CREATE TABLE kb_chunk_vec (

    id          BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,

    chunk_id    BIGINT NOT NULL UNIQUE,

    tenant_id   BIGINT,

    base_id     BIGINT NOT NULL,

    doc_id      BIGINT NOT NULL,

    version     INT DEFAULT 1,

    content     TEXT,

    embedding   vector(1024) NOT NULL

);

CREATE INDEX idx_kb_vec_embedding ON kb_chunk_vec

    USING hnsw (embedding vector_cosine_ops);

写入用 ON CONFLICT (chunk_id) DO UPDATE,同一个切片重算向量时直接覆盖,不会产生重复行。SQL 里用 CAST(:embedding AS vector) 把参数转成向量字面量:

sql = text("""

    INSERT INTO kb_chunk_vec

        (chunk_id, tenant_id, base_id, doc_id, version, content, embedding)

    VALUES

        (:chunk_id, :tenant_id, :base_id, :doc_id, :version, :content,

         CAST(:embedding AS vector))

    ON CONFLICT (chunk_id) DO UPDATE

    SET embedding = EXCLUDED.embedding,

        content   = EXCLUDED.content,

        version   = EXCLUDED.version

""")

三、批量向量化:一批失败不影响其他批

把待处理切片按 64 条分组,每批单独调用、单独提交。某批失败只把这一批标成失败,下一批继续跑。

for start in range(0, len(rows), batch_size):

    batch = rows[start:start + batch_size]

    batch_ids = [row["chunk_id"] for row in batch]

    try:

        vectors, tokens = await embeddings.embed_texts([row["content"] for row in batch])

        await vector_store.upsert_many(session, [

            {**row, "embedding": vector} for row, vector in zip(batch, vectors)

        ])

        await session.execute(update(KbChunk)

            .where(KbChunk.id.in_(batch_ids))

            .values(embed_status=2))

        await session.commit()

        await ai_log.record(scene=1, model=settings.embed_model,

                            tokens=tokens, success=True, ...)

    except Exception as exc:

        await session.rollback()

        await session.execute(update(KbChunk)

            .where(KbChunk.id.in_(batch_ids))

            .values(embed_status=3))

        await session.commit()

        await ai_log.record(..., success=False, error_msg=str(exc))

失败也要记流水,因为请求已经发出,多数供应商照样计费。还有一个容易忽略的点:rollback() 会让 ORM 实例全部过期,失败分支再访问字段会报 MissingGreenlet。所以进循环前先把 chunk_id/content/tenant_id 拷成普通字典。

四、补偿任务:谁没向量化,自己会补

上传成功只意味着“待向量化”。后台任务每 30 秒扫一批 embed_status=0 的切片,流程是:

  1. 先把卡在“向量化中”超过 10 分钟的切片重置为待处理;
  2. 捞 200 条待处理切片;
  3. 按租户分组,检查租户的月度 token 配额;
  4. 把这一组标记为“向量化中”,防止多副本重复调用模型;
  5. 调 Embedding、写 pgvector、回写状态;
  6. 按切片结果重算文档级状态:还有待处理就是待处理,有失败就是失败,全成功就是成功。

“标记中”这一步是省钱的关键:模型调用是外部计费动作,不能让两个副本同时处理同一批。状态卡在“中”也要有兜底,否则进程一崩,这批切片永远不会再被扫到。

五、验证一下

-- 切片数和向量数应该一致(当前版本)

SELECT count(*) FROM kb_chunk;

SELECT count(*) FROM kb_chunk_vec;

-- 看一眼最近写入的向量

SELECT chunk_id, doc_id, version,

       1 - (embedding <=> (SELECT embedding FROM kb_chunk_vec LIMIT 1)) AS similarity

FROM kb_chunk_vec

ORDER BY embedding <=> (SELECT embedding FROM kb_chunk_vec LIMIT 1)

LIMIT 5;

如果两个 count 对不上,先看切片状态:失败(3)会重试,待处理(0)说明补偿任务没跑或配额被拦。

六、本篇踩坑

  • 维度不一致。 换 Embedding 模型必须先确认维度,否则写入直接失败。
  • 向量表报错会污染整个事务。 在 PostgreSQL 里,一条语句失败后整个事务变成 aborted,后面的关键词查询也会连坐。向量操作包一层 SAVEPOINT,失败只降级。
  • HNSW 索引不是无条件生效。 查询必须带 ORDER BY 距离 和 LIMIT,否则可能走全表扫描。
  • 状态卡死。 “向量化中”必须有超时重置,不然一次进程崩溃会留下永久积压。

下一篇

向量有了,接下来回答一个关键问题:用户提问时,怎么在几秒内从成千上万张切片里找出最相关的几段?下一篇讲向量 + 关键词的双路召回和 RRF 融合。

上一篇:解析、切片与补偿任务 | 下一篇:混合检索:向量 + 关键词

赞(0)
未经允许不得转载:AI项目博客 » 从 0 搭一套 Python AI 知识库(五):把切片变成向量

评论 抢沙发

AI项目博客

专注 AI 工程化落地 —— 覆盖智能体、大模型、Java AI 与 Python 的学习笔记,以及 AI 平台的项目实战记录。

关于本站文章归档

登录

找回密码

注册