AI-Native Solutions Architect

RAG for Engineering: Tự build hệ thống RAG để query codebase nội bộ

7/19/2026 · 12p đọc


title: "RAG for Engineering: Tự build hệ thống RAG để query codebase nội bộ"
series: "AI-Native Solutions Architect: Từ Coder đến Kiến trúc sư AI"
season: "Season 2 — AI-Augmented Architecture & System Design"
order: 14
audience: "Software Engineer hướng tới Solutions Architect"
reading_time: "13 phút"
tags: ["RAG", "vector database", "embedding", "codebase search", "LLM context", "AI engineering", "system design"]

RAG for Engineering: Tự build hệ thống RAG để query codebase nội bộ

Bạn hỏi AI assistant "hàm calculateOrderTotal này được gọi ở đâu, và có module nào khác đang giả định logic tính thuế của nó không?" — và nó trả lời rất tự tin, dẫn ra 3 file. Bạn mở ra thì cả 3 đều sai, hoặc chỉ đúng một nửa, vì AI chưa từng thấy 90% codebase của bạn. Nó chỉ thấy vài file bạn paste vào context, cộng với suy đoán dựa trên convention đặt tên phổ biến.

Đây là giới hạn cứng của mọi LLM: context window dù có lớn tới đâu (128K, 200K, hay 1M token) cũng không thể chứa một codebase enterprise thực sự — hàng trăm nghìn dòng code, hàng nghìn file, trải qua nhiều năm với nhiều convention khác nhau. Ở Bài 2 (Context Engineering) trong Season 1, chúng ta đã nói về việc dùng rules file (.cursorrules, CLAUDE.md, system prompt tĩnh) để nạp context cho AI. Nhưng rules file là tĩnh — nó mô tả convention, kiến trúc tổng quan, không thể chứa toàn bộ chi tiết implementation của từng module. Khi câu hỏi cần AI "biết" một đoạn code cụ thể nằm sâu trong một service ít ai động tới, rules file bó tay.

Đây chính là lúc kiến trúc sư cần một tầng trung gian: hệ thống truy xuất thông tin tự động, chỉ lấy đúng phần codebase liên quan đến câu hỏi rồi mới đưa cho LLM xử lý. Đó là RAG — Retrieval-Augmented Generation. Bài này không nói RAG cho chatbot hỏi-đáp tài liệu (use case phổ biến nhất người ta hay demo), mà tập trung vào một bài toán engineering cụ thể: RAG để LLM "hiểu" và trả lời chính xác về một codebase nội bộ.

Vấn đề

Hình dung một hệ thống CRM backend viết bằng NestJS với 40 module, mỗi module có controller, service, entity, DTO riêng — tổng cộng vài trăm nghìn dòng code. Một kỹ sư mới join team, hoặc chính bạn sau 6 tháng không đụng vào một module, cần trả lời những câu hỏi kiểu:

  • "Logic tính hoa hồng bán hàng (commission) được implement ở module nào, và nó phụ thuộc vào bảng nào?"
  • "Có bao nhiêu nơi trong code đang gọi trực tiếp OrderRepository thay vì qua OrderService?"
  • "Nếu tôi đổi field status trong entity Deal, những service nào sẽ bị ảnh hưởng?"

Ba cách tiếp cận truyền thống đều có vấn đề:

  1. Grep/search theo từ khóa: nhanh nhưng chỉ tìm được exact match, không hiểu ngữ nghĩa. Hỏi "logic tính hoa hồng" mà code đặt tên biến là calcPayout thì grep vô dụng.
  2. Paste toàn bộ module liên quan vào prompt: hoạt động nếu bạn đã biết module nào liên quan — nhưng vấn đề chính là bạn không biết, đó là lý do bạn hỏi.
  3. Nạp toàn bộ codebase vào context window (kể cả với model 1M token): tốn kém, chậm, và thực tế downstream research cho thấy hiệu suất truy xuất thông tin của LLM suy giảm khi context quá dài và chứa nhiều nội dung không liên quan — hiện tượng thường gọi là "lost in the middle". Nhét dư thừa không miễn phí về chất lượng câu trả lời.

