第 9 章 进阶拓展:端到端 RAG 落地与演进


文档摘要

第 9 章 进阶拓展:端到端 RAG 落地与演进 前八章你掌握了 Zvec 的全部能力。这一章把它们拼成一套完整可用的 RAG 系统——从文档切块、Embedding、入库、混合检索,到拼 Prompt 调 LLM 生成答案。然后再讲三个进阶方向:增量更新、端侧 RAG、未来演进。 9.1 完整 RAG 全景:我们站在哪 先把整个 RAG 链路画出来,标出 Zvec 负责的部分和本教程覆盖的环节: 前面章节我们主要在 ③④ 打转,并涉及了 ②。这一章补齐 ①文档切块 和 ⑤LLM 生成,让全链路闭环。 9.2 第①环:文档切块(Chunking) RAG 的检索粒度是"文档切片"而非整篇文档——整篇太长,喂给 LLM 既超 token 又稀释信号。切块质量直接影响召回和生成质量。

第 9 章 进阶拓展:端到端 RAG 落地与演进

前八章你掌握了 Zvec 的全部能力。这一章把它们拼成一套完整可用的 RAG 系统——从文档切块、Embedding、入库、混合检索,到拼 Prompt 调 LLM 生成答案。然后再讲三个进阶方向:增量更新、端侧 RAG、未来演进。

9.1 完整 RAG 全景:我们站在哪

先把整个 RAG 链路画出来,标出 Zvec 负责的部分和本教程覆盖的环节:

┌─────────────────────────────────────────────────────────────────────┐ │ RAG 完整链路 │ │ │ │ ① 文档预处理 ② Embedding ③ 入库 Zvec ④ 检索 Zvec │ │ ──────────── ──────────── ──────────── ──────────── │ │ 加载/切块/ 文本→稠密向量 create_and_open query() │ │ 清洗 文本→稀疏向量 insert + optimize 混合检索+融合 │ │ │ │ │ ▼ │ │ ⑤ 拼 Prompt + 调 LLM 生成 │ │ ──────────────────── │ │ 把检索结果喂给大模型 │ │ │ │ │ ▼ │ │ ⑥ 返回答案 │ └─────────────────────────────────────────────────────────────────────┘ 本教程重点:③④(Zvec 主战场),并打通 ①②⑤

前面章节我们主要在 ③④ 打转,并涉及了 ②。这一章补齐 ①文档切块⑤LLM 生成,让全链路闭环。

9.2 第①环:文档切块(Chunking)

RAG 的检索粒度是"文档切片"而非整篇文档——整篇太长,喂给 LLM 既超 token 又稀释信号。切块质量直接影响召回和生成质量。

切块的核心权衡

切太碎 切太大
单切片语义不完整,检索到了但 LLM 看不懂 一个切片含多个主题,检索信号被稀释
上下文断裂 喂给 LLM 的 token 浪费

💡 经验起点:中文按 300500 字、英文按 200400 token 切,相邻块重叠 10%~20%(overlap)防止语义在边界断裂。这是绝大多数 RAG 的安全默认值,再按实际效果调。

概念性切块代码

切块逻辑和 Zvec 无关,但它是入库前必须的一步。下面是一个带 overlap 的固定长度切块思路(生产中可用专门的切块库或按段落/标题切):

def chunk_text(text: str, size: int = 400, overlap: int = 80) -> list[str]: """固定长度 + 重叠切块。size/overlap 单位为字符(中文场景)。""" chunks = [] start = 0 while start < len(text): end = start + size chunks.append(text[start:end]) start += size - overlap # 步进 = size - overlap,保证重叠 return [c for c in chunks if c.strip()] # 实际中更推荐:按标题/段落语义切,保持语义完整 # 例如 markdown 按标题层级切、PDF 按章节切

⚠️ 切块时要保留元数据:每块带上 source(来源文件)、section(章节)、publish_year 等信息,写入 Zvec 的标量字段。这些既用于过滤,也用于在 Prompt 里告诉 LLM 出处(提升可信度)。

9.3 第②③环:Embedding + 入库(合体版)

把第 3、4、7 章的精华合体成一个 index_documents 函数——从原始文档到 Zvec 可检索,一步到位:

