从零搭建生产级 RAG:Embedding、Chunking、Hybrid Search 与 Reranker

用 TypeScript + PostgreSQL + pgvector 从零搭建一套完整 RAG 系统的实战笔记。涵盖 Embedding 原理、Chunking 策略、向量检索、HNSW 索引、防幻觉 Prompt、Hybrid Search 与 Reranker。看完能独立实现一个生产可用的检索增强问答系统。

目录


一、RAG 是什么

RAG = Retrieval-Augmented Generation(检索增强生成)。一句话:LLM 不知道的事,先从知识库检索相关内容,再喂给 LLM 让它基于材料回答

为什么需要 RAG

LLM 单独使用有几个硬伤:

  • 知识截止到训练时间,不知道最新信息
  • 不知道你的私有数据(内部文档、专业知识库)
  • 会幻觉,即"自信地编造"
  • 微调(fine-tune)成本高,且无法即时更新

RAG 给 LLM 装了一个"外挂知识库",比微调便宜得多,且知识库随时可更新。

RAG 的两个阶段

makefile 复制代码
离线阶段(灌库):
  原始文档 → Chunking 切分 → Embedding 向量化 → 存入数据库

在线阶段(检索 + 生成):
  用户提问 → Query 向量化 → 检索 top-K → 拼 Prompt → LLM 生成答案

二、Embedding:把语义变成向量

核心原理

Embedding 模型把一段文本转换成一个定长的数字数组(向量),关键特性是:语义相近的文本,向量在空间中距离也近

scss 复制代码
"什么是合同?"      → [0.12, -0.34, 0.56, ..., 0.89]  (1024 维)
"合同的定义是什么?" → [0.13, -0.33, 0.57, ..., 0.88]  (1024 维)
                                                       ↑ 两个向量很接近

这样,判断"两段文字是否相关"就变成了"计算两个向量的距离"。这是语义搜索的底层。

余弦相似度

衡量两个向量距离最常用的是余弦相似度:

  • 值接近 1:语义高度相近
  • 值接近 0:基本无关
  • 值为负:语义相反

实测中,同义句的相似度约 0.85+,完全无关的句子约 0.3 以下。

主流 Embedding 模型

模型 维度 特点
OpenAI text-embedding-3-small 1536 主流,便宜
OpenAI text-embedding-3-large 3072 更准,贵 6 倍
智谱 embedding-2 1024 国内可用,中文好
BGE 系列 768/1024 开源,可本地部署

TypeScript 实现

调用 Embedding API 的核心代码。注意:有些厂商虽然声称"OpenAI 兼容",但 SDK 解析可能有偏差,直接用 fetch 更稳:

typescript 复制代码
const ZHIPU_BASE_URL = 'https://open.bigmodel.cn/api/paas/v4'

export async function createEmbedding(text: string): Promise<number[]> {
  const apiKey = process.env.ZHIPU_API_KEY
  if (!apiKey) throw new Error('API key not set')

  const response = await fetch(`${ZHIPU_BASE_URL}/embeddings`, {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${apiKey}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      model: 'embedding-2',
      input: text,
    }),
  })

  if (!response.ok) {
    throw new Error(`Embedding API error: ${response.status}`)
  }

  const data = await response.json()

  if (!data.data?.[0]?.embedding) {
    throw new Error('Invalid embedding response')
  }

  return data.data[0].embedding
}

经验 :接入任何新的 LLM/Embedding API,第一步先 console.log 打印完整响应,确认字段结构,再写解析代码。"OpenAI 兼容"不等于 100% 一致。


三、pgvector:让 PostgreSQL 存向量

pgvector 是什么