Giải pháp thực dụng là: đừng bắt LLM đọc hết, hãy xây một tầng tìm kiếm ngữ nghĩa (semantic search) đứng trước LLM, chỉ đưa vào đúng những đoạn code có khả năng liên quan cao nhất. Đó là bản chất của RAG.

Kỹ thuật cốt lõi

RAG gồm hai luồng tách biệt: luồng chuẩn bị dữ liệu (index-time, chạy một lần hoặc định kỳ khi code thay đổi) và luồng truy vấn (query-time, chạy mỗi khi có câu hỏi).

Luồng chuẩn bị dữ liệu (Indexing)

Codebase (source files)
     │
     ▼
[1] Chunking — chia code thành các đoạn có ý nghĩa
     │  (theo function/class/method, KHÔNG cắt cơ học theo số dòng)
     ▼
[2] Embedding — đưa mỗi đoạn qua embedding model
     │  → vector số thực (ví dụ 1536 chiều)
     ▼
[3] Lưu vào Vector Database
     (vector + metadata: file path, tên function, dòng bắt đầu/kết thúc,
      module, ngôn ngữ, git commit hash...)

Luồng truy vấn (Query-time)

Câu hỏi của developer
     │
     ▼
[1] Embedding câu hỏi — dùng CÙNG embedding model với lúc index
     │
     ▼
[2] Retrieve — tìm top-k vector gần nhất trong Vector DB
     │  (cosine similarity / dot product)
     │  → k đoạn code liên quan nhất (kèm metadata)
     ▼
[3] Ghép context — format các đoạn retrieve được thành prompt
     │
     ▼
[4] LLM sinh câu trả lời dựa trên context vừa ghép
     │
     ▼
Trả lời kèm trích dẫn file:line cụ thể

Vài khái niệm cần làm rõ cho kỹ sư quen code hơn là quen ML:

  • Chunking: đơn vị chia nhỏ code. Sai lầm phổ biến nhất là chia theo số ký tự cố định (ví dụ mỗi 500 token một chunk) — cách này cắt ngang giữa một function, làm mất ngữ cảnh logic. Cách đúng là chunking theo cấu trúc cú pháp: dùng AST parser (Tree-sitter là lựa chọn phổ biến, hỗ trợ nhiều ngôn ngữ) để tách theo ranh giới function/method/class. Mỗi chunk nên là một đơn vị hoàn chỉnh về mặt logic.

  • Embedding: một model chuyên biệt (khác với LLM sinh text) chuyển đoạn code thành vector số. Hai đoạn code có ý nghĩa gần nhau sẽ có vector gần nhau trong không gian nhiều chiều, dù chúng dùng từ khóa khác nhau hoàn toàn — đây là điểm mạnh so với grep. Có model embedding tổng quát và cả model được huấn luyện chuyên cho code (hiểu cú pháp, tên biến, import statement tốt hơn).

  • Vector Database: hệ lưu trữ tối ưu cho tìm kiếm similarity trên hàng triệu vector — Qdrant, Pinecone, Weaviate, hoặc pgvector (extension cho Postgres) là các lựa chọn phổ biến. Điểm khác biệt so với DB truyền thống: query không phải "WHERE x = y" mà là "tìm N vector gần nhất với vector này".

  • Retrieval top-k: không phải lúc nào cũng trả về đúng đoạn code cần thiết — đây là điểm yếu cố hữu cần biết trước (nói kỹ ở phần Cạm bẫy).

Một điểm kiến trúc quan trọng: RAG không thay thế reasoning của LLM, nó chỉ thay thế việc bạn phải tự tay tìm và paste code. LLM vẫn là thứ đọc hiểu và tổng hợp câu trả lời; RAG chỉ là bộ lọc thông tin đầu vào.

Thực hành

1. Chunking theo function/class (ví dụ TypeScript/NestJS)