import zvec def build_rag_kb(docs: list[dict], path: str, embed_fn): """ docs: [{"text":..., "source":..., "publish_year":...}, ...] embed_fn: 统一的向量化函数,返回 (dense_list, sparse_list) """ # 黄金 Schema:语义(稠密) + 关键词(稀疏) + 全文 + 过滤字段 schema = zvec.CollectionSchema( name="rag_kb", fields=[ zvec.FieldSchema(name="text", data_type=zvec.DataType.STRING, index_param=zvec.FtsIndexParam(tokenizer_name="jieba")), zvec.FieldSchema(name="source", data_type=zvec.DataType.STRING, index_param=zvec.InvertIndexParam()), zvec.FieldSchema(name="publish_year", data_type=zvec.DataType.INT32, index_param=zvec.InvertIndexParam(enable_range_optimization=True)), ], vectors=[ zvec.VectorSchema(name="dense", data_type=zvec.DataType.VECTOR_FP32, dimension=1024, # 与 embed_fn 输出维度一致 index_param=zvec.HnswIndexParam(metric_type=zvec.MetricType.COSINE)), zvec.VectorSchema(name="sparse", data_type=zvec.DataType.SPARSE_VECTOR_FP32, index_param=zvec.HnswIndexParam(metric_type=zvec.MetricType.IP)), ], ) kb = zvec.create_and_open(path=path, schema=schema) # 切块 + 向量化 + 写入(embed_fn 入库查询共用,保证一致) docs_to_insert = [] for d in docs: for i, chunk in enumerate(chunk_text(d["text"])): docs_to_insert.append({ "id": f"{d['source']}#{i}", "text": chunk, "source": d["source"], "publish_year": d.get("publish_year", 2026), }) dense, sparse = embed_fn([x["text"] for x in docs_to_insert]) kb.insert([ zvec.Doc(id=x["id"], vectors={"dense": dense[i], "sparse": sparse[i]}, fields={"text": x["text"], "source": x["source"], "publish_year": x["publish_year"]}) for i, x in enumerate(docs_to_insert) ]) kb.optimize() # 入库后合并索引 return kb

9.4 第④⑤环:检索 + LLM 生成(RAG 的回答时刻)

这是用户真正感知的部分——一个问题进来,检索 + 生成出答案。把第 7 章混合检索和 LLM 调用拼起来:

def rag_answer(question: str, kb, embed_fn, llm_chat) -> dict: # ① 问题向量化(与入库同一个 embed_fn) q_dense, q_sparse = embed_fn([question]) q_dense, q_sparse = q_dense[0], q_sparse[0] # ② 混合检索:稠密(语义) + 稀疏(关键词),RRF 融合 hits = kb.query( topk=10, queries=[ zvec.Query(field_name="dense", vector=q_dense), zvec.Query(field_name="sparse", vector=q_sparse), ], reranker=zvec.RrfReRanker(topn=5, rank_constant=60), output_fields=["text", "source"], ) # ③ 拼 Prompt:把检索到的切片作为上下文 context_blocks = [f"[{i+1}] (来源: {h.fields['source']})\n{h.fields['text']}" for i, h in enumerate(hits)] context = "\n\n".join(context_blocks) prompt = ( "你是一个严谨的助手,只根据下方资料回答问题。" "如果资料中没有答案,请明确说不知道,不要编造。\n\n" f"资料:\n{context}\n\n问题: {question}" ) # ④ 调 LLM 生成(llm_chat 由你选 OpenAI / 本地模型) answer = llm_chat(prompt) # ⑤ 返回答案 + 引用来源(可追溯是 RAG 的关键优势) return { "answer": answer, "sources": [{"id": h.id, "source": h.fields["source"], "score": h.score} for h in hits], }

💡 Prompt 工程要点

  • 明确"只根据资料回答,不知道就说不知道"——这是抑制 LLM 幻觉的第一道防线。
  • 带上来源标注(如 [1] (来源: xxx))——既让 LLM 知道证据,也让答案可追溯。
  • 控制 context 长度——topn 别太大,否则稀释信号又浪费 token。RAG 通常 3~8 段。

LLM 接入:在线与本地

llm_chat 的实现取决于你用什么模型,但接口统一:

# 在线:OpenAI 兼容接口 from openai import OpenAI client = OpenAI() def llm_chat(prompt: str) -> str: r = client.chat.completions.create( model="gpt-4o-mini", messages=[{"role": "user", "content": prompt}], ) return r.choices[0].message.content # 本地:Ollama / vLLM / llama.cpp 等,同样走 OpenAI 兼容接口 # client = OpenAI(base_url="http://localhost:11434/v1", api_key="ollama") # 端侧 RAG 的关键:Embedding + LLM 都本地化(见 9.6)

9.5 进阶一:增量更新与知识库演进

知识库不是一次建好就完事——新文档要加、旧文档要改、有时要删。Zvec 的工程化更新策略:

操作 API 注意点
新增文档 insert(新 id)或 upsert(不关心是否已存在) 写入后记得 optimize
修改文档 upsert(同 id 覆盖)或局部 update 覆盖会重新走暂存区
删除文档 delete(ids=...)delete_by_filter(filter=...) 按条件批量删很实用
改 Schema add_column / create_index(第 4 章 DDL) 向量字段不能在线增删
# 增量更新典型流程:新来一批文档,切块→Embedding→upsert(覆盖同 id) new_docs = [{"text": "...", "source": "v2.md", "publish_year": 2026}] # ... 切块、embed ... kb.upsert([zvec.Doc(id="v2.md#0", vectors={...}, fields={...})]) kb.optimize() # 过期文档批量清理 kb.delete_by_filter(filter="publish_year < 2020")

⚠️ id 设计要支持幂等更新。用 {source}#{chunk_index} 这类稳定 id,这样同一文档重新切块后 upsert 能精准覆盖旧切片,不会产生重复。如果用随机 id,每次更新都会新增而非覆盖,知识库会膨胀。