pgvector 是 PostgreSQL 的扩展,装上之后 PostgreSQL 就能:

  • 存储向量字段(vector 类型)
  • 计算向量距离(<-><=><#> 操作符)
  • 用向量索引加速检索(HNSW / IVFFlat)

这是"AI 时代为什么选 PostgreSQL"的核心理由:一个数据库同时搞定关系数据、JSON、全文搜索和向量,不用额外搭专门的向量数据库。

启用扩展

用预装 pgvector 的 Docker 镜像 pgvector/pgvector:pg16,然后:

sql 复制代码
CREATE EXTENSION IF NOT EXISTS vector;

Drizzle Schema 定义

css 复制代码
import { pgTable, uuid, varchar, text, timestamp, vector } from 'drizzle-orm/pg-core'

export const documents = pgTable('documents', {
  id: uuid('id').defaultRandom().primaryKey(),
  title: varchar('title', { length: 200 }).notNull(),
  source: varchar('source', { length: 200 }),
  content: text('content').notNull(),
  createdAt: timestamp('created_at').defaultNow().notNull(),
})

三种距离操作符

操作符 含义 适用场景
<-> 欧几里得距离 (L2) 通用向量
<=> 余弦距离 文本语义搜索(主流)
<#> 内积 性能优先

用 Drizzle 原生 API 做检索

不要手写 raw SQL,Drizzle 提供了 cosineDistance 原生函数:

typescript 复制代码
import { cosineDistance, sql, desc } from 'drizzle-orm'

const queryEmbedding = await createEmbedding(query)

// distance: 0=最近, similarity = 1 - distance
const similarity = sql<number>`1 - (${cosineDistance(chunks.embedding, queryEmbedding)})`

const results = await db
  .select({
    content: chunks.content,
    similarity,
  })
  .from(chunks)
  .orderBy(desc(similarity))
  .limit(5)

对应的还有 l2DistanceinnerProduct。用原生 API 而非 raw SQL,能保留完整 TypeScript 类型推导,代码也更清晰。


四、Chunking:长文档切分策略

为什么要切分

真实文档动辄几万字,不能整篇 Embedding:

  • 超 token 限制:Embedding 模型一次最多处理约 8K token
  • 语义被平均:整篇文档的向量会把所有主题混在一起,失去精确检索能力
  • 检索后太大:即使匹配上,整篇喂给 LLM 会超长且浪费 token

核心思想:让检索的颗粒度匹配用户问题的颗粒度

三种切分策略

策略 1:固定字符切分

每 N 个字符切一段,简单粗暴。优点是长度可预测,缺点是可能在句子中间切断。

策略 2:段落切分

按段落分隔符(双换行)切。优点是保持段落完整,缺点是段落长度差异大。

策略 3:递归切分(主流)

按"分隔符层级"递归切:优先用大分隔符(段落),某段还太大就用下一级分隔符(句号、逗号),最后兜底固定切。这是 LangChain / LlamaIndex 的默认策略。

scss 复制代码
export function chunkByRecursive(text: string, opts: ChunkOptions = {}): Chunk[] {
  const { chunkSize, overlap } = validateOptions(opts)
  if (text.length === 0) return []

  // 分隔符按语义大小排序
  const separators = ['\n\n', '\n', '。', '!', '?', ',', ' ', '']
  const splits = recursiveSplit(text, separators, chunkSize)

  // 合并切片 + 加 overlap
  const chunks: Chunk[] = []
  for (let i = 0; i < splits.length; i++) {
    let content = splits[i]
    if (i > 0 && overlap > 0) {
      content = splits[i - 1].slice(-overlap) + content
    }
    chunks.push({ content, index: i, charCount: content.length })
  }
  return chunks
}

关键参数

  • chunkSize:目标长度,典型 200-1000 字
  • overlap:相邻 chunk 的重叠长度,典型为 chunkSize 的 10-20%,作用是避免关键句被切断
  • separators:分隔符层级

防御性编程的重要性

切分逻辑里有一个隐蔽的死循环陷阱:如果 overlap >= chunkSize,每次循环的步长会变成 0 或负数,导致无限循环、内存暴涨、进程 OOM 崩溃。

必须加参数校验:

javascript 复制代码
function validateOptions(opts: ChunkOptions): Required<ChunkOptions> {
  const chunkSize = opts.chunkSize ?? 500
  const overlap = opts.overlap ?? 50

  if (chunkSize <= 0) throw new Error('chunkSize 必须大于 0')
  if (overlap < 0) throw new Error('overlap 不能为负')
  if (overlap >= chunkSize) {
    throw new Error('overlap 必须小于 chunkSize,否则会死循环')
  }
  return { chunkSize, overlap }
}

看到 JavaScript heap out of memory,优先怀疑死循环。


五、HNSW:向量检索索引

为什么需要索引

没有索引时,向量检索是暴力扫描:对每条数据计算与 query 的距离再排序,复杂度 O(N)。

yaml 复制代码
1 万条:    暴力 ~100ms
100 万条:  暴力 ~10s     ← 用户无法忍受
1000 万条: 暴力 ~100s

HNSW 是什么

HNSW(Hierarchical Navigable Small World,分层导航小世界图)是一种近似最近邻算法。类似图书馆的分层目录,搜索时跳跃式接近目标,复杂度从 O(N) 降到 O(log N)。

代价:

  • 索引占用额外空间(约数据量的 1-2 倍)
  • 写入稍慢
  • 是近似查询,约 1% 的概率漏掉真正的最近邻

对 RAG 来说,top-K 检索损失 1% 精度完全可接受。

Drizzle 里建 HNSW 索引

注意用 Drizzle 当前推荐的"数组"写法((table) => [...]),旧的"对象"写法已弃用:

css 复制代码
import { pgTable, ..., index } from 'drizzle-orm/pg-core'

export const chunks = pgTable(
  'chunks',
  {
    id: uuid('id').defaultRandom().primaryKey(),
    documentId: uuid('document_id')
      .notNull()
      .references(() => documents.id, { onDelete: 'cascade' }),
    chunkIndex: integer('chunk_index').notNull(),
    content: text('content').notNull(),
    embedding: vector('embedding', { dimensions: 1024 }),
    createdAt: timestamp('created_at').defaultNow().notNull(),
  },
  (table) => [    index('chunks_embedding_idx').using(      'hnsw',      table.embedding.op('vector_cosine_ops')    ),  ]
)

vector_cosine_ops 对应余弦距离,要和检索时用的操作符一致。

索引的通用认知

索引不是向量专属概念,是数据库通用能力。该加索引的字段:经常出现在 WHEREJOINORDER BY 的字段,外键,以及向量字段。验证索引是否生效用 EXPLAIN ANALYZE,看到 Index Scan 是走了索引,Seq Scan 是全表扫描。


六、完整 RAG 端到端

把前面的零件组装起来,就是完整 RAG。核心三个函数:检索、拼 Prompt、生成。

php 复制代码
// 1. 检索:query 转向量,查 top-K 相似 chunks
export async function retrieveChunks(query: string, topK = 5) {
  const queryEmbedding = await createEmbedding(query)
  const similarity = sql<number>`1 - (${cosineDistance(chunks.embedding, queryEmbedding)})`

  return db
    .select({
      content: chunks.content,
      similarity,
      documentTitle: documents.title,
      documentSource: documents.source,
    })
    .from(chunks)
    .innerJoin(documents, eq(chunks.documentId, documents.id))
    .orderBy(desc(similarity))
    .limit(topK)
}

// 2. 完整流程:检索 + 生成
export async function ragAsk(question: string, topK = 5) {
  const retrievedChunks = await retrieveChunks(question, topK)
  const systemPrompt = buildRAGPrompt(retrievedChunks)

  const response = await llm.chat.completions.create({
    model: 'glm-4-flash',
    messages: [
      { role: 'system', content: systemPrompt },
      { role: 'user', content: question },
    ],
    temperature: 0.1, // 低温度,减少创造性,提高严谨性
  })

  return {
    question,
    answer: response.choices[0]?.message.content || '',
    sources: retrievedChunks,
  }
}

temperature: 0.1 是关键:RAG 场景要的是"基于材料严谨回答",不是"创造性发挥",所以用接近 0 的温度。


七、防幻觉 Prompt 设计

为什么 Prompt 是 RAG 的核心

很多人以为 RAG 就是"接 Embedding API + 接 LLM API"。实际上生产中:约 50% 时间在调 chunking 策略,约 30% 在调 Prompt。

即使把检索到的材料喂给了 LLM,如果 Prompt 写得弱,LLM 仍可能"无视材料,用自己的知识回答",照样幻觉。

弱 Prompt vs 强 Prompt

弱 Prompt:"你是法律助手,这是参考材料,请回答。" ------ LLM 会"参考"材料,但仍可能补充自己的知识。

强 Prompt 要用明确的强约束:

javascript 复制代码
function buildRAGPrompt(retrievedChunks: RetrievedChunk[]): string {
  if (retrievedChunks.length === 0) {
    return `你是法律助手。当前没有找到相关参考材料,
请直接告诉用户"没有找到相关法律依据",不要编造内容。`
  }

  const materials = retrievedChunks
    .map((c, i) => `[材料 ${i + 1}] 来源:${c.documentSource}\n${c.content}`)
    .join('\n\n')

  return `你是一个严谨的法律助手。你必须严格遵守以下规则:

1. 只基于下面提供的"参考材料"回答用户问题
2. 如果材料中没有答案,明确说"参考材料中未涉及此内容",不要用你自己的知识补充
3. 回答时必须标注引用的材料编号,格式如"[材料 1]"
4. 如果用户问题与材料无关,礼貌说明"此问题不在我的知识范围内"

【参考材料】
${materials}

请基于以上材料,用中文准确、简洁地回答用户问题。`
}

四个关键设计点

  • 只用提供的材料:明确禁止用模型自己的知识
  • 没答案就说不知道:给 LLM 一个"台阶",避免它为了回答而编造
  • 强制引用来源 :输出里标注 [材料 N],实现可追溯
  • 不相关就说不相关:处理 query 与知识库无关的情况

把 chunks 拼成带编号的 [材料 N] 格式,LLM 就能在回答里引用,用户能反查每句话的依据。


八、Hybrid Search:向量 + 全文搜索

纯向量的盲区

纯向量检索擅长语义匹配,但有明显盲区:

  • 数字精确匹配("第 502 条"、日期、ID)
  • 专有名词、产品型号、代码标识符
  • 任何需要"字面命中"的场景

原因是 Embedding 关注语义,会把"502"这样的数字"模糊化"。

两路检索并行,再融合:

scss 复制代码
        用户问题
            │
      ┌─────┴─────┐
      ↓           ↓
  向量检索      全文搜索
  (语义相近)   (字面精确)
      │           │
      └─────┬─────┘
            ↓
       RRF 融合排序
            ↓
         top-K

PostgreSQL 全文搜索 + 中文分词

英文有空格天然分词,中文需要分词器。PostgreSQL 原生对中文支持弱,一个简单实用的方案是 bigram(二元分词):

arduino 复制代码
"违约责任" → "违约 约责 责任"  (每相邻两字一组)

bigram 工具:

typescript 复制代码
export function toBigrams(text: string): string {
  const cleaned = text.replace(/[^\u4e00-\u9fa5a-zA-Z0-9]/g, '')
  if (cleaned.length <= 1) return cleaned

  const bigrams: string[] = []
  for (let i = 0; i < cleaned.length - 1; i++) {
    bigrams.push(cleaned.slice(i, i + 2))
  }
  return bigrams.join(' ')
}

// 把查询转成 tsquery 表达式,用 | (OR) 连接,提高召回
export function queryToTsquery(query: string, operator: '&' | '|' = '|'): string {
  const bigrams = toBigrams(query).split(' ').filter(Boolean)
  if (bigrams.length === 0) return ''
  return bigrams.join(` ${operator} `)
}

Schema 里加 tsvector 字段(Drizzle 无原生类型,用 customType)和 GIN 索引:

typescript 复制代码
const tsVector = customType<{ data: string }>({
  dataType() {
    return 'tsvector'
  },
})

// chunks 表里加字段
contentTsv: tsVector('content_tsv'),

// 索引部分
index('chunks_content_tsv_idx').using('gin', table.contentTsv),

灌库时写入 tsvector(bigram 在 JS 端处理):

css 复制代码
await db.insert(chunks).values({
  content: chunk.content,
  embedding,
  contentTsv: sql`to_tsvector('simple', ${toBigrams(chunk.content)})`,
})

两路检索

csharp 复制代码
// 路径 1:向量检索
const vectorResults = await db
  .select({ /* ... */ })
  .from(chunks)
  .orderBy(desc(vectorSim))
  .limit(poolSize)

// 路径 2:全文搜索
const tsqueryStr = queryToTsquery(query, '|')
const ftsResults = await db
  .select({ /* ... */ })
  .from(chunks)
  .where(sql`${chunks.contentTsv} @@ to_tsquery('simple', ${tsqueryStr})`)
  .orderBy(desc(ftsRank))
  .limit(poolSize)

注意:用 to_tsquery + 手动 OR 连接,不要用 plainto_tsquery,后者会把空格当 AND,过于严格导致召回为 0。


九、RRF 融合算法

问题

两路检索各自有一批结果,但分数尺度不同:向量相似度是 0-1,全文搜索的 ts_rank 可能是 0.001-100。直接把分数相加会偏向某一路。

RRF 解法

RRF(Reciprocal Rank Fusion,倒数排名融合)只看排名,不看分数大小:

ini 复制代码
score = 1 / (k + rank)

k    = 经验常数(论文推荐 60)
rank = 在该路结果中的排名(1, 2, 3, ...)

关键效果:如果一个 chunk 在两路里都出现,两份得分相加,排名会显著上升。这正是 Hybrid 比单路强的原因------两路都认可的结果最可信。

实现

typescript 复制代码
const RRF_K = 60
const scoreMap = new Map<string, FusedItem>()

// 累计向量排名得分
vectorResults.forEach((chunk, idx) => {
  const score = 1 / (RRF_K + idx + 1)
  scoreMap.set(chunk.chunkId, { chunk, score })
})

// 累计全文排名得分
ftsResults.forEach((chunk, idx) => {
  const score = 1 / (RRF_K + idx + 1)
  const existing = scoreMap.get(chunk.chunkId)
  if (existing) {
    existing.score += score // 两路都命中 → 相加
  } else {
    scoreMap.set(chunk.chunkId, { chunk, score })
  }
})

// 按融合得分排序
const sorted = Array.from(scoreMap.values())
  .sort((a, b) => b.score - a.score)

举例:某 chunk 在向量路排第 2、全文路排第 1,融合得分 = 1/62 + 1/61 ≈ 0.0325,比任何单路命中(约 0.016)都高,排名飙升。


十、Reranker:粗排到精排

为什么需要 Reranker

Embedding 检索有个固有问题:一段几百字的 chunk 被压缩成一个定长向量,细微的相关性差异在压缩中丢失了。

Reranker 是专门评估相关性的模型,工作方式不同:把 (query, chunk) 作为一对完整输入到模型,直接输出 0-1 的相关性分数。不压缩,所以更精准。

代价是慢------每个候选都要单独算一次。所以采用两阶段:

makefile 复制代码
粗排:Hybrid Search 召回 top 20(快)
       ↓
精排:Reranker 对 20 个重新打分(准)
       ↓
取真正的 top 5

相关性准确率通常能提升 10-30%。

实现

typescript 复制代码
export async function rerank(
  query: string,
  documents: string[],
  topN = 5
): Promise<RerankResult[]> {
  if (documents.length === 0) return []

  const response = await fetch(`${ZHIPU_BASE_URL}/rerank`, {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.ZHIPU_API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      model: 'rerank',
      query,
      documents,
      top_n: Math.min(topN, documents.length),
    }),
  })

  if (!response.ok) {
    throw new Error(`Rerank API error: ${response.status}`)
  }

  const data = await response.json()
  return data.results.map((r: any) => ({
    index: r.index,
    score: r.relevance_score,
  }))
}