Dùng Tree-sitter (hoặc TypeScript Compiler API) để parse file và chunk theo node cấp function/method/class, không cắt cơ học. Ví dụ pseudo-code chunking bằng ts-morph (thư viện wrap TypeScript Compiler API, dễ dùng hơn AST thô):

// chunker.ts — chunking theo function/class thay vì cắt theo số dòng
import { Project, SyntaxKind } from 'ts-morph';

interface CodeChunk {
  filePath: string;
  symbolName: string;
  kind: 'function' | 'method' | 'class';
  startLine: number;
  endLine: number;
  content: string; // bao gồm cả docblock/comment phía trên nếu có
}

function chunkFile(filePath: string): CodeChunk[] {
  const project = new Project();
  const sourceFile = project.addSourceFileAtPath(filePath);
  const chunks: CodeChunk[] = [];

  // Chunk theo class (bao gồm toàn bộ methods bên trong nếu class ngắn,
  // hoặc tách riêng từng method nếu class quá dài)
  sourceFile.getClasses().forEach((cls) => {
    const classText = cls.getFullText();
    const lineCount = classText.split('\n').length;

    if (lineCount <= 150) {
      // Class nhỏ: giữ nguyên cả class làm 1 chunk — tránh mất ngữ cảnh
      // giữa method và property/constructor cùng class
      chunks.push({
        filePath,
        symbolName: cls.getName() ?? 'AnonymousClass',
        kind: 'class',
        startLine: cls.getStartLineNumber(),
        endLine: cls.getEndLineNumber(),
        content: classText,
      });
    } else {
      // Class lớn: chunk từng method riêng, nhưng vẫn giữ lại
      // class declaration + constructor làm phần "header" chung
      const header = `class ${cls.getName()} {\n  // ...constructor, properties...\n`;
      cls.getMethods().forEach((method) => {
        chunks.push({
          filePath,
          symbolName: `${cls.getName()}.${method.getName()}`,
          kind: 'method',
          startLine: method.getStartLineNumber(),
          endLine: method.getEndLineNumber(),
          content: header + method.getFullText() + '\n}',
        });
      });
    }
  });

  // Chunk các standalone function (không thuộc class nào)
  sourceFile.getFunctions().forEach((fn) => {
    chunks.push({
      filePath,
      symbolName: fn.getName() ?? 'anonymous',
      kind: 'function',
      startLine: fn.getStartLineNumber(),
      endLine: fn.getEndLineNumber(),
      content: fn.getFullText(),
    });
  });

  return chunks;
}

Nguyên tắc: mỗi chunk phải tự đứng được về mặt đọc hiểu (đủ signature, đủ docblock, không bị cắt cụt logic) dù đôi khi phải chấp nhận trade-off — class quá dài thì chunk theo method nhưng vẫn kèm theo context tối thiểu (tên class, constructor).

2. Tạo embedding và lưu vào Vector DB (ví dụ với Qdrant — phù hợp vì stack đã có sẵn Qdrant trong dự án)

// indexer.ts
import { QdrantClient } from '@qdrant/js-client-rest';
import OpenAI from 'openai';

const qdrant = new QdrantClient({ url: 'http://localhost:6333' });
const openai = new OpenAI();

const COLLECTION_NAME = 'codebase_chunks';

async function embedAndIndex(chunks: CodeChunk[]) {
  for (const chunk of chunks) {
    // Ghép metadata vào text trước khi embed giúp embedding "hiểu"
    // ngữ cảnh module/file, không chỉ thuần code
    const textToEmbed = [
      `File: ${chunk.filePath}`,
      `Symbol: ${chunk.symbolName} (${chunk.kind})`,
      chunk.content,
    ].join('\n');

    const embeddingResponse = await openai.embeddings.create({
      model: 'text-embedding-3-small',
      input: textToEmbed,
    });

    await qdrant.upsert(COLLECTION_NAME, {
      points: [
        {
          id: `${chunk.filePath}:${chunk.symbolName}`,
          vector: embeddingResponse.data[0].embedding,
          payload: {
            filePath: chunk.filePath,
            symbolName: chunk.symbolName,
            kind: chunk.kind,
            startLine: chunk.startLine,
            endLine: chunk.endLine,
            content: chunk.content,
          },
        },
      ],
    });
  }
}