9.6 进阶二:端侧 RAG(Edge / On-Device RAG)

Zvec 的进程内特性,让它特别适合端侧 RAG——在笔记本、边缘设备、内网环境里,不依赖任何云端服务跑完整 RAG。这是隐私敏感场景(医疗、法律、企业机密)的刚需。

端侧 RAG 的关键,是全链路本地化

┌─────────────────────────────────────────────────────┐ │ 端侧 RAG 组件(全部本地) │ │ │ │ 本地 Embedding 模型 ──► Zvec(进程内) ──► 本地 LLM │ │ (bge-small/e5/gte) (pip install) (Ollama/ │ │ llama.cpp) │ │ │ │ 优势:数据不出本机、零网络延迟、零 API 费用 │ └─────────────────────────────────────────────────────┘
端侧 RAG 优势 对应场景
数据隐私 医疗病历、法律卷宗、企业机密文档
离线可用 现场作业、网络不稳定环境
零 API 成本 个人知识库、长期高频使用
低延迟 实时助手、无网络往返

💡 端侧选型建议:Embedding 用小模型(bge-small-zh 512 维、e5-small),Zvec 用 HNSW + INT8 量化省内存,LLM 用量化后的 7B~14B 本地模型(Ollama 跑 qwen2.5/llama3)。一套下来,普通笔记本就能跑一个像样的私人 RAG。

9.7 进阶三:评估与持续优化

RAG 上线后,怎么知道它好不好?建立评估闭环是持续优化的基础:

评估维度 怎么测 工具思路
召回质量 准备"问题-标准答案"集,看 top-k 是否命中相关文档 人工标注或用 LLM 辅助评估
检索延迟 记录每次 query 耗时 应用日志 + p50/p99 统计
生成质量 评估答案的事实准确性、是否幻觉 人工评审或 LLM-as-judge
覆盖率 知识库能回答多少比例的问题 分析"不知道"的比例

💡 RAG 优化的飞轮:评估 → 定位瓶颈(召回?生成?)→ 针对性改进(切块?混合检索?Prompt?)→ 再评估。绝大多数 RAG 问题出在召回(前 8 章的 Zvec 部分),而非生成——所以先压实检索,再优化 LLM。

9.8 未来演进方向

Zvec 还在快速演进,几个值得关注的趋势:

  1. 更多向量字段在线演进:当前向量字段不能在线增删,未来支持后,换 Embedding 模型会更平滑。
  2. 更强的混合检索:reranker 策略持续丰富(如引入交叉编码器 Cross-Encoder 精排)。
  3. 多模态 RAG:图像/音频向量与文本向量同库,支持"以图搜文""以文搜图"的混合检索。
  4. 端侧生态:配合移动端 SDK(已有 Dart/Flutter 方向),让端侧 RAG 覆盖更多设备。
  5. 与 Agent 深度结合:RAG 作为 Agent 的记忆层,Zvec 的低延迟特性适配高频工具调用场景。

9.9 动手实验:搭一个属于你的 RAG

把这一章的所有片段串起来,完成一个端到端项目:

  1. 准备语料:选 3~5 篇你熟悉的文档(技术博客/产品手册/笔记),用 9.2 的方法切块。
  2. 建库:用 9.3 的 build_rag_kb 建一个混合检索知识库(bge-m3 或任一双输出模型)。
  3. 问答:用 9.4 的 rag_answer 对 5 个真实问题提问,检查答案质量和来源可追溯性。
  4. 优化迭代:对照 9.7 的评估维度,找出最弱的环节(切块?召回?Prompt?),针对性改进一轮。
  5. (进阶)端侧化:把 Embedding 和 LLM 都换成本地模型,体验完全离线的私人 RAG。

本章小结

  • 完整 RAG 链路六环:文档预处理 → Embedding → 入库 Zvec → 检索 Zvec → 拼 Prompt 调 LLM → 返回答案;本教程重点在中间的 Zvec 主战场。
  • 文档切块:中文 300~500 字 + 10%~20% overlap,保留元数据用于过滤和追溯。
  • 给了端到端的 build_rag_kb + rag_answer 两个核心函数,串起前八章所有知识。
  • 增量更新用 upsert + 稳定 id(source#index)保证幂等;Schema 演进用 DDL。
  • 端侧 RAG 是 Zvec 的天然主场:全链路本地化,隐私 + 离线 + 零成本。
  • 建立评估闭环(召回/延迟/生成质量/覆盖率),绝大多数 RAG 问题出在召回而非生成。

🎉 恭喜,你已经走完了从 0 到 1 的完整旅程。 你现在有能力用 Zvec 搭建一套"语义 + 关键词 + 融合重排"的生产级 RAG 系统,并知道如何调优、上线、演进。

记住贯穿全教程的两条心法:召回质量决定 RAG 上限,度量对齐是底线。带着它们去构建你自己的知识库应用吧。

需要快速查阅术语、过滤表达式或索引选型时,参见附录


发布者: 作者: 转发
评论区 (0)
U