容错降级

Reranker 是外部服务,可能失败(未开通、欠费、网络问题)。生产代码要做降级,失败时退回 RRF 排序,而不是让整个请求崩溃:

javascript 复制代码
let finalItems
try {
  const rerankResults = await rerank(query, poolResults.map((p) => p.chunk.content), topK)
  finalItems = rerankResults.map((r) => ({ item: poolResults[r.index], rerankScore: r.score }))
} catch (err) {
  console.warn('Reranker 失败,降级为 RRF 排序')
  finalItems = poolResults.slice(0, topK).map((item) => ({ item }))
}

poolSize 怎么定

粗排召回数量(poolSize)太小,Reranker 没调整空间;太大则慢且贵。经验值:简单场景 20,复杂场景 50,极致场景 100。


十一、常见坑与排查

坑 1:第三方 API 静默失败

调用没报错,但返回的向量全是 0。排查方法:用 curl 绕过 SDK 直接打 API。如果 curl 正常而 SDK 不正常,就是 SDK 解析问题,改用 fetch。"OpenAI 兼容"不代表 100% 一致。

坑 2:死循环导致 OOM

Chunking 参数 overlap >= chunkSize 会让步长为 0,无限循环直到内存耗尽。看到 heap out of memory 优先怀疑死循环,加参数校验。