Lưu ý thực tế: chỉ re-index những file thay đổi (dựa vào git diff hoặc file hash), không re-index toàn bộ codebase mỗi lần — với repo lớn việc này tốn embedding API cost và thời gian không cần thiết.

3. Truy vấn — retrieve top-k rồi ghép prompt

// query.ts
async function queryCodebase(question: string, topK = 8) {
  const questionEmbedding = await openai.embeddings.create({
    model: 'text-embedding-3-small',
    input: question,
  });

  const results = await qdrant.search(COLLECTION_NAME, {
    vector: questionEmbedding.data[0].embedding,
    limit: topK,
    with_payload: true,
  });

  return results.map((r) => r.payload);
}

4. Prompt mẫu — format context đã retrieve để đưa vào LLM

Đây là phần quan trọng nhất về mặt thực hành: cách format retrieved context quyết định LLM có trích dẫn chính xác được hay không.

Bạn là trợ lý kỹ thuật giúp trả lời câu hỏi về codebase nội bộ của hệ thống CRM.

Dưới đây là các đoạn code được truy xuất tự động (semantic search),
có thể KHÔNG đầy đủ 100% và CÓ THỂ chứa đoạn không liên quan.
Nếu context không đủ để trả lời chắc chắn, hãy nói rõ "không đủ thông tin"
thay vì đoán.

--- CONTEXT ĐOẠN 1 ---
File: src/modules/commission/commission.service.ts
Symbol: CommissionService.calculatePayout (method)
Dòng: 42-78

