模块五-AI系统架构设计 | 第32讲:向量数据库选型与实战 - Chroma、Milvus、Qdrant 的架构差异与适用场景
本讲目标:理解向量数据库在代码审核与语义检索中的价值与边界;从存储引擎、索引结构(HNSW、IVF、FLAT)、过滤能力、扩展性四维度对比 Chroma、Milvus、Qdrant;梳理代码 embedding 模型选型(OpenAI、开源代码模型、通用句向量);给出 CodeSentinel 向量层的元数据设计与 ChromaDB 可运行示例(索引管线、语义搜索、相似重复检测);用一组可复现实验思路讨论延迟、内存、建索引耗时。读完你应能为团队写一份「向量层 RFC」:何时嵌入式即可、何时必须分布式、以及如何把向量检索纳入成本与 SLA。
阅读建议:本讲与第31讲的「RAG/Agent 模式」前后呼应——模式决定你要不要检索,向量库决定检索是否可靠、可扩展、可观测。建议你把两讲的架构图拼在一起,检查是否出现「有模式无评测」或「有向量无版本」的断层。
开场:没有向量层,RAG 只是「关键词的幻觉加强版」
在代码审核场景里,工程师最常提出的需求听起来很简单:「找一找仓库里有没有类似写法」「这次改动是不是在重复昨天的坑」「这条规则在过去半年哪些 PR 里触发过」。如果只用 BM25 或文件名搜索,你会漏掉语义层面相似但标识符不同的实现;如果直接把整个仓库塞进 LLM 上下文,成本和窗口都不允许。向量检索的价值在于:把「代码语义」映射到高维空间,用近似最近邻(ANN)在可接受延迟内找到候选集合,再交给重排序、规则引擎或模型做最终判断。
但向量数据库不是魔法。索引质量、embedding 模型域适配、chunk 边界、元数据过滤共同决定上限。选错产品形态会导致:POC 时很快、上量后崩溃;或部署很重、团队维护不起。Chroma、Milvus、Qdrant 代表了三种常见工程权衡:嵌入式与 Python 生态友好、云原生分布式、以及 Rust 实现的中等规模高性能。CodeSentinel 的贯穿思路是:先用 Chroma 打通端到端闭环,在命中规模化瓶颈后再评估 Milvus 或 Qdrant 的迁移路径,而不是一开始就背负分布式运维。
本讲先给全局架构对比与检索链路,再深入原理,最后提供完整可运行 Python:本地 Chroma 持久化、代码分片嵌入、语义搜索 API、相似度聚类式重复检测。你会看到:同样的业务需求,在不同产品上的参数调优焦点完全不同——有的要调 HNSW 的 efConstruction,有的要调 IVF 的 nlist,有的要关注过滤与 payload 索引。
从学习方法上,本讲建议你采用「对照实验」而不是「读参数手册」。具体做法是:固定一套 chunk 与同一批 PR 样本,分别把 Chroma、Qdrant、Milvus(若可搭建)跑到相同的召回@K 目标,记录各自需要的运维动作与故障恢复时间。你会很快发现:性能曲线不是唯一决策变量,可维护性与团队技能栈同样决定成败。对多数初创团队,Chroma 的价值在于把产品迭代跑起来;对大型平台团队,Milvus 的价值在于把成本曲线压平;对强调过滤与稳定 p95 的中等团队,Qdrant 往往是更顺手的折中。CodeSentinel 作为教学主线选择 Chroma,不是因为它「最强」,而是因为它最能帮助你把注意力放在「chunk、元数据、评测」这些更本质的工程问题上。
全局视角:向量检索在 CodeSentinel 中的位置(Mermaid)
flowchart LR
subgraph Ingest[入库管线]
GIT[Git 快照 / diff]
CH[分块与清洗]
EMB[Embedding 计算]
META[元数据抽取]
end
subgraph Store[向量存储]
VDB[(Chroma / Milvus / Qdrant)]
OBJ[(对象存储\n原始源码片段)]
end
subgraph Query[在线查询]
Q[审核查询 / 相似检索]
ANN[ANN 检索]
FIL[元数据过滤]
RR[重排序\n后续讲]
end
GIT --> CH --> EMB --> VDB
CH --> META --> VDB
CH --> OBJ
Q --> EMB
EMB --> ANN --> FIL --> RR
VDB --> ANN
三类向量库:架构差异总览(Mermaid)
flowchart TB
subgraph Chroma[Chroma]
C1[嵌入式/服务端部署]
C2[Python 原生体验]
C3[适合原型与中小规模]
end
subgraph Milvus[Milvus]
M1[分布式与云原生]
M2[多种索引类型组合]
M3[适合大规模生产]
end
subgraph Qdrant[Qdrant]
Q1[Rust 实现]
Q2[丰富过滤与 payload]
Q3[中等规模高性能]
end
CS[CodeSentinel POC] --> Chroma
CS2[CodeSentinel 规模化] --> Milvus
CS3[CodeSentinel 混合过滤 SLA] --> Qdrant
核心原理:为什么代码审核特别需要向量库
1. 语义代码搜索 vs 文本搜索
代码具有结构化重复:变量名不同但逻辑相同的分支、复制粘贴后微调的配置、同一模式在不同服务中的实现。BM25 擅长匹配标识符与关键字,但对「语义等价」无能为力。向量检索把代码(或 AST 子树序列化文本)嵌入到语义空间,使「相近实现」在距离上更近。注意:近邻不等于正确,它只是候选生成器(candidate generator),后续仍需静态分析或模型判别。
2. 索引结构:HNSW、IVF、FLAT
- FLAT:暴力检索,精度最高,延迟随数据线性恶化,适合极小集合或离线评测基线。
- IVF(倒排量化):通过聚类把向量分区,查询只扫部分桶;适合百万级以上规模,调参
nlist、nprobe权衡精度与延迟。 - HNSW(分层小世界图):图索引,查询快,建索引成本更高;在 Qdrant、Milvus、Chroma(依赖底层实现)中常见。
代码库 embedding 往往维度较高(768/1024/1536),建索引时间与内存占用必须纳入容量规划。
3. 过滤与多租户
生产审核平台常有:repo_id、language、path_prefix、commit_sha、symbol_name 等过滤条件。纯向量检索 + 后过滤会在 ANN 阶段返回不足候选;优秀实践是选择支持 payload 过滤与索引 的引擎,或在入库时按租户/仓库分 collection,减少扫描空间。Milvus 与 Qdrant 在过滤表达力上通常强于轻量嵌入式方案;Chroma 也在持续增强 metadata 查询,但极端复杂布尔过滤需评估。
4. 代码 embedding 模型怎么选
- OpenAI / 云厂商 embedding:工程上最省事,维度与配额清晰;缺点是成本、外发合规、以及是否覆盖你主要语言。
- 通用句向量模型(如
all-MiniLM):对自然语言注释友好,对「纯代码 token」有时偏弱。 - 代码专用模型(如 CodeBERT、StarCoder 系列衍生 embedding):更贴近标识符与 API 习惯用法,但部署与推理成本更高。
CodeSentinel 建议:默认云 embedding 快速落地;对离线或合规场景提供 本地 ONNX 模型;并在评测集上对比「误报候选检索」与「漏检」。
5. Chroma:轻量、Python 原生、适合 POC
Chroma 适合作为 MVP 向量层:本地持久化、与 FastAPI 同进程或侧车部署都简单。它的优势是迭代速度快;风险是超大规模与复杂过滤时可能遇到瓶颈。对 CodeSentinel 教学主线,Chroma 能快速证明「向量检索 + 元数据」的产品价值。
6. Milvus:分布式与云原生,面向规模化
Milvus 强调 水平扩展与高吞吐,适合「多仓库、海量 chunk、持续增量索引」的企业场景。运维复杂度高于 Chroma,需要对象存储、消息队列或 K8s 运维能力。索引类型与参数丰富,调优门槛更高,但上限也更高。
7. Qdrant:Rust 实现,过滤与性能平衡
Qdrant 常在 中等规模、强过滤、需要稳定 p95 的场景被选中。payload 索引与查询 DSL 较友好;也可自托管或云服务。对 CodeSentinel,如果检索以「仓库内 + 语言 + 路径」过滤为主,Qdrant 是强候选。
8. 「向量检索」在审核流水线中的职责边界
为了避免团队把向量库当成数据库或搜索引擎的替代品,建议在架构文档里写清楚三件事。第一,向量层只负责候选召回:它回答的是「可能相关的片段有哪些」,而不是「是否存在漏洞」。第二,向量相似不等于业务等价:两段代码 embedding 相近,可能一个是安全实现、另一个是刻意绕过的变体;最终结论必须结合规则与工具。第三,向量层必须有拒答与空结果策略:当候选集为空或分数普遍很差时,系统应提示「证据不足」而不是让模型硬编故事。CodeSentinel 在 Agentic RAG 中把向量检索当作「观察输入」,而不是最终裁决器,这是模式组合的关键。
9. 脏数据:代码仓库里什么东西会毒害索引
真实仓库里常见噪声:自动生成的 protobuf、锁文件、巨型 JSON fixture、以及把密钥误提交后又删除的历史 blob。若不做过滤,向量索引会被这些「高频大块文本」占据,检索结果被无关内容污染。工程上通常采用 路径 denylist、文件大小上限、语言检测、以及按扩展名路由不同 chunk 策略。另外,测试代码与生产代码是否同库索引是产品决策:同库能检索到测试范例,但也会把大量 mock 实现混进候选;分库则增加运维复杂度。没有标准答案,但必须显式选择并在元数据中标记 is_test,否则评测会对不上。
10. 版本化:embedding 模型与索引必须绑定
当你升级 embedding 模型或调整 chunk 规则时,旧向量与新向量不可混用,否则距离度量失去意义。CodeSentinel 建议把 embedding_model_id 与 chunker_version 写入 collection 名或元数据,并在查询侧强制一致。迁移时采用后台重建任务,而不是在线悄悄替换。很多团队事故来自「只重建了一半集合」导致线上检索随机漂移。
架构对比表(教学向量化,生产以实测为准)
| 维度 | Chroma | Milvus | Qdrant |
|---|---|---|---|
| 部署心智负担 | 低 | 高 | 中 |
| 水平扩展 | 有限/依赖部署形态 | 强 | 中到强 |
| 过滤能力 | 中(持续演进) | 强 | 强 |
| 典型索引 | 依赖版本与后端 | HNSW/IVF 等组合 | HNSW 等 |
| Python 体验 | 优 | 中(SDK 成熟) | 优 |
| 适用阶段 | POC、中小规模 | 大规模生产 | 中等规模强过滤 |
性能基准:如何自己做「可信」对比
下表不是绝对数值,而是你在 自建基准 时应记录的指标维度。建议在固定硬件、固定 embedding 模型、固定 chunk 策略下对比:
| 指标 | 含义 | 调优提示 |
|---|---|---|
| 查询 p50/p95 延迟 | 端到端检索耗时 | HNSW ef、IVF nprobe |
| 建索引耗时 | 全量重建成本 | 批量 upsert、并行 embedding |
| 内存占用 | 进程 RSS / 容器 limit | 向量精度、副本数 |
| QPS | 并发查询吞吐 | 连接池、只读副本 |
| 召回@K | 与暴力检索对比 | 调整索引参数 |
教学建议:先用 1~5 万 chunk 做压测曲线,再推演到百万级的增长是否线性;不要直接用厂商宣传 QPS 作为架构依据。
再谈「距离」:不要迷信单一相似度分数
不同系统对「距离/相似度」的定义可能不同:有的是 cosine distance,有的是 inner product,有的返回已经归一化的 score。你在 CodeSentinel 里做阈值告警(例如 duplicate detection)时,必须把 度量方式、embedding 是否归一化、以及索引参数 写进配置,否则线上调参会出现「改了一个版本,阈值全失效」的痛苦。更稳妥的工程习惯是:离线保存一组标注样本,绘制 score 分布,再选择阈值与 topK,而不是拍脑袋设 0.25。
读写路径分离:为什么生产常做「异步索引」
PR 审核通常是在线路径:工程师希望尽快看到评论。全量重建索引是离线路径:耗时长、占用 CPU/GPU。把两者混在同一个 Web 进程里,极易造成 p95 抖动。推荐模式是:Webhook 快速入队;worker 异步更新向量;在线查询读取最近一次一致的快照版本。向量库侧可以用 版本号 或 双 collection 切换 实现「读写分离式的发布」。这套思路对 Chroma、Milvus、Qdrant 都适用:产品不同,工程模式相通。
代码实战:ChromaDB 代码索引、语义搜索与相似重复检测
依赖
chromadb>=0.5.0
说明:Chroma 默认嵌入函数会拉取小型模型;若环境无外网,可改为自管 embedding(见
HashingEmbedding兜底示例,保证可运行)。
完整可运行示例(vector_lab_chroma.py)
"""
CodeSentinel 向量实验:Chroma 持久化 + 代码语义检索 + 相似重复检测
运行: python vector_lab_chroma.py
"""
from __future__ import annotations
import hashlib
import os
import time
import uuid
from dataclasses import dataclass
from typing import Dict, Iterable, List, Sequence, Tuple
import chromadb
from chromadb.api.models.Collection import Collection
from chromadb.api.types import Documents, EmbeddingFunction, Embeddings
@dataclass
class CodeChunk:
chunk_id: str
repo: str
path: str
language: str
symbol: str
text: str
class HashingEmbedding(EmbeddingFunction):
"""无外网/无 ONNX 时的兜底:确定性伪向量,仅用于打通流程(不适合真实语义)。"""
def __init__(self, dim: int = 128) -> None:
self.dim = dim
def __call__(self, input: Documents) -> Embeddings:
return [self._embed_one(t) for t in list(input)]
def _embed_one(self, text: str) -> List[float]:
h = hashlib.sha256(text.encode("utf-8")).digest()
vec: List[float] = []
for i in range(self.dim):
b = h[i % len(h)]
vec.append((b / 255.0) * 2 - 1.0)
return vec
def build_collection(persist_dir: str) -> Tuple[chromadb.PersistentClient, Collection]:
os.makedirs(persist_dir, exist_ok=True)
client = chromadb.PersistentClient(path=persist_dir)
use_real = os.getenv("USE_CHROMA_DEFAULT_EMBEDDINGS", "1") == "1"
if use_real:
coll = client.get_or_create_collection(
name="codesentinel_code_chunks",
metadata={"hnsw:space": "cosine"},
)
else:
emb = HashingEmbedding(dim=128)
coll = client.get_or_create_collection(
name="codesentinel_code_chunks",
embedding_function=emb,
metadata={"hnsw:space": "cosine"},
)
return client, coll
def upsert_chunks(coll: Collection, chunks: Sequence[CodeChunk]) -> None:
ids = [c.chunk_id for c in chunks]
documents = [c.text for c in chunks]
metadatas = [
{
"repo": c.repo,
"path": c.path,
"language": c.language,
"symbol": c.symbol,
}
for c in chunks
]
coll.upsert(ids=ids, documents=documents, metadatas=metadatas) # type: ignore[arg-type]
def semantic_search(
coll: Collection,
query: str,
repo: str | None = None,
language: str | None = None,
k: int = 5,
) -> List[Dict]:
where: Dict | None = None
filters = []
if repo:
filters.append({"repo": repo})
if language:
filters.append({"language": language})
if len(filters) == 1:
where = filters[0]
elif len(filters) > 1:
where = {"$and": filters}
res = coll.query(query_texts=[query], n_results=k, where=where)
out: List[Dict] = []
for i in range(len(res["ids"][0])):
out.append(
{
"id": res["ids"][0][i],
"distance": res["distances"][0][i] if res.get("distances") else None,
"document": res["documents"][0][i],
"metadata": res["metadatas"][0][i],
}
)
return out
def detect_near_duplicates(
coll: Collection,
chunks: Sequence[CodeChunk],
threshold: float = 0.25,
) -> List[Tuple[str, str, float]]:
"""
教学版「相似重复」:对每个 chunk 做查询,若命中其他路径且距离很小则记录。
threshold 需按距离定义调整;cosine 距离越小越相似(取决于 Chroma 返回定义)。
"""
pairs: List[Tuple[str, str, float]] = []
seen = set()
for c in chunks:
hits = coll.query(query_texts=[c.text], n_results=4)
for j in range(len(hits["ids"][0])):
hid = hits["ids"][0][j]
if hid == c.chunk_id:
continue
dist = hits["distances"][0][j] if hits.get("distances") else None
if dist is None:
continue
if dist > threshold:
continue
a, b = sorted([c.chunk_id, hid])
key = (a, b)
if key in seen:
continue
seen.add(key)
pairs.append((a, b, float(dist)))
return pairs
def seed_demo_chunks() -> List[CodeChunk]:
repo = "demo/codesentinel"
return [
CodeChunk(
chunk_id=str(uuid.uuid4()),
repo=repo,
path="app/auth/oauth.py",
language="python",
symbol="exchange_code_for_token",
text=(
"def exchange_code_for_token(code: str, state: str) -> dict:\n"
" if not secrets.compare_digest(expected_state, state):\n"
" raise SecurityError('invalid state')\n"
" return token_endpoint.exchange(code)\n"
),
),
CodeChunk(
chunk_id=str(uuid.uuid4()),
repo=repo,
path="services/login/callback.ts",
language="typescript",
symbol="handleCallback",
text=(
"export async function handleCallback(code: string, state: string) {\n"
" if (!timingSafeEqual(expectedState, state)) throw new Error('invalid state');\n"
" return exchangeToken(code);\n"
"}\n"
),
),
CodeChunk(
chunk_id=str(uuid.uuid4()),
repo=repo,
path="legacy/oauth1.py",
language="python",
symbol="get_token",
text=(
"def get_token(request):\n"
" # TODO: migrate off oauth1\n"
" return signer.sign(request)\n"
),
),
]
def benchmark_build_and_query(coll: Collection, chunks: Sequence[CodeChunk]) -> None:
t0 = time.perf_counter()
upsert_chunks(coll, chunks)
t1 = time.perf_counter()
_ = semantic_search(coll, "OAuth callback state validation", repo="demo/codesentinel", k=3)
t2 = time.perf_counter()
print(f"upsert {len(chunks)} chunks: {(t1 - t0)*1000:.1f} ms")
print(f"query: {(t2 - t1)*1000:.1f} ms")
def main() -> None:
persist = os.getenv("CHROMA_DIR", ".chroma_codesentinel_lab")
_, coll = build_collection(persist)
chunks = seed_demo_chunks()
benchmark_build_and_query(coll, chunks)
print("\n=== 语义搜索 ===")
for row in semantic_search(
coll,
"如何在回调里防止 state 被篡改?",
repo="demo/codesentinel",
k=3,
):
print(row)
print("\n=== 相似重复(教学阈值)===")
for a, b, d in detect_near_duplicates(coll, chunks):
print(a, b, f"dist={d:.4f}")
if __name__ == "__main__":
main()
CodeSentinel 元数据字段建议
- 必选:
repo_id、commit_sha(或索引版本)、path、language、chunk_type(file/function/class)、symbol。 - 推荐:
module、layer(domain 划分)、owner_team(用于过滤与审计)。 - 谨慎:不要在 metadata 存大段源码原文(应走对象存储或内容寻址),metadata 保持可索引的小字段。
代码导读:为什么示例同时支持默认嵌入与哈希兜底
教学代码里 USE_CHROMA_DEFAULT_EMBEDDINGS 开关不是为了炫技,而是贴合真实企业的两种约束:有外网、可下载默认模型 的环境走 Chroma 默认嵌入,能快速验证语义检索;无外网、CI 沙箱 的环境走 HashingEmbedding,保证流水线可运行,但语义能力退化为「结构化演示」。这对应生产中的事实:嵌入服务本身也是可用性链路,你必须为它设计降级:缓存、队列、异步回补、以及「降级为关键词检索」的开关。CodeSentinel 的向量层设计里,建议把嵌入计算做成独立模块,向量库只存向量与元数据,避免把业务逻辑绑死在某一家的默认实现上。
semantic_search:过滤条件如何影响召回
示例函数用 where 拼接 repo 与 language。真实系统里过滤往往更复杂:路径前缀、分支、模块归属、甚至「是否包含机密目录」。要警惕一种错误:过滤太严导致候选为空,Agent 误以为仓库没有相关知识,从而开始幻觉补全。解决办法是分层检索:先带严格过滤取一轮;若结果不足,再用宽松过滤扩一轮,并把扩检索标记为「低置信度证据」,在最终评论里要求人工确认。
detect_near_duplicates:教学实现与生产差异
教学版 duplicate detection 通过「对每个 chunk 再查询向量库」找到近邻,复杂度近似 (O(n \cdot k)),只适合小规模演示。生产常用路径包括:批量向量聚类、LSH/MinHash 预筛选 + 向量精排、或 依赖 AST 指纹(tree-sitter hash) 先做确定性候选。对 CodeSentinel,重复代码检测往往服务于「架构腐化治理」:你要输出不是「它们很像」,而是「它们是否违反 DRY 策略、是否存在复制粘贴导致的安全补丁遗漏」。因此最终仍要回到差异 diff 与变更历史,而不是停在距离分数上。
生产环境实战:从实验室到线上
1. 嵌入计算的批处理与缓存
线上入库应 批量 embedding(按 token 上限切批),并对 (model_version, text_hash) 做缓存。CodeSentinel 常见瓶颈不在向量库,而在 embedding QPS 与 Git 读取。
2. 多环境隔离
开发、预发、生产的 collection 必须隔离;重建索引要有蓝绿或双写切换策略,避免审核任务读到半索引状态。
3. 合规与数据外发
若代码不可出网,需 自托管 embedding 与 自托管向量库;Chroma 仍可本地;Milvus/Qdrant 亦可内网部署。此时更要关注磁盘加密与备份策略。
4. 迁移策略
不要「一次性重写」。建议:双写 → 对账召回 → 读流量切换 → 停旧集群。迁移时保持 chunk_id 规则稳定,避免评论引用失效。
5. 观测指标
- 入库延迟、失败率、DLQ(死信队列)长度
- 查询 p95、空结果率、过滤后候选不足率
- embedding 失败率与回退路径触发次数
6. 成本模型:把「每次 PR 审核」换算成钱
向量成本通常由三部分组成:embedding 调用、向量库存储与查询、以及 下游 LLM 对候选的解释。只优化向量库而不控制候选数量,常见后果是:检索返回 20 段代码,LLM 上下文爆炸。CodeSentinel 的推荐策略是:ANN 先取 K=30~100,经元数据过滤后到 10~20,再经 cross-encoder 重排到 3~8(下一讲会展开)。在 POC 阶段就要记录「每个 PR 平均候选 token 数」,否则上线后费用曲线会出乎意料。
7. On-call 手册:典型故障与第一反应
- 查询突然变慢:先看是否触发全量过滤导致候选扫描变大;再看 embedding 服务是否抖动;最后看索引是否进入压缩/合并阶段。
- 召回骤降:优先怀疑模型版本不一致、索引只重建了一部分、或 metadata 过滤条件被错误拼接。
- 结果明显胡说:先别调 prompt,先抽样看检索 topK 是否已偏;检索偏了,生成再强也救不回来。
本讲小结(Mermaid mindmap)
mindmap
root((第32讲 向量数据库))
价值
语义候选生成
结合过滤与重排
索引
HNSW
IVF
FLAT基线
产品
Chroma POC
Milvus 规模化
Qdrant 过滤性能
代码嵌入
云API
代码专用模型
合规本地模型
CodeSentinel
元数据设计
批处理缓存
迁移与观测
思考题
- 若你的仓库以「生成式重复」为主(复制粘贴微改),向量检索是否一定优于 AST 哈希?请给出组合策略。
- 多租户场景下,你更倾向于「单库多 collection」还是「租户独立集群」?权衡运维与 noisy neighbor。
- 当 Chroma 查询 p95 超出 SLA,你第一步会调参、扩容、还是改分片策略?依据是什么?
实战复盘:用一张检查清单结束「选型争论」
当你与团队在 Chroma、Milvus、Qdrant 之间争执不下时,可以把决策收敛为一张检查清单(建议直接贴进 RFC)。规模:预计 chunk 数量级是十万、百万还是千万?是否需要分片与多副本?过滤:过滤表达式是否能表达你的多租户隔离与路径策略?延迟:p95 目标是 50ms、200ms 还是 1s?不同目标会倒推索引类型与硬件。运维:是否有专职平台同学负责 K8s、对象存储与备份?合规:向量与原文能否出网?embedding 是否必须本地化?生态:团队更熟 Python 侧车还是云托管?迁移:未来 12 个月是否确定会换 embedding 模型?若会,索引版本化方案是否准备好?
这张清单的意义在于:它把「谁更强」变成「谁更匹配约束」。CodeSentinel 的主线实现先用 Chroma,是因为教学与中小团队落地需要极低启动成本;但当你的索引进入百万 chunk 且查询并发稳定上升,就要认真评估 Milvus 或 Qdrant 的托管方案,并把 Chroma 当作「本地开发环境」而不是「生产终态」。架构师的价值不是提前三年上分布式,而是在正确的拐点做正确的升级,同时把数据迁移与回放评测写进计划,而不是靠拍脑袋切换。
下一讲预告
第33讲:代码库向量化索引,将把整个仓库变成可检索知识库:分块策略(文件/函数/类/语义)、元数据增强、基于 git diff 的增量索引,以及 CodebaseIndexer 编排代码与搜索 API。
附录:Milvus 与 Qdrant 的最小接入草图(可选)
以下片段用于架构对照,不要求默认环境可运行(需 Docker 服务)。生产请替换为官方最新 SDK 与鉴权。
补充两点工程细节,避免你在对照试验时踩坑。第一,向量维度必须端到端一致:从 embedding 模型输出,到建库 schema,再到查询向量,任何一处维度不匹配都会在运行时才爆炸。第二,注意 ID 策略:教学示例用 UUID 当 chunk_id 很省事,但增量更新时更推荐确定性 ID(例如 sha256(repo|path|symbol|content_hash)),这样 upsert 才能真正幂等,否则你会在库里堆积大量「内容相同但 id 不同」的重复向量,检索质量会莫名其妙变差。
# Milvus(示例草图)
# from pymilvus import connections, FieldSchema, CollectionSchema, DataType, Collection
# connections.connect(alias="default", host="127.0.0.1", port="19530")
# fields = [
# FieldSchema(name="pk", dtype=DataType.INT64, is_primary=True, auto_id=True),
# FieldSchema(name="repo", dtype=DataType.VARCHAR, max_length=128),
# FieldSchema(name="embedding", dtype=DataType.FLOAT_VECTOR, dim=768),
# ]
# schema = CollectionSchema(fields, description="codesentinel")
# col = Collection("code_chunks", schema)
# Qdrant(示例草图)
# from qdrant_client import QdrantClient
# from qdrant_client.models import Distance, VectorParams, PointStruct
# client = QdrantClient(url="http://127.0.0.1:6333")
# client.recreate_collection(collection_name="code_chunks", vectors_config=VectorParams(size=768, distance=Distance.COSINE))
收尾提示:当你把 Milvus/Qdrant 草图跑通后,记得把同样的
CodeChunk元数据字段带过去;否则你会在迁移后才发现过滤条件无法表达,最终又被迫回改 chunk 规则。把字段对齐当作迁移验收项,而不是事后补救项。