坑 3:ORM 的 SQL 片段不能复用

Drizzle 的 sql`...` 模板片段内部带参数占位符状态,当作变量复用多次会导致参数错乱。能用普通字符串/数字的地方就用普通值,SQL 片段尽量一次性使用。

坑 4:plainto_tsquery 过于严格

plainto_tsquery 把空格当 AND,要求所有词都命中,中文 bigram 场景下经常一条都匹配不上。改用 to_tsquery + 手动用 |(OR)连接。

坑 5:用户表达 ≠ 文档表达

这是 RAG 的经典难题,不是 bug。例如用户问"第 502 条"(阿拉伯数字),文档里是"第五百零二条"(中文数字),bigram 字面对不上,全文搜索召回 0。

解决方向:数字归一化、同义词扩展、用 LLM 做 query 改写。但注意:这种情况下向量检索通常仍能命中,因为向量理解语义------这恰好说明了 Hybrid 的价值:两路互补,各自的盲区由对方兜底。

排查方法论

"数据正常但功能不对"时,用二分法:先在数据库里手动执行 SQL。如果手动跑能出结果,说明数据和 SQL 语法没问题,问题在应用层代码;如果手动跑也不行,问题在数据或 SQL。


十二、技术选型与延伸

中文全文搜索的几个档次