```typescript
async calculatePayout(dealId: string): Promise<number> {
  const deal = await this.dealRepository.findOne({ where: { id: dealId } });
  if (!deal) throw new NotFoundException('Deal not found');
  const rate = await this.rateService.getRateForRegion(deal.region);
  return deal.amount * rate * (1 - deal.discountRate);
}

--- HẾT ĐOẠN 1 ---

--- CONTEXT ĐOẠN 2 ---
File: src/modules/deal/deal.entity.ts
Symbol: Deal (class)
Dòng: 1-35

@Entity('deals')
export class Deal {
  @Column({ type: 'decimal' })
  amount: number;

  @Column({ nullable: true })
  region: string;

  @Column({ type: 'decimal', default: 0 })
  discountRate: number;
  // ...
}

--- HẾT ĐOẠN 2 ---

[... các đoạn context khác nếu top-k > 2 ...]

CÂU HỎI CỦA DEVELOPER:
"Logic tính hoa hồng bán hàng được implement ở module nào, và nó phụ thuộc
vào field nào của bảng deals?"

YÊU CẦU TRẢ LỜI:

  1. Nêu rõ file:dòng cụ thể chứa logic.
  2. Liệt kê các field/bảng mà logic đó phụ thuộc, trích từ context trên
    (KHÔNG suy đoán field không xuất hiện trong context).
  3. Nếu nghi ngờ còn chỗ khác trong code cũng ảnh hưởng nhưng không nằm
    trong context được cung cấp, hãy nói rõ giới hạn này.

Ba chi tiết đáng chú ý trong prompt trên:
- Đánh dấu rõ ranh giới từng đoạn context (`--- CONTEXT ĐOẠN N ---`) để LLM không nhầm lẫn giữa các file khác nhau khi trích dẫn.
- Chỉ định rõ "không đủ thông tin thì nói rõ" — giảm rủi ro hallucination khi retrieval không đủ tốt.
- Yêu cầu LLM tự khai báo giới hạn (context được cung cấp có thể không đầy đủ) — đây là kỷ luật kiến trúc, không phải câu văn lịch sự.

### 5. Bảng lựa chọn Vector DB tham khảo nhanh

| Vector DB | Phù hợp khi | Lưu ý |
|---|---|---|
| Qdrant | Đã tự host, cần kiểm soát hạ tầng, tích hợp filter theo metadata phức tạp | Cần vận hành thêm 1 service |
| pgvector | Team đã dùng Postgres, muốn tránh thêm hệ thống mới | Hiệu năng giảm dần khi số vector rất lớn (chục triệu+) so với DB chuyên dụng |
| Pinecone/Weaviate Cloud | Muốn managed service, không muốn tự vận hành | Chi phí theo dung lượng/tần suất query, cần đánh giá theo scale thực tế của bạn |

## Cạm bẫy thường gặp

**Chunking cắt ngang logic.** Nếu pipeline chunking của bạn chia theo số token cố định thay vì theo ranh giới cú pháp, một function 60 dòng có thể bị cắt làm đôi — nửa đầu chứa điều kiện `if`, nửa sau chứa `return`. Khi retrieve trúng đúng nửa sau, LLM sẽ đọc một đoạn code vô nghĩa hoặc hiểu sai hoàn toàn logic, nhưng vẫn trả lời rất tự tin vì nó không biết mình đang thiếu nửa kia. Luôn validate: mỗi chunk phải compile được độc lập về mặt cú pháp (hoặc ít nhất parse được thành AST hợp lệ) trước khi đưa vào index.

**Retrieve nhầm — false positive do semantic search không phải phép màu.** Embedding tìm theo độ tương đồng ngữ nghĩa, không phải độ chính xác tuyệt đối. Câu hỏi về "logic xóa user" có thể retrieve trúng cả đoạn code "soft-delete order" vì hai đoạn dùng từ vựng gần nhau (`delete`, `remove`, `deactivate`). Nếu top-k không đủ lớn hoặc không có bước rerank, LLM sẽ nhận context sai và trả lời như thể nó đúng — đây là rủi ro nguy hiểm nhất của RAG vì nó "tự tin sai" chứ không báo lỗi rõ ràng. Giảm thiểu bằng cách: kết hợp semantic search với keyword/metadata filter (hybrid search), thêm bước rerank bằng model nhỏ hơn sau khi retrieve, và luôn bắt LLM trích dẫn file:line cụ thể để developer tự verify — không tin mù quáng câu trả lời.

**Index không đồng bộ với code.** Nếu bạn không có pipeline tự động re-index khi code thay đổi (hook vào CI/CD hoặc pre-commit), vector DB sẽ dần "lệch" khỏi thực tế — RAG trả lời dựa trên code đã bị xóa hoặc refactor từ tuần trước. Đây là lỗi âm thầm, khó phát hiện hơn cả hai lỗi trên vì câu trả lời "nghe có vẻ đúng" với context cũ.

> 🧭 **Góc nhìn Solutions Architect**
> Khi thiết kế RAG cho codebase, câu hỏi không phải "embedding model nào tốt nhất" mà là: hệ thống của tôi có cơ chế nào để LLM tự nhận biết và báo hiệu khi retrieval không đủ tin cậy, hay nó luôn trả lời với giọng điệu chắc chắn như nhau bất kể chất lượng context? Tôi có đo lường được retrieval precision/recall trước khi tin tưởng đưa hệ thống này vào quy trình làm việc thật của team không, hay đang triển khai dựa trên cảm giác "demo chạy ổn"?

## 🔗 Bài viết liên quan

- [Bài 2 — Context Engineering](ans-02-context-engineering-cursorrules-codebase-map.md) — nền tảng về context tĩnh (rules file) mà RAG là bước nâng cấp khi cần context động.
- [Bài 20 — AI Integration Patterns](ans-20-ai-integration-patterns-nhung-llm-agents.md) — các pattern tích hợp LLM/Agent ở tầm hệ thống lớn hơn, trong đó RAG là một building block.

---
Bài trước: [Cost Optimization with AI](ans-13-cost-optimization-with-ai.md) · Bài tiếp theo: [Event-Driven Design](ans-15-event-driven-design-ai-ho-tro.md)
RAG for Engineering: Tự build hệ thống RAG để query codebase nội bộ