用Claude Code从零搭建向量搜索引擎:语义搜索实战
如果你维护过中大型知识库多半遇到过这种尴尬用户想问的明明是一件常见事但换个说法之后关键词搜索就找不到了。“订单怎么变更”和“我要改收货地址、想换套餐、临时加购”在字面上差异很大在语义上却是同一件事。传统全文检索用倒排索引能高效匹配词面却很难匹配语义。要彻底解决这类问题就得引入向量搜索引擎。这篇文章要聊的不只是向量搜索引擎本身而是展示一条新的实现路径用 Claude Code 这样的 AI 编码 Agent把“需求 → 设计 → 编码 → 测试 → 排错 → 优化”这个循环压缩成对话式协作。这个案例来自“Claude Code 100个案例”系列主题定为“用 AI 搭建向量搜索引擎”。先说我的判断Claude Code 的价值不只是“自动补代码”它更像一个能读项目、改文件、跑命令、看日志的工程助手。你可以把需求讲清楚让它自己规划并落地一个完整功能。但前提是你自己得懂目标系统大概长什么样也知道如何验收结果。所以这篇文章会把三层内容都讲透向量搜索的核心原理、Claude Code 的安装与使用方式、以及一套可以直接跑起来的中文向量搜索引擎示例代码。如果你是第一次接触 Claude Code或者想做语义检索却不知道从哪下手这篇文章正好可以作为起点。1. 案例背景为什么把向量搜索引擎作为案例先回答一个更根本的问题传统搜索为什么不够用了在过去很长一段时间里企业内网搜索、文档检索、商品搜索基本都靠关键词匹配。数据库的LIKE %关键词%只能做子串匹配Elasticsearch 这类搜索引擎引入了倒排索引和分词但本质上匹配的还是“词面”。这种方案有三个明显的痛点第一用户不会按你设定的关键词搜索。一个商品叫“移动电源”用户可能输入“充电宝”“便携充电”“手机备用电池”。同义词词典可以覆盖一部分但词典的维护成本会随着业务规模不断扩大。第二分词会引入误差。中文本就存在歧义切分。“研究生命科学”既可以理解为“研究/生命科学”也可能被错误切分。切分错误会直接影响召回。第三跨语言、口语化、错别字场景很难处理。比如用英文搜索中文文档或者用户输入有错别字传统方案基本无能为力。向量搜索解决的是“语义匹配”问题而不是“词面匹配”问题。它把文本映射成向量在向量空间里计算相似度因此对改写、口语表达、同义词、甚至部分错别字都有更好的容忍能力。那为什么这篇案例选择向量搜索引擎来演示 Claude Code因为向量搜索引擎的技术栈非常典型文本加载与切分、模型推理、向量索引、命令行交互、索引持久化、异常处理一个小项目里把这些模块全包含了。而且整个开发过程天然是迭代式的——先跑通最小版本再补功能再优化性能。这种“连续修改、持续验证”的过程恰恰是 Claude Code 这类 AI 编程 Agent 最擅长的场景。换句话说这篇文章不只给你一套代码还会告诉你如何使用 Claude Code 把一个“有点模糊的需求”变成“能运行、能扩展、能排错”的工程。2. 向量搜索引擎的核心概念在开始写代码之前有必要把几个概念拆开讲清楚。很多新手上来就用faiss遇到问题后才发现自己对 embedding 的理解是错的。2.1 什么是文本向量化Embedding文本向量化就是用一个模型把一段文字转换成一串固定长度的浮点数数组。这串数字可以看作这段文字在“高维语义空间”里的坐标。语义相近的句子在这个空间里距离更近语义无关的句子距离更远。如果只看结论可以这样理解传统搜索在“词典”里找相同词向量搜索在“语义空间”里找最近邻。常用的文本向量化模型分为两类。一类是通用模型例如sentence-transformers系列、bge系列、m3e系列另一类是业务专用模型通过领域微调得到。对于中文语料可以优先选择支持中文的开源模型例如BAAI/bge-small-zh-v1.5。这个模型体积不大生成 512 维向量适合个人电脑和中小规模语料。2.2 相似度计算内积与余弦相似度拿到向量之后怎么判断“相似”最常用的指标是余弦相似度。余弦相似度计算的是两个向量夹角的余弦值取值范围在 -1 到 1 之间越接近 1 表示越相似。用 Python 写一个最简版本import numpy as np def cosine_similarity(a, b): return np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b))这里有一个优化技巧如果提前把所有文本向量做 L2 归一化那么向量的内积就等于余弦相似度。FAISS 的IndexFlatIP计算的就是内积所以我们在向量化时使用了normalize_embeddingsTrue让后面的内积计算等于余弦相似度。2.3 向量索引暴力扫描与 ANN拿到向量之后最直接的做法是在所有候选向量里逐个计算相似度取 TopK。这被称为暴力扫描。在数据量小于几万条时暴力扫描完全够用而且结果精确。但如果语料达到百万级别暴力扫描的延迟就不可接受了。这时需要建立近似最近邻索引常见算法包括 HNSW、IVF、PQ 等。FAISS 提供了多种索引类型IndexFlatIP适合小规模演示IndexHNSWFlat适合大规模场景。2.4 与传统搜索的对比对比维度传统关键词搜索向量搜索匹配对象关键词、分词词项文本向量是否能处理同义改写依赖同义词词典天然支持语义近似索引结构倒排索引ANN 或暴力索引构建成本需要分词、去停用词需要下载模型、向量化主要瓶颈语义理解差召回质量与检索延迟典型场景日志检索、精确匹配知识库问答、语义检索一句话总结向量搜索解决的是“语义召回”问题但它不一定能替代传统搜索。实际生产系统中更常见的做法是“关键词检索 向量检索”混合召回再做重排序。3. 环境准备与 Claude Code 安装3.1 安装 Node.js 与 Claude Code CLIClaude Code 是一个命令行工具通过 npm 分发。官方也会提供原生安装方式但 npm 全局安装是最通用、最容易理解的一种。运行 Claude Code 通常需要 Node.js 18 以上版本具体以官方要求为准。node -v npm install -g anthropic-ai/claude-code安装完成后验证版本claude --version如果能看到版本号说明 CLI 已经安装成功。接下来在目标项目目录中运行cd /path/to/your-project claude第一次运行会引导你完成登录。一般有两条路径使用 Claude 账号授权或者使用 API Key。选择哪种取决于你手上有什么资源。这里要特别提醒一种情况如果所在组织的管理员在管理后台关闭了 Claude Code 权限登录时会遇到类似 “your organization has disabled claude subscription access for claude code” 的提示。这类限制属于组织策略一般需要联系管理员处理而不是企图绕过权限管控。3.2 在 VSCode 中使用 Claude CodeClaude Code 使用最多的场景是终端但如果你想在编辑器里更方便地查看上下文可以在 VSCode 中打开项目终端直接运行claude。也可以在 VSCode 扩展市场搜索官方扩展安装后获得更图形化的工作区视图。在 VSCode 中使用的核心逻辑不变项目目录是你的工作区Claude Code 会在该目录内读取文件、修改文件、执行命令。建议始终把 Claude Code 限定在独立项目目录里避免误操作其他目录。3.3 安装 Python 环境与相关依赖向量搜索引擎的运行需要 Python 环境。建议使用虚拟环境隔离依赖python -m venv .venv source .venv/bin/activateWindows 环境下激活命令不同.venv\Scripts\activate然后安装依赖pip install -U sentence-transformers faiss-cpu numpy三个库分工明确sentence-transformers加载向量化模型将文本转为向量。faiss-cpu向量索引库负责构建索引和相似度检索。numpy向量数组的底层计算。在部分环境下如果安装的是faiss而不是faiss-cpu可能会遇到动态库冲突。更稳妥的做法是直接安装faiss-cpu。如果后续回归测试需要 GPU再切换为 GPU 版。4. 用 Claude Code 进行需求沟通Claude Code 是一个对话式 AI 编码 Agent它和你之间的交互方式是自然语言。但“自然语言”不等于“模糊语言”。想让 Agent 高质量地产出代码需求描述必须结构清晰。4.1 一份好的需求描述长什么样我在实际使用中总结了一个简单模板包含五个要素项目目标你要做什么。输入与输出从哪读数据向哪写结果。技术选型允许用什么技术栈。非功能要求日志、异常处理、可读性、扩展性。执行方式是否分步执行、是否运行验证。对应到向量搜索引擎可以这样写请帮我搭建一个面向中文语料的向量搜索引擎要求如下 1. 输入data/ 目录下的多个 .txt 文件。 2. 处理流程自动读取文本、切分成片段、向量化、建立向量索引。 3. 输出命令行搜索接口输入查询语句后返回 TopK 文本片段和相似度分数。 4. 技术栈sentence-transformers faiss-cpu numpy。 5. 代码质量模块化设计添加日志和异常处理支持索引持久化。 请先分析技术方案和项目结构再逐步实现。每完成一个模块后运行命令或测试验证当前结果。看到这段需求后Claude Code 会进入一个典型的工作循环读取目录结构 → 规划文件与模块 → 生成代码 → 执行命令 → 观察报错 → 修复并重试 → 再次验证。这正是它和普通代码补全工具的本质区别它不止在编辑器里“接下一段代码”而是能在终端里跑起来在日志里发现问题再回到代码里修问题。4.2 如何在这个环节控制结果质量Claude Code 不是万能工具直接抛一句“帮我写个搜索引擎”通常不会得到理想结果。根据我的经验有四个控制点很重要。第一先让 AI 给设计再让它写代码。在正式编码前可以先追加一句“先不要写文件先输出你的技术方案我来确认。”这样可以避免 AI 一头扎进实现细节结果方向错了。第二用小语料验证。不要让 AI 一开始就在整个生产语料上跑先准备两三个小.txt文件跑通流程后再扩大。第三一次只提一个主要需求。需求堆得太多Claude Code 容易顾此失彼。每次让它完成一个模块再进入下一个。第四关键文件必须人工 review。模型选择、路径处理、权限控制、数据删除这类代码不要完全交给 AI 自由发挥。Claude Code 还支持自定义“技能”skill。你可以在项目根目录的.claude/skills/下添加SKILL.md描述团队希望 Agent 遵循的编码规范比如“所有 Python 文件必须包含函数注释”“禁止提交 .env 文件”。这样后续每次对话都会自动读取这些规则。5. 完整代码与实现为了让演示不依赖外部数据库我们选用 FAISS 作为内存索引用纯文件做持久化。项目结构如下vector-search/ ├── config.py # 全局配置 ├── text_splitter.py # 文本切分 ├── embedder.py # 文本向量化 ├── store.py # FAISS 索引封装 ├── main.py # 命令行入口 └── data/ # 存放 .txt 语料5.1 配置文件config.pyimport os BASE_DIR os.path.dirname(os.path.abspath(__file__)) DATA_DIR os.path.join(BASE_DIR, data) INDEX_PATH os.path.join(DATA_DIR, index.bin) CHUNKS_PATH os.path.join(DATA_DIR, chunks.json) EMBEDDING_MODEL BAAI/bge-small-zh-v1.5 CHUNK_SIZE 200 CHUNK_OVERLAP 20 TOP_K 3这里的INDEX_PATH是 FAISS 索引文件的路径CHUNKS_PATH是文本片段列表的 JSON 文件路径。实际上索引和文本片段必须一一对应所以两者要一起保存。5.2 文本切分text_splitter.pyimport re def split_text(text: str, chunk_size: int 200, overlap: int 20) - list: # 按中文结束标点切分为句子 sentences re.split(r(?[。]), text) sentences [s.strip() for s in sentences if s.strip()] chunks [] current for s in sentences: if len(current) len(s) chunk_size: current s else: if current: chunks.append(current) # 保留上一片段尾部部分内容避免切断了语义承接 current current[-overlap:] s if overlap else s if current: chunks.append(current) return chunks切分是向量检索中很容易被低估的环节。切得太大向量里包含过多无关信息召回精度下降切得太小语义不完整向量表达力不足。这里的实现按句子聚合到chunk_size左右并让相邻片段保留少量重叠尽量减少语义断层。5.3 文本向量化embedder.pyfrom sentence_transformers import SentenceTransformer from config import EMBEDDING_MODEL _model None def get_model(): global _model if _model is None: _model SentenceTransformer(EMBEDDING_MODEL) return _model def embed_texts(texts): model get_model() # 归一化向量便于后续用内积等价地计算余弦相似度 vectors model.encode(texts, normalize_embeddingsTrue) return vectors注意_model这个模块级缓存。每次调用都重新加载模型会非常慢所以只加载一次后续重复使用。5.4 向量索引封装store.pyimport json import os import faiss import numpy as np from config import CHUNKS_PATH, INDEX_PATH class VectorStore: def __init__(self): self.index None self.texts [] def add(self, texts, vectors): arr np.asarray(vectors, dtypefloat32) if self.index is None: # 内积索引配合归一化向量相当于余弦相似度检索 self.index faiss.IndexFlatIP(arr.shape[1]) self.index.add(arr) self.texts.extend(texts) def search(self, query_vec, k3): arr np.asarray(query_vec, dtypefloat32) distances, indices self.index.search(arr, k) results [] for idx, score in zip(indices[0], distances[0]): if idx 0: results.append( { score: float(score), text: self.texts[idx], } ) return results def save(self): os.makedirs(os.path.dirname(INDEX_PATH), exist_okTrue) faiss.write_index(self.index, INDEX_PATH) with open(CHUNKS_PATH, w, encodingutf-8) as f: json.dump(self.texts, f, ensure_asciiFalse, indent2) def load(self): if not os.path.exists(INDEX_PATH) or not os.path.exists(CHUNKS_PATH): raise FileNotFoundError(索引文件不存在请先运行构建命令) self.index faiss.read_index(INDEX_PATH) with open(CHUNKS_PATH, r, encodingutf-8) as f: self.texts json.load(f)IndexFlatIP是 FAISS 中的内积索引。因为向量已经做了 L2 归一化内积结果就等于余弦相似度这是一种非常实用的工程技巧不需要手动对每条结果再算一遍余弦。5.5 命令行入口main.pyimport glob import os import sys from config import DATA_DIR, TOP_K from embedder import embed_texts from store import VectorStore from text_splitter import split_text def load_corpus(): files glob.glob(os.path.join(DATA_DIR, *.txt)) chunks [] for file_path in files: with open(file_path, r, encodingutf-8) as f: content f.read() chunks.extend(split_text(content)) return chunks def build_index(): chunks load_corpus() if not chunks: print(f未在 {DATA_DIR} 目录下找到任何 .txt 文件) return print(f共切分出 {len(chunks)} 个文本块开始向量化...) vectors embed_texts(chunks) store VectorStore() store.add(chunks, vectors) store.save() print(f索引构建完成向量维度 {store.index.d}共 {store.index.ntotal} 条记录) def search_interactive(): store VectorStore() try: store.load() except FileNotFoundError as e: print(f加载索引失败{e}) print(请先运行 python main.py build 构建索引) return while True: try: query input(请输入查询输入 exit 退出).strip() except (KeyboardInterrupt, EOFError): break if not query or query.lower() in (exit, quit): break query_vec embed_texts([query]) results store.search(query_vec, kTOP_K) print(查询结果) for i, item in enumerate(results, 1): print(f Top{i} 相似度{item[score]:.4f}) print(f {item[text][:80]}) print() def main(): if len(sys.argv) 2: print(用法python main.py build | python main.py search) return action sys.argv[1] if action build: build_index() elif action search: search_interactive() else: print(未知操作仅支持 build 或 search) if __name__ __main__: main()一个小细节在search_interactive中使用try/except捕获FileNotFoundError这是为了让用户在没有构建索引时得到清晰提示而不是看到一长串堆栈。5.6 让 Claude Code 继续完善项目上面这份代码是向量搜索引擎的最小可用版本但它已经覆盖了搜索的核心链路。在你的实际工作流里可以让 Claude Code 在这个基础上继续补功能例如请继续改进这个向量搜索引擎 1. 支持把查询结果导出为 JSON 文件。 2. 为所有函数补充类型注解和 docstring。 3. 增加单元测试覆盖 text_splitter 和 store 的边界情况。 4