方案 分词质量 部署成本 适用
bigram 中(够用) 零依赖 学习 / 中小项目 / RAG 辅助层
zhparser / pg_jieba 装 PG 扩展 自建 PG 的中型项目
Elasticsearch 很高 独立服务 搜索是核心的大项目

配合 Reranker 时,关键词层不需要很精确,bigram 通常够用。

向量数据库选型

中小规模(千万级以下)用 PostgreSQL + pgvector 足够,不用引入新组件。亿级数据量或需要分布式时,再考虑 Pinecone / Milvus 等专用向量数据库。

进一步优化方向

  • HyDE:让 LLM 先生成一个"假答案",用假答案的向量去检索,缓解短 query 语义模糊问题
  • Multi-Query:让 LLM 生成多个查询变体,分别检索后合并
  • Query 改写:用 LLM 把口语化的用户问题改写成更接近文档表达的形式
  • Evals:建立评估体系,科学地调 chunkSize、topK、poolSize 等参数

RAG 参数没有标准答案

chunkSize、overlap、topK、poolSize、RRF 的 k 值,都要根据真实数据和领域调整。法律文档、代码、小说的最优策略各不相同。这正是 RAG 工程师"调参"工作的核心,也是为什么需要 Evals 评估体系。


小结

一套生产级 RAG 的完整链路:

ruby 复制代码
灌库:文档 → 递归 Chunking → Embedding → 存入 PostgreSQL(向量 + tsvector)
            + HNSW 索引 + GIN 索引

检索:Query → 向量检索 ┐
              全文检索 ┘→ RRF 融合 → Reranker 精排 → top-K

生成:top-K → 防幻觉 Prompt(强约束 + 引用来源) → LLM → 带依据的答案

RAG 不是"接两个 API"那么简单。Chunking 策略、Hybrid 检索、融合算法、Reranker、Prompt 设计,每一环都有工程权衡。理解每个环节的原理和取舍,才算真正掌握 RAG。


参考资源

相关推荐
卡卡军2 小时前
vue3-sketch-ruler v3 升级详解:从 Vue 组件到跨框架标尺引擎
前端
还有多久拿退休金2 小时前
让看不见的 AI 动手画画——我意外造出了一个"绘图 Agent"
前端
陆枫Larry2 小时前
一次 iOS 橡皮筋弹性滚动的排查:从 absolute 到 fixed
前端
她的男孩2 小时前
从零搭一个企业后台,为什么我把能力拆成 Starter 和 Plugin
java·后端·架构
灏仟亿前端技术团队2 小时前
拆解亿级 SaaS 平台:Shopify 前端技术生态与架构避坑指南
前端
亲亲小宝宝鸭2 小时前
如何监听DOM尺寸的变化?element-resize-detector 和 resizeObserver
前端·javascript
胡志辉2 小时前
本地 AI 编码助手从 0 配起来:先选模型,再接 Ollama、VS Code、Claude Code 和 Codex
前端·后端
一颗小青松2 小时前
uniapp输入框fixed定位,导致页面顶起解决方案
前端·uni-app
RainCity2 小时前
Java Swing 自定义组件库分享(七)
java·笔记·后端