Final Project / 05
知识库构建
面试诊断 Agent 的诊断质量上限取决于知识库。如果没有高质量参考答案做对比,诊断就会退化成 LLM 的自由发挥——结果不稳定、标准不统一。
我们的知识库来源是现成的:zero2Agent 项目的 learn-agent-interview 模块,15 个维度、385+ 道面试题,每题都有标准格式的新手答、高手答和差距分析。
本篇解决三个问题:
- 怎么从 Markdown 文件中解析出结构化数据
- 怎么存储和索引(FTS5 全文 + embedding 向量)
- 怎么检索(双通道合并排序)
数据源概况
learn-agent-interview/
├── 01-architecture-design/index.md 34 题
├── 02-tool-management/index.md 25 题
├── 03-fault-tolerance/index.md 23 题
├── 04-memory-context/index.md 46 题
├── 05-eval-and-vision/index.md 28 题
├── 06-multi-agent-collab/index.md 20 题
├── 07-engineering-pitfalls/index.md 47 题
├── 08-prompt-engineering/index.md 16 题
├── 09-rag-retrieval/index.md 54 题
├── 10-training-and-data/index.md 45 题
├── 11-ai-code-testing/index.md 7 题
├── 12-business-ai-engineering/index.md 7 题
├── 13-project-deep-dive/index.md 20 题
├── 14-company-preferences/index.md —
├── 15-agent-concepts/index.md 13 题
─────
385+ 题
每题的 Markdown 格式是稳定的:
### Q:{面试问题}
> 来源:{公司/岗位}
**新手答**:"{浅层回答}"
**高手答**:
{深度回答,多段,带具体方案}
**差距在哪**:{分析}
这个统一格式就是我们的解析契约。
模块结构
knowledge/
├── import.ts # Markdown → 结构化 JSON 解析器
├── embed.ts # 批量生成 embedding
├── store.ts # SQLite 知识库读写
├── search.ts # 双通道检索逻辑
├── types.ts # 数据类型
└── data/
└── knowledge.db # 导入后的 SQLite 数据库
数据模型
// knowledge/types.ts
export interface KnowledgeEntry {
id: string; // dimension:index 如 "architecture-design:3"
dimension: string; // 维度标识
dimensionLabel: string; // 维度中文名
question: string; // 面试问题
source?: string; // 来源(公司/岗位)
noviceAnswer: string; // 新手答
expertAnswer: string; // 高手答
gapAnalysis: string; // 差距在哪
keywords: string[]; // 从问题中提取的关键词
embedding?: Float32Array; // 向量(question + expertAnswer 拼接后编码)
}
export interface SearchOptions {
dimension?: string;
limit?: number;
threshold?: number; // embedding 相似度阈值
}
export interface SearchResult extends KnowledgeEntry {
similarity: number; // 0-1
matchType: 'fts' | 'embedding' | 'both';
}
SQLite Schema
-- knowledge/schema.sql
CREATE TABLE knowledge (
id TEXT PRIMARY KEY,
dimension TEXT NOT NULL,
dimension_label TEXT NOT NULL,
question TEXT NOT NULL,
source TEXT,
novice_answer TEXT NOT NULL,
expert_answer TEXT NOT NULL,
gap_analysis TEXT NOT NULL,
keywords TEXT NOT NULL, -- JSON array
embedding BLOB, -- Float32Array serialized
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- FTS5 全文索引(对 question + expert_answer + keywords 建索引)
CREATE VIRTUAL TABLE knowledge_fts USING fts5(
id,
question,
expert_answer,
keywords,
content=knowledge,
content_rowid=rowid,
tokenize='unicode61'
);
-- 触发器:knowledge 表变更时同步 FTS
CREATE TRIGGER knowledge_ai AFTER INSERT ON knowledge BEGIN
INSERT INTO knowledge_fts(rowid, id, question, expert_answer, keywords)
VALUES (new.rowid, new.id, new.question, new.expert_answer, new.keywords);
END;
CREATE TRIGGER knowledge_ad AFTER DELETE ON knowledge BEGIN
INSERT INTO knowledge_fts(knowledge_fts, rowid, id, question, expert_answer, keywords)
VALUES ('delete', old.rowid, old.id, old.question, old.expert_answer, old.keywords);
END;
-- 维度索引(按维度过滤用)
CREATE INDEX idx_knowledge_dimension ON knowledge(dimension);
Markdown 解析器:从文件到结构化数据
解析逻辑的核心挑战是处理格式变体。虽然大部分题目遵循标准格式,但实际文件里存在:
## Q和### Q两种标题级别- 有些题有“差距在哪”,有些用“考察点”
- 高手答可能包含代码块、列表、多层级标题
// knowledge/import.ts
import { readFileSync, readdirSync } from 'fs';
import { join } from 'path';
import { KnowledgeEntry } from './types';
interface DimensionConfig {
dir: string;
id: string;
label: string;
}
const DIMENSIONS: DimensionConfig[] = [
{ dir: '01-architecture-design', id: 'architecture-design', label: '架构选型' },
{ dir: '02-tool-management', id: 'tool-management', label: '工具管理' },
{ dir: '03-fault-tolerance', id: 'fault-tolerance', label: '容错与兜底' },
{ dir: '04-memory-context', id: 'memory-context', label: '记忆与上下文' },
{ dir: '05-eval-and-vision', id: 'eval-and-vision', label: '评估与愿景' },
{ dir: '06-multi-agent-collab', id: 'multi-agent-collab', label: '多Agent协作' },
{ dir: '07-engineering-pitfalls', id: 'engineering-pitfalls', label: '工程踩坑' },
{ dir: '08-prompt-engineering', id: 'prompt-engineering', label: 'Prompt工程' },
{ dir: '09-rag-retrieval', id: 'rag-retrieval', label: 'RAG检索' },
{ dir: '10-training-and-data', id: 'training-and-data', label: '训练与数据' },
{ dir: '11-ai-code-testing', id: 'ai-code-testing', label: 'AI代码测试' },
{ dir: '12-business-ai-engineering', id: 'business-ai-engineering', label: '业务AI工程' },
{ dir: '13-project-deep-dive', id: 'project-deep-dive', label: '项目深挖' },
{ dir: '15-agent-concepts', id: 'agent-concepts', label: 'Agent概念' },
];
export function importAll(interviewDir: string): KnowledgeEntry[] {
const entries: KnowledgeEntry[] = [];
for (const dim of DIMENSIONS) {
const filePath = join(interviewDir, dim.dir, 'index.md');
const content = readFileSync(filePath, 'utf-8');
const questions = parseMarkdown(content, dim);
entries.push(...questions);
}
console.log(`Imported ${entries.length} entries from ${DIMENSIONS.length} dimensions`);
return entries;
}
function parseMarkdown(content: string, dim: DimensionConfig): KnowledgeEntry[] {
const entries: KnowledgeEntry[] = [];
// 按 Q 标题分割(支持 ## Q 和 ### Q)
const sections = content.split(/(?=^#{2,3}\s*Q[::])/m);
let index = 0;
for (const section of sections) {
if (!section.match(/^#{2,3}\s*Q[::]/m)) continue;
index++;
const entry = parseQuestion(section, dim, index);
if (entry) entries.push(entry);
}
return entries;
}
function parseQuestion(
section: string,
dim: DimensionConfig,
index: number
): KnowledgeEntry | null {
// 提取问题
const questionMatch = section.match(/^#{2,3}\s*Q[::]\s*(.+)/m);
if (!questionMatch) return null;
const question = questionMatch[1].trim();
// 提取来源
const sourceMatch = section.match(/>\s*来源[::]\s*(.+)/);
const source = sourceMatch?.[1]?.trim();
// 提取新手答
const noviceMatch = section.match(/\*\*新手答\*\*[::]\s*["""]?(.+?)["""]?\s*$/m);
const noviceAnswer = noviceMatch?.[1]?.trim() ?? '';
// 提取高手答(从"**高手答**:"到下一个"**"标记)
const expertMatch = section.match(
/\*\*高手答\*\*[::]\s*\n([\s\S]+?)(?=\n\*\*(?:差距|考察|关键))/
);
const expertAnswer = expertMatch?.[1]?.trim() ?? '';
// 提取差距分析
const gapMatch = section.match(
/\*\*(?:差距在哪|考察点|关键差距)\*\*[::]\s*([\s\S]+?)(?=\n---|\n#{2,3}\s|$)/
);
const gapAnalysis = gapMatch?.[1]?.trim() ?? '';
if (!expertAnswer) return null;
// 提取关键词
const keywords = extractKeywords(question + ' ' + expertAnswer);
return {
id: `${dim.id}:${index}`,
dimension: dim.id,
dimensionLabel: dim.label,
question,
source,
noviceAnswer,
expertAnswer,
gapAnalysis,
keywords,
};
}
function extractKeywords(text: string): string[] {
// 提取技术术语(英文词 + 中文专有名词)
const techTerms = text.match(
/\b(?:ReAct|LangGraph|RAG|CoT|ToT|Agent|Tool|MCP|embedding|vector|prompt|token|LLM|fine-?tune|RLHF|hallucination|context|memory|planning|reflection)\b/gi
) ?? [];
// 去重 + 小写化
return [...new Set(techTerms.map(t => t.toLowerCase()))];
}
Embedding 生成:批量向量化
// knowledge/embed.ts
import OpenAI from 'openai';
import { KnowledgeEntry } from './types';
const EMBEDDING_MODEL = 'text-embedding-3-small';
const BATCH_SIZE = 100; // OpenAI embedding API 单次最多 2048,100 比较安全
const EMBEDDING_DIM = 1536;
export async function generateEmbeddings(
entries: KnowledgeEntry[],
apiKey: string,
): Promise<KnowledgeEntry[]> {
const client = new OpenAI({ apiKey });
const results: KnowledgeEntry[] = [];
for (let i = 0; i < entries.length; i += BATCH_SIZE) {
const batch = entries.slice(i, i + BATCH_SIZE);
const texts = batch.map(e => buildEmbeddingText(e));
const response = await client.embeddings.create({
model: EMBEDDING_MODEL,
input: texts,
});
for (let j = 0; j < batch.length; j++) {
results.push({
...batch[j],
embedding: new Float32Array(response.data[j].embedding),
});
}
console.log(`Embedded ${Math.min(i + BATCH_SIZE, entries.length)}/${entries.length}`);
// 限流:避免打满 API
if (i + BATCH_SIZE < entries.length) {
await sleep(200);
}
}
return results;
}
function buildEmbeddingText(entry: KnowledgeEntry): string {
// 拼接 question + expertAnswer 的前 500 字作为 embedding 输入
// 原因:question 太短语义不够,expertAnswer 太长浪费 token
const expert = entry.expertAnswer.slice(0, 500);
return `问题:${entry.question}\n答案:${expert}`;
}
function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
成本估算:
385 道题 × 平均 200 tokens/题 = ~77,000 tokens
text-embedding-3-small 价格: $0.02 / 1M tokens
总成本: ~$0.002(忽略不计)
知识库存储
// knowledge/store.ts
import Database from 'better-sqlite3';
import { KnowledgeEntry, SearchOptions, SearchResult } from './types';
export class KnowledgeStore {
private db: Database.Database;
constructor(dbPath: string) {
this.db = new Database(dbPath);
this.db.pragma('journal_mode = WAL');
this.initSchema();
}
private initSchema(): void {
this.db.exec(SCHEMA_SQL); // 上面的 schema.sql
}
insertBatch(entries: KnowledgeEntry[]): void {
const stmt = this.db.prepare(`
INSERT OR REPLACE INTO knowledge
(id, dimension, dimension_label, question, source, novice_answer, expert_answer, gap_analysis, keywords, embedding)
VALUES
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
const tx = this.db.transaction((items: KnowledgeEntry[]) => {
for (const e of items) {
stmt.run(
e.id,
e.dimension,
e.dimensionLabel,
e.question,
e.source ?? null,
e.noviceAnswer,
e.expertAnswer,
e.gapAnalysis,
JSON.stringify(e.keywords),
e.embedding ? Buffer.from(e.embedding.buffer) : null,
);
}
});
tx(entries);
console.log(`Stored ${entries.length} entries`);
}
getEntry(id: string): KnowledgeEntry | null {
const row = this.db.prepare('SELECT * FROM knowledge WHERE id = ?').get(id);
return row ? this.rowToEntry(row) : null;
}
getDimensions(): Array<{ id: string; label: string; count: number }> {
return this.db.prepare(`
SELECT dimension as id, dimension_label as label, COUNT(*) as count
FROM knowledge GROUP BY dimension ORDER BY dimension
`).all() as any;
}
getStats(): { totalEntries: number; dimensions: number; withEmbedding: number } {
const total = this.db.prepare('SELECT COUNT(*) as c FROM knowledge').get() as any;
const dims = this.db.prepare('SELECT COUNT(DISTINCT dimension) as c FROM knowledge').get() as any;
const embedded = this.db.prepare('SELECT COUNT(*) as c FROM knowledge WHERE embedding IS NOT NULL').get() as any;
return {
totalEntries: total.c,
dimensions: dims.c,
withEmbedding: embedded.c,
};
}
sampleQuestions(opts: { dimension?: string; count: number }): KnowledgeEntry[] {
let sql = 'SELECT * FROM knowledge';
const params: any[] = [];
if (opts.dimension) {
sql += ' WHERE dimension = ?';
params.push(opts.dimension);
}
sql += ' ORDER BY RANDOM() LIMIT ?';
params.push(opts.count);
const rows = this.db.prepare(sql).all(...params);
return rows.map(r => this.rowToEntry(r));
}
private rowToEntry(row: any): KnowledgeEntry {
return {
id: row.id,
dimension: row.dimension,
dimensionLabel: row.dimension_label,
question: row.question,
source: row.source,
noviceAnswer: row.novice_answer,
expertAnswer: row.expert_answer,
gapAnalysis: row.gap_analysis,
keywords: JSON.parse(row.keywords),
embedding: row.embedding ? new Float32Array(row.embedding.buffer) : undefined,
};
}
}
双通道检索
检索是知识库最关键的能力。单用 FTS 会漏掉语义相似但措辞不同的题,单用 embedding 会漏掉精确匹配。双通道合并才能兼顾准确率和召回率。
// knowledge/search.ts
import { KnowledgeStore } from './store';
import { SearchOptions, SearchResult, KnowledgeEntry } from './types';
import OpenAI from 'openai';
export class KnowledgeSearch {
private store: KnowledgeStore;
private openai: OpenAI;
constructor(store: KnowledgeStore, openaiApiKey: string) {
this.store = store;
this.openai = new OpenAI({ apiKey: openaiApiKey });
}
async search(query: string, opts: SearchOptions = {}): Promise<SearchResult[]> {
const { dimension, limit = 3, threshold = 0.5 } = opts;
// 通道 1: FTS5 全文检索
const ftsResults = this.searchFTS(query, { dimension, limit: limit * 3 });
// 通道 2: Embedding 语义检索
const embeddingResults = await this.searchEmbedding(query, { dimension, limit: limit * 3, threshold });
// 合并 + 重排序
return this.mergeResults(ftsResults, embeddingResults, limit);
}
searchFTS(query: string, opts: { dimension?: string; limit: number }): SearchResult[] {
// 构造 FTS5 查询(分词 + OR 连接)
const tokens = this.tokenize(query);
const ftsQuery = tokens.map(t => `"${t}"`).join(' OR ');
let sql = `
SELECT k.*, rank
FROM knowledge_fts fts
JOIN knowledge k ON k.id = fts.id
WHERE knowledge_fts MATCH ?
`;
const params: any[] = [ftsQuery];
if (opts.dimension) {
sql += ' AND k.dimension = ?';
params.push(opts.dimension);
}
sql += ' ORDER BY rank LIMIT ?';
params.push(opts.limit);
const rows = this.store.db.prepare(sql).all(...params);
return rows.map((row: any) => ({
...this.store.rowToEntry(row),
similarity: this.normalizeRank(row.rank),
matchType: 'fts' as const,
}));
}
async searchEmbedding(
query: string,
opts: { dimension?: string; limit: number; threshold: number }
): Promise<SearchResult[]> {
// 生成 query embedding
const response = await this.openai.embeddings.create({
model: 'text-embedding-3-small',
input: query,
});
const queryVec = new Float32Array(response.data[0].embedding);
// 从 DB 取出所有候选(有 embedding 的行)
let sql = 'SELECT * FROM knowledge WHERE embedding IS NOT NULL';
const params: any[] = [];
if (opts.dimension) {
sql += ' AND dimension = ?';
params.push(opts.dimension);
}
const rows = this.store.db.prepare(sql).all(...params);
// 计算余弦相似度 + 排序
const scored: Array<{ entry: KnowledgeEntry; similarity: number }> = [];
for (const row of rows) {
const entry = this.store.rowToEntry(row);
if (!entry.embedding) continue;
const sim = cosineSimilarity(queryVec, entry.embedding);
if (sim >= opts.threshold) {
scored.push({ entry, similarity: sim });
}
}
scored.sort((a, b) => b.similarity - a.similarity);
return scored.slice(0, opts.limit).map(s => ({
...s.entry,
similarity: s.similarity,
matchType: 'embedding' as const,
}));
}
private mergeResults(
fts: SearchResult[],
embedding: SearchResult[],
limit: number,
): SearchResult[] {
const merged = new Map<string, SearchResult>();
// embedding 结果优先(语义匹配通常更准)
for (const r of embedding) {
merged.set(r.id, r);
}
// FTS 结果补充(可能捕获精确匹配)
for (const r of fts) {
if (merged.has(r.id)) {
// 两个通道都命中——提升分数
const existing = merged.get(r.id)!;
existing.similarity = Math.min(1.0, existing.similarity * 1.2);
existing.matchType = 'both';
} else {
merged.set(r.id, r);
}
}
// 按 similarity 排序
return Array.from(merged.values())
.sort((a, b) => b.similarity - a.similarity)
.slice(0, limit);
}
private tokenize(text: string): string[] {
// 简单分词:按空格 + 中文字符边界切
const tokens = text
.replace(/[,。?!、;:""''()《》【】]/g, ' ')
.split(/\s+/)
.filter(t => t.length >= 2);
return [...new Set(tokens)];
}
private normalizeRank(rank: number): number {
// FTS5 rank 是负数(越小越好),转成 0-1
return Math.min(1.0, Math.max(0, 1 + rank / 10));
}
}
function cosineSimilarity(a: Float32Array, b: Float32Array): number {
let dot = 0, normA = 0, normB = 0;
for (let i = 0; i < a.length; i++) {
dot += a[i] * b[i];
normA += a[i] * a[i];
normB += b[i] * b[i];
}
return dot / (Math.sqrt(normA) * Math.sqrt(normB));
}
导入脚本:一键构建知识库
// knowledge/cli.ts — 通过 Commander 暴露为 CLI 命令
import { importAll } from './import';
import { generateEmbeddings } from './embed';
import { KnowledgeStore } from './store';
export async function buildKnowledgeBase(opts: {
interviewDir: string;
dbPath: string;
openaiApiKey: string;
}): Promise<void> {
console.log('Step 1/3: Parsing Markdown files...');
const entries = importAll(opts.interviewDir);
console.log(` → Parsed ${entries.length} questions`);
console.log('Step 2/3: Generating embeddings...');
const withEmbeddings = await generateEmbeddings(entries, opts.openaiApiKey);
console.log(` → Generated ${withEmbeddings.length} embeddings`);
console.log('Step 3/3: Storing to SQLite...');
const store = new KnowledgeStore(opts.dbPath);
store.insertBatch(withEmbeddings);
const stats = store.getStats();
console.log(`\nDone! Knowledge base ready:`);
console.log(` Total entries: ${stats.totalEntries}`);
console.log(` Dimensions: ${stats.dimensions}`);
console.log(` With embedding: ${stats.withEmbedding}`);
console.log(` Database: ${opts.dbPath}`);
}
使用:
pnpm tsx knowledge/cli.ts build \
--interview-dir ../learn-agent-interview \
--db-path ./data/knowledge.db
输出:
Step 1/3: Parsing Markdown files...
→ Parsed 385 questions
Step 2/3: Generating embeddings...
Embedded 100/385
Embedded 200/385
Embedded 300/385
Embedded 385/385
→ Generated 385 embeddings
Step 3/3: Storing to SQLite...
Stored 385 entries
Done! Knowledge base ready:
Total entries: 385
Dimensions: 14
With embedding: 385
Database: ./data/knowledge.db
检索效果验证
知识库建好了,必须验证检索质量。核心指标:用户问的面试题能否命中相关参考答案。
// knowledge/eval.ts
export async function evaluateSearch(
search: KnowledgeSearch,
testCases: Array<{ query: string; expectedDimension: string; expectedKeywords: string[] }>,
): Promise<void> {
let hits = 0;
for (const tc of testCases) {
const results = await search.search(tc.query, { limit: 3 });
const topResult = results[0];
const dimensionMatch = topResult?.dimension === tc.expectedDimension;
const keywordMatch = tc.expectedKeywords.some(kw =>
topResult?.expertAnswer.includes(kw)
);
if (dimensionMatch || keywordMatch) hits++;
console.log(`[${dimensionMatch ? '✓' : '✗'}] "${tc.query.slice(0, 40)}..." → ${topResult?.dimension ?? 'NO RESULT'} (sim: ${topResult?.similarity.toFixed(3) ?? 'N/A'})`);
}
console.log(`\nAccuracy: ${hits}/${testCases.length} (${(hits / testCases.length * 100).toFixed(1)}%)`);
}
// 测试用例示例
const TEST_CASES = [
{ query: 'Agent 的记忆系统怎么设计', expectedDimension: 'memory-context', expectedKeywords: ['长短期', 'memory'] },
{ query: 'ReAct 和 Plan-and-Execute 怎么选', expectedDimension: 'architecture-design', expectedKeywords: ['ReAct', 'Plan'] },
{ query: 'RAG 检索质量怎么提升', expectedDimension: 'rag-retrieval', expectedKeywords: ['chunk', 'embedding', 'rerank'] },
{ query: '多 Agent 之间怎么通信', expectedDimension: 'multi-agent-collab', expectedKeywords: ['消息', '协议'] },
{ query: 'Tool 调用失败了怎么兜底', expectedDimension: 'fault-tolerance', expectedKeywords: ['重试', 'fallback'] },
];
增量更新:新题入库
当 learn-agent-interview 新增面试题时,知识库需要增量更新而不是全量重建。
// knowledge/incremental.ts
export async function incrementalUpdate(
store: KnowledgeStore,
interviewDir: string,
openaiApiKey: string,
): Promise<{ added: number; updated: number }> {
const entries = importAll(interviewDir);
let added = 0, updated = 0;
for (const entry of entries) {
const existing = store.getEntry(entry.id);
if (!existing) {
// 新题:生成 embedding + 入库
const [withEmbed] = await generateEmbeddings([entry], openaiApiKey);
store.insertBatch([withEmbed]);
added++;
} else if (existing.expertAnswer !== entry.expertAnswer) {
// 答案更新:重新生成 embedding
const [withEmbed] = await generateEmbeddings([entry], openaiApiKey);
store.insertBatch([withEmbed]);
updated++;
}
// 无变化则跳过
}
return { added, updated };
}
性能考量
当前规模(385 题)下的性能特征:
FTS5 检索: < 1ms(SQLite 内存级)
Embedding 检索:
- 生成 query embedding: ~200ms(网络往返)
- 余弦相似度计算 385 × 1536 维: < 5ms(CPU)
- 总计: ~205ms
双通道合并: < 1ms
385 题全部加载到内存做余弦相似度完全可行。如果未来题库增长到 10000+,需要引入 ANN(近似最近邻)索引,比如:
- sqlite-vss(SQLite 向量搜索扩展)
- 或导出到 Faiss / Hnswlib
当前阶段不需要——过早优化是万恶之源。
与 Tool 层的集成
知识库通过 query_knowledge_base Tool 暴露给 Agent:
// 在 Tool 的 execute 中使用
const kbResult = await knowledgeSearch.search(input.question, {
dimension: input.dimension,
limit: input.limit ?? 3,
});
return {
success: true,
data: {
results: kbResult.map(r => ({
question: r.question,
noviceAnswer: r.noviceAnswer,
expertAnswer: r.expertAnswer,
gap: r.gapAnalysis,
dimension: r.dimensionLabel,
similarity: r.similarity,
})),
totalMatched: kbResult.length,
},
};
小结
- 数据源是现成的:learn-agent-interview 15 个维度 385+ 道题,格式统一
- Markdown 解析器处理
## Q/### Q两种标题、新手答/高手答/差距分析三段结构 - SQLite FTS5 做全文检索(精确匹配),embedding 做语义检索(模糊匹配),双通道合并
- 导入成本极低:embedding 不到 $0.01,全流程 < 1 分钟
- 检索延迟 ~200ms(主要是 embedding API 往返),FTS 本地 < 1ms
- 支持增量更新,不需要每次全量重建
- 385 题规模下纯暴力余弦相似度足够,不需要 ANN 索引
下一篇建议继